diff --git a/README.md b/README.md index e8b9453..2ac8dbc 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,30 @@ Verificacion minima: - `docker compose logs -f historical-runner` - 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/`. ## Evolucion prevista diff --git a/ai/architecture-index.md b/ai/architecture-index.md index 2b1f83c..507afb9 100644 --- a/ai/architecture-index.md +++ b/ai/architecture-index.md @@ -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. - 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`. +- 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`. diff --git a/ai/repo-context.md b/ai/repo-context.md index d7777d5..7d6e8ae 100644 --- a/ai/repo-context.md +++ b/ai/repo-context.md @@ -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. - 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. +- 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 diff --git a/ai/tasks/pending/TASK-129-parse-materialize-player-profile-message-blocks.md b/ai/tasks/done/TASK-129-parse-materialize-player-profile-message-blocks.md similarity index 82% rename from ai/tasks/pending/TASK-129-parse-materialize-player-profile-message-blocks.md rename to ai/tasks/done/TASK-129-parse-materialize-player-profile-message-blocks.md index 1e23b09..020497e 100644 --- a/ai/tasks/pending/TASK-129-parse-materialize-player-profile-message-blocks.md +++ b/ai/tasks/done/TASK-129-parse-materialize-player-profile-message-blocks.md @@ -1,7 +1,7 @@ --- id: TASK-129 title: Parse and materialize player profile MESSAGE blocks -status: pending +status: done type: backend team: Backend Senior supporting_teams: @@ -74,3 +74,12 @@ Observed AdminLog `MESSAGE` blocks can include profile-like stats such as first - Stage only intended files. - Commit the completed implementation. - 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. diff --git a/ai/tasks/pending/TASK-130-add-player-profile-enrichment-api.md b/ai/tasks/done/TASK-130-add-player-profile-enrichment-api.md similarity index 77% rename from ai/tasks/pending/TASK-130-add-player-profile-enrichment-api.md rename to ai/tasks/done/TASK-130-add-player-profile-enrichment-api.md index 9b0e674..286be56 100644 --- a/ai/tasks/pending/TASK-130-add-player-profile-enrichment-api.md +++ b/ai/tasks/done/TASK-130-add-player-profile-enrichment-api.md @@ -1,7 +1,7 @@ --- id: TASK-130 title: Add player profile enrichment API -status: pending +status: done type: backend team: Backend Senior supporting_teams: @@ -71,3 +71,13 @@ Profile MESSAGE snapshots can enrich player rows later, but they are historical - Stage only intended files. - Commit the completed implementation. - 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. diff --git a/ai/tasks/pending/TASK-131-add-rcon-data-pipeline-validation-script.md b/ai/tasks/done/TASK-131-add-rcon-data-pipeline-validation-script.md similarity index 72% rename from ai/tasks/pending/TASK-131-add-rcon-data-pipeline-validation-script.md rename to ai/tasks/done/TASK-131-add-rcon-data-pipeline-validation-script.md index 8399732..47598ea 100644 --- a/ai/tasks/pending/TASK-131-add-rcon-data-pipeline-validation-script.md +++ b/ai/tasks/done/TASK-131-add-rcon-data-pipeline-validation-script.md @@ -1,7 +1,7 @@ --- id: TASK-131 title: Add RCON data pipeline validation script -status: pending +status: done type: platform team: Backend Senior supporting_teams: @@ -74,3 +74,12 @@ The RCON data pipeline now spans parsing, storage, AdminLog ingestion, materiali - Stage only intended files. - Commit the completed implementation. - 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. diff --git a/ai/tasks/pending/TASK-132-document-rcon-first-historical-architecture.md b/ai/tasks/done/TASK-132-document-rcon-first-historical-architecture.md similarity index 77% rename from ai/tasks/pending/TASK-132-document-rcon-first-historical-architecture.md rename to ai/tasks/done/TASK-132-document-rcon-first-historical-architecture.md index 1250006..e0ce7c0 100644 --- a/ai/tasks/pending/TASK-132-document-rcon-first-historical-architecture.md +++ b/ai/tasks/done/TASK-132-document-rcon-first-historical-architecture.md @@ -1,7 +1,7 @@ --- id: TASK-132 title: Document RCON-first historical architecture -status: pending +status: done type: documentation team: PM supporting_teams: @@ -84,3 +84,10 @@ HLL Vietnam is building a historical/live data platform for Comunidad Hispana se - Stage only intended files. - Commit the completed implementation. - 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. diff --git a/ai/tasks/done/TASK-133-polish-rcon-match-detail-timestamps-and-labels.md b/ai/tasks/done/TASK-133-polish-rcon-match-detail-timestamps-and-labels.md new file mode 100644 index 0000000..ad2e1f5 --- /dev/null +++ b/ai/tasks/done/TASK-133-polish-rcon-match-detail-timestamps-and-labels.md @@ -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. diff --git a/backend/README.md b/backend/README.md index ab98277..adbcaac 100644 --- a/backend/README.md +++ b/backend/README.md @@ -426,6 +426,28 @@ Captura historica prospectiva por RCON: como fallback para operaciones competitivas que aun no tienen paridad RCON - 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/`: ```powershell diff --git a/backend/app/rcon_admin_log_parser.py b/backend/app/rcon_admin_log_parser.py index 6a5e433..e8e2c0c 100644 --- a/backend/app/rcon_admin_log_parser.py +++ b/backend/app/rcon_admin_log_parser.py @@ -107,6 +107,29 @@ class ParsedRconAdminLogEvent: 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: raw_message = str(message or "") 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 +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: if value is None: return None @@ -238,6 +321,19 @@ def _coerce_int(value: object) -> int | 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: if allied_score is None or axis_score is 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: return "axis" 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 diff --git a/backend/app/rcon_admin_log_storage.py b/backend/app/rcon_admin_log_storage.py index 459e95e..da57346 100644 --- a/backend/app/rcon_admin_log_storage.py +++ b/backend/app/rcon_admin_log_storage.py @@ -10,6 +10,7 @@ from pathlib import 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_player_profile_snapshot from .rcon_historical_storage import initialize_rcon_historical_storage 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 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) @@ -115,6 +147,12 @@ def persist_rcon_admin_log_entries( 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), @@ -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) @@ -178,3 +298,74 @@ def list_rcon_admin_log_event_counts(*, db_path: Path | None = None) -> list[dic ).fetchall() 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 {} diff --git a/backend/app/rcon_historical_read_model.py b/backend/app/rcon_historical_read_model.py index ade8515..abe17da 100644 --- a/backend/app/rcon_historical_read_model.py +++ b/backend/app/rcon_historical_read_model.py @@ -206,6 +206,7 @@ def get_rcon_historical_match_detail( def _build_materialized_recent_item(item: dict[str, object]) -> dict[str, object]: server_slug = item.get("external_server_id") or item.get("target_key") + timestamps = _build_materialized_timestamp_payload(item) return { "server": { "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"), "internal_detail_match_id": item.get("match_key"), - "started_at": item.get("started_at"), - "ended_at": item.get("ended_at"), - "closed_at": item.get("ended_at") or item.get("started_at"), + "started_at": timestamps["started_at"], + "ended_at": timestamps["ended_at"], + "closed_at": timestamps["closed_at"], + "timestamp_confidence": timestamps["timestamp_confidence"], "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( server_slug=server_slug, map_name=item.get("map_pretty_name") or item.get("map_name"), - started_at=item.get("started_at"), - ended_at=item.get("ended_at"), + started_at=timestamps["started_at"], + ended_at=timestamps["ended_at"], duration_seconds=_calculate_match_duration_seconds(item), ), "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]: + from .rcon_admin_log_storage import get_latest_rcon_player_profile_summaries + match = materialized["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 { **recent_item, "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 deaths = _coerce_optional_int(row.get("deaths")) or 0 - return { + player = { "player_name": row.get("player_name"), "team": row.get("team"), "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")), "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]]: @@ -304,6 +325,26 @@ def _top_counter(raw_value: object, *, limit: int = 5) -> list[dict[str, object] 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( primary_items: list[dict[str, object]], fallback_items: list[dict[str, object]], diff --git a/backend/tests/test_rcon_admin_log_parser.py b/backend/tests/test_rcon_admin_log_parser.py index a301df0..94176b6 100644 --- a/backend/tests/test_rcon_admin_log_parser.py +++ b/backend/tests/test_rcon_admin_log_parser.py @@ -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_player_profile_snapshot + + def test_parse_match_start(): parsed = parse_rcon_admin_log_message( "[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_id == "76561198109813520" 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 diff --git a/backend/tests/test_rcon_admin_log_storage.py b/backend/tests/test_rcon_admin_log_storage.py index 8648714..b59ff91 100644 --- a/backend/tests/test_rcon_admin_log_storage.py +++ b/backend/tests/test_rcon_admin_log_storage.py @@ -1,4 +1,5 @@ import gc +import json import sqlite3 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() assert "rcon_admin_log_events" in table_names + assert "rcon_player_profile_snapshots" in table_names assert { "target_key", "event_type", @@ -84,6 +86,87 @@ def test_persist_rcon_admin_log_entries_inserts_then_reports_duplicates(tmp_path 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): db_path = tmp_path / "admin_log.sqlite3" original_entry = { diff --git a/backend/tests/test_rcon_materialization_pipeline.py b/backend/tests/test_rcon_materialization_pipeline.py index cc39d72..2aff075 100644 --- a/backend/tests/test_rcon_materialization_pipeline.py +++ b/backend/tests/test_rcon_materialization_pipeline.py @@ -73,10 +73,102 @@ class RconMaterializationPipelineTests(unittest.TestCase): self.assertIsNotNone(detail) self.assertEqual(detail["result_source"], "admin-log-match-ended") self.assertEqual(detail["result"]["allied_score"], 5) + self.assertEqual(detail["timestamp_confidence"], "absolute") self.assertNotIn("player_id", detail["players"][0]) self.assertIn("kd_ratio", detail["players"][0]) 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: with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "historical.sqlite3" diff --git a/docs/decisions.md b/docs/decisions.md index e8f20f1..e93b019 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 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 piezas Elo/MMR, migraciones, datos persistidos y modulos historicos se conservan, pero su operativa compleja sigue pausada y desacoplada salvo task diff --git a/frontend/assets/js/historico-partida.js b/frontend/assets/js/historico-partida.js index 0c71b48..80dde3a 100644 --- a/frontend/assets/js/historico-partida.js +++ b/frontend/assets/js/historico-partida.js @@ -65,7 +65,7 @@ function renderMatchDetail(item, nodes) { const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible"; const serverName = item.server?.name || item.server?.slug || "Servidor no disponible"; 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.grid.innerHTML = [ renderDetailCard("Servidor", serverName), @@ -74,8 +74,8 @@ function renderMatchDetail(item, nodes) { renderDetailCard("Marcador", formatScore(item.result)), renderDetailCard("Ganador", formatWinner(item.winner || item.result?.winner)), renderDetailCard("Resultado", formatMatchResult(item.result)), - renderDetailCard("Inicio", formatTimestamp(item.started_at)), - renderDetailCard("Fin", formatTimestamp(item.closed_at || item.ended_at)), + renderDetailCard("Inicio", formatMatchTimestamp(item, "start")), + renderDetailCard("Fin", formatMatchTimestamp(item, "end")), renderDetailCard("Duracion", formatDuration(item.duration_seconds)), renderDetailCard("Confianza", formatConfidence(item.confidence)), renderDetailCard( @@ -173,7 +173,7 @@ function renderActions(item, actionsNode) { target="_blank" rel="noopener noreferrer" > - Ver scoreboard + Abrir en scoreboard `; actionsNode.hidden = false; @@ -191,11 +191,24 @@ function renderDetailCard(label, value) { function buildDetailNote(item) { const source = formatSourceBasis(item.source_basis || item.result_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."; } +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) { if (value === "rcon-materialized-admin-log") { return "Registro RCON materializado"; @@ -211,10 +224,10 @@ function formatCaptureBasis(value) { function formatSourceBasis(value) { if (value === "admin-log-match-ended") { - return "cierre de partida RCON"; + return "cierre RCON confirmado"; } if (value === "rcon-session") { - return "sesion RCON"; + return "sesion RCON registrada"; } if (value === "public-scoreboard-match") { return "scoreboard externo"; @@ -376,6 +389,17 @@ function formatTimestamp(timestamp) { }).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) { if (typeof value !== "string" || !value.trim()) { return ""; diff --git a/frontend/assets/js/historico.js b/frontend/assets/js/historico.js index bca4115..8aa2c5c 100644 --- a/frontend/assets/js/historico.js +++ b/frontend/assets/js/historico.js @@ -823,7 +823,7 @@ function renderRecentMatchCard(item) { target="_blank" rel="noopener noreferrer" > - Ver scoreboard + Ver partida ` : "", diff --git a/scripts/run-rcon-data-pipeline-tests.ps1 b/scripts/run-rcon-data-pipeline-tests.ps1 new file mode 100644 index 0000000..b770bfc --- /dev/null +++ b/scripts/run-rcon-data-pipeline-tests.ps1 @@ -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