Merge branch 'codex/task-133-polish-rcon-match-detail'

This commit is contained in:
devRaGonSa
2026-05-19 15:51:13 +02:00
19 changed files with 1099 additions and 20 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -823,7 +823,7 @@ function renderRecentMatchCard(item) {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
Ver scoreboard Ver partida
</a> </a>
` `
: "", : "",

View 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