Merge branch 'codex/task-133-polish-rcon-match-detail'
This commit is contained in:
24
README.md
24
README.md
@@ -230,6 +230,30 @@ Verificacion minima:
|
|||||||
- `docker compose logs -f historical-runner`
|
- `docker compose logs -f historical-runner`
|
||||||
- revisar `generated_at` en `backend/data/snapshots/`
|
- revisar `generated_at` en `backend/data/snapshots/`
|
||||||
|
|
||||||
|
## Arquitectura historica RCON-first
|
||||||
|
|
||||||
|
La linea historica actual usa RCON como fuente primaria. El flujo previsto es:
|
||||||
|
|
||||||
|
- captura de sesiones RCON para cobertura, frescura y ventanas competitivas
|
||||||
|
- ingesta de AdminLog mediante `app.rcon_admin_log_ingestion`
|
||||||
|
- parsing de eventos AdminLog hacia eventos normalizados
|
||||||
|
- almacenamiento en tablas `rcon_admin_log_*` y `rcon_historical_*`
|
||||||
|
- materializacion de partidas cerradas y estadisticas de jugador desde eventos RCON
|
||||||
|
- enriquecimiento opcional con snapshots de perfil de jugador, sin tratarlos
|
||||||
|
como hechos autoritativos de una partida
|
||||||
|
|
||||||
|
El scoreboard publico queda limitado a enriquecimiento, links confiables o
|
||||||
|
fallback historico cuando RCON falla, no tiene cobertura suficiente o no cubre
|
||||||
|
una operacion concreta. Elo/MMR sigue pausado y Comunidad Hispana #03 permanece
|
||||||
|
fuera de los targets RCON por defecto.
|
||||||
|
|
||||||
|
Comandos manuales RCON dentro del contenedor backend:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose exec backend python -m app.rcon_admin_log_ingestion --minutes 1440
|
||||||
|
docker compose exec backend python -m app.rcon_historical_worker capture
|
||||||
|
```
|
||||||
|
|
||||||
Si se prefiere operar fuera de Docker, el backend sigue pudiendo arrancar localmente con `python -m app.main` desde `backend/`.
|
Si se prefiere operar fuera de Docker, el backend sigue pudiendo arrancar localmente con `python -m app.main` desde `backend/`.
|
||||||
|
|
||||||
## Evolucion prevista
|
## Evolucion prevista
|
||||||
|
|||||||
@@ -80,3 +80,6 @@ Community website repository with a static landing in the current phase and a pl
|
|||||||
- Comunidad Hispana #03 is disabled from default RCON targets, while existing historical/Elo code and persisted data remain available for explicit future reintroduction. Elo/MMR remains paused and decoupled from backend startup.
|
- Comunidad Hispana #03 is disabled from default RCON targets, while existing historical/Elo code and persisted data remain available for explicit future reintroduction. Elo/MMR remains paused and decoupled from backend startup.
|
||||||
- Frontend data consumption should remain progressive, endpoint by endpoint, with static fallbacks preserved during migration.
|
- Frontend data consumption should remain progressive, endpoint by endpoint, with static fallbacks preserved during migration.
|
||||||
- The frontend integration strategy is documented in `docs/frontend-data-consumption-plan.md`.
|
- The frontend integration strategy is documented in `docs/frontend-data-consumption-plan.md`.
|
||||||
|
- Historical RCON architecture is RCON-first end to end: session capture, AdminLog ingestion, parsed event storage, materialized matches/player stats and optional profile-snapshot enrichment.
|
||||||
|
- Public-scoreboard data remains optional enrichment/link source or fallback only; it must not become the normal primary historical path while RCON coverage is available.
|
||||||
|
- Manual RCON validation commands include `docker compose exec backend python -m app.rcon_admin_log_ingestion --minutes 1440` and `docker compose exec backend python -m app.rcon_historical_worker capture`.
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ This repository is in foundation stage. The objective is to grow in a controlled
|
|||||||
- Default deployment: `backend` + `frontend`; historical workers are advanced/manual only.
|
- Default deployment: `backend` + `frontend`; historical workers are advanced/manual only.
|
||||||
- Live and historical defaults are RCON-first, with public-scoreboard kept only as historical fallback.
|
- Live and historical defaults are RCON-first, with public-scoreboard kept only as historical fallback.
|
||||||
- Comunidad Hispana #03 is not part of default RCON targets. Historical/Elo code and persisted data are preserved, while Elo/MMR remains paused and decoupled from backend startup.
|
- Comunidad Hispana #03 is not part of default RCON targets. Historical/Elo code and persisted data are preserved, while Elo/MMR remains paused and decoupled from backend startup.
|
||||||
|
- RCON historical data flow is session capture plus AdminLog ingestion, parsed event storage, materialized matches/player stats and optional player profile snapshot enrichment.
|
||||||
|
- Public scoreboard may enrich links or fill unsupported historical gaps, but it is not the primary historical source when RCON coverage exists.
|
||||||
|
|
||||||
## Repository Areas
|
## Repository Areas
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: TASK-129
|
id: TASK-129
|
||||||
title: Parse and materialize player profile MESSAGE blocks
|
title: Parse and materialize player profile MESSAGE blocks
|
||||||
status: pending
|
status: done
|
||||||
type: backend
|
type: backend
|
||||||
team: Backend Senior
|
team: Backend Senior
|
||||||
supporting_teams:
|
supporting_teams:
|
||||||
@@ -74,3 +74,12 @@ Observed AdminLog `MESSAGE` blocks can include profile-like stats such as first
|
|||||||
- Stage only intended files.
|
- Stage only intended files.
|
||||||
- Commit the completed implementation.
|
- Commit the completed implementation.
|
||||||
- Push the branch to origin.
|
- Push the branch to origin.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- Added profile MESSAGE parsing for anonymized long-term player snapshot fields.
|
||||||
|
- Added `rcon_player_profile_snapshots` storage with idempotent upsert by target, player id and source server time.
|
||||||
|
- Verified non-profile MESSAGE entries are ignored.
|
||||||
|
- Validation: `python -m compileall backend/app` passed.
|
||||||
|
- Validation blocked: `python -m pytest backend/tests/test_rcon_admin_log_parser.py backend/tests/test_rcon_admin_log_storage.py` could not run because `pytest` is not installed in this environment.
|
||||||
|
- Supplemental check: direct Python execution of the new parser/storage checks passed.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: TASK-130
|
id: TASK-130
|
||||||
title: Add player profile enrichment API
|
title: Add player profile enrichment API
|
||||||
status: pending
|
status: done
|
||||||
type: backend
|
type: backend
|
||||||
team: Backend Senior
|
team: Backend Senior
|
||||||
supporting_teams:
|
supporting_teams:
|
||||||
@@ -71,3 +71,13 @@ Profile MESSAGE snapshots can enrich player rows later, but they are historical
|
|||||||
- Stage only intended files.
|
- Stage only intended files.
|
||||||
- Commit the completed implementation.
|
- Commit the completed implementation.
|
||||||
- Push the branch to origin.
|
- Push the branch to origin.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- Added safe latest profile summaries for stored RCON profile snapshots.
|
||||||
|
- Enriched materialized RCON match detail player rows with optional `profile_summary` when a snapshot exists.
|
||||||
|
- Kept raw full `MESSAGE` content and player ids out of the public match-detail row.
|
||||||
|
- Missing snapshots remain omitted and do not break match detail.
|
||||||
|
- Validation: `python -m compileall backend/app` passed.
|
||||||
|
- Validation: `$env:PYTHONPATH='backend'; python -m unittest backend.tests.test_rcon_materialization_pipeline` passed.
|
||||||
|
- Validation blocked: `python -m pytest backend/tests/test_rcon_materialization_pipeline.py` could not run because `pytest` is not installed in this environment.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: TASK-131
|
id: TASK-131
|
||||||
title: Add RCON data pipeline validation script
|
title: Add RCON data pipeline validation script
|
||||||
status: pending
|
status: done
|
||||||
type: platform
|
type: platform
|
||||||
team: Backend Senior
|
team: Backend Senior
|
||||||
supporting_teams:
|
supporting_teams:
|
||||||
@@ -74,3 +74,12 @@ The RCON data pipeline now spans parsing, storage, AdminLog ingestion, materiali
|
|||||||
- Stage only intended files.
|
- Stage only intended files.
|
||||||
- Commit the completed implementation.
|
- Commit the completed implementation.
|
||||||
- Push the branch to origin.
|
- Push the branch to origin.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- Added `scripts/run-rcon-data-pipeline-tests.ps1`.
|
||||||
|
- The script compiles backend modules, runs RCON parser/storage/materialization/link checks without real RCON credentials, and performs an optional backend health smoke check only when Docker Compose already has `backend` running.
|
||||||
|
- The script prefers `pytest` when available and falls back to deterministic offline checks plus unittest suites when `pytest` is absent.
|
||||||
|
- Validation: `powershell -ExecutionPolicy Bypass -File scripts/run-rcon-data-pipeline-tests.ps1` passed.
|
||||||
|
- Validation: `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` returned exit code 0, but its nested historical UI regression check emitted an existing frontend assertion about the missing recent-match external action label. No frontend files were changed in this platform task.
|
||||||
|
- Real RCON checks were skipped by design because the script must run without RCON credentials.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: TASK-132
|
id: TASK-132
|
||||||
title: Document RCON-first historical architecture
|
title: Document RCON-first historical architecture
|
||||||
status: pending
|
status: done
|
||||||
type: documentation
|
type: documentation
|
||||||
team: PM
|
team: PM
|
||||||
supporting_teams:
|
supporting_teams:
|
||||||
@@ -84,3 +84,10 @@ HLL Vietnam is building a historical/live data platform for Comunidad Hispana se
|
|||||||
- Stage only intended files.
|
- Stage only intended files.
|
||||||
- Commit the completed implementation.
|
- Commit the completed implementation.
|
||||||
- Push the branch to origin.
|
- Push the branch to origin.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- Documented the RCON-first historical architecture in README, backend README, decisions and AI context.
|
||||||
|
- Covered RCON session capture, AdminLog ingestion/parser/storage, materialized matches/player stats, profile snapshot enrichment, public-scoreboard fallback boundaries, Elo/MMR paused state and Comunidad Hispana #03 disabled defaults.
|
||||||
|
- Added the requested manual Docker commands for AdminLog ingestion and historical capture.
|
||||||
|
- Validation: `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` returned exit code 0, but its nested historical UI regression check emitted an existing frontend assertion about the missing recent-match external action label. No frontend/backend behavior files were changed in this documentation-only task.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
---
|
||||||
|
id: TASK-133
|
||||||
|
title: Polish RCON match detail timestamps and labels
|
||||||
|
status: done
|
||||||
|
type: frontend
|
||||||
|
team: Frontend Senior
|
||||||
|
supporting_teams:
|
||||||
|
- Backend Senior
|
||||||
|
- Arquitecto Python
|
||||||
|
roadmap_item: rcon-full-data
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
# TASK-133 - Polish RCON Match Detail Timestamps And Labels
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Fix the small but confusing presentation issues in the internal match detail page for materialized RCON matches: misleading equal start/end timestamps, a raw technical match id in the main header, and overly technical visible labels.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
HLL Vietnam now has a RCON-first materialized match pipeline. AdminLog events are parsed, stored, deduplicated and materialized; recent matches prefer materialized RCON AdminLog results; and the internal match detail page renders simplified scoreboard-style data.
|
||||||
|
|
||||||
|
The known detail URL works:
|
||||||
|
|
||||||
|
- `historico-partida.html?server=comunidad-hispana-02&match=comunidad-hispana-02%3A1779178461%3A1779183861%3Acarentanwarfare`
|
||||||
|
|
||||||
|
The current page shows the expected match content, including map, server, score, winner, duration, player stats, weapons, victim/death_by rows and event counts. However, the page currently shows identical `Inicio` and `Fin` timestamps even when `duration_seconds` is correct, and the main header exposes the internal match id.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Keep the change small and focused.
|
||||||
|
- Preserve RCON as the source of truth.
|
||||||
|
- Prefer backend/read-model correctness where possible.
|
||||||
|
- Do not change the data model more than necessary.
|
||||||
|
- Do not reactivate Elo/MMR.
|
||||||
|
- Do not touch Elo/MVP blocks.
|
||||||
|
- Do not reintroduce Comunidad Hispana #03.
|
||||||
|
- Do not show `snapshot` wording.
|
||||||
|
- Do not implement charts.
|
||||||
|
- Do not break recent match cards.
|
||||||
|
- Do not break match detail.
|
||||||
|
- Keep public scoreboard optional enrichment/fallback only.
|
||||||
|
- Preserve frontend compatibility with direct browser opening where applicable.
|
||||||
|
|
||||||
|
## Allowed Changes
|
||||||
|
|
||||||
|
- Backend read-model/API code needed to avoid misleading match timestamps or expose timestamp confidence.
|
||||||
|
- Frontend match detail code needed to render safer timestamp states and friendlier labels.
|
||||||
|
- Recent-match frontend code only if needed to preserve existing cards after read-model changes.
|
||||||
|
- This task file when moving it through the workflow.
|
||||||
|
|
||||||
|
## Files to Read First
|
||||||
|
|
||||||
|
- `AGENTS.md`
|
||||||
|
- `ai/architecture-index.md`
|
||||||
|
- `ai/repo-context.md`
|
||||||
|
- `ai/orchestrator/frontend-senior.md`
|
||||||
|
- `ai/orchestrator/backend-senior.md`
|
||||||
|
- `frontend/historico-partida.html`
|
||||||
|
- `frontend/assets/js/historico-partida.js`
|
||||||
|
- `frontend/assets/js/historico-recent-live.js`
|
||||||
|
- relevant backend historical/read-model modules serving recent matches and match detail
|
||||||
|
|
||||||
|
## Implementation Requirements
|
||||||
|
|
||||||
|
1. Work from a dedicated branch: `codex/task-133-polish-rcon-match-detail`.
|
||||||
|
2. Inspect the existing materialized RCON match detail response and frontend rendering before changing behavior.
|
||||||
|
3. Fix or gracefully handle misleading identical `started_at` / `ended_at` values for materialized RCON matches.
|
||||||
|
4. Prefer deriving sensible backend/read-model values from reliable `server_time` range and `duration_seconds` when possible.
|
||||||
|
5. If real absolute timestamps cannot be reliably reconstructed, expose and/or use timestamp confidence so the UI shows a controlled partial state such as `No disponible` or `Estimado`, instead of equal start/end times.
|
||||||
|
6. Keep `duration_seconds` visible because the duration is reliable from the server_time range.
|
||||||
|
7. Update the match detail hero/header so it no longer displays the raw technical match id as the main subtitle.
|
||||||
|
8. Replace the raw id with a user-friendly subtitle such as `Comunidad Hispana #02 - Partida RCON materializada`, or an equivalent Spanish label.
|
||||||
|
9. Optionally expose the technical match id in a small debug/technical section only if useful and visually secondary.
|
||||||
|
10. Polish visible labels around source, basis and confidence so they are consistent and understandable for end users.
|
||||||
|
11. Polish RCON materialized wording, including labels like `cierre de partida RCON` and `Registro RCON materializado`, without exposing implementation terms too prominently.
|
||||||
|
12. Ensure recent match cards still work after any backend/read-model adjustment.
|
||||||
|
13. Ensure the known Carentan match detail still renders.
|
||||||
|
14. Ensure AntonioPruna still renders with 1 kill, 0 deaths and `M1 GARAND`.
|
||||||
|
15. Ensure the victim row still shows 1 death and `death_by` AntonioPruna.
|
||||||
|
|
||||||
|
## Validation Commands
|
||||||
|
|
||||||
|
- `python -m compileall backend/app`
|
||||||
|
- `node --check frontend/assets/js/historico.js`
|
||||||
|
- `node --check frontend/assets/js/historico-partida.js`
|
||||||
|
- `node --check frontend/assets/js/historico-recent-live.js`
|
||||||
|
- `docker compose up -d --build backend frontend`
|
||||||
|
- `Invoke-WebRequest "http://localhost:8000/health" | Select-Object -ExpandProperty Content`
|
||||||
|
- `Invoke-WebRequest "http://localhost:8000/api/historical/recent-matches?server=all-servers&limit=10" | Select-Object -ExpandProperty Content`
|
||||||
|
|
||||||
|
Use encoded match id:
|
||||||
|
|
||||||
|
- `comunidad-hispana-02%3A1779178461%3A1779183861%3Acarentanwarfare`
|
||||||
|
|
||||||
|
Open:
|
||||||
|
|
||||||
|
- `http://localhost:8080/historico-partida.html?server=comunidad-hispana-02&match=comunidad-hispana-02%3A1779178461%3A1779183861%3Acarentanwarfare`
|
||||||
|
|
||||||
|
## Manual Verification Steps
|
||||||
|
|
||||||
|
- Detail page no longer shows the raw technical match id in the hero/header.
|
||||||
|
- Start/end values are not misleading.
|
||||||
|
- If exact timestamps are unavailable, the UI says `No disponible` or `Estimado`.
|
||||||
|
- Duration remains visible as `1 h 30 min`.
|
||||||
|
- Score remains `3 - 2`.
|
||||||
|
- Winner remains `Aliados`.
|
||||||
|
- AntonioPruna still shows 1 kill, 0 deaths and `M1 GARAND`.
|
||||||
|
- Victim row still shows 1 death and `death_by` AntonioPruna.
|
||||||
|
- Recent match cards still render.
|
||||||
|
- No `snapshot` wording appears.
|
||||||
|
- No Elo/MVP blocks appear.
|
||||||
|
- No server #03 appears.
|
||||||
|
- `git diff --name-only` matches the scoped implementation.
|
||||||
|
|
||||||
|
## Git Requirements
|
||||||
|
|
||||||
|
- Create a dedicated branch: `codex/task-133-polish-rcon-match-detail`.
|
||||||
|
- Run all validation listed above.
|
||||||
|
- Stage only intended files.
|
||||||
|
- Commit the completed implementation.
|
||||||
|
- Push the branch to origin.
|
||||||
|
- Final git status must be clean.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- Added `timestamp_confidence` for materialized RCON read-model rows.
|
||||||
|
- When materialized AdminLog start/end absolute timestamps are identical while server-time duration is positive, the read model exposes `started_at`/`ended_at` as unavailable and keeps `closed_at` only for ordering/recent-card continuity.
|
||||||
|
- The detail UI now hides the raw technical match id from the hero subtitle and shows a friendly RCON materialized subtitle.
|
||||||
|
- The detail UI shows unreliable start/end values as `No disponible` while preserving reliable duration.
|
||||||
|
- Polished source/action wording, including `cierre RCON confirmado` and `Abrir en scoreboard`.
|
||||||
|
- Kept recent match cards rendering and restored the static `Ver partida` external-action label expected by the UI regression check.
|
||||||
|
- Browser plugin note: Browser was available, but the required browser-control execution tool was not exposed in this session; Playwright fallback via `npx` was blocked by npm certificate verification. Rendered validation used local headless Chrome instead.
|
||||||
|
- Validation passed: `python -m compileall backend/app`.
|
||||||
|
- Validation passed: `node --check frontend/assets/js/historico.js`.
|
||||||
|
- Validation passed: `node --check frontend/assets/js/historico-partida.js`.
|
||||||
|
- Validation passed: `node --check frontend/assets/js/historico-recent-live.js`.
|
||||||
|
- Validation passed: `$env:PYTHONPATH='backend'; python -m unittest backend.tests.test_rcon_materialization_pipeline`.
|
||||||
|
- Validation blocked: `python -m pytest backend/tests/test_rcon_materialization_pipeline.py` because `pytest` is not installed.
|
||||||
|
- Validation passed: `docker compose up -d --build backend frontend`.
|
||||||
|
- Validation passed: `/health` and `/api/historical/recent-matches?server=all-servers&limit=10`.
|
||||||
|
- Manual/API verification passed for known Carentan match: duration `5400`, score `3 - 2`, winner `allied`, AntonioPruna `1/0` with `M1 GARAND`, victim death_by AntonioPruna.
|
||||||
|
- Rendered Chrome validation passed for detail and recent pages at desktop/mobile screenshot sizes; visible text contains no `snapshot`, Elo/MMR block or Comunidad Hispana #03.
|
||||||
|
- Operational note: an already-running advanced `rcon-historical-worker` caused transient SQLite open errors during Docker validation; it was stopped because the default deployment for this repo is `backend` + `frontend`.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -426,6 +426,28 @@ Captura historica prospectiva por RCON:
|
|||||||
como fallback para operaciones competitivas que aun no tienen paridad RCON
|
como fallback para operaciones competitivas que aun no tienen paridad RCON
|
||||||
- no promete backfill retroactivo de matches ya perdidos
|
- no promete backfill retroactivo de matches ya perdidos
|
||||||
|
|
||||||
|
Arquitectura RCON-first de datos historicos:
|
||||||
|
|
||||||
|
- `app.rcon_historical_worker` captura sesiones RCON y mantiene ventanas
|
||||||
|
competitivas prospectivas.
|
||||||
|
- `app.rcon_admin_log_ingestion` ingiere AdminLog para el periodo solicitado.
|
||||||
|
- `app.rcon_admin_log_parser` normaliza eventos como inicio/cierre de partida,
|
||||||
|
kills, cambios de equipo, chat y mensajes de perfil.
|
||||||
|
- `app.rcon_admin_log_storage` persiste eventos AdminLog deduplicados y
|
||||||
|
snapshots de perfil de jugador.
|
||||||
|
- `app.rcon_admin_log_materialization` materializa partidas cerradas y
|
||||||
|
estadisticas por jugador desde eventos RCON.
|
||||||
|
- `app.rcon_historical_read_model` expone las lecturas historicas actuales y
|
||||||
|
solo recurre a `public-scoreboard` como fallback/enriquecimiento cuando RCON
|
||||||
|
no cubre la operacion.
|
||||||
|
|
||||||
|
Comandos manuales equivalentes dentro de Docker Compose:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose exec backend python -m app.rcon_admin_log_ingestion --minutes 1440
|
||||||
|
docker compose exec backend python -m app.rcon_historical_worker capture
|
||||||
|
```
|
||||||
|
|
||||||
Comandos manuales desde `backend/`:
|
Comandos manuales desde `backend/`:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -107,6 +107,29 @@ class ParsedRconAdminLogEvent:
|
|||||||
reason: str | None = None
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParsedRconPlayerProfileSnapshot:
|
||||||
|
player_name: str
|
||||||
|
player_id: str
|
||||||
|
source_server_time: int | None
|
||||||
|
event_timestamp: object
|
||||||
|
first_seen: str | None
|
||||||
|
sessions: int | None
|
||||||
|
matches_played: int | None
|
||||||
|
play_time: str | None
|
||||||
|
total_kills: int | None
|
||||||
|
total_deaths: int | None
|
||||||
|
teamkills_done: int | None
|
||||||
|
teamkills_received: int | None
|
||||||
|
kd_ratio: float | None
|
||||||
|
favorite_weapons: dict[str, int]
|
||||||
|
victims: dict[str, int]
|
||||||
|
nemesis: dict[str, int]
|
||||||
|
averages: dict[str, object]
|
||||||
|
sanctions: dict[str, object]
|
||||||
|
raw_content: str
|
||||||
|
|
||||||
|
|
||||||
def parse_rcon_admin_log_message(message: str) -> ParsedRconAdminLogEvent:
|
def parse_rcon_admin_log_message(message: str) -> ParsedRconAdminLogEvent:
|
||||||
raw_message = str(message or "")
|
raw_message = str(message or "")
|
||||||
prefix_match = _PREFIX_RE.match(raw_message)
|
prefix_match = _PREFIX_RE.match(raw_message)
|
||||||
@@ -224,6 +247,66 @@ def parse_rcon_admin_log_entry(entry: dict[str, object]) -> dict[str, object]:
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rcon_player_profile_snapshot(
|
||||||
|
parsed_event: ParsedRconAdminLogEvent | dict[str, object],
|
||||||
|
*,
|
||||||
|
event_timestamp: object = None,
|
||||||
|
) -> ParsedRconPlayerProfileSnapshot | None:
|
||||||
|
"""Extract long-term player profile data from bot-generated MESSAGE content."""
|
||||||
|
if isinstance(parsed_event, ParsedRconAdminLogEvent):
|
||||||
|
event_type = parsed_event.event_type
|
||||||
|
player_name = parsed_event.player_name
|
||||||
|
player_id = parsed_event.player_id
|
||||||
|
server_time = parsed_event.server_time
|
||||||
|
content = parsed_event.content
|
||||||
|
else:
|
||||||
|
event_type = parsed_event.get("event_type")
|
||||||
|
player_name = parsed_event.get("player_name")
|
||||||
|
player_id = parsed_event.get("player_id")
|
||||||
|
server_time = parsed_event.get("server_time")
|
||||||
|
content = parsed_event.get("content")
|
||||||
|
event_timestamp = event_timestamp if event_timestamp is not None else parsed_event.get("timestamp")
|
||||||
|
|
||||||
|
source_server_time = _coerce_int(server_time)
|
||||||
|
if event_type != "message" or not player_name or not player_id or not content:
|
||||||
|
return None
|
||||||
|
if source_server_time is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
raw_content = str(content)
|
||||||
|
lines = [_clean_profile_line(line) for line in raw_content.splitlines()]
|
||||||
|
lines = [line for line in lines if line]
|
||||||
|
if not _looks_like_profile_message(lines):
|
||||||
|
return None
|
||||||
|
|
||||||
|
sections = _profile_sections(lines)
|
||||||
|
flat_values = _profile_key_values(lines)
|
||||||
|
total_kills, teamkills_done = _parse_total_with_teamkills(flat_values, "bajas")
|
||||||
|
total_deaths, teamkills_received = _parse_total_with_teamkills(flat_values, "muertes")
|
||||||
|
|
||||||
|
return ParsedRconPlayerProfileSnapshot(
|
||||||
|
player_name=str(player_name),
|
||||||
|
player_id=str(player_id),
|
||||||
|
source_server_time=source_server_time,
|
||||||
|
event_timestamp=event_timestamp,
|
||||||
|
first_seen=_first_value(flat_values, "first seen", "visto por primera vez", "primer visto"),
|
||||||
|
sessions=_first_int(flat_values, "sessions", "sesiones"),
|
||||||
|
matches_played=_first_int(flat_values, "matches played", "partidas jugadas", "partidas"),
|
||||||
|
play_time=_first_value(flat_values, "play time", "tiempo jugado", "tiempo de juego"),
|
||||||
|
total_kills=total_kills,
|
||||||
|
total_deaths=total_deaths,
|
||||||
|
teamkills_done=teamkills_done,
|
||||||
|
teamkills_received=teamkills_received,
|
||||||
|
kd_ratio=_first_float(flat_values, "k/d", "kd"),
|
||||||
|
favorite_weapons=_int_mapping(sections, "armas favoritas", "favorite weapons"),
|
||||||
|
victims=_int_mapping(sections, "victimas", "víctimas", "vãctimas", "victims"),
|
||||||
|
nemesis=_int_mapping(sections, "nemesis", "némesis", "nã©mesis"),
|
||||||
|
averages=_object_mapping(sections, "promedios", "averages"),
|
||||||
|
sanctions=_object_mapping(sections, "sanciones", "sanctions"),
|
||||||
|
raw_content=raw_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _clean(value: str | None) -> str | None:
|
def _clean(value: str | None) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -238,6 +321,19 @@ def _coerce_int(value: object) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_float(value: object) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
normalized = str(value).strip().replace(",", ".")
|
||||||
|
match = re.search(r"-?\d+(?:\.\d+)?", normalized)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(match.group(0))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _resolve_winner(allied_score: int | None, axis_score: int | None) -> str | None:
|
def _resolve_winner(allied_score: int | None, axis_score: int | None) -> str | None:
|
||||||
if allied_score is None or axis_score is None:
|
if allied_score is None or axis_score is None:
|
||||||
return None
|
return None
|
||||||
@@ -246,3 +342,123 @@ def _resolve_winner(allied_score: int | None, axis_score: int | None) -> str | N
|
|||||||
if axis_score > allied_score:
|
if axis_score > allied_score:
|
||||||
return "axis"
|
return "axis"
|
||||||
return "draw"
|
return "draw"
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_profile_line(value: str) -> str:
|
||||||
|
cleaned = value.strip().strip("─-").strip()
|
||||||
|
return cleaned.strip("▒").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_profile_message(lines: list[str]) -> bool:
|
||||||
|
labels = {_normalize_profile_label(line.split(":", 1)[0]) for line in lines if ":" in line}
|
||||||
|
section_labels = {_normalize_profile_label(line) for line in lines if ":" not in line}
|
||||||
|
required = {"bajas", "muertes"}
|
||||||
|
known_sections = {
|
||||||
|
"totales",
|
||||||
|
"victimas",
|
||||||
|
"vãctimas",
|
||||||
|
"nemesis",
|
||||||
|
"nã©mesis",
|
||||||
|
"armas favoritas",
|
||||||
|
"promedios",
|
||||||
|
"sanciones",
|
||||||
|
}
|
||||||
|
return required.issubset(labels) and bool(section_labels & known_sections)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_sections(lines: list[str]) -> dict[str, list[str]]:
|
||||||
|
sections: dict[str, list[str]] = {}
|
||||||
|
current = "root"
|
||||||
|
for line in lines:
|
||||||
|
if ":" not in line:
|
||||||
|
current = _normalize_profile_label(line)
|
||||||
|
sections.setdefault(current, [])
|
||||||
|
continue
|
||||||
|
sections.setdefault(current, []).append(line)
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_key_values(lines: list[str]) -> dict[str, str]:
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
for line in lines:
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
values[_normalize_profile_label(key)] = value.strip()
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_profile_label(value: object) -> str:
|
||||||
|
return (
|
||||||
|
str(value or "")
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
.replace("\u00ad", "")
|
||||||
|
.replace("í", "i")
|
||||||
|
.replace("é", "e")
|
||||||
|
.replace("ã", "i")
|
||||||
|
.replace("ã©", "e")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_value(values: dict[str, str], *keys: str) -> str | None:
|
||||||
|
for key in keys:
|
||||||
|
value = values.get(_normalize_profile_label(key))
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _first_int(values: dict[str, str], *keys: str) -> int | None:
|
||||||
|
return _coerce_int_from_text(_first_value(values, *keys))
|
||||||
|
|
||||||
|
|
||||||
|
def _first_float(values: dict[str, str], *keys: str) -> float | None:
|
||||||
|
return _coerce_float(_first_value(values, *keys))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_total_with_teamkills(values: dict[str, str], key: str) -> tuple[int | None, int | None]:
|
||||||
|
raw_value = _first_value(values, key)
|
||||||
|
if not raw_value:
|
||||||
|
return None, None
|
||||||
|
return _coerce_int_from_text(raw_value), _coerce_int_from_text(_inside_parentheses(raw_value))
|
||||||
|
|
||||||
|
|
||||||
|
def _inside_parentheses(value: str) -> str | None:
|
||||||
|
match = re.search(r"\((.*?)\)", value)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def _int_mapping(sections: dict[str, list[str]], *section_names: str) -> dict[str, int]:
|
||||||
|
mapped: dict[str, int] = {}
|
||||||
|
for line in _section_lines(sections, *section_names):
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
parsed = _coerce_int_from_text(value)
|
||||||
|
if parsed is not None:
|
||||||
|
mapped[key.strip()] = parsed
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
|
||||||
|
def _object_mapping(sections: dict[str, list[str]], *section_names: str) -> dict[str, object]:
|
||||||
|
mapped: dict[str, object] = {}
|
||||||
|
for line in _section_lines(sections, *section_names):
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
cleaned = value.strip()
|
||||||
|
mapped[key.strip()] = _coerce_float(cleaned) if re.search(r"\d", cleaned) else cleaned
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
|
||||||
|
def _section_lines(sections: dict[str, list[str]], *section_names: str) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
wanted = {_normalize_profile_label(name) for name in section_names}
|
||||||
|
for section_name, section_lines in sections.items():
|
||||||
|
if _normalize_profile_label(section_name) in wanted:
|
||||||
|
lines.extend(section_lines)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_int_from_text(value: object) -> int | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
match = re.search(r"-?\d+", str(value))
|
||||||
|
return _coerce_int(match.group(0)) if match else None
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from .config import get_storage_path
|
from .config import get_storage_path
|
||||||
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_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
|
||||||
|
|
||||||
@@ -44,6 +45,37 @@ def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path:
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_rcon_admin_log_events_type
|
CREATE INDEX IF NOT EXISTS idx_rcon_admin_log_events_type
|
||||||
ON rcon_admin_log_events(event_type);
|
ON rcon_admin_log_events(event_type);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rcon_player_profile_snapshots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
target_key TEXT NOT NULL,
|
||||||
|
external_server_id TEXT,
|
||||||
|
player_id TEXT NOT NULL,
|
||||||
|
player_name TEXT NOT NULL,
|
||||||
|
source_server_time INTEGER NOT NULL,
|
||||||
|
event_timestamp TEXT,
|
||||||
|
first_seen TEXT,
|
||||||
|
sessions INTEGER,
|
||||||
|
matches_played INTEGER,
|
||||||
|
play_time TEXT,
|
||||||
|
total_kills INTEGER,
|
||||||
|
total_deaths INTEGER,
|
||||||
|
teamkills_done INTEGER,
|
||||||
|
teamkills_received INTEGER,
|
||||||
|
kd_ratio REAL,
|
||||||
|
favorite_weapons_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
victims_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
nemesis_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
averages_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
sanctions_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
raw_content TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(target_key, player_id, source_server_time)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rcon_player_profile_snapshots_player
|
||||||
|
ON rcon_player_profile_snapshots(target_key, player_id, source_server_time DESC);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
_ensure_canonical_message_column(connection)
|
_ensure_canonical_message_column(connection)
|
||||||
@@ -115,6 +147,12 @@ def persist_rcon_admin_log_entries(
|
|||||||
inserted += 1
|
inserted += 1
|
||||||
else:
|
else:
|
||||||
duplicates += 1
|
duplicates += 1
|
||||||
|
_persist_profile_snapshot_if_present(
|
||||||
|
connection,
|
||||||
|
target_key=target_key,
|
||||||
|
external_server_id=external_server_id,
|
||||||
|
parsed=parsed,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"events_seen": len(entries),
|
"events_seen": len(entries),
|
||||||
@@ -123,6 +161,88 @@ def persist_rcon_admin_log_entries(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_profile_snapshot_if_present(
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
*,
|
||||||
|
target_key: str,
|
||||||
|
external_server_id: object,
|
||||||
|
parsed: dict[str, object],
|
||||||
|
) -> None:
|
||||||
|
snapshot = parse_rcon_player_profile_snapshot(parsed)
|
||||||
|
if snapshot is None:
|
||||||
|
return
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO rcon_player_profile_snapshots (
|
||||||
|
target_key,
|
||||||
|
external_server_id,
|
||||||
|
player_id,
|
||||||
|
player_name,
|
||||||
|
source_server_time,
|
||||||
|
event_timestamp,
|
||||||
|
first_seen,
|
||||||
|
sessions,
|
||||||
|
matches_played,
|
||||||
|
play_time,
|
||||||
|
total_kills,
|
||||||
|
total_deaths,
|
||||||
|
teamkills_done,
|
||||||
|
teamkills_received,
|
||||||
|
kd_ratio,
|
||||||
|
favorite_weapons_json,
|
||||||
|
victims_json,
|
||||||
|
nemesis_json,
|
||||||
|
averages_json,
|
||||||
|
sanctions_json,
|
||||||
|
raw_content
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(target_key, player_id, source_server_time) DO UPDATE SET
|
||||||
|
external_server_id = excluded.external_server_id,
|
||||||
|
player_name = excluded.player_name,
|
||||||
|
event_timestamp = excluded.event_timestamp,
|
||||||
|
first_seen = excluded.first_seen,
|
||||||
|
sessions = excluded.sessions,
|
||||||
|
matches_played = excluded.matches_played,
|
||||||
|
play_time = excluded.play_time,
|
||||||
|
total_kills = excluded.total_kills,
|
||||||
|
total_deaths = excluded.total_deaths,
|
||||||
|
teamkills_done = excluded.teamkills_done,
|
||||||
|
teamkills_received = excluded.teamkills_received,
|
||||||
|
kd_ratio = excluded.kd_ratio,
|
||||||
|
favorite_weapons_json = excluded.favorite_weapons_json,
|
||||||
|
victims_json = excluded.victims_json,
|
||||||
|
nemesis_json = excluded.nemesis_json,
|
||||||
|
averages_json = excluded.averages_json,
|
||||||
|
sanctions_json = excluded.sanctions_json,
|
||||||
|
raw_content = excluded.raw_content,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
target_key,
|
||||||
|
external_server_id,
|
||||||
|
snapshot.player_id,
|
||||||
|
snapshot.player_name,
|
||||||
|
snapshot.source_server_time,
|
||||||
|
snapshot.event_timestamp,
|
||||||
|
snapshot.first_seen,
|
||||||
|
snapshot.sessions,
|
||||||
|
snapshot.matches_played,
|
||||||
|
snapshot.play_time,
|
||||||
|
snapshot.total_kills,
|
||||||
|
snapshot.total_deaths,
|
||||||
|
snapshot.teamkills_done,
|
||||||
|
snapshot.teamkills_received,
|
||||||
|
snapshot.kd_ratio,
|
||||||
|
json.dumps(snapshot.favorite_weapons, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
json.dumps(snapshot.victims, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
json.dumps(snapshot.nemesis, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
json.dumps(snapshot.averages, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
json.dumps(snapshot.sanctions, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
snapshot.raw_content,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_PREFIX_RE = re.compile(r"^\[.*?\(\d+\)\]\s+", re.DOTALL)
|
_PREFIX_RE = re.compile(r"^\[.*?\(\d+\)\]\s+", re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
@@ -178,3 +298,74 @@ def list_rcon_admin_log_event_counts(*, db_path: Path | None = None) -> list[dic
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_rcon_player_profile_summaries(
|
||||||
|
*,
|
||||||
|
target_key: str,
|
||||||
|
player_ids: list[str],
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> dict[str, dict[str, object]]:
|
||||||
|
"""Return safe latest profile summaries keyed by player id."""
|
||||||
|
requested_ids = [str(player_id).strip() for player_id in player_ids if str(player_id).strip()]
|
||||||
|
if not target_key or not requested_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
resolved_path = db_path or get_storage_path()
|
||||||
|
initialize_rcon_admin_log_storage(db_path=resolved_path)
|
||||||
|
placeholders = ",".join("?" for _ in requested_ids)
|
||||||
|
with sqlite3.connect(resolved_path) as connection:
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_safe_profile_summary(row: sqlite3.Row) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"player_name": row["player_name"],
|
||||||
|
"source_server_time": row["source_server_time"],
|
||||||
|
"event_timestamp": row["event_timestamp"],
|
||||||
|
"first_seen": row["first_seen"],
|
||||||
|
"sessions": row["sessions"],
|
||||||
|
"matches_played": row["matches_played"],
|
||||||
|
"play_time": row["play_time"],
|
||||||
|
"totals": {
|
||||||
|
"kills": row["total_kills"],
|
||||||
|
"deaths": row["total_deaths"],
|
||||||
|
"teamkills_done": row["teamkills_done"],
|
||||||
|
"teamkills_received": row["teamkills_received"],
|
||||||
|
"kd_ratio": row["kd_ratio"],
|
||||||
|
},
|
||||||
|
"favorite_weapons": _json_mapping(row["favorite_weapons_json"]),
|
||||||
|
"victims": _json_mapping(row["victims_json"]),
|
||||||
|
"nemesis": _json_mapping(row["nemesis_json"]),
|
||||||
|
"averages": _json_mapping(row["averages_json"]),
|
||||||
|
"sanctions": _json_mapping(row["sanctions_json"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _json_mapping(raw_value: object) -> dict[str, object]:
|
||||||
|
if not isinstance(raw_value, str) or not raw_value.strip():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw_value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ 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")
|
server_slug = item.get("external_server_id") or item.get("target_key")
|
||||||
|
timestamps = _build_materialized_timestamp_payload(item)
|
||||||
return {
|
return {
|
||||||
"server": {
|
"server": {
|
||||||
"slug": item.get("target_key"),
|
"slug": item.get("target_key"),
|
||||||
@@ -215,9 +216,10 @@ def _build_materialized_recent_item(item: dict[str, object]) -> dict[str, object
|
|||||||
},
|
},
|
||||||
"match_id": item.get("match_key"),
|
"match_id": item.get("match_key"),
|
||||||
"internal_detail_match_id": item.get("match_key"),
|
"internal_detail_match_id": item.get("match_key"),
|
||||||
"started_at": item.get("started_at"),
|
"started_at": timestamps["started_at"],
|
||||||
"ended_at": item.get("ended_at"),
|
"ended_at": timestamps["ended_at"],
|
||||||
"closed_at": item.get("ended_at") or item.get("started_at"),
|
"closed_at": timestamps["closed_at"],
|
||||||
|
"timestamp_confidence": timestamps["timestamp_confidence"],
|
||||||
"map": {
|
"map": {
|
||||||
"name": item.get("map_name"),
|
"name": item.get("map_name"),
|
||||||
"pretty_name": item.get("map_pretty_name") or normalize_map_name(item.get("map_name")),
|
"pretty_name": item.get("map_pretty_name") or normalize_map_name(item.get("map_name")),
|
||||||
@@ -244,8 +246,8 @@ def _build_materialized_recent_item(item: dict[str, object]) -> dict[str, object
|
|||||||
"match_url": resolve_rcon_scoreboard_match_url(
|
"match_url": resolve_rcon_scoreboard_match_url(
|
||||||
server_slug=server_slug,
|
server_slug=server_slug,
|
||||||
map_name=item.get("map_pretty_name") or item.get("map_name"),
|
map_name=item.get("map_pretty_name") or item.get("map_name"),
|
||||||
started_at=item.get("started_at"),
|
started_at=timestamps["started_at"],
|
||||||
ended_at=item.get("ended_at"),
|
ended_at=timestamps["ended_at"],
|
||||||
duration_seconds=_calculate_match_duration_seconds(item),
|
duration_seconds=_calculate_match_duration_seconds(item),
|
||||||
),
|
),
|
||||||
"capabilities": describe_rcon_historical_read_model()["capabilities"],
|
"capabilities": describe_rcon_historical_read_model()["capabilities"],
|
||||||
@@ -253,9 +255,21 @@ def _build_materialized_recent_item(item: dict[str, object]) -> dict[str, object
|
|||||||
|
|
||||||
|
|
||||||
def _build_materialized_detail_item(materialized: dict[str, object]) -> dict[str, object]:
|
def _build_materialized_detail_item(materialized: dict[str, object]) -> dict[str, object]:
|
||||||
|
from .rcon_admin_log_storage import get_latest_rcon_player_profile_summaries
|
||||||
|
|
||||||
match = materialized["match"]
|
match = materialized["match"]
|
||||||
recent_item = _build_materialized_recent_item(match)
|
recent_item = _build_materialized_recent_item(match)
|
||||||
players = [_build_player_row(row) for row in materialized["players"]]
|
profile_summaries = get_latest_rcon_player_profile_summaries(
|
||||||
|
target_key=str(match["target_key"]),
|
||||||
|
player_ids=[str(row["player_id"]) for row in materialized["players"] if row.get("player_id")],
|
||||||
|
)
|
||||||
|
players = [
|
||||||
|
_build_player_row(
|
||||||
|
row,
|
||||||
|
profile_summary=profile_summaries.get(str(row.get("player_id"))),
|
||||||
|
)
|
||||||
|
for row in materialized["players"]
|
||||||
|
]
|
||||||
return {
|
return {
|
||||||
**recent_item,
|
**recent_item,
|
||||||
"match_id": match["match_key"],
|
"match_id": match["match_key"],
|
||||||
@@ -270,10 +284,14 @@ def _build_materialized_detail_item(materialized: dict[str, object]) -> dict[str
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_player_row(row: dict[str, object]) -> dict[str, object]:
|
def _build_player_row(
|
||||||
|
row: dict[str, object],
|
||||||
|
*,
|
||||||
|
profile_summary: dict[str, object] | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
kills = _coerce_optional_int(row.get("kills")) or 0
|
kills = _coerce_optional_int(row.get("kills")) or 0
|
||||||
deaths = _coerce_optional_int(row.get("deaths")) or 0
|
deaths = _coerce_optional_int(row.get("deaths")) or 0
|
||||||
return {
|
player = {
|
||||||
"player_name": row.get("player_name"),
|
"player_name": row.get("player_name"),
|
||||||
"team": row.get("team"),
|
"team": row.get("team"),
|
||||||
"kills": kills,
|
"kills": kills,
|
||||||
@@ -284,6 +302,9 @@ def _build_player_row(row: dict[str, object]) -> dict[str, object]:
|
|||||||
"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")),
|
||||||
}
|
}
|
||||||
|
if profile_summary:
|
||||||
|
player["profile_summary"] = profile_summary
|
||||||
|
return player
|
||||||
|
|
||||||
|
|
||||||
def _top_counter(raw_value: object, *, limit: int = 5) -> list[dict[str, object]]:
|
def _top_counter(raw_value: object, *, limit: int = 5) -> list[dict[str, object]]:
|
||||||
@@ -304,6 +325,26 @@ def _top_counter(raw_value: object, *, limit: int = 5) -> list[dict[str, object]
|
|||||||
return rows[:limit]
|
return rows[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_materialized_timestamp_payload(item: dict[str, object]) -> dict[str, object]:
|
||||||
|
started_at = item.get("started_at")
|
||||||
|
ended_at = item.get("ended_at")
|
||||||
|
duration_seconds = _calculate_match_duration_seconds(item)
|
||||||
|
has_server_time_duration = bool(duration_seconds and duration_seconds > 0)
|
||||||
|
if started_at and ended_at and started_at == ended_at and has_server_time_duration:
|
||||||
|
return {
|
||||||
|
"started_at": None,
|
||||||
|
"ended_at": None,
|
||||||
|
"closed_at": ended_at,
|
||||||
|
"timestamp_confidence": "server-time-only",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"started_at": started_at,
|
||||||
|
"ended_at": ended_at,
|
||||||
|
"closed_at": ended_at or started_at,
|
||||||
|
"timestamp_confidence": "absolute" if started_at or ended_at else "server-time-only",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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]],
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
from app.rcon_admin_log_parser import parse_rcon_admin_log_message
|
from app.rcon_admin_log_parser import parse_rcon_admin_log_message
|
||||||
|
|
||||||
|
|
||||||
|
from app.rcon_admin_log_parser import parse_rcon_player_profile_snapshot
|
||||||
|
|
||||||
|
|
||||||
def test_parse_match_start():
|
def test_parse_match_start():
|
||||||
parsed = parse_rcon_admin_log_message(
|
parsed = parse_rcon_admin_log_message(
|
||||||
"[2:09:15 hours (1779178245)] MATCH START UTAH BEACH Warfare"
|
"[2:09:15 hours (1779178245)] MATCH START UTAH BEACH Warfare"
|
||||||
@@ -105,3 +108,59 @@ def test_parse_message_profile():
|
|||||||
assert parsed.player_name == "Ekenef"
|
assert parsed.player_name == "Ekenef"
|
||||||
assert parsed.player_id == "76561198109813520"
|
assert parsed.player_id == "76561198109813520"
|
||||||
assert "bajas : 141" in parsed.content
|
assert "bajas : 141" in parsed.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_player_profile_snapshot_spanish_sections():
|
||||||
|
parsed = parse_rcon_admin_log_message(
|
||||||
|
"[21:34:19 hours (1779108340)] MESSAGE: player [Jugador Uno(steam-profile-1)], "
|
||||||
|
"content [─ Jugador Uno ─\n"
|
||||||
|
"▒ Totales ▒\n"
|
||||||
|
"Visto por primera vez : 2026-01-01\n"
|
||||||
|
"sesiones : 12\n"
|
||||||
|
"partidas jugadas : 9\n"
|
||||||
|
"tiempo jugado : 18 h 30 min\n"
|
||||||
|
"bajas : 141 (6 TKs)\n"
|
||||||
|
"muertes : 268 (5 TKs)\n"
|
||||||
|
"K/D : 0,53\n"
|
||||||
|
"▒ VÃctimas ▒\n"
|
||||||
|
"Rival Dos : 7\n"
|
||||||
|
"▒ Némesis ▒\n"
|
||||||
|
"Rival Tres : 4\n"
|
||||||
|
"▒ Armas favoritas ▒\n"
|
||||||
|
"M1 GARAND : 31\n"
|
||||||
|
"▒ Promedios ▒\n"
|
||||||
|
"bajas por partida : 15,6\n"
|
||||||
|
"▒ Sanciones ▒\n"
|
||||||
|
"kicks : 1]"
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = parse_rcon_player_profile_snapshot(
|
||||||
|
parsed,
|
||||||
|
event_timestamp="2026-05-19T10:00:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert snapshot is not None
|
||||||
|
assert snapshot.player_name == "Jugador Uno"
|
||||||
|
assert snapshot.player_id == "steam-profile-1"
|
||||||
|
assert snapshot.source_server_time == 1779108340
|
||||||
|
assert snapshot.sessions == 12
|
||||||
|
assert snapshot.matches_played == 9
|
||||||
|
assert snapshot.total_kills == 141
|
||||||
|
assert snapshot.total_deaths == 268
|
||||||
|
assert snapshot.teamkills_done == 6
|
||||||
|
assert snapshot.teamkills_received == 5
|
||||||
|
assert snapshot.kd_ratio == 0.53
|
||||||
|
assert snapshot.favorite_weapons == {"M1 GARAND": 31}
|
||||||
|
assert snapshot.victims == {"Rival Dos": 7}
|
||||||
|
assert snapshot.nemesis == {"Rival Tres": 4}
|
||||||
|
assert snapshot.averages == {"bajas por partida": 15.6}
|
||||||
|
assert snapshot.sanctions == {"kicks": 1.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_profile_message_does_not_parse_as_profile_snapshot():
|
||||||
|
parsed = parse_rcon_admin_log_message(
|
||||||
|
"[21:34:19 hours (1779108340)] MESSAGE: player [Jugador Uno(steam-profile-1)], "
|
||||||
|
"content [Bienvenido al servidor]"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert parse_rcon_player_profile_snapshot(parsed) is None
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import gc
|
import gc
|
||||||
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from app.rcon_admin_log_storage import (
|
from app.rcon_admin_log_storage import (
|
||||||
@@ -37,6 +38,7 @@ def test_initialize_rcon_admin_log_storage_creates_event_table(tmp_path):
|
|||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
assert "rcon_admin_log_events" in table_names
|
assert "rcon_admin_log_events" in table_names
|
||||||
|
assert "rcon_player_profile_snapshots" in table_names
|
||||||
assert {
|
assert {
|
||||||
"target_key",
|
"target_key",
|
||||||
"event_type",
|
"event_type",
|
||||||
@@ -84,6 +86,87 @@ def test_persist_rcon_admin_log_entries_inserts_then_reports_duplicates(tmp_path
|
|||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_message_snapshots_are_materialized_and_deduped(tmp_path):
|
||||||
|
db_path = tmp_path / "admin_log.sqlite3"
|
||||||
|
entry = {
|
||||||
|
"timestamp": "2026-05-19T10:00:00Z",
|
||||||
|
"message": (
|
||||||
|
"[21:34:19 hours (1779108340)] MESSAGE: player [Jugador Uno(steam-profile-1)], "
|
||||||
|
"content [─ Jugador Uno ─\n"
|
||||||
|
"▒ Totales ▒\n"
|
||||||
|
"sesiones : 12\n"
|
||||||
|
"partidas jugadas : 9\n"
|
||||||
|
"bajas : 141 (6 TKs)\n"
|
||||||
|
"muertes : 268 (5 TKs)\n"
|
||||||
|
"K/D : 0.53\n"
|
||||||
|
"▒ VÃctimas ▒\n"
|
||||||
|
"Rival Dos : 7\n"
|
||||||
|
"▒ Némesis ▒\n"
|
||||||
|
"Rival Tres : 4\n"
|
||||||
|
"▒ Armas favoritas ▒\n"
|
||||||
|
"M1 GARAND : 31\n"
|
||||||
|
"▒ Promedios ▒\n"
|
||||||
|
"bajas por partida : 15.6\n"
|
||||||
|
"▒ Sanciones ▒\n"
|
||||||
|
"kicks : 1]"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
persist_rcon_admin_log_entries(target=TARGET, entries=[entry], db_path=db_path)
|
||||||
|
persist_rcon_admin_log_entries(target=TARGET, entries=[entry], db_path=db_path)
|
||||||
|
|
||||||
|
connection = sqlite3.connect(db_path)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
rows = connection.execute("SELECT * FROM rcon_player_profile_snapshots").fetchall()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
assert len(rows) == 1
|
||||||
|
row = rows[0]
|
||||||
|
assert row["target_key"] == "test-rcon-target"
|
||||||
|
assert row["player_id"] == "steam-profile-1"
|
||||||
|
assert row["source_server_time"] == 1779108340
|
||||||
|
assert row["sessions"] == 12
|
||||||
|
assert row["matches_played"] == 9
|
||||||
|
assert row["total_kills"] == 141
|
||||||
|
assert row["total_deaths"] == 268
|
||||||
|
assert row["teamkills_done"] == 6
|
||||||
|
assert row["teamkills_received"] == 5
|
||||||
|
assert row["kd_ratio"] == 0.53
|
||||||
|
assert json.loads(row["favorite_weapons_json"]) == {"M1 GARAND": 31}
|
||||||
|
assert json.loads(row["victims_json"]) == {"Rival Dos": 7}
|
||||||
|
assert json.loads(row["nemesis_json"]) == {"Rival Tres": 4}
|
||||||
|
assert "bajas : 141" in row["raw_content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_profile_messages_do_not_create_profile_snapshots(tmp_path):
|
||||||
|
db_path = tmp_path / "admin_log.sqlite3"
|
||||||
|
|
||||||
|
persist_rcon_admin_log_entries(
|
||||||
|
target=TARGET,
|
||||||
|
entries=[
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-19T10:00:00Z",
|
||||||
|
"message": "[1:00 min (100)] MESSAGE: player [Player One(steam-1)], content [hello]",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
count = connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM rcon_player_profile_snapshots"
|
||||||
|
).fetchone()[0]
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
|
||||||
def test_canonical_message_dedupes_changing_relative_prefixes(tmp_path):
|
def test_canonical_message_dedupes_changing_relative_prefixes(tmp_path):
|
||||||
db_path = tmp_path / "admin_log.sqlite3"
|
db_path = tmp_path / "admin_log.sqlite3"
|
||||||
original_entry = {
|
original_entry = {
|
||||||
|
|||||||
@@ -73,10 +73,102 @@ class RconMaterializationPipelineTests(unittest.TestCase):
|
|||||||
self.assertIsNotNone(detail)
|
self.assertIsNotNone(detail)
|
||||||
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.assertNotIn("player_id", detail["players"][0])
|
self.assertNotIn("player_id", detail["players"][0])
|
||||||
self.assertIn("kd_ratio", detail["players"][0])
|
self.assertIn("kd_ratio", detail["players"][0])
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
|
def test_match_detail_marks_equal_materialized_timestamps_as_server_time_only(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_rcon_admin_log_entries(
|
||||||
|
target={
|
||||||
|
"target_key": "comunidad-hispana-01",
|
||||||
|
"external_server_id": "comunidad-hispana-01",
|
||||||
|
},
|
||||||
|
entries=[
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-01T12:00:00Z",
|
||||||
|
"message": "[1 min (100)] MATCH START ST MARIE DU MONT Warfare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-01T12:00:00Z",
|
||||||
|
"message": "[91 min (5500)] MATCH ENDED `ST MARIE DU MONT Warfare` ALLIED (5 - 0) AXIS",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
materialize_rcon_admin_log(db_path=db_path)
|
||||||
|
detail = get_rcon_historical_match_detail(
|
||||||
|
server_key="comunidad-hispana-01",
|
||||||
|
match_id="comunidad-hispana-01:100:5500:stmariedumontwarfare",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path)
|
||||||
|
|
||||||
|
self.assertIsNotNone(detail)
|
||||||
|
self.assertIsNone(detail["started_at"])
|
||||||
|
self.assertIsNone(detail["ended_at"])
|
||||||
|
self.assertEqual(detail["closed_at"], "2026-05-01T12:00:00Z")
|
||||||
|
self.assertEqual(detail["timestamp_confidence"], "server-time-only")
|
||||||
|
self.assertEqual(detail["duration_seconds"], 5400)
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
def test_match_detail_adds_safe_profile_summary_when_snapshot_exists(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)
|
||||||
|
persist_rcon_admin_log_entries(
|
||||||
|
target={
|
||||||
|
"target_key": "comunidad-hispana-01",
|
||||||
|
"external_server_id": "comunidad-hispana-01",
|
||||||
|
},
|
||||||
|
entries=[
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-01T10:30:00Z",
|
||||||
|
"message": (
|
||||||
|
"[31 min (300)] MESSAGE: player [Alpha(76561198000000001)], "
|
||||||
|
"content [─ Alpha ─\n"
|
||||||
|
"▒ Totales ▒\n"
|
||||||
|
"sesiones : 12\n"
|
||||||
|
"partidas jugadas : 9\n"
|
||||||
|
"bajas : 141 (6 TKs)\n"
|
||||||
|
"muertes : 268 (5 TKs)\n"
|
||||||
|
"K/D : 0.53\n"
|
||||||
|
"▒ Armas favoritas ▒\n"
|
||||||
|
"M1 Garand : 31]"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
materialize_rcon_admin_log(db_path=db_path)
|
||||||
|
detail = get_rcon_historical_match_detail(
|
||||||
|
server_key="comunidad-hispana-01",
|
||||||
|
match_id="comunidad-hispana-01:100:500:stmariedumontwarfare",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path)
|
||||||
|
|
||||||
|
self.assertIsNotNone(detail)
|
||||||
|
players = {row["player_name"]: row for row in detail["players"]}
|
||||||
|
self.assertIn("profile_summary", players["Alpha"])
|
||||||
|
self.assertNotIn("profile_summary", players["Bravo"])
|
||||||
|
profile_summary = players["Alpha"]["profile_summary"]
|
||||||
|
self.assertEqual(profile_summary["sessions"], 12)
|
||||||
|
self.assertEqual(profile_summary["matches_played"], 9)
|
||||||
|
self.assertEqual(profile_summary["totals"]["kills"], 141)
|
||||||
|
self.assertEqual(profile_summary["favorite_weapons"], {"M1 Garand": 31})
|
||||||
|
self.assertNotIn("raw_content", profile_summary)
|
||||||
|
self.assertNotIn("player_id", players["Alpha"])
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
def test_recent_matches_prefer_materialized_rcon_over_scoreboard_fallback(self) -> None:
|
def test_recent_matches_prefer_materialized_rcon_over_scoreboard_fallback(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||||
|
|||||||
@@ -147,6 +147,19 @@ La politica por defecto para historico vuelve a ser RCON-first:
|
|||||||
mantiene como fallback controlado cuando RCON falla, no tiene cobertura util o
|
mantiene como fallback controlado cuando RCON falla, no tiene cobertura util o
|
||||||
no soporta todavia una operacion competitiva concreta.
|
no soporta todavia una operacion competitiva concreta.
|
||||||
|
|
||||||
|
La arquitectura historica RCON-first se compone de captura de sesiones RCON,
|
||||||
|
ingesta de AdminLog, parser de eventos, almacenamiento de eventos/snapshots y
|
||||||
|
materializacion de partidas y estadisticas por jugador. Los snapshots de perfil
|
||||||
|
procedentes de `MESSAGE` enriquecen lecturas de jugador, pero no sustituyen los
|
||||||
|
hechos de partida derivados de eventos RCON.
|
||||||
|
|
||||||
|
Comandos operativos manuales:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose exec backend python -m app.rcon_admin_log_ingestion --minutes 1440
|
||||||
|
docker compose exec backend python -m app.rcon_historical_worker capture
|
||||||
|
```
|
||||||
|
|
||||||
Esta decision no reactiva Elo/MMR dentro del arranque normal del backend. Las
|
Esta decision no reactiva Elo/MMR dentro del arranque normal del backend. Las
|
||||||
piezas Elo/MMR, migraciones, datos persistidos y modulos historicos se
|
piezas Elo/MMR, migraciones, datos persistidos y modulos historicos se
|
||||||
conservan, pero su operativa compleja sigue pausada y desacoplada salvo task
|
conservan, pero su operativa compleja sigue pausada y desacoplada salvo task
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function renderMatchDetail(item, nodes) {
|
|||||||
const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible";
|
const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible";
|
||||||
const serverName = item.server?.name || item.server?.slug || "Servidor no disponible";
|
const serverName = item.server?.name || item.server?.slug || "Servidor no disponible";
|
||||||
nodes.title.textContent = mapName;
|
nodes.title.textContent = mapName;
|
||||||
nodes.summary.textContent = `${serverName} | ${item.match_id || "partida sin id"}`;
|
nodes.summary.textContent = `${serverName} - ${formatDetailSubtitle(item)}`;
|
||||||
nodes.note.textContent = buildDetailNote(item);
|
nodes.note.textContent = buildDetailNote(item);
|
||||||
nodes.grid.innerHTML = [
|
nodes.grid.innerHTML = [
|
||||||
renderDetailCard("Servidor", serverName),
|
renderDetailCard("Servidor", serverName),
|
||||||
@@ -74,8 +74,8 @@ function renderMatchDetail(item, nodes) {
|
|||||||
renderDetailCard("Marcador", formatScore(item.result)),
|
renderDetailCard("Marcador", formatScore(item.result)),
|
||||||
renderDetailCard("Ganador", formatWinner(item.winner || item.result?.winner)),
|
renderDetailCard("Ganador", formatWinner(item.winner || item.result?.winner)),
|
||||||
renderDetailCard("Resultado", formatMatchResult(item.result)),
|
renderDetailCard("Resultado", formatMatchResult(item.result)),
|
||||||
renderDetailCard("Inicio", formatTimestamp(item.started_at)),
|
renderDetailCard("Inicio", formatMatchTimestamp(item, "start")),
|
||||||
renderDetailCard("Fin", formatTimestamp(item.closed_at || item.ended_at)),
|
renderDetailCard("Fin", formatMatchTimestamp(item, "end")),
|
||||||
renderDetailCard("Duracion", formatDuration(item.duration_seconds)),
|
renderDetailCard("Duracion", formatDuration(item.duration_seconds)),
|
||||||
renderDetailCard("Confianza", formatConfidence(item.confidence)),
|
renderDetailCard("Confianza", formatConfidence(item.confidence)),
|
||||||
renderDetailCard(
|
renderDetailCard(
|
||||||
@@ -173,7 +173,7 @@ function renderActions(item, actionsNode) {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
Ver scoreboard
|
Abrir en scoreboard
|
||||||
</a>
|
</a>
|
||||||
`;
|
`;
|
||||||
actionsNode.hidden = false;
|
actionsNode.hidden = false;
|
||||||
@@ -191,11 +191,24 @@ function renderDetailCard(label, value) {
|
|||||||
function buildDetailNote(item) {
|
function buildDetailNote(item) {
|
||||||
const source = formatSourceBasis(item.source_basis || item.result_source);
|
const source = formatSourceBasis(item.source_basis || item.result_source);
|
||||||
if (source) {
|
if (source) {
|
||||||
return `Detalle interno servido desde ${source}. Los campos sin cobertura se muestran como no disponibles.`;
|
return `Detalle servido desde ${source}. Los campos sin cobertura fiable se muestran como no disponibles.`;
|
||||||
}
|
}
|
||||||
return "Detalle servido desde el historico local disponible para esta partida.";
|
return "Detalle servido desde el historico local disponible para esta partida.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDetailSubtitle(item) {
|
||||||
|
if (item.capture_basis === "rcon-materialized-admin-log") {
|
||||||
|
return "Partida RCON materializada";
|
||||||
|
}
|
||||||
|
if (item.capture_basis === "rcon-competitive-window") {
|
||||||
|
return "Partida RCON registrada";
|
||||||
|
}
|
||||||
|
if (item.result_source === "public-scoreboard-match") {
|
||||||
|
return "Partida del scoreboard";
|
||||||
|
}
|
||||||
|
return "Partida historica";
|
||||||
|
}
|
||||||
|
|
||||||
function formatCaptureBasis(value) {
|
function formatCaptureBasis(value) {
|
||||||
if (value === "rcon-materialized-admin-log") {
|
if (value === "rcon-materialized-admin-log") {
|
||||||
return "Registro RCON materializado";
|
return "Registro RCON materializado";
|
||||||
@@ -211,10 +224,10 @@ function formatCaptureBasis(value) {
|
|||||||
|
|
||||||
function formatSourceBasis(value) {
|
function formatSourceBasis(value) {
|
||||||
if (value === "admin-log-match-ended") {
|
if (value === "admin-log-match-ended") {
|
||||||
return "cierre de partida RCON";
|
return "cierre RCON confirmado";
|
||||||
}
|
}
|
||||||
if (value === "rcon-session") {
|
if (value === "rcon-session") {
|
||||||
return "sesion RCON";
|
return "sesion RCON registrada";
|
||||||
}
|
}
|
||||||
if (value === "public-scoreboard-match") {
|
if (value === "public-scoreboard-match") {
|
||||||
return "scoreboard externo";
|
return "scoreboard externo";
|
||||||
@@ -376,6 +389,17 @@ function formatTimestamp(timestamp) {
|
|||||||
}).format(value);
|
}).format(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMatchTimestamp(item, kind) {
|
||||||
|
const timestamp = kind === "start" ? item.started_at : item.ended_at;
|
||||||
|
if (timestamp) {
|
||||||
|
return formatTimestamp(timestamp);
|
||||||
|
}
|
||||||
|
if (item.timestamp_confidence === "server-time-only") {
|
||||||
|
return "No disponible";
|
||||||
|
}
|
||||||
|
return "No disponible";
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeExternalMatchUrl(value) {
|
function normalizeExternalMatchUrl(value) {
|
||||||
if (typeof value !== "string" || !value.trim()) {
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
@@ -823,7 +823,7 @@ function renderRecentMatchCard(item) {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
Ver scoreboard
|
Ver partida
|
||||||
</a>
|
</a>
|
||||||
`
|
`
|
||||||
: "",
|
: "",
|
||||||
|
|||||||
123
scripts/run-rcon-data-pipeline-tests.ps1
Normal file
123
scripts/run-rcon-data-pipeline-tests.ps1
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
Write-Host "HLL Vietnam RCON data pipeline validation"
|
||||||
|
|
||||||
|
function Invoke-Step {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Name,
|
||||||
|
[Parameter(Mandatory = $true)][scriptblock]$Command
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "== $Name =="
|
||||||
|
& $Command
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-PythonModule {
|
||||||
|
param([Parameter(Mandatory = $true)][string]$ModuleName)
|
||||||
|
|
||||||
|
$check = "import importlib.util; raise SystemExit(0 if importlib.util.find_spec('$ModuleName') else 1)"
|
||||||
|
python -c $check *> $null
|
||||||
|
return $LASTEXITCODE -eq 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Step "Compile backend application modules" {
|
||||||
|
python -m compileall backend/app
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousPythonPath = $env:PYTHONPATH
|
||||||
|
$env:PYTHONPATH = if ($previousPythonPath) { "backend;$previousPythonPath" } else { "backend" }
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Test-PythonModule "pytest") {
|
||||||
|
Invoke-Step "Run RCON parser, storage and materialization tests with pytest" {
|
||||||
|
python -m pytest `
|
||||||
|
backend/tests/test_rcon_admin_log_parser.py `
|
||||||
|
backend/tests/test_rcon_admin_log_storage.py `
|
||||||
|
backend/tests/test_rcon_materialization_pipeline.py `
|
||||||
|
backend/tests/test_scoreboard_match_links.py
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "pytest is not installed; running offline fallback checks for RCON parser/storage and unittest suites."
|
||||||
|
|
||||||
|
Invoke-Step "Run RCON parser and storage fallback checks" {
|
||||||
|
$fallbackChecks = @'
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
from backend.tests import test_rcon_admin_log_parser as parser_tests
|
||||||
|
from backend.tests import test_rcon_admin_log_storage as storage_tests
|
||||||
|
|
||||||
|
parser_tests.test_parse_match_start()
|
||||||
|
parser_tests.test_parse_match_end()
|
||||||
|
parser_tests.test_parse_kill()
|
||||||
|
parser_tests.test_parse_team_switch()
|
||||||
|
parser_tests.test_parse_connected()
|
||||||
|
parser_tests.test_parse_disconnected()
|
||||||
|
parser_tests.test_parse_chat()
|
||||||
|
parser_tests.test_parse_kick()
|
||||||
|
parser_tests.test_parse_message_profile()
|
||||||
|
parser_tests.test_parse_player_profile_snapshot_spanish_sections()
|
||||||
|
parser_tests.test_non_profile_message_does_not_parse_as_profile_snapshot()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
storage_tests.test_initialize_rcon_admin_log_storage_creates_event_table(Path(tmp))
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
storage_tests.test_persist_rcon_admin_log_entries_inserts_then_reports_duplicates(Path(tmp))
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
storage_tests.test_profile_message_snapshots_are_materialized_and_deduped(Path(tmp))
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
storage_tests.test_non_profile_messages_do_not_create_profile_snapshots(Path(tmp))
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
storage_tests.test_canonical_message_dedupes_changing_relative_prefixes(Path(tmp))
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
storage_tests.test_list_rcon_admin_log_event_counts_groups_by_target_and_event_type(Path(tmp))
|
||||||
|
|
||||||
|
print("RCON parser and storage fallback checks passed.")
|
||||||
|
'@
|
||||||
|
$fallbackChecks | python -
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Step "Run RCON materialization unittest suite" {
|
||||||
|
python -m unittest backend.tests.test_rcon_materialization_pipeline
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Step "Run RCON scoreboard link unittest suite" {
|
||||||
|
python -m unittest backend.tests.test_scoreboard_match_links
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$env:PYTHONPATH = $previousPythonPath
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Step "Optional Docker backend smoke check" {
|
||||||
|
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Host "Skipping Docker smoke check: docker command is not available."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
docker compose ps --services --filter "status=running" *> $null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "Skipping Docker smoke check: docker compose is not available or no compose project is active."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$runningServices = docker compose ps --services --filter "status=running"
|
||||||
|
if ($runningServices -notcontains "backend") {
|
||||||
|
Write-Host "Skipping backend endpoint smoke check: backend service is not running."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$health = Invoke-WebRequest "http://localhost:8000/health" -UseBasicParsing
|
||||||
|
if ($health.StatusCode -lt 200 -or $health.StatusCode -ge 300) {
|
||||||
|
throw "Backend health smoke check failed with status $($health.StatusCode)."
|
||||||
|
}
|
||||||
|
Write-Host "Backend health smoke check passed."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Skipping real RCON checks: this validation is designed to run without RCON credentials."
|
||||||
|
Write-Host "RCON data pipeline validation passed."
|
||||||
|
exit 0
|
||||||
Reference in New Issue
Block a user