merge: add rcon admin log backfill

This commit is contained in:
2026-05-23 14:16:52 +02:00
120 changed files with 13833 additions and 406 deletions

1
.gitignore vendored
View File

@@ -17,6 +17,7 @@ ai/reports/*.md
# Local backend runtime data # Local backend runtime data
backend/runtime/ backend/runtime/
backend/data/*.sqlite3 backend/data/*.sqlite3
backend/data/*.writer.lock
!backend/data/.gitkeep !backend/data/.gitkeep
backend/data/snapshots/** backend/data/snapshots/**
!backend/data/snapshots/.gitkeep !backend/data/snapshots/.gitkeep

View File

@@ -17,3 +17,6 @@ Date | Task | Duration | Result | Notes
2026-03-19T13:59:33 | worker-cycle | 641.24 sec | success | codex-runner 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-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-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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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=<server_slug>`. 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.

View File

@@ -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=<server_slug>`. 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -1,7 +1,8 @@
HLL_BACKEND_HOST=0.0.0.0 HLL_BACKEND_HOST=0.0.0.0
HLL_BACKEND_PORT=8000 HLL_BACKEND_PORT=8000
HLL_BACKEND_STORAGE_PATH=/app/data/hll_vietnam_dev.sqlite3 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_REFRESH_INTERVAL_SECONDS=120
HLL_BACKEND_LIVE_DATA_SOURCE=rcon HLL_BACKEND_LIVE_DATA_SOURCE=rcon
HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon

View File

@@ -71,6 +71,10 @@ Variables opcionales:
- `HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS` - `HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS`
- `HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES` - `HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES`
- `HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS` - `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_WRITER_TIMEOUT_SECONDS`
- `HLL_BACKEND_SQLITE_BUSY_TIMEOUT_MS` - `HLL_BACKEND_SQLITE_BUSY_TIMEOUT_MS`
- `HLL_BACKEND_WRITER_LOCK_TIMEOUT_SECONDS` - `HLL_BACKEND_WRITER_LOCK_TIMEOUT_SECONDS`
@@ -97,6 +101,7 @@ Variables especialmente relevantes para Docker y Compose:
- `HLL_BACKEND_HOST` - `HLL_BACKEND_HOST`
- `HLL_BACKEND_PORT` - `HLL_BACKEND_PORT`
- `HLL_BACKEND_DATABASE_URL`
- `HLL_BACKEND_STORAGE_PATH` - `HLL_BACKEND_STORAGE_PATH`
- `HLL_BACKEND_ALLOWED_ORIGINS` - `HLL_BACKEND_ALLOWED_ORIGINS`
- `HLL_BACKEND_LIVE_DATA_SOURCE` - `HLL_BACKEND_LIVE_DATA_SOURCE`
@@ -132,6 +137,21 @@ Dentro del contenedor arranca por defecto con:
- `HLL_BACKEND_PORT=8000` - `HLL_BACKEND_PORT=8000`
- `HLL_BACKEND_STORAGE_PATH=/app/data/hll_vietnam_dev.sqlite3` - `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: Build local:
```powershell ```powershell
@@ -160,8 +180,10 @@ HTML si una demo local necesita un intervalo distinto.
Valor por defecto de `HLL_BACKEND_ALLOWED_ORIGINS`: Valor por defecto de `HLL_BACKEND_ALLOWED_ORIGINS`:
- `null` - `null`
- `http://127.0.0.1`
- `http://127.0.0.1:5500` - `http://127.0.0.1:5500`
- `http://127.0.0.1:8080` - `http://127.0.0.1:8080`
- `http://localhost`
- `http://localhost:5500` - `http://localhost:5500`
- `http://localhost:8080` - `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 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/`: Comandos manuales desde `backend/`:
```powershell ```powershell
@@ -1404,7 +1435,9 @@ locales mas comunes del proyecto:
- `http://127.0.0.1:5500` - `http://127.0.0.1:5500`
- `http://localhost:5500` - `http://localhost:5500`
- `http://127.0.0.1`
- `http://127.0.0.1:8080` - `http://127.0.0.1:8080`
- `http://localhost`
- `http://localhost:8080` - `http://localhost:8080`
Las respuestas `GET` y `OPTIONS` incluyen `Access-Control-Allow-Origin` cuando 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 RCON-backed primario y usar `public-scoreboard` solo como suplemento/fallback
para estadisticas por jugador sin paridad RCON 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 ## Elo/MMR Monthly Ranking
Se añade una primera base operativa inspirada en el documento Se añade una primera base operativa inspirada en el documento

View File

@@ -35,14 +35,19 @@ DEFAULT_PLAYER_EVENT_REFRESH_RETRY_DELAY_SECONDS = 30
DEFAULT_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS = 600 DEFAULT_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS = 600
DEFAULT_RCON_HISTORICAL_CAPTURE_MAX_RETRIES = 2 DEFAULT_RCON_HISTORICAL_CAPTURE_MAX_RETRIES = 2
DEFAULT_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS = 15 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_WRITER_TIMEOUT_SECONDS = 30.0
DEFAULT_SQLITE_BUSY_TIMEOUT_MS = 30000 DEFAULT_SQLITE_BUSY_TIMEOUT_MS = 30000
DEFAULT_WRITER_LOCK_TIMEOUT_SECONDS = 120.0 DEFAULT_WRITER_LOCK_TIMEOUT_SECONDS = 120.0
DEFAULT_WRITER_LOCK_POLL_INTERVAL_SECONDS = 1.0 DEFAULT_WRITER_LOCK_POLL_INTERVAL_SECONDS = 1.0
DEFAULT_ALLOWED_ORIGINS = ( DEFAULT_ALLOWED_ORIGINS = (
"null", "null",
"http://127.0.0.1",
"http://127.0.0.1:5500", "http://127.0.0.1:5500",
"http://127.0.0.1:8080", "http://127.0.0.1:8080",
"http://localhost",
"http://localhost:5500", "http://localhost:5500",
"http://localhost:8080", "http://localhost:8080",
) )
@@ -85,6 +90,20 @@ def get_storage_path() -> Path:
return Path(configured_path) if configured_path else default_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: def get_sqlite_writer_timeout_seconds() -> float:
"""Return the SQLite connection timeout shared by writer-capable storage layers.""" """Return the SQLite connection timeout shared by writer-capable storage layers."""
configured_value = os.getenv( configured_value = os.getenv(
@@ -217,20 +236,40 @@ def get_historical_crcon_retry_delay_seconds() -> float:
def get_historical_refresh_interval_seconds() -> int: def get_historical_refresh_interval_seconds() -> int:
"""Return the default interval used by the historical refresh loop.""" """Return the default interval used by the historical refresh loop."""
configured_value = os.getenv( return _read_int_env(
"HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS", "HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS",
os.getenv( os.getenv(
"HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS", "HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS",
str(DEFAULT_HISTORICAL_SNAPSHOT_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: 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: def get_historical_refresh_max_retries() -> int:
"""Return the retry count used by the historical refresh loop.""" """Return the retry count used by the historical refresh loop."""
configured_value = os.getenv( return _read_int_env(
"HLL_HISTORICAL_REFRESH_MAX_RETRIES", "HLL_HISTORICAL_REFRESH_MAX_RETRIES",
str(DEFAULT_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.""" """Return the wait time between historical refresh retries."""
configured_value = os.getenv( return _read_float_env(
"HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS", "HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS",
str(DEFAULT_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: 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 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: def get_a2s_targets_payload() -> str | None:
"""Return the optional JSON payload that overrides local A2S targets.""" """Return the optional JSON payload that overrides local A2S targets."""
raw_payload = os.getenv(DEFAULT_A2S_TARGETS_ENV_VAR) raw_payload = os.getenv(DEFAULT_A2S_TARGETS_ENV_VAR)

View File

@@ -14,8 +14,11 @@ ACCURACY_APPROXIMATE = "approximate"
ACCURACY_PARTIAL = "partial" ACCURACY_PARTIAL = "partial"
DEFAULT_BASE_MMR = 1000.0 DEFAULT_BASE_MMR = 1000.0
ELO_K_FACTOR = 60.0
MIN_VALID_MATCH_DURATION_SECONDS = 900 MIN_VALID_MATCH_DURATION_SECONDS = 900
MIN_VALID_MATCH_PLAYERS = 20 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_PLAYER_COUNT = 70
FULL_QUALITY_DURATION_SECONDS = 3600 FULL_QUALITY_DURATION_SECONDS = 3600
MONTHLY_MIN_VALID_MATCHES = 5 MONTHLY_MIN_VALID_MATCHES = 5

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import time import time
import traceback
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
@@ -39,7 +40,7 @@ def run_periodic_historical_refresh(
*, *,
interval_seconds: int, interval_seconds: int,
max_retries: int, max_retries: int,
retry_delay_seconds: int, retry_delay_seconds: float,
server_slug: str | None = None, server_slug: str | None = None,
max_pages: int | None = None, max_pages: int | None = None,
page_size: int | None = None, page_size: int | None = None,
@@ -73,7 +74,7 @@ def run_periodic_historical_refresh(
page_size=page_size, page_size=page_size,
run_number=completed_runs, 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: if max_runs is not None and completed_runs >= max_runs:
break break
@@ -86,7 +87,7 @@ def run_periodic_historical_refresh(
def _run_refresh_with_retries( def _run_refresh_with_retries(
*, *,
max_retries: int, max_retries: int,
retry_delay_seconds: int, retry_delay_seconds: float,
server_slug: str | None, server_slug: str | None,
max_pages: int | None, max_pages: int | None,
page_size: int | None, page_size: int | None,
@@ -182,12 +183,25 @@ def _run_refresh_with_retries(
"elo_mmr_result": elo_mmr_result, "elo_mmr_result": elo_mmr_result,
} }
except Exception as exc: 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: if attempt > max_retries:
return { return {
"status": "error", "status": "error",
"attempts_used": attempt, "attempts_used": attempt,
"max_retries": max_retries, "max_retries": max_retries,
"error_type": type(exc).__name__,
"error": str(exc), "error": str(exc),
"traceback": failure_payload["traceback"],
} }
if retry_delay_seconds > 0: if retry_delay_seconds > 0:
time.sleep(retry_delay_seconds) time.sleep(retry_delay_seconds)
@@ -202,6 +216,15 @@ def generate_historical_snapshots(
generated_at = datetime.now(timezone.utc) generated_at = datetime.now(timezone.utc)
full_snapshot_every_runs = get_historical_full_snapshot_every_runs() 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 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: if should_run_full_refresh:
result = generate_and_persist_historical_snapshots( result = generate_and_persist_historical_snapshots(
server_key=server_slug, 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]: def _describe_refresh_scope(server_slug: str | None) -> list[str]:
if server_slug: if server_slug:
return [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") totals = rcon_capture_result.get("totals")
if isinstance(totals, dict) and int(totals.get("samples_inserted") or 0) > 0: if isinstance(totals, dict) and int(totals.get("samples_inserted") or 0) > 0:
return True 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") targets = rcon_capture_result.get("targets")
if not isinstance(targets, list): if not isinstance(targets, list):
return False return False
@@ -349,7 +381,7 @@ def main() -> None:
) )
parser.add_argument( parser.add_argument(
"--retry-delay", "--retry-delay",
type=int, type=float,
default=get_historical_refresh_retry_delay_seconds(), default=get_historical_refresh_retry_delay_seconds(),
help="Seconds to wait between failed attempts.", help="Seconds to wait between failed attempts.",
) )

View File

@@ -6,7 +6,7 @@ import json
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path 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_models import HistoricalSnapshotRecord
from .historical_snapshots import validate_snapshot_identity from .historical_snapshots import validate_snapshot_identity
@@ -46,6 +46,22 @@ def persist_historical_snapshot(
raise ValueError("server_key is required for historical snapshots.") raise ValueError("server_key is required for historical snapshots.")
validate_snapshot_identity(snapshot_type=snapshot_type, metric=metric) 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) snapshots_root = initialize_historical_snapshot_storage(db_path=db_path)
generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc)) generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc))
payload_json = json.dumps(payload, ensure_ascii=True) payload_json = json.dumps(payload, ensure_ascii=True)
@@ -148,6 +164,15 @@ def get_historical_snapshot(
) -> dict[str, object] | None: ) -> dict[str, object] | None:
"""Return one persisted snapshot and decoded payload, if present.""" """Return one persisted snapshot and decoded payload, if present."""
validate_snapshot_identity(snapshot_type=snapshot_type, metric=metric) 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) snapshots_root = resolve_historical_snapshot_storage_path(db_path=db_path)
snapshot_path = _build_snapshot_path( snapshot_path = _build_snapshot_path(
snapshots_root=snapshots_root, snapshots_root=snapshots_root,

View File

@@ -2,11 +2,12 @@
from __future__ import annotations from __future__ import annotations
import json
import sqlite3 import sqlite3
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path 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 .data_sources import SOURCE_KIND_RCON, get_rcon_historical_read_model
from .historical_storage import ( from .historical_storage import (
ALL_SERVERS_SLUG, ALL_SERVERS_SLUG,
@@ -66,7 +67,6 @@ SUPPORTED_LEADERBOARD_METRICS = frozenset(
PREWARM_SNAPSHOT_SERVER_KEYS = ( PREWARM_SNAPSHOT_SERVER_KEYS = (
"comunidad-hispana-01", "comunidad-hispana-01",
"comunidad-hispana-02", "comunidad-hispana-02",
"comunidad-hispana-03",
ALL_SERVERS_SLUG, ALL_SERVERS_SLUG,
) )
PREWARM_LEADERBOARD_METRICS = ("kills",) PREWARM_LEADERBOARD_METRICS = ("kills",)
@@ -135,9 +135,16 @@ def build_historical_server_snapshots(
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""Build all precomputed historical snapshots required for one server.""" """Build all precomputed historical snapshots required for one server."""
generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc)) 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)] snapshots = [_build_server_summary_snapshot(server_key, generated_at_value, db_path=db_path)]
for metric in SNAPSHOT_LEADERBOARD_METRICS: for metric in SNAPSHOT_LEADERBOARD_METRICS:
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, metric=metric)
snapshots.append( snapshots.append(
_build_weekly_leaderboard_snapshot( _build_weekly_leaderboard_snapshot(
server_key, server_key,
@@ -147,6 +154,7 @@ def build_historical_server_snapshots(
db_path=db_path, db_path=db_path,
) )
) )
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_LEADERBOARD, metric=metric)
snapshots.append( snapshots.append(
_build_monthly_leaderboard_snapshot( _build_monthly_leaderboard_snapshot(
server_key, server_key,
@@ -157,6 +165,7 @@ def build_historical_server_snapshots(
) )
) )
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP)
snapshots.append( snapshots.append(
_build_monthly_mvp_snapshot( _build_monthly_mvp_snapshot(
server_key, server_key,
@@ -165,6 +174,7 @@ def build_historical_server_snapshots(
db_path=db_path, db_path=db_path,
) )
) )
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP_V2)
snapshots.append( snapshots.append(
_build_monthly_mvp_v2_snapshot( _build_monthly_mvp_v2_snapshot(
server_key, server_key,
@@ -174,6 +184,7 @@ def build_historical_server_snapshots(
) )
) )
for snapshot_type in PLAYER_EVENT_SNAPSHOT_TYPES: for snapshot_type in PLAYER_EVENT_SNAPSHOT_TYPES:
_log_snapshot_build_started(server_key, snapshot_type)
snapshots.append( snapshots.append(
_build_player_event_snapshot( _build_player_event_snapshot(
server_key, server_key,
@@ -184,6 +195,7 @@ def build_historical_server_snapshots(
) )
) )
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_RECENT_MATCHES)
snapshots.append( snapshots.append(
_build_recent_matches_snapshot( _build_recent_matches_snapshot(
server_key, server_key,
@@ -205,12 +217,23 @@ def build_priority_historical_snapshots(
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""Build the minimum warm snapshot set required by the historical UI.""" """Build the minimum warm snapshot set required by the historical UI."""
generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc)) 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]] = [] snapshots: list[dict[str, object]] = []
for server_key in server_keys: for server_key in server_keys:
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_SERVER_SUMMARY)
snapshots.append( snapshots.append(
_build_server_summary_snapshot(server_key, generated_at_value, db_path=db_path) _build_server_summary_snapshot(server_key, generated_at_value, db_path=db_path)
) )
for metric in PREWARM_LEADERBOARD_METRICS: for metric in PREWARM_LEADERBOARD_METRICS:
_log_snapshot_build_started(
server_key,
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
metric=metric,
)
snapshots.append( snapshots.append(
_build_weekly_leaderboard_snapshot( _build_weekly_leaderboard_snapshot(
server_key, server_key,
@@ -220,6 +243,11 @@ def build_priority_historical_snapshots(
db_path=db_path, db_path=db_path,
) )
) )
_log_snapshot_build_started(
server_key,
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,
metric=metric,
)
snapshots.append( snapshots.append(
_build_monthly_leaderboard_snapshot( _build_monthly_leaderboard_snapshot(
server_key, server_key,
@@ -229,6 +257,7 @@ def build_priority_historical_snapshots(
db_path=db_path, db_path=db_path,
) )
) )
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP)
snapshots.append( snapshots.append(
_build_monthly_mvp_snapshot( _build_monthly_mvp_snapshot(
server_key, server_key,
@@ -237,6 +266,7 @@ def build_priority_historical_snapshots(
db_path=db_path, db_path=db_path,
) )
) )
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP_V2)
snapshots.append( snapshots.append(
_build_monthly_mvp_v2_snapshot( _build_monthly_mvp_v2_snapshot(
server_key, server_key,
@@ -246,6 +276,7 @@ def build_priority_historical_snapshots(
) )
) )
for snapshot_type in PLAYER_EVENT_SNAPSHOT_TYPES: for snapshot_type in PLAYER_EVENT_SNAPSHOT_TYPES:
_log_snapshot_build_started(server_key, snapshot_type)
snapshots.append( snapshots.append(
_build_player_event_snapshot( _build_player_event_snapshot(
server_key, server_key,
@@ -255,6 +286,7 @@ def build_priority_historical_snapshots(
db_path=db_path, db_path=db_path,
) )
) )
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_RECENT_MATCHES)
snapshots.append( snapshots.append(
_build_recent_matches_snapshot( _build_recent_matches_snapshot(
server_key, server_key,
@@ -402,12 +434,24 @@ def _build_weekly_leaderboard_snapshot(
limit: int, limit: int,
db_path: Path | None = None, db_path: Path | None = None,
) -> dict[str, object]: ) -> dict[str, object]:
leaderboard_result = list_weekly_leaderboard( if get_historical_data_source_kind() == SOURCE_KIND_RCON:
limit=limit, from .rcon_historical_leaderboards import list_rcon_materialized_leaderboard
server_id=server_key,
metric=metric, leaderboard_result = list_rcon_materialized_leaderboard(
db_path=db_path, 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 { return {
"server_key": server_key, "server_key": server_key,
"snapshot_type": SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, "snapshot_type": SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
@@ -435,12 +479,24 @@ def _build_monthly_leaderboard_snapshot(
limit: int, limit: int,
db_path: Path | None = None, db_path: Path | None = None,
) -> dict[str, object]: ) -> dict[str, object]:
leaderboard_result = list_monthly_leaderboard( if get_historical_data_source_kind() == SOURCE_KIND_RCON:
limit=limit, from .rcon_historical_leaderboards import list_rcon_materialized_leaderboard
server_id=server_key,
metric=metric, leaderboard_result = list_rcon_materialized_leaderboard(
db_path=db_path, 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 { return {
"server_key": server_key, "server_key": server_key,
"snapshot_type": SNAPSHOT_TYPE_MONTHLY_LEADERBOARD, "snapshot_type": SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,
@@ -678,7 +734,7 @@ def _get_latest_player_event_month_key(
with _connect(resolved_path) as connection: with _connect(resolved_path) as connection:
row = connection.execute( row = connection.execute(
f""" 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 FROM player_event_raw_ledger
WHERE occurred_at IS NOT NULL WHERE occurred_at IS NOT NULL
AND {where_sql} AND {where_sql}
@@ -706,7 +762,7 @@ def _get_player_event_source_range(
MAX(occurred_at) AS source_range_end MAX(occurred_at) AS source_range_end
FROM player_event_raw_ledger FROM player_event_raw_ledger
WHERE occurred_at IS NOT NULL WHERE occurred_at IS NOT NULL
AND substr(occurred_at, 1, 7) = ? AND substr(CAST(occurred_at AS TEXT), 1, 7) = ?
AND {where_sql} AND {where_sql}
""", """,
[month_key, *params], [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: 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 = sqlite3.connect(db_path)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
return connection return connection
@@ -749,3 +809,34 @@ def _as_utc(value: datetime) -> datetime:
def _to_iso(value: datetime) -> str: def _to_iso(value: datetime) -> str:
return _as_utc(value).isoformat().replace("+00:00", "Z") 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,
)

View File

@@ -12,10 +12,12 @@ from .config import (
get_historical_weekly_fallback_max_weekday, get_historical_weekly_fallback_max_weekday,
get_historical_weekly_fallback_min_matches, get_historical_weekly_fallback_min_matches,
get_storage_path, get_storage_path,
use_postgres_rcon_storage,
) )
from .historical_models import HistoricalServerDefinition from .historical_models import HistoricalServerDefinition
from .monthly_mvp import build_monthly_mvp_rankings from .monthly_mvp import build_monthly_mvp_rankings
from .monthly_mvp_v2 import build_monthly_mvp_v2_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 ( from .scoreboard_origins import (
list_trusted_public_scoreboard_origins, list_trusted_public_scoreboard_origins,
resolve_trusted_scoreboard_match_url, resolve_trusted_scoreboard_match_url,
@@ -739,6 +741,10 @@ def list_recent_historical_matches(
db_path: Path | None = None, db_path: Path | None = None,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""Return recent persisted matches grouped for the historical API layer.""" """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) resolved_path = initialize_historical_storage(db_path=db_path)
where_clause = "" where_clause = ""
params: list[object] = [] params: list[object] = []
@@ -821,6 +827,13 @@ def get_historical_match_detail(
normalized_match_id = _stringify(match_id) normalized_match_id = _stringify(match_id)
if not normalized_server_slug or not normalized_match_id: if not normalized_server_slug or not normalized_match_id:
return None 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) resolved_path = initialize_historical_storage(db_path=db_path)
with _connect(resolved_path) as connection: with _connect(resolved_path) as connection:
row = connection.execute( row = connection.execute(
@@ -860,6 +873,7 @@ def get_historical_match_detail(
SELECT SELECT
historical_players.display_name, historical_players.display_name,
historical_players.stable_player_key, historical_players.stable_player_key,
historical_players.steam_id,
historical_player_match_stats.team_side, historical_player_match_stats.team_side,
historical_player_match_stats.level, historical_player_match_stats.level,
historical_player_match_stats.kills, historical_player_match_stats.kills,
@@ -913,6 +927,7 @@ def get_historical_match_detail(
"name": player_row["display_name"], "name": player_row["display_name"],
"stable_player_key": player_row["stable_player_key"], "stable_player_key": player_row["stable_player_key"],
"team_side": player_row["team_side"], "team_side": player_row["team_side"],
**build_external_player_profile_fields(steam_id=player_row["steam_id"]),
"level": _coerce_int(player_row["level"]), "level": _coerce_int(player_row["level"]),
"kills": _coerce_int(player_row["kills"]), "kills": _coerce_int(player_row["kills"]),
"deaths": _coerce_int(player_row["deaths"]), "deaths": _coerce_int(player_row["deaths"]),
@@ -939,6 +954,10 @@ def list_historical_server_summaries(
db_path: Path | None = None, db_path: Path | None = None,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""Return aggregate historical metrics per server.""" """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) resolved_path = initialize_historical_storage(db_path=db_path)
if _is_all_servers_selector(server_slug): if _is_all_servers_selector(server_slug):
return [_build_all_servers_summary(db_path=resolved_path)] return [_build_all_servers_summary(db_path=resolved_path)]
@@ -1247,6 +1266,15 @@ def list_weekly_leaderboard(
db_path: Path | None = None, db_path: Path | None = None,
) -> dict[str, object]: ) -> dict[str, object]:
"""Return ranked weekly leaderboard totals from persisted historical match stats.""" """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) resolved_path = initialize_historical_storage(db_path=db_path)
aggregate_all_servers = _is_all_servers_selector(server_id) aggregate_all_servers = _is_all_servers_selector(server_id)
current_time = datetime.now(timezone.utc) current_time = datetime.now(timezone.utc)
@@ -1432,6 +1460,15 @@ def list_monthly_leaderboard(
db_path: Path | None = None, db_path: Path | None = None,
) -> dict[str, object]: ) -> dict[str, object]:
"""Return ranked monthly leaderboard totals from persisted historical match stats.""" """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) resolved_path = initialize_historical_storage(db_path=db_path)
aggregate_all_servers = _is_all_servers_selector(server_id) aggregate_all_servers = _is_all_servers_selector(server_id)
current_time = datetime.now(timezone.utc) current_time = datetime.now(timezone.utc)
@@ -1805,7 +1842,7 @@ def list_monthly_mvp_v2_ranking(
FROM player_event_raw_ledger FROM player_event_raw_ledger
WHERE event_type = 'player_kill_summary' WHERE event_type = 'player_kill_summary'
AND occurred_at IS NOT NULL 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 {event_scope_sql}
AND killer_player_key IS NOT NULL AND killer_player_key IS NOT NULL
AND victim_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 FROM player_event_raw_ledger
WHERE event_type = 'player_death_summary' WHERE event_type = 'player_death_summary'
AND occurred_at IS NOT NULL 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 {event_scope_sql}
AND killer_player_key IS NOT NULL AND killer_player_key IS NOT NULL
AND victim_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 FROM player_event_raw_ledger
WHERE event_type = 'player_kill_summary' WHERE event_type = 'player_kill_summary'
AND occurred_at IS NOT NULL 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 {event_scope_sql}
AND killer_player_key IS NOT NULL AND killer_player_key IS NOT NULL
AND victim_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: with _connect(db_path) as connection:
latest_row = connection.execute( latest_row = connection.execute(
f""" 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 FROM player_event_raw_ledger
WHERE occurred_at IS NOT NULL WHERE occurred_at IS NOT NULL
AND {scope_sql} AND {scope_sql}
@@ -1998,7 +2035,7 @@ def _get_monthly_player_event_coverage(
MAX(occurred_at) AS source_range_end MAX(occurred_at) AS source_range_end
FROM player_event_raw_ledger FROM player_event_raw_ledger
WHERE occurred_at IS NOT NULL WHERE occurred_at IS NOT NULL
AND substr(occurred_at, 1, 7) = ? AND substr(CAST(occurred_at AS TEXT), 1, 7) = ?
AND {scope_sql} AND {scope_sql}
""", """,
[month_key, *scope_params], [month_key, *scope_params],

View File

@@ -3,10 +3,12 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import date, datetime
from http import HTTPStatus from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from .config import get_allowed_origins, get_bind_address from .config import get_allowed_origins, get_bind_address
from .payloads import build_error_payload
from .routes import resolve_get_payload from .routes import resolve_get_payload
@@ -23,7 +25,15 @@ class HealthHandler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler interface 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: if status is None:
self._write_json( self._write_json(
HTTPStatus.NOT_FOUND, HTTPStatus.NOT_FOUND,
@@ -38,7 +48,7 @@ class HealthHandler(BaseHTTPRequestHandler):
return return
def _write_json(self, status: HTTPStatus, payload: dict[str, object]) -> None: 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_response(status)
self._send_default_headers(content_length=len(body)) self._send_default_headers(content_length=len(body))
self.end_headers() self.end_headers()
@@ -61,6 +71,13 @@ def create_server() -> ThreadingHTTPServer:
return ThreadingHTTPServer((host, port), HealthHandler) 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: def run() -> None:
"""Start the local bootstrap server.""" """Start the local bootstrap server."""
host, port = get_bind_address() host, port = get_bind_address()

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
import re
from .config import ( from .config import (
get_historical_data_source_kind, 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 .rcon_historical_read_model import get_rcon_historical_match_detail
from .normalizers import normalize_map_name 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 .scoreboard_origins import get_trusted_public_scoreboard_origin
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history 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"(?<!\d)#0*{server_number}(?!\d)")
if numbered_marker.search(normalized_label):
return True
return f"comunidad hispana #{server_number:02d}" in normalized_label
def build_current_match_kill_feed_payload(
*,
server_slug: str,
limit: int = 30,
since_event_id: str | None = None,
) -> 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]: def build_error_payload(message: str) -> dict[str, str]:
"""Return the shared error payload shape used by the backend bootstrap.""" """Return the shared error payload shape used by the backend bootstrap."""
return { return {

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import sqlite3 import sqlite3
from pathlib import Path 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 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 COALESCE(SUM(net_value), 0) AS net_duel_value
FROM duel_pairs FROM duel_pairs
GROUP BY player_a_key, player_a_name, player_b_key, player_b_name 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 ? LIMIT ?
""", """,
[*params, limit], [*params, limit],
@@ -239,7 +242,7 @@ def _build_common_where(
clauses.append("server_slug = ?") clauses.append("server_slug = ?")
params.append(server_slug.strip()) params.append(server_slug.strip())
if month: 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()) params.append(month.strip())
if external_match_id: if external_match_id:
clauses.append("external_match_id = ?") clauses.append("external_match_id = ?")
@@ -249,6 +252,10 @@ def _build_common_where(
def _connect(db_path: Path) -> sqlite3.Connection: 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 = sqlite3.connect(db_path or get_storage_path())
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
return connection return connection

View File

@@ -7,7 +7,11 @@ from collections.abc import Iterable
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path 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 .player_event_models import PlayerEventRecord
from .sqlite_utils import connect_sqlite_writer from .sqlite_utils import connect_sqlite_writer
@@ -95,6 +99,10 @@ def upsert_player_events(
db_path: Path | None = None, db_path: Path | None = None,
) -> dict[str, int]: ) -> dict[str, int]:
"""Insert normalized events idempotently into the raw ledger.""" """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) resolved_path = initialize_player_event_storage(db_path=db_path)
inserted = 0 inserted = 0
duplicates = 0 duplicates = 0

View File

@@ -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"

View File

@@ -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")

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ from collections.abc import Iterable
from contextlib import closing from contextlib import closing
from pathlib import Path 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 .normalizers import normalize_map_name
from .rcon_admin_log_storage import initialize_rcon_admin_log_storage from .rcon_admin_log_storage import initialize_rcon_admin_log_storage
from .rcon_historical_storage import list_rcon_historical_competitive_windows 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: def initialize_rcon_materialized_storage(*, db_path: Path | None = None) -> Path:
"""Create SQLite structures used by the materialized RCON match pipeline.""" """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) resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
with closing(connect_sqlite_writer(resolved_path)) as connection: with closing(connect_sqlite_writer(resolved_path)) as connection:
with 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]: def materialize_rcon_admin_log(*, db_path: Path | None = None) -> dict[str, object]:
"""Materialize matches and player stats from stored AdminLog events.""" """Materialize matches and player stats from stored AdminLog events."""
resolved_path = initialize_rcon_materialized_storage(db_path=db_path) 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] = [] errors: list[str] = []
matches_seen = 0 matches_seen = 0
matches_materialized = 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_materialized = 0
player_stats_updated = 0 player_stats_updated = 0
with closing(connect_sqlite_writer(resolved_path)) as connection: try:
with connection: match_rows = _derive_admin_log_matches(connection)
try: matches_seen = len(match_rows)
match_rows = _derive_admin_log_matches(connection) for row in match_rows:
matches_seen = len(match_rows) outcome = _upsert_match(connection, row)
for row in match_rows: matches_materialized += int(outcome == "inserted")
outcome = _upsert_match(connection, row) matches_updated += int(outcome == "updated")
matches_materialized += int(outcome == "inserted") session_rows = _derive_session_fallback_matches(
matches_updated += int(outcome == "updated") connection,
session_rows = _derive_session_fallback_matches(connection, db_path=resolved_path) db_path=session_window_db_path,
matches_seen += len(session_rows) )
for row in session_rows: matches_seen += len(session_rows)
outcome = _upsert_match(connection, row) for row in session_rows:
matches_materialized += int(outcome == "inserted") outcome = _upsert_match(connection, row)
matches_updated += int(outcome == "updated") 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))
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 { return {
"matches_seen": matches_seen, "matches_seen": matches_seen,
"matches_materialized": matches_materialized, "matches_materialized": matches_materialized,
@@ -149,21 +195,41 @@ def list_materialized_rcon_matches(
clauses: list[str] = [] clauses: list[str] = []
params: list[object] = [] params: list[object] = []
if target_key: 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]) params.extend([target_key, target_key])
if only_ended: if only_ended:
clauses.append("source_basis = ?") clauses.append("m.source_basis = ?")
params.append(MATCH_RESULT_SOURCE) params.append(MATCH_RESULT_SOURCE)
where = "WHERE " + " AND ".join(clauses) if clauses else "" where = "WHERE " + " AND ".join(clauses) if clauses else ""
params.append(limit) 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( rows = connection.execute(
f""" f"""
SELECT * SELECT
FROM rcon_materialized_matches 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} {where}
ORDER BY COALESCE(ended_at, started_at) DESC, ORDER BY COALESCE(m.ended_at, m.started_at) DESC,
COALESCE(ended_server_time, started_server_time) DESC COALESCE(m.ended_server_time, m.started_server_time) DESC
LIMIT ? LIMIT ?
""", """,
params, params,
@@ -179,7 +245,13 @@ def get_materialized_rcon_match_detail(
) -> dict[str, object] | None: ) -> dict[str, object] | None:
"""Return one materialized match with player stats.""" """Return one materialized match with player stats."""
resolved_path = initialize_rcon_materialized_storage(db_path=db_path) 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( match = connection.execute(
""" """
SELECT * SELECT *
@@ -190,6 +262,16 @@ def get_materialized_rcon_match_detail(
""", """,
(match_key, server_key, server_key), (match_key, server_key, server_key),
).fetchone() ).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: if match is None:
return None return None
stat_rows = connection.execute( 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]: def summarize_rcon_materialization_status(*, db_path: Path | None = None) -> dict[str, object]:
"""Return a small diagnostic summary for stored RCON materialization state.""" """Return a small diagnostic summary for stored RCON materialization state."""
resolved_path = initialize_rcon_materialized_storage(db_path=db_path) 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( match_count = connection.execute(
"SELECT COUNT(*) AS count FROM rcon_materialized_matches" "SELECT COUNT(*) AS count FROM rcon_materialized_matches"
).fetchone()["count"] ).fetchone()["count"]
@@ -242,7 +330,7 @@ def summarize_rcon_materialization_status(*, db_path: Path | None = None) -> dic
SELECT 1 SELECT 1
FROM rcon_match_player_stats FROM rcon_match_player_stats
GROUP BY target_key, match_key GROUP BY target_key, match_key
) ) AS stats_matches
""" """
).fetchone()["count"] ).fetchone()["count"]
ranges = connection.execute( 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 ORDER BY target_key ASC, event_count DESC
""" """
).fetchall() ).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 { return {
"materialized_matches": int(match_count or 0), "materialized_matches": int(match_count or 0),
"matches_with_player_stats": int(stats_match_count or 0), "matches_with_player_stats": int(stats_match_count or 0),
"server_time_ranges": [dict(row) for row in ranges], "server_time_ranges": [dict(row) for row in ranges],
"event_counts": [dict(row) for row in event_counts], "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( def _derive_session_fallback_matches(
connection: sqlite3.Connection, connection: sqlite3.Connection,
*, *,
db_path: Path, db_path: Path | None,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
rows: list[dict[str, object]] = [] rows: list[dict[str, object]] = []
existing = { existing = {

View File

@@ -5,18 +5,29 @@ from __future__ import annotations
import json import json
import re import re
import sqlite3 import sqlite3
from collections import Counter
from collections.abc import Mapping from collections.abc import Mapping
from contextlib import closing
from datetime import datetime, timedelta, timezone
from pathlib import Path 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_admin_log_entry
from .rcon_admin_log_parser import parse_rcon_player_profile_snapshot from .rcon_admin_log_parser import parse_rcon_player_profile_snapshot
from .rcon_historical_storage import initialize_rcon_historical_storage from .rcon_historical_storage import initialize_rcon_historical_storage
from .sqlite_utils import connect_sqlite_writer 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: def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path:
"""Create SQLite structures for parsed RCON AdminLog events.""" """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) resolved_path = initialize_rcon_historical_storage(db_path=db_path)
with connect_sqlite_writer(resolved_path) as connection: with connect_sqlite_writer(resolved_path) as connection:
@@ -90,6 +101,9 @@ def persist_rcon_admin_log_entries(
db_path: Path | None = None, db_path: Path | None = None,
) -> dict[str, int]: ) -> dict[str, int]:
"""Persist raw and parsed AdminLog entries idempotently.""" """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) 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 "") target_key = str(target.get("target_key") or target.get("external_server_id") or "")
if not target_key: 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]]: 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.""" """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() resolved_path = db_path or get_storage_path()
initialize_rcon_admin_log_storage(db_path=resolved_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] 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( def get_latest_rcon_player_profile_summaries(
*, *,
target_key: str, 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()] 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: if not target_key or not requested_ids:
return {} 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() resolved_path = db_path or get_storage_path()
initialize_rcon_admin_log_storage(db_path=resolved_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: except json.JSONDecodeError:
return {} return {}
return parsed if isinstance(parsed, dict) else {} 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,
}

View File

@@ -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())

View File

@@ -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())

View File

@@ -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")

View File

@@ -7,6 +7,7 @@ from datetime import datetime, timedelta, timezone
from .historical_storage import ALL_SERVERS_SLUG from .historical_storage import ALL_SERVERS_SLUG
from .normalizers import normalize_map_name 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_scoreboard_correlation import resolve_rcon_scoreboard_match_url
from .rcon_historical_storage import ( from .rcon_historical_storage import (
find_rcon_historical_competitive_window, 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]: 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) 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 { return {
"server": { "server": {
"slug": item.get("target_key"), "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"),
}, },
"winner": item.get("winner"), "winner": item.get("winner"),
"player_count": None, "player_count": player_count,
"peak_players": None, "peak_players": None,
"sample_count": None, "sample_count": None,
"duration_seconds": _calculate_match_duration_seconds(item), "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 else SESSION_RESULT_SOURCE
), ),
"match_url": resolve_rcon_scoreboard_match_url( "match_url": resolve_rcon_scoreboard_match_url(
server_slug=server_slug, **scoreboard_correlation,
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"),
), ),
"capabilities": describe_rcon_historical_read_model()["capabilities"], "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"] for row in materialized["players"]
] ]
player_count = len(players) if players else recent_item.get("player_count")
return { return {
**recent_item, **recent_item,
"match_id": match["match_key"], "match_id": match["match_key"],
@@ -280,6 +276,7 @@ def _build_materialized_detail_item(materialized: dict[str, object]) -> dict[str
"winner": match.get("winner"), "winner": match.get("winner"),
"confidence": match.get("confidence_mode"), "confidence": match.get("confidence_mode"),
"source_basis": match.get("source_basis"), "source_basis": match.get("source_basis"),
"player_count": player_count,
"players": players, "players": players,
"timeline": { "timeline": {
"event_counts": materialized.get("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( def _build_player_row(
row: dict[str, object], row: dict[str, object],
*, *,
@@ -304,6 +313,7 @@ def _build_player_row(
"top_weapons": _top_counter(row.get("weapons_json")), "top_weapons": _top_counter(row.get("weapons_json")),
"most_killed": _top_counter(row.get("most_killed_json")), "most_killed": _top_counter(row.get("most_killed_json")),
"death_by": _top_counter(row.get("death_by_json")), "death_by": _top_counter(row.get("death_by_json")),
**build_external_player_profile_fields(player_id=row.get("player_id")),
} }
if profile_summary: if profile_summary:
player["profile_summary"] = 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( def _merge_recent_items(
primary_items: list[dict[str, object]], primary_items: list[dict[str, object]],
fallback_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: def _parse_datetime(value: object) -> datetime | None:
if not isinstance(value, str) or not value.strip(): if isinstance(value, datetime):
return None parsed = value
try: elif isinstance(value, str) and value.strip():
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) try:
except ValueError: parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
return None
else:
return None return None
if parsed.tzinfo is None: if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc) parsed = parsed.replace(tzinfo=timezone.utc)
@@ -567,31 +597,23 @@ def _parse_datetime(value: object) -> datetime | None:
def _calculate_coverage_hours( def _calculate_coverage_hours(
first_sample_at: str | None, first_sample_at: object,
last_sample_at: str | None, last_sample_at: object,
) -> float | None: ) -> 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 return None
first_point = datetime.fromisoformat(first_sample_at.replace("Z", "+00:00")) delta = last_point - first_point
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)
return round(delta.total_seconds() / 3600, 2) return round(delta.total_seconds() / 3600, 2)
def _calculate_duration_seconds(first_seen_at: object, last_seen_at: object) -> int | None: 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 return None
first_point = datetime.fromisoformat(first_seen_at.replace("Z", "+00:00")) return max(0, int((last_point - first_point).total_seconds()))
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()))
def _calculate_match_duration_seconds(item: dict[str, object]) -> int | None: def _calculate_match_duration_seconds(item: dict[str, object]) -> int | None:

View File

@@ -8,7 +8,7 @@ from collections.abc import Mapping
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path 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 .normalizers import normalize_map_name
from .rcon_client import load_rcon_targets from .rcon_client import load_rcon_targets
from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer 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: def initialize_rcon_historical_storage(*, db_path: Path | None = None) -> Path:
"""Create the SQLite structures used by prospective RCON capture.""" """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 = db_path or get_storage_path()
resolved_path.parent.mkdir(parents=True, exist_ok=True) resolved_path.parent.mkdir(parents=True, exist_ok=True)
@@ -131,6 +137,11 @@ def start_rcon_historical_capture_run(
db_path: Path | None = None, db_path: Path | None = None,
) -> int: ) -> int:
"""Create one run row for prospective RCON capture.""" """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) resolved_path = initialize_rcon_historical_storage(db_path=db_path)
with _connect(resolved_path) as connection: with _connect(resolved_path) as connection:
cursor = connection.execute( cursor = connection.execute(
@@ -159,6 +170,20 @@ def finalize_rcon_historical_capture_run(
db_path: Path | None = None, db_path: Path | None = None,
) -> None: ) -> None:
"""Finalize one prospective RCON capture run.""" """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) resolved_path = initialize_rcon_historical_storage(db_path=db_path)
with _connect(resolved_path) as connection: with _connect(resolved_path) as connection:
connection.execute( connection.execute(
@@ -196,6 +221,17 @@ def persist_rcon_historical_sample(
db_path: Path | None = None, db_path: Path | None = None,
) -> dict[str, int]: ) -> dict[str, int]:
"""Persist one prospective RCON sample and refresh its checkpoint.""" """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) resolved_path = initialize_rcon_historical_storage(db_path=db_path)
with _connect(resolved_path) as connection: with _connect(resolved_path) as connection:
target_id = _upsert_target(connection, target=target) target_id = _upsert_target(connection, target=target)
@@ -254,6 +290,12 @@ def mark_rcon_historical_capture_failure(
db_path: Path | None = None, db_path: Path | None = None,
) -> None: ) -> None:
"""Persist failure metadata for one target inside a capture run.""" """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) resolved_path = initialize_rcon_historical_storage(db_path=db_path)
with _connect(resolved_path) as connection: with _connect(resolved_path) as connection:
target_id = _upsert_target(connection, target=target) target_id = _upsert_target(connection, target=target)
@@ -282,6 +324,11 @@ def list_rcon_historical_target_statuses(
db_path: Path | None = None, db_path: Path | None = None,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""Return per-target coverage and freshness for prospective RCON capture.""" """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) resolved_path = _resolve_db_path(db_path)
try: try:
with _connect_readonly(resolved_path) as connection: with _connect_readonly(resolved_path) as connection:
@@ -353,6 +400,11 @@ def list_recent_rcon_historical_samples(
db_path: Path | None = None, db_path: Path | None = None,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""Return recent prospective RCON samples for one or all configured targets.""" """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) resolved_path = _resolve_db_path(db_path)
where_clause = "" where_clause = ""
params: list[object] = [limit] params: list[object] = [limit]
@@ -413,6 +465,11 @@ def list_rcon_historical_competitive_windows(
db_path: Path | None = None, db_path: Path | None = None,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""Return recent RCON-backed competitive windows derived from persisted samples.""" """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) resolved_path = _resolve_db_path(db_path)
where_clause = "" where_clause = ""
params: list[object] = [limit] params: list[object] = [limit]
@@ -498,6 +555,11 @@ def count_rcon_historical_samples_since(
db_path: Path | None = None, db_path: Path | None = None,
) -> int: ) -> int:
"""Return how many RCON samples were captured after one timestamp.""" """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: if not since:
return 0 return 0
resolved_path = _resolve_db_path(db_path) resolved_path = _resolve_db_path(db_path)
@@ -522,6 +584,11 @@ def list_rcon_historical_competitive_summary_rows(
db_path: Path | None = None, db_path: Path | None = None,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""Return RCON-backed per-target summary rows over competitive windows.""" """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) resolved_path = _resolve_db_path(db_path)
where_clause = "" where_clause = ""
params: list[object] = [] params: list[object] = []
@@ -593,6 +660,15 @@ def find_rcon_historical_competitive_window(
db_path: Path | None = None, db_path: Path | None = None,
) -> dict[str, object] | None: ) -> dict[str, object] | None:
"""Return the closest competitive window for one server/match if coverage exists.""" """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: if not ended_at:
return None return None
resolved_path = _resolve_db_path(db_path) resolved_path = _resolve_db_path(db_path)
@@ -672,6 +748,14 @@ def get_rcon_historical_competitive_window_by_session(
db_path: Path | None = None, db_path: Path | None = None,
) -> dict[str, object] | None: ) -> dict[str, object] | None:
"""Return one persisted competitive RCON window by its synthetic session key.""" """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() normalized_session_key = str(session_key or "").strip()
if not normalized_session_key: if not normalized_session_key:
return None return None

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from datetime import date, datetime
import json import json
import os import os
import time import time
@@ -16,6 +17,7 @@ from .config import (
get_rcon_request_timeout_seconds, get_rcon_request_timeout_seconds,
) )
from .rcon_admin_log_ingestion import ingest_rcon_admin_logs from .rcon_admin_log_ingestion import ingest_rcon_admin_logs
from .rcon_admin_log_materialization import materialize_rcon_admin_log
from .rcon_client import ( from .rcon_client import (
RconQueryError, RconQueryError,
build_rcon_target_key, build_rcon_target_key,
@@ -44,6 +46,8 @@ class RconHistoricalCaptureStats:
admin_log_events_inserted: int = 0 admin_log_events_inserted: int = 0
admin_log_duplicate_events: int = 0 admin_log_duplicate_events: int = 0
admin_log_failed_targets: int = 0 admin_log_failed_targets: int = 0
materialized_matches_inserted: int = 0
materialized_matches_updated: int = 0
def run_rcon_historical_capture( 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.""" """Capture one prospective RCON sample assuming the shared writer lock is already held."""
initialize_rcon_historical_storage() initialize_rcon_historical_storage()
selected_targets = _select_targets(target_key) 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() admin_log_lookback_minutes = get_rcon_admin_log_lookback_minutes()
captured_at = utc_now().isoformat().replace("+00:00", "Z") captured_at = utc_now().isoformat().replace("+00:00", "Z")
target_scope = target_key or "all-configured-rcon-targets" target_scope = target_key or "all-configured-rcon-targets"
@@ -127,6 +132,14 @@ def run_rcon_historical_capture_unlocked(
result=admin_log_result, 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") status = "success" if not errors else ("partial" if items else "failed")
finalize_rcon_historical_capture_run( finalize_rcon_historical_capture_run(
run_id, run_id,
@@ -158,7 +171,12 @@ def run_rcon_historical_capture_unlocked(
"targets": items, "targets": items,
"errors": errors, "errors": errors,
"admin_log_errors": admin_log_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": { "totals": {
"targets_seen": stats.targets_seen, "targets_seen": stats.targets_seen,
"samples_inserted": stats.samples_inserted, "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_events_inserted": stats.admin_log_events_inserted,
"admin_log_duplicate_events": stats.admin_log_duplicate_events, "admin_log_duplicate_events": stats.admin_log_duplicate_events,
"admin_log_failed_targets": stats.admin_log_failed_targets, "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: ) -> None:
"""Run prospective RCON capture in a local loop.""" """Run prospective RCON capture in a local loop."""
completed_runs = 0 completed_runs = 0
print( startup_targets = _describe_loop_targets(target_key)
json.dumps( _emit_worker_event(
{ "rcon-historical-capture-worker-started",
"event": "rcon-historical-capture-loop-started", interval_seconds=interval_seconds,
"interval_seconds": interval_seconds, max_retries=max_retries,
"max_retries": max_retries, retry_delay_seconds=retry_delay_seconds,
"retry_delay_seconds": retry_delay_seconds, target_scope=target_key or "all-configured-rcon-targets",
"target_scope": target_key or "all-configured-rcon-targets", target_count=len(startup_targets),
}, targets=startup_targets,
indent=2,
)
) )
print("Press Ctrl+C to stop.") print("Press Ctrl+C to stop.")
try: try:
while max_runs is None or completed_runs < max_runs: while max_runs is None or completed_runs < max_runs:
completed_runs += 1 completed_runs += 1
_emit_worker_event(
"rcon-historical-capture-cycle-started",
run=completed_runs,
)
payload = _run_capture_with_retries( payload = _run_capture_with_retries(
max_retries=max_retries, max_retries=max_retries,
retry_delay_seconds=retry_delay_seconds, retry_delay_seconds=retry_delay_seconds,
target_key=target_key, 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: if max_runs is not None and completed_runs >= max_runs:
break break
_emit_worker_event(
"rcon-historical-capture-sleep-started",
run=completed_runs,
sleep_seconds=interval_seconds,
)
time.sleep(interval_seconds) time.sleep(interval_seconds)
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nRCON historical capture loop stopped by user.") 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( def _run_capture_with_retries(
@@ -229,12 +267,32 @@ def _run_capture_with_retries(
} }
except Exception as exc: except Exception as exc:
if attempt > max_retries: 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 { return {
"status": "error", "status": "error",
"attempts_used": attempt, "attempts_used": attempt,
"error": str(exc), "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: 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) time.sleep(retry_delay_seconds)
@@ -256,6 +314,42 @@ def _select_targets(target_key: str | None) -> list[object]:
return selected 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: def get_rcon_admin_log_lookback_minutes() -> int:
"""Return the AdminLog lookback window used by periodic RCON capture.""" """Return the AdminLog lookback window used by periodic RCON capture."""
configured_value = os.getenv("HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES", "60") 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": if args.mode == "capture":
result = run_rcon_historical_capture(target_key=args.target_key) 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 return 0
if args.interval <= 0: if args.interval <= 0:

View File

@@ -6,7 +6,7 @@ import sqlite3
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path 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 .normalizers import normalize_map_name
from .scoreboard_origins import resolve_trusted_scoreboard_match_url from .scoreboard_origins import resolve_trusted_scoreboard_match_url
from .sqlite_utils import connect_sqlite_readonly from .sqlite_utils import connect_sqlite_readonly
@@ -30,12 +30,42 @@ def resolve_rcon_scoreboard_match_url(
db_path: Path | None = None, db_path: Path | None = None,
) -> str | None: ) -> str | None:
"""Return a trusted scoreboard URL for an RCON window only on strong evidence.""" """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_server_slug = str(server_slug or "").strip()
normalized_map = normalize_map_name(map_name) normalized_map = normalize_map_name(map_name)
rcon_start = _parse_timestamp(started_at) rcon_start = _parse_timestamp(started_at)
rcon_end = _parse_timestamp(ended_at) rcon_end = _parse_timestamp(ended_at)
if not normalized_server_slug or not normalized_map or not rcon_start or not rcon_end: 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: if rcon_end < rcon_start:
rcon_start, rcon_end = 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 is not None
] ]
if not scored_candidates: 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) scored_candidates.sort(key=lambda item: item["score"], reverse=True)
best = scored_candidates[0] best = scored_candidates[0]
if int(best["score"]) < MIN_CONFIDENCE_SCORE: 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"]): if len(scored_candidates) > 1 and int(scored_candidates[1]["score"]) >= int(best["score"]):
return None return {
return str(best["match_url"]) "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( def _list_persisted_scoreboard_candidates(
@@ -76,6 +218,16 @@ def _list_persisted_scoreboard_candidates(
server_slug: str, server_slug: str,
db_path: Path, db_path: Path,
) -> list[dict[str, object]]: ) -> 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: try:
with connect_sqlite_readonly(db_path) as connection: with connect_sqlite_readonly(db_path) as connection:
rows = connection.execute( rows = connection.execute(
@@ -128,6 +280,10 @@ def _list_persisted_scoreboard_candidates(
"match_url": match_url, "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 return items
@@ -207,10 +363,61 @@ def _score_candidate(
return None return None
return { return {
"score": score, "score": score,
"external_match_id": candidate.get("external_match_id"),
"match_url": candidate["match_url"], "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( def _overlap_seconds(
first_start: datetime, first_start: datetime,
first_end: datetime, first_end: datetime,

View File

@@ -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())

View File

@@ -5,8 +5,12 @@ from __future__ import annotations
from http import HTTPStatus from http import HTTPStatus
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from .config import get_historical_data_source_kind
from .payloads import ( from .payloads import (
build_community_payload, build_community_payload,
build_current_match_kill_feed_payload,
build_current_match_player_stats_payload,
build_current_match_payload,
build_discord_payload, build_discord_payload,
build_elo_mmr_leaderboard_payload, build_elo_mmr_leaderboard_payload,
build_elo_mmr_player_payload, build_elo_mmr_player_payload,
@@ -37,6 +41,8 @@ from .payloads import (
build_weekly_leaderboard_payload, build_weekly_leaderboard_payload,
build_weekly_top_kills_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 = { 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.BAD_REQUEST, build_error_payload("Invalid limit parameter")
return HTTPStatus.OK, build_server_history_payload(limit=limit) 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": if parsed.path == "/api/historical/weekly-top-kills":
limit = _parse_limit(parsed.query) limit = _parse_limit(parsed.query)
if limit is None: 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") return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter")
if timeframe not in {"weekly", "monthly"}: if timeframe not in {"weekly", "monthly"}:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter") 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( return HTTPStatus.OK, build_leaderboard_snapshot_payload(
limit=limit, limit=limit,
server_id=server_id, 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] metric = params.get("metric", ["kills"])[0]
if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}: if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter") 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( return HTTPStatus.OK, build_monthly_leaderboard_snapshot_payload(
limit=limit, limit=limit,
server_id=server_id, 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] metric = params.get("metric", ["kills"])[0]
if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}: if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter") 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( return HTTPStatus.OK, build_weekly_leaderboard_snapshot_payload(
limit=limit, limit=limit,
server_id=server_id, server_id=server_id,

View File

@@ -5,11 +5,17 @@ from __future__ import annotations
import argparse import argparse
import json import json
from datetime import datetime, timezone from datetime import datetime, timezone
from collections.abc import Mapping
from typing import Iterable from typing import Iterable
from .historical_storage import initialize_historical_storage, list_historical_servers, upsert_historical_match 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 .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_MAX_PAGES = 20
DEFAULT_PAGE_SIZE = 100 DEFAULT_PAGE_SIZE = 100
@@ -34,8 +40,19 @@ def run_backfill(*, server: dict[str, object], start_at: datetime, end_at: datet
provider = PublicScoreboardHistoricalDataSource() provider = PublicScoreboardHistoricalDataSource()
server_slug = str(server["slug"]) server_slug = str(server["slug"])
base_url = str(server["scoreboard_base_url"]) 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]] = [] errors: list[dict[str, object]] = []
skipped_unsafe_urls = 0
stopped_after_window = False stopped_after_window = False
for page in range(1, max_pages + 1): for page in range(1, max_pages + 1):
try: try:
@@ -56,6 +73,27 @@ def run_backfill(*, server: dict[str, object], start_at: datetime, end_at: datet
continue continue
if ref_time and ref_time >= end_at: if ref_time and ref_time >= end_at:
continue 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")) match_id = _stringify(match.get("id"))
if match_id: if match_id:
ids.append(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")) counters["player_rows_updated"] += _coerce_int(delta.get("player_rows_updated"))
if stopped_after_window: if stopped_after_window:
break 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: def build_arg_parser() -> argparse.ArgumentParser:
@@ -137,6 +175,65 @@ def _pick_match_timestamp(match: dict[str, object]) -> object:
return None 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: def _stringify(value: object) -> str | None:
if value is None: if value is None:
return None return None
@@ -151,5 +248,12 @@ def _coerce_int(value: object) -> int:
return 0 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__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())

View File

@@ -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())

View File

@@ -79,3 +79,19 @@ def resolve_trusted_scoreboard_match_url(
if candidate_parts.params or candidate_parts.query or candidate_parts.fragment: if candidate_parts.params or candidate_parts.query or candidate_parts.fragment:
return None return None
return candidate 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,
)

View File

@@ -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()

View File

@@ -7,7 +7,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Iterable, Mapping 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 from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer
@@ -90,10 +90,19 @@ def persist_snapshot_batch(
db_path: Path | None = None, db_path: Path | None = None,
) -> dict[str, object]: ) -> dict[str, object]:
"""Persist a batch of normalized snapshots into local SQLite storage.""" """Persist a batch of normalized snapshots into local SQLite storage."""
resolved_path = initialize_storage(db_path=db_path)
source_definition = dict(DEFAULT_GAME_SOURCE) source_definition = dict(DEFAULT_GAME_SOURCE)
if game_source is not None: if game_source is not None:
source_definition.update(game_source) 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 persisted = 0
with _connect(resolved_path) as connection: 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]]: def list_latest_snapshots(*, db_path: Path | None = None) -> list[dict[str, object]]:
"""Return the latest persisted snapshot for each known server.""" """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) resolved_path = resolve_storage_path(db_path=db_path)
if not resolved_path.exists(): if not resolved_path.exists():
return [] return []
@@ -190,6 +203,10 @@ def list_snapshot_history(
limit: int = 20, limit: int = 20,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""Return recent persisted snapshots across all servers.""" """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) resolved_path = resolve_storage_path(db_path=db_path)
if not resolved_path.exists(): if not resolved_path.exists():
return [] return []
@@ -230,6 +247,10 @@ def list_server_history(
limit: int = 20, limit: int = 20,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""Return recent history for one server by numeric id or external id.""" """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) resolved_path = resolve_storage_path(db_path=db_path)
if not resolved_path.exists(): if not resolved_path.exists():
return [] return []

View File

@@ -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()

View File

@@ -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

View File

@@ -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"]

View File

@@ -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()

View File

@@ -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",
},
)

View File

@@ -1,9 +1,12 @@
import gc import gc
import json import json
import sqlite3 import sqlite3
from datetime import datetime, timezone
from unittest.mock import patch
from app.rcon_admin_log_storage import ( from app.rcon_admin_log_storage import (
initialize_rcon_admin_log_storage, initialize_rcon_admin_log_storage,
list_current_match_kill_feed,
list_rcon_admin_log_event_counts, list_rcon_admin_log_event_counts,
persist_rcon_admin_log_entries, 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() 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()

View File

@@ -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()

View File

@@ -74,8 +74,18 @@ class RconMaterializationPipelineTests(unittest.TestCase):
self.assertEqual(detail["result_source"], "admin-log-match-ended") self.assertEqual(detail["result_source"], "admin-log-match-ended")
self.assertEqual(detail["result"]["allied_score"], 5) self.assertEqual(detail["result"]["allied_score"], 5)
self.assertEqual(detail["timestamp_confidence"], "absolute") self.assertEqual(detail["timestamp_confidence"], "absolute")
self.assertNotIn("player_id", detail["players"][0]) players = {row["player_name"]: row for row in detail["players"]}
self.assertIn("kd_ratio", detail["players"][0]) 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() gc.collect()
def test_match_detail_marks_equal_materialized_timestamps_as_server_time_only(self) -> None: 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") self.assertNotEqual(payload["data"]["selected_source"], "public-scoreboard")
gc.collect() 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: def test_public_scoreboard_fallback_used_only_without_rcon_activity(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "historical.sqlite3" db_path = Path(tmpdir) / "historical.sqlite3"

View File

@@ -8,7 +8,9 @@ import sqlite3
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from app.scoreboard_candidate_backfill import run_backfill
from app.historical_storage import ( from app.historical_storage import (
get_historical_match_detail, get_historical_match_detail,
initialize_historical_storage, 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 persist_rcon_historical_sample
from app.rcon_historical_storage import start_rcon_historical_capture_run 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_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): 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: def test_recent_and_detail_payloads_expose_safe_persisted_match_url(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "historical.sqlite3" db_path = Path(tmpdir) / "historical.sqlite3"
@@ -70,6 +143,40 @@ class PersistedScoreboardMatchLinkTests(unittest.TestCase):
self.assertIsNone(detail["match_url"]) self.assertIsNone(detail["match_url"])
gc.collect() 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: def test_rcon_match_detail_does_not_fabricate_external_scoreboard_url(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "historical.sqlite3" db_path = Path(tmpdir) / "historical.sqlite3"
@@ -167,6 +274,70 @@ class PersistedScoreboardMatchLinkTests(unittest.TestCase):
self.assertIsNone(detail["match_url"]) self.assertIsNone(detail["match_url"])
gc.collect() 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( def _persist_match(
db_path: Path, db_path: Path,
@@ -176,6 +347,7 @@ def _persist_match(
map_name: str = "carentan", map_name: str = "carentan",
started_at: str = "2026-05-01T10:00:00Z", started_at: str = "2026-05-01T10:00:00Z",
ended_at: str = "2026-05-01T11:20:00Z", ended_at: str = "2026-05-01T11:20:00Z",
player_stats: list[dict[str, object]] | None = None,
) -> None: ) -> None:
upsert_historical_match( upsert_historical_match(
server_slug=server_slug, server_slug=server_slug,
@@ -186,12 +358,29 @@ def _persist_match(
"end": ended_at, "end": ended_at,
"map": {"name": map_name}, "map": {"name": map_name},
"result": {"allied": 3, "axis": 2}, "result": {"allied": 3, "axis": 2},
"player_stats": [], "player_stats": player_stats or [],
}, },
db_path=db_path, 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( def _persist_rcon_window(
db_path: Path, db_path: Path,
*, *,

View File

@@ -1,4 +1,22 @@
services: 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: backend:
build: build:
context: ./backend context: ./backend
@@ -6,6 +24,7 @@ services:
env_file: env_file:
- ./backend/.env.example - ./backend/.env.example
environment: 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_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon}
HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_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_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}]} ${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: ports:
- "8000:8000" - "8000:8000"
depends_on:
postgres:
condition: service_healthy
volumes: volumes:
- ./backend/data:/app/data - ./backend/data:/app/data
restart: unless-stopped restart: unless-stopped
@@ -27,13 +49,17 @@ services:
env_file: env_file:
- ./backend/.env.example - ./backend/.env.example
environment: 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_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon}
HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_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_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20}
HLL_BACKEND_RCON_TARGETS: >- 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}]} ${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: depends_on:
- backend postgres:
condition: service_healthy
backend:
condition: service_started
volumes: volumes:
- ./backend/data:/app/data - ./backend/data:/app/data
restart: unless-stopped restart: unless-stopped
@@ -48,6 +74,7 @@ services:
env_file: env_file:
- ./backend/.env.example - ./backend/.env.example
environment: 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_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon}
HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_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_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_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_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_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: depends_on:
- backend postgres:
condition: service_healthy
backend:
condition: service_started
volumes: volumes:
- ./backend/data:/app/data - ./backend/data:/app/data
restart: unless-stopped restart: unless-stopped
@@ -71,3 +102,6 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
restart: unless-stopped restart: unless-stopped
volumes:
postgres-data:

View File

@@ -177,3 +177,66 @@ son confiables `comunidad-hispana-01`, con origen
defecto nuevos. Los datos historicos ya persistidos no se eliminan, pero las 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), 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/`. 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -10,6 +10,407 @@
display: none !important; 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 { .historical-scoreboard-layout {
display: grid; display: grid;
gap: 18px; gap: 18px;
@@ -189,3 +590,163 @@
height: 96px; 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;
}

View File

@@ -88,6 +88,47 @@
object-fit: cover; 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 { .historical-content {
gap: 22px; gap: 22px;
} }
@@ -548,6 +589,304 @@
gap: 14px; 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 { .historical-comparison-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
@@ -733,6 +1072,11 @@
flex-direction: column; flex-direction: column;
} }
.current-match-player-intro {
align-items: flex-start;
flex-direction: column;
}
.historical-mvp-card__meta { .historical-mvp-card__meta {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -754,6 +1098,16 @@
justify-content: flex-start; justify-content: flex-start;
} }
.historical-pagination,
.historical-pagination__nav {
align-items: stretch;
flex-direction: column;
}
.historical-pagination__size {
justify-content: space-between;
}
.historical-tab { .historical-tab {
width: 100%; width: 100%;
} }
@@ -765,6 +1119,49 @@
.historical-table { .historical-table {
min-width: 540px; 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 { .historical-match-card--clean {
@@ -776,22 +1173,49 @@
} }
.historical-match-meta--clean { .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; 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 { .historical-match-card__actions-cell {
display: flex; display: flex;
align-items: end; align-items: end;
justify-content: flex-end; justify-content: flex-end;
min-width: 250px; min-width: 340px;
} }
.historical-match-card__actions-cell .historical-match-card__actions { .historical-match-card__actions-cell .historical-match-card__actions {
gap: 10px;
align-items: center; align-items: center;
justify-content: flex-end; 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) { @media (max-width: 920px) {
.historical-match-meta--clean { .historical-match-meta--clean {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
@@ -805,50 +1229,15 @@
.historical-match-card__actions-cell .historical-match-card__actions { .historical-match-card__actions-cell .historical-match-card__actions {
justify-content: flex-start; justify-content: flex-start;
} }
}
.historical-match-card--clean { #recent-matches-list .historical-match-card__actions {
gap: 12px; flex-wrap: wrap;
}
.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 {
justify-content: flex-start; justify-content: flex-start;
} }
} }
/* Hide public scoreboard action only in the recent matches list. /* Hide public scoreboard action only in the recent matches list.
The internal detail page can still show its own scoreboard button. */ 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; display: none;
} }

View File

@@ -617,7 +617,7 @@ h2 {
} }
.server-card__top--stats { .server-card__top--stats {
align-items: stretch; align-items: flex-start;
gap: 18px; gap: 18px;
} }
@@ -669,8 +669,11 @@ h2 {
.server-card__actions { .server-card__actions {
margin: 0; margin: 0;
width: 100%;
display: flex; display: flex;
align-items: flex-end;
align-self: end;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end; justify-content: flex-end;
} }
@@ -678,7 +681,7 @@ h2 {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: auto;
max-width: 100%; max-width: 100%;
min-height: 42px; min-height: 42px;
min-width: 136px; min-width: 136px;
@@ -740,6 +743,14 @@ h2 {
gap: 12px; 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 { .server-card__quickfact {
min-width: 0; min-width: 0;
padding: 12px 14px; padding: 12px 14px;
@@ -1017,13 +1028,17 @@ h2 {
} }
.server-card__actions { .server-card__actions {
justify-content: start; justify-content: flex-start;
} }
.server-card__quickfacts { .server-card__quickfacts {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.server-card__bottom {
grid-template-columns: 1fr;
}
.clan-card__brand { .clan-card__brand {
grid-template-columns: 1fr; grid-template-columns: 1fr;
justify-items: start; justify-items: start;

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Some files were not shown because too many files have changed in this diff Show More