# Backend

Esta carpeta contiene el bootstrap minimo del futuro backend principal en Python para HLL Vietnam.

## Objetivo en esta fase

- dejar un punto de entrada claro para la aplicacion
- validar que el backend puede arrancar localmente
- exponer rutas placeholder coherentes con el contrato frontend-backend

## Stack actual del bootstrap

- Python 3
- libreria estandar de Python (`http.server`, sin frameworks ni dependencias externas)

## Estructura minima

```text
backend/
|-- README.md
|-- requirements.txt
`-- app/
    |-- a2s_client.py
    |-- __init__.py
    |-- collector.py
    |-- main.py
    |-- historical_ingestion.py
    |-- historical_models.py
    |-- historical_runner.py
    |-- historical_storage.py
    |-- normalizers.py
    |-- payloads.py
    |-- routes.py
    |-- server_targets.py
    `-- snapshots.py
```

La persistencia local de desarrollo se crea bajo `backend/data/` cuando el
colector la necesita por primera vez.

`app` es el paquete Python del backend. El archivo correcto del paquete es
`backend/app/__init__.py`; no debe existir una variante `init.py`.

## Punto de entrada

El entrypoint real del backend es el modulo `app.main`, ubicado en
`backend/app/main.py`.

Desde la carpeta `backend/`, se puede arrancar localmente con:

```powershell
python -m app.main
```

Ese comando usa imports relativos de paquete (`from .routes import ...`), por lo
que la forma soportada de arranque es por modulo y no ejecutando el archivo como
script suelto.

Por defecto escuchara en `127.0.0.1:8000`.

Variables opcionales:

- `HLL_BACKEND_HOST`
- `HLL_BACKEND_PORT`
- `HLL_BACKEND_ALLOWED_ORIGINS`
- `HLL_BACKEND_REFRESH_INTERVAL_SECONDS`
- `HLL_BACKEND_LIVE_DATA_SOURCE`
- `HLL_BACKEND_HISTORICAL_DATA_SOURCE`
- `HLL_BACKEND_RCON_TIMEOUT_SECONDS`
- `HLL_BACKEND_RCON_TARGETS`
- `HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS`
- `HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES`
- `HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS`
- `HLL_RCON_BACKFILL_CHUNK_HOURS`
- `HLL_RCON_BACKFILL_SLEEP_SECONDS`
- `HLL_RCON_BACKFILL_MAX_DAYS_BACK`
- `HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES`
- `HLL_BACKEND_SQLITE_WRITER_TIMEOUT_SECONDS`
- `HLL_BACKEND_SQLITE_BUSY_TIMEOUT_MS`
- `HLL_BACKEND_WRITER_LOCK_TIMEOUT_SECONDS`
- `HLL_BACKEND_WRITER_LOCK_POLL_INTERVAL_SECONDS`
- `HLL_HISTORICAL_CRCON_PAGE_SIZE`
- `HLL_HISTORICAL_CRCON_TIMEOUT_SECONDS`
- `HLL_HISTORICAL_CRCON_DETAIL_WORKERS`
- `HLL_HISTORICAL_CRCON_REQUEST_RETRIES`
- `HLL_HISTORICAL_CRCON_RETRY_DELAY_SECONDS`
- `HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS`
- `HLL_HISTORICAL_REFRESH_OVERLAP_HOURS`
- `HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS`
- `HLL_HISTORICAL_FULL_SNAPSHOT_EVERY_RUNS`
- `HLL_HISTORICAL_REFRESH_MAX_RETRIES`
- `HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS`
- `HLL_BACKEND_SQLITE_WRITER_TIMEOUT_SECONDS`
- `HLL_BACKEND_SQLITE_BUSY_TIMEOUT_MS`
- `HLL_BACKEND_WRITER_LOCK_TIMEOUT_SECONDS`
- `HLL_BACKEND_WRITER_LOCK_POLL_INTERVAL_SECONDS`
- `HLL_HISTORICAL_WEEKLY_FALLBACK_MIN_MATCHES`
- `HLL_HISTORICAL_WEEKLY_FALLBACK_MAX_WEEKDAY`

Variables especialmente relevantes para Docker y Compose:

- `HLL_BACKEND_HOST`
- `HLL_BACKEND_PORT`
- `HLL_BACKEND_DATABASE_URL`
- `HLL_BACKEND_STORAGE_PATH`
- `HLL_BACKEND_ALLOWED_ORIGINS`
- `HLL_BACKEND_LIVE_DATA_SOURCE`
- `HLL_BACKEND_HISTORICAL_DATA_SOURCE`
- `HLL_BACKEND_RCON_TIMEOUT_SECONDS`
- `HLL_BACKEND_RCON_TARGETS`
- `HLL_HISTORICAL_CRCON_PAGE_SIZE`
- `HLL_HISTORICAL_CRCON_TIMEOUT_SECONDS`
- `HLL_HISTORICAL_CRCON_DETAIL_WORKERS`
- `HLL_HISTORICAL_CRCON_REQUEST_RETRIES`
- `HLL_HISTORICAL_CRCON_RETRY_DELAY_SECONDS`
- `HLL_HISTORICAL_REFRESH_OVERLAP_HOURS`
- `HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS`
- `HLL_HISTORICAL_FULL_SNAPSHOT_EVERY_RUNS`
- `HLL_HISTORICAL_REFRESH_MAX_RETRIES`
- `HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS`

Para ejecucion containerizada, el repositorio incluye tambien:

- `backend/Dockerfile`
- `backend/.dockerignore`
- `backend/.env.example`

El contenedor usa el mismo entrypoint real del proyecto:

```powershell
python -m app.main
```

Dentro del contenedor arranca por defecto con:

- `HLL_BACKEND_HOST=0.0.0.0`
- `HLL_BACKEND_PORT=8000`
- `HLL_BACKEND_STORAGE_PATH=/app/data/hll_vietnam_dev.sqlite3`

Compose configura ademas `HLL_BACKEND_DATABASE_URL` para que PostgreSQL sea el
almacenamiento autoritativo de la fase 1 RCON: muestras/ventanas de captura,
AdminLog, snapshots de perfil y partidas/estadisticas materializadas. Sin esa
variable, la ejecucion local mantiene fallback SQLite para esos dominios.

Diagnostico rapido del backend activo:

```powershell
python -m app.storage_diagnostics
```

La salida lista el backend RCON activo, counts de las tablas migradas, la ultima
partida materializada por servidor y que superficies siguen temporalmente en
SQLite en esta fase.

Build local:

```powershell
docker build -t hll-vietnam-backend ./backend
```

Ejecucion local con persistencia bind-mounted:

```powershell
docker run --rm `
  -p 8000:8000 `
  --env-file backend/.env.example `
  -v ${PWD}\backend\data:/app/data `
  hll-vietnam-backend
```

Si se prefiere no usar `--env-file`, el contenedor puede arrancar solo con sus
defaults para host, puerto y path de SQLite. El bind mount de `/app/data` sigue
siendo la forma recomendada de no perder persistencia al recrear el contenedor.

El `frontend/index.html` viene preparado para volver a consultar el bloque de
servidores cada `120000` ms (`120s`) sin recargar la pagina completa. La landing
lee ese valor desde `data-server-refresh-ms`, por lo que puede ajustarse en el
HTML si una demo local necesita un intervalo distinto.

Valor por defecto de `HLL_BACKEND_ALLOWED_ORIGINS`:

- `null`
- `http://127.0.0.1`
- `http://127.0.0.1:5500`
- `http://127.0.0.1:8080`
- `http://localhost`
- `http://localhost:5500`
- `http://localhost:8080`

Esto cubre el caso de abrir `frontend/index.html` directamente desde `file://`
y los puertos locales mas habituales cuando el frontend se sirve con un
servidor sencillo.

Prueba local recomendada para validar frontend y backend juntos:

1. En una terminal, desde `backend/`, arrancar el backend:

   ```powershell
   python -m app.main
   ```

2. En otra terminal, desde `frontend/`, servir la landing:

   ```powershell
   python -m http.server 8080
   ```

3. Abrir `http://localhost:8080`.

Si se necesita otra combinacion de origenes locales, puede sobrescribirse
`HLL_BACKEND_ALLOWED_ORIGINS` con una lista separada por comas. El backend
normaliza espacios y barras finales para mantener la comparacion con el header
`Origin` del navegador.

## Endpoints placeholder disponibles

- `GET /health`
- `GET /api/community`
- `GET /api/trailer`
- `GET /api/discord`
- `GET /api/servers`
- `GET /api/servers/latest`
- `GET /api/servers/history?limit=20`
- `GET /api/servers/{id}/history?limit=20`
- `GET /api/historical/weekly-top-kills?limit=10&server=comunidad-hispana-01`
- `GET /api/historical/weekly-leaderboard?metric=kills&limit=10&server=comunidad-hispana-01`
- `GET /api/historical/leaderboard?timeframe=monthly&metric=kills&limit=10&server=comunidad-hispana-01`
- `GET /api/historical/monthly-mvp?limit=10&server=comunidad-hispana-01`
- `GET /api/historical/monthly-mvp-v2?limit=10&server=comunidad-hispana-01`
- `GET /api/historical/player-events?view=most-killed&limit=10&server=comunidad-hispana-01`
- `GET /api/historical/recent-matches?limit=20&server=comunidad-hispana-01`
- `GET /api/historical/server-summary?server=comunidad-hispana-01`
- `GET /api/historical/snapshots/server-summary?server=comunidad-hispana-01`
- `GET /api/historical/snapshots/weekly-leaderboard?metric=kills&limit=10&server=comunidad-hispana-01`
- `GET /api/historical/snapshots/leaderboard?timeframe=monthly&metric=kills&limit=10&server=comunidad-hispana-01`
- `GET /api/historical/snapshots/monthly-mvp?limit=10&server=comunidad-hispana-01`
- `GET /api/historical/snapshots/monthly-mvp-v2?limit=10&server=comunidad-hispana-01`
- `GET /api/historical/snapshots/player-events?view=most-killed&limit=10&server=comunidad-hispana-01`
- `GET /api/historical/snapshots/recent-matches?limit=6&server=comunidad-hispana-01`
- `GET /api/historical/player-profile?player=steam%3A76561198000000000`

`GET /health` expone tambien:

- `live_data_source`
- `historical_data_source`

`GET /api/servers` trata el ultimo snapshot persistido como cache local y lo
reutiliza solo si sigue dentro del objetivo de `120` segundos. Si ese snapshot
esta vencido, el endpoint intenta primero una consulta RCON real inmediata
contra los targets configurados. Solo si RCON falla o no devuelve snapshots
utilizables, cae a A2S de forma controlada antes de responder.

La respuesta incluye metadata de frescura pensada para frontend:

- `last_snapshot_at`
- `snapshot_age_seconds`
- `snapshot_age_minutes`
- `max_snapshot_age_seconds`
- `is_stale`
- `freshness`
- `source`
- `refresh_attempted`
- `refresh_status`
- `primary_source`
- `selected_source`
- `fallback_used`
- `fallback_reason`
- `source_attempts`

Si la consulta real falla, `/api/servers` devuelve el ultimo snapshot valido
disponible marcado como stale. Si no existe ningun snapshot valido, responde
`items: []` en lugar de reintroducir servidores de respaldo ajenos a la
comunidad. Cada respuesta deja tambien trazabilidad de arbitraje de fuente para
que sea visible si se sirvio RCON directo o si hubo fallback a A2S.

Los endpoints historicos leen la persistencia local SQLite creada por el
colector. Si todavia no hay snapshots guardados, responden `status: "ok"` con
`items: []` para mantener un contrato simple en desarrollo.

## Seleccion de fuente de datos

El backend separa ahora la fuente de datos del contrato HTTP del producto.
Esto permite cambiar proveedores por entorno sin tocar `routes.py`, payloads de
UI ni el formato consumido por frontend.

Variables nuevas:

- `HLL_BACKEND_LIVE_DATA_SOURCE`
- `HLL_BACKEND_HISTORICAL_DATA_SOURCE`

Valores soportados en esta fase:

- live:
  - `rcon` como camino primario recomendado
  - `a2s` como fallback legacy o override explicito
- historico:
  - `rcon` como camino primario recomendado para captura y writer path primario
  - `public-scoreboard` como fallback legacy controlado

Defaults actuales:

- `HLL_BACKEND_LIVE_DATA_SOURCE=rcon`
- `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon`

La seleccion efectiva se resuelve en `app/data_sources.py` y en adapters
dedicados dentro de `app/providers/`:

- `get_live_data_source()` entrega el proveedor usado por `payloads.py`
  cuando `/api/servers` necesita un refresh real
- `get_historical_data_source()` entrega el proveedor usado por
  `historical_ingestion.py` para bootstrap y refresh incremental
- `providers/public_scoreboard_provider.py` encapsula la semantica actual del
  scoreboard/CRCON publico bajo el contrato historico
- `providers/rcon_provider.py` encapsula el proveedor live basado en comandos
  RCON HLL v2 mediante `ServerConnect`, `Login` y `GetServerInformation`

Proveedores operativos en esta fase:

- live `rcon`
- live `a2s`
- historico `rcon` solo para read model minimo y captura prospectiva
- historico `public-scoreboard` como fallback para cobertura competitiva sin paridad RCON

Politica funcional actual:

- live:
  - RCON primero
  - A2S solo si RCON falla o no devuelve snapshots utilizables
- historico/recopilacion:
  - RCON primero
  - `public-scoreboard` solo si RCON no soporta aun esa operacion concreta o
    falla la captura primaria

Estado real de "historico por RCON" en esta repo:

- no existe backfill retroactivo por RCON con el cliente actual
- la viabilidad documentada hoy es solo para captura prospectiva separada
- `historical_ingestion.py` intenta primero el writer path prospectivo RCON
- la persistencia competitiva `historical_*` sigue necesitando fallback a
  `public-scoreboard` mientras RCON no exponga pagina historica/detalle de
  match cerrada con paridad suficiente
- el diseno tecnico de esa linea prospectiva queda en
  `docs/rcon-historical-ingestion-design.md`

Variables especificas de RCON live:

- `HLL_BACKEND_RCON_TIMEOUT_SECONDS`
- `HLL_BACKEND_RCON_TARGETS`

Variables especificas de captura historica prospectiva RCON:

- `HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS`
- `HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES`
- `HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS`

`HLL_BACKEND_RCON_TARGETS` acepta un array JSON con:

- `name`
- `slug` opcional como alias legacy
- `host`
- `port`
- `password`
- `external_server_id` opcional
- `region` opcional
- `game_port` opcional
- `query_port` opcional
- `source_name` opcional

Compatibilidad operativa del loader:

- si llega `slug` pero no `external_server_id`, el backend reutiliza `slug`
  como `external_server_id`
- si falta `name` pero existe `slug`, el backend genera un nombre razonable a
  partir del slug en vez de dejar `Unnamed RCON target`
- los errores de validacion indican el campo que falta y las claves
  efectivamente recibidas

Timeout recomendado por defecto:

- `HLL_BACKEND_RCON_TIMEOUT_SECONDS=20`

Diagnostico operativo del cliente RCON:

- el cliente informa ahora el stage exacto del fallo cuando puede distinguirlo
- stages observables:
  - `tcp_connect`
  - `server_connect_request`
  - `server_connect_response`
  - `xor_key_decode`
  - `login_request`
  - `login_response`
  - `get_server_information_request`
  - `get_server_information_response`
  - `payload_decode`
  - `unexpected_response`
  - `timeout`
- esto mejora el diagnostico del protocolo, pero no resuelve por si solo la
  conectividad real si el servidor acepta TCP y luego no responde al handshake
  RCON o al comando `GetServerInformation`

Ejemplo:

```powershell
$env:HLL_BACKEND_RCON_TARGETS='[
  {
    "name": "Comunidad Hispana #01",
    "slug": "comunidad-hispana-01",
    "host": "152.114.195.174",
    "port": 7779,
    "password": "replace-me",
    "external_server_id": "comunidad-hispana-01",
    "region": "ES",
    "game_port": null,
    "query_port": null,
    "source_name": "community-hispana-rcon"
  }
]'
```

Runbook operativo minimo:

- modo recomendado por defecto:
  - `HLL_BACKEND_LIVE_DATA_SOURCE=rcon`
  - `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon`
  - usar solo `comunidad-hispana-01` y `comunidad-hispana-02` en los targets
    RCON por defecto
- modo historico/RCON avanzado:
  - iniciar workers solo de forma explicita
  - no reintroducir `comunidad-hispana-03` salvo validacion nueva
- override legacy live/A2S:
  - `HLL_BACKEND_LIVE_DATA_SOURCE=a2s`
  - `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon`

Verificacion minima del proveedor activo:

```powershell
Invoke-WebRequest http://127.0.0.1:8000/health | Select-Object -Expand Content
```

La respuesta incluye `live_data_source` y `historical_data_source`, util para
confirmar si la instancia esta usando `a2s` o `rcon` para live.

Captura historica prospectiva por RCON:

- se ejecuta fuera del request path HTTP
- persiste muestras live hacia delante en tablas `rcon_historical_*`
- usa RCON como camino historico primario y mantiene `public-scoreboard` solo
  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
```

Backfill historico RCON/AdminLog:

- runbook: `docs/historical-rcon-backfill.md`
- ejemplo seco:

  ```powershell
  docker compose run --rm rcon-historical-worker python -m app.rcon_historical_backfill --ensure-recent-matches 100 --servers comunidad-hispana-01,comunidad-hispana-02 --dry-run
  ```

Comandos manuales desde `backend/`:

```powershell
python -m app.rcon_historical_worker capture
python -m app.rcon_historical_worker capture --target comunidad-hispana-01
python -m app.rcon_historical_worker loop --interval 120
```

Runbook minimo:

- una pasada manual sobre todos los targets RCON configurados:

  ```powershell
  python -m app.rcon_historical_worker capture
  ```

- una validacion acotada sobre un target concreto:

  ```powershell
  python -m app.rcon_historical_worker capture --target comunidad-hispana-01
  ```

- un worker local en bucle:

  ```powershell
  python -m app.rcon_historical_worker loop --interval 120 --max-runs 1
  ```

La salida del worker incluye:

- `target_scope`
- `captured_at`
- `targets`
- `errors`
- `storage_status`

Cuando una captura falla, cada error incluye como minimo:

- `target_key`
- `external_server_id`
- `name`
- `host`
- `port`
- `timeout_seconds`
- `error_type`
- `error_stage`
- `message`

`error_type` intenta clasificar al menos:

- `timeout`
- `auth/login`
- `connection-refused`
- `payload-invalid`
- `other-error`

La persistencia queda separada del historico `historical_*` actual y usa:

- `rcon_historical_targets`
- `rcon_historical_capture_runs`
- `rcon_historical_samples`
- `rcon_historical_checkpoints`

Lectura historica minima cuando `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon`:

- endpoints soportados hoy directamente por RCON persistido:
  - `GET /api/historical/server-summary`
  - `GET /api/historical/recent-matches`
- lo que devuelven:
  - cobertura por target RCON configurado
  - frescura del ultimo capture exitoso
  - actividad reciente persistida
- endpoints que hoy caen automaticamente a `public-scoreboard` para mantener el
  contrato completo:
  - `GET /api/historical/weekly-top-kills`
  - `GET /api/historical/weekly-leaderboard`
  - `GET /api/historical/leaderboard`
  - `GET /api/historical/monthly-mvp`
  - `GET /api/historical/monthly-mvp-v2`
  - `GET /api/historical/player-events`
  - `GET /api/historical/player-profile`
  - `GET /api/historical/snapshots/*`

Cuando esos endpoints se consultan con `historical_data_source=rcon`, el backend
intenta primero RCON para la operacion soportada y, si no hay cobertura o la
capacidad aun no existe, cae automaticamente a `public-scoreboard`. La
respuesta deja trazabilidad con `primary_source`, `selected_source`,
`fallback_used`, `fallback_reason` y `source_attempts`.

## Criterio de estructura

- `__init__.py` declara el paquete `app` y reexporta las utilidades publicas
  minimas del bootstrap.
- `collector.py` define el flujo minimo de captura para desarrollo usando una
  fuente controlada.
- `a2s_client.py` encapsula una consulta minima A2S_INFO por UDP para probar
  servidores reales sin acoplar todavia el backend a una fuente mas compleja.
- `rcon_client.py` encapsula una conexion minima HLL RCON v2 por TCP con
  `ServerConnect`, XOR key base64, `authToken` y `GetServerInformation` para
  consultas live de produccion.
- `config.py` centraliza host, puerto y allowlist minima de origenes locales.
- `data_sources.py` define los contratos y la seleccion por entorno para live e historico.
- `historical_ingestion.py` intenta primero el writer path RCON y, si hace falta
  poblar `historical_*`, cae de forma explicita a la capa JSON publica de CRCON.
- `historical_models.py` fija las entidades historicas minimas del dominio.
- `historical_snapshots.py` fija los tipos y selectores validos de snapshots historicos precalculados.
- `historical_snapshot_storage.py` persiste snapshots historicos precalculados listos para lectura rapida.
- `historical_runner.py` ejecuta refresh incremental periodico con reintentos basicos.
- `historical_storage.py` prepara la persistencia `historical_*` y las consultas agregadas iniciales.
- `main.py` contiene el entrypoint HTTP y la creacion del servidor.
- `normalizers.py` transforma registros crudos o respuestas A2S a un modelo
  comun del colector.
- `routes.py` resuelve las rutas GET soportadas.
- `payloads.py` centraliza respuestas placeholder y mock.
- `server_targets.py` registra targets A2S de prueba de forma desacoplada del
  flujo principal del colector.
- `snapshots.py` construye snapshots consistentes con timestamp comun de
  captura.
- `storage.py` prepara una persistencia local minima en SQLite para
  `game_sources`, `servers` y `server_snapshots`.

## Persistencia local minima

El backend ya puede guardar snapshots en un SQLite local de desarrollo usando
solo libreria estandar de Python. Esta base minima sigue el modelo logico de:

- `game_sources`
- `servers`
- `server_snapshots`
- `historical_servers`
- `historical_maps`
- `historical_matches`
- `historical_players`
- `historical_player_match_stats`
- `historical_ingestion_runs`

Por defecto el archivo se crea en:

```text
backend/data/hll_vietnam_dev.sqlite3
```

En Docker, ese mismo rol de persistencia debe montarse fuera del contenedor en:

```text
/app/data/hll_vietnam_dev.sqlite3
```

Politica comun SQLite para writers:

- `timeout` explicito compartido
- `PRAGMA foreign_keys = ON`
- `PRAGMA journal_mode = WAL`
- `PRAGMA busy_timeout`
- `row_factory = sqlite3.Row`

Esta politica se aplica de forma uniforme a las capas writer-capable que
comparten el mismo SQLite, incluyendo:

- `historical_storage.py`
- `player_event_storage.py`
- `rcon_historical_storage.py`
- `storage.py`

Politica read-only para historico:

- las rutas de lectura de `historical_storage.py` no ejecutan ya
  `initialize_historical_storage()`
- si el SQLite historico todavia no existe, esas lecturas devuelven resultados
  vacios o defaults estables sin crear archivo ni correr seed/migraciones
- cuando el archivo ya existe, esas lecturas abren `mode=ro` con
  `row_factory = sqlite3.Row` y `PRAGMA busy_timeout`
- la inicializacion, migraciones, seed y normalizaciones siguen reservadas al
  writer path explicito

Variable opcional:

- `HLL_BACKEND_STORAGE_PATH`
- `HLL_BACKEND_A2S_TARGETS`

La base logica sigue documentada en
`docs/stats-database-schema-foundation.md` para snapshots live y en
`docs/historical-domain-model.md` para el historico CRCON. Esta implementacion
no introduce ORM, migraciones ni una decision de almacenamiento productivo.

## Snapshots historicos precalculados

La capa historica persiste ahora los snapshots precalculados orientados a UI
como archivos JSON independientes en disco, separados del SQLite del historico
bruto. Esta capa esta preparada para guardar:

- `server-summary`
- `weekly-leaderboard` con metricas `kills`, `deaths`, `support` y `matches_over_100_kills`
- `monthly-leaderboard` con las mismas metricas semanticas
- `monthly-mvp`
- `recent-matches`

Por defecto se escriben bajo:

```text
backend/data/snapshots/<server_key>/
```

En Docker, estos snapshots deben persistirse bajo:

```text
/app/data/snapshots/<server_key>/
```

Ejemplos:

- `backend/data/snapshots/comunidad-hispana-01/server-summary.json`
- `backend/data/snapshots/comunidad-hispana-01/weekly-kills.json`
- `backend/data/snapshots/comunidad-hispana-02/recent-matches.json`
- `backend/data/snapshots/all-servers/weekly-support.json`
- `backend/data/snapshots/all-servers/monthly-mvp.json`

Cada archivo conserva metadatos operativos minimos:

- `server_key`
- `snapshot_type`
- `metric`
- `window`
- `payload`
- `generated_at`
- `source_range_start`
- `source_range_end`
- `is_stale`

La persistencia usa una identidad de archivo estable por combinacion de
servidor, tipo y metrica para que cada refresh reemplace el artefacto anterior
sin mezclarlo con el historico bruto.

Resumen de persistencia recomendada para contenedor:

- montar `/app/data`
- conservar el SQLite historico en `/app/data/hll_vietnam_dev.sqlite3`
- conservar los snapshots JSON en `/app/data/snapshots/`

Con `docker compose`, esa persistencia ya queda montada desde:

- `./backend/data -> /app/data`

## Bootstrap del colector

El backend incluye un bootstrap minimo para el futuro flujo de snapshots:

- `fetch_controlled_server_source()` obtiene datos controlados de desarrollo
- `query_server_info()` permite consultar metadata basica real por A2S_INFO
- `fetch_a2s_probe()` adapta una consulta A2S real al modelo interno del colector
- `fetch_configured_a2s_probes()` consulta la lista configurada de targets A2S
- `normalize_server_record()` reduce los registros a una forma comun
- `normalize_a2s_server_info()` reduce una respuesta A2S al mismo contrato interno
- `build_server_snapshot()` y `build_snapshot_batch()` generan snapshots con
  `captured_at`
- `collect_server_snapshots()` orquesta captura, normalizacion, ensamblado y
  persistencia opcional
- `persist_snapshot_batch()` escribe el lote en SQLite y mantiene identidad de
  servidor separada del historico

Ejecucion manual desde `backend/`:

```powershell
python -m app.collector --source auto
```

Ese comando intenta consultar primero los targets A2S configurados. Si ninguno
responde y no se ha desactivado el fallback, usa la fuente controlada de
desarrollo para no romper el flujo local. El resultado imprime el modo usado,
los errores de consulta y el lote de snapshots persistido en SQLite.

Si se quiere forzar solo A2S real:

```powershell
python -m app.collector --source a2s --no-fallback
```

Ese flujo es la validacion local minima extremo a extremo para los targets
reales configurados de Comunidad Hispana. El timeout por defecto del cliente
A2S es `6.0s` para tolerar mejor latencia puntual entre multiples consultas
reales consecutivas. Cuando responden ambos targets por defecto, el comando
debe devolver:

- `collection_mode: "a2s"`
- `target_count: 2`
- `success_count: 2`
- un snapshot con `external_server_id: "comunidad-hispana-01"`
- un snapshot con `external_server_id: "comunidad-hispana-02"`
- `source_name: "community-hispana-a2s"`
- `snapshot_origin: "real-a2s"` en ambos
- `source_ref: "a2s://152.114.195.174:7778"`
- `source_ref: "a2s://152.114.195.150:7878"`
- persistencia en `backend/data/hll_vietnam_dev.sqlite3`

Si la consulta se ejecuta desde un entorno con red restringida, sin salida UDP
o con latencia puntual alta, el cliente puede devolver timeout aunque el target
este sano. En ese caso el resultado conserva errores controlados por target y
puede acabar con `success_count` parcial o `0` segun cuantas consultas fallen.

Los snapshots persistidos y los endpoints historicos exponen ademas:

- `snapshot_origin` para distinguir `real-a2s` frente a `controlled-fallback`
- `source_ref` para conservar una referencia de procedencia util en historico

Si se quiere seguir usando solo datos controlados:

```powershell
python -m app.collector --source controlled
```

## Refresco local periodico de snapshots

Para evitar lanzar el colector manualmente en cada captura, el backend incluye
un bucle local de refresco periodico pensado solo para desarrollo:

```powershell
python -m app.scheduler
```

Ese comando ejecuta capturas persistidas de forma repetida usando el mismo
flujo del colector y la base SQLite local. Por defecto:

- usa `--source auto`
- espera `120` segundos entre ejecuciones
- permite fallback controlado si A2S no responde
- sigue en ejecucion hasta que se detiene manualmente

Se puede detener de forma segura con `Ctrl+C`.

Variables y flags utiles:

- `HLL_BACKEND_REFRESH_INTERVAL_SECONDS` para cambiar el intervalo por defecto
- `--interval 120` para fijar el intervalo en segundos en una ejecucion concreta
- `--source a2s --no-fallback` para forzar solo capturas reales
- `--max-runs 3` para limitar el numero de ciclos y evitar un bucle indefinido

Ejemplos:

```powershell
python -m app.scheduler --interval 120
python -m app.scheduler --source a2s --no-fallback --max-runs 2
```

Flujo local recomendado para ver datos vivos en la landing:

1. Desde `backend/`, arrancar la API:

   ```powershell
   python -m app.main
   ```

2. En otra terminal, dejar el scheduler corriendo:

   ```powershell
   python -m app.scheduler
   ```

3. Servir `frontend/` con un servidor local sencillo y abrir la landing. El
   frontend volvera a pedir `/api/servers` cada `120` segundos, por lo que los
   cambios de mapa o poblacion apareceran sin recarga manual cuando existan
   snapshots nuevos.

Este mecanismo deja el refresco desacoplado del servidor HTTP y es facil de
reemplazar mas adelante por un scheduler mas serio sin rehacer el colector.

Prueba manual minima de A2S desde `backend/`:

```powershell
python -m app.a2s_client 203.0.113.10 27015
```

Ese comando lanza una consulta `A2S_INFO` por UDP y devuelve JSON con nombre de
servidor, mapa, jugadores y capacidad maxima cuando el query port responde.
Tambien puede reutilizarse desde Python con `query_server_info()` o
`fetch_a2s_probe()`. Si el servidor no responde o el puerto es incorrecto, el
cliente eleva errores controlados de timeout o protocolo para que la siguiente
task pueda integrarlo en el pipeline de snapshots sin romper el backend.

## Registro local de targets A2S

La lista de targets A2S vive en `app/server_targets.py`. Por defecto el backend
registra solo el primer target real verificado del proyecto:

- `Comunidad Hispana #01`
- host/IP: `152.114.195.174`
- `query_port`: `7778`
- `game_port`: `7777`
- `source_name`: `community-hispana-a2s`
- `external_server_id`: `comunidad-hispana-01`

`query_port` es el puerto usado para `A2S_INFO`; `game_port` se conserva por
separado para documentar el puerto de juego real sin mezclar ambos conceptos en
la configuracion.

El registro por defecto incluye dos targets reales verificados:

- `Comunidad Hispana #01`
  - host/IP: `152.114.195.174`
  - `query_port`: `7778`
  - `game_port`: `7777`
  - `external_server_id`: `comunidad-hispana-01`
- `Comunidad Hispana #02`
  - host/IP: `152.114.195.150`
  - `query_port`: `7878`
  - `game_port`: `7877`
  - `external_server_id`: `comunidad-hispana-02`

Si se quiere cambiar la lista sin editar codigo, puede definirse
`HLL_BACKEND_A2S_TARGETS` como un array JSON:

```powershell
$env:HLL_BACKEND_A2S_TARGETS='[
  {
    "name": "Comunidad Hispana #01",
    "host": "152.114.195.174",
    "query_port": 7778,
    "game_port": 7777,
    "source_name": "community-hispana-a2s",
    "external_server_id": "comunidad-hispana-01",
    "region": "ES"
  },
  {
    "name": "Comunidad Hispana #02",
    "host": "152.114.195.150",
    "query_port": 7878,
    "game_port": 7877,
    "source_name": "community-hispana-a2s",
    "external_server_id": "comunidad-hispana-02",
    "region": "ES"
  }
]'
```

Cada target soporta:

- `name`
- `host`
- `query_port`
- `game_port` opcional
- `source_name`
- `external_server_id` opcional
- `region` opcional

El colector puede resolver esos targets con `load_a2s_targets()` o
`fetch_configured_a2s_probes()` sin depender de constantes dispersas.

## Consulta historica minima

Una vez existen snapshots persistidos, el backend expone una primera capa de
consulta historica:

- `/api/servers/latest` devuelve el ultimo snapshot conocido por servidor
- `/api/servers/history` devuelve snapshots recientes agregados
- `/api/servers/{id}/history` devuelve el historial reciente de un servidor

`{id}` acepta el `server_id` numerico interno o el `external_server_id`
persistido por el colector. El parametro opcional `limit` acepta valores entre
`1` y `100`.

La capa historica propia expone:

- `/api/historical/weekly-top-kills`
- `/api/historical/weekly-leaderboard`
- `/api/historical/leaderboard`
- `/api/historical/recent-matches`
- `/api/historical/server-summary`
- `/api/historical/snapshots/server-summary`
- `/api/historical/snapshots/weekly-leaderboard`
- `/api/historical/snapshots/leaderboard`
- `/api/historical/snapshots/recent-matches`
- `/api/historical/player-profile`

Parametros opcionales:

- `limit` entre `1` y `100`
- `server` con slug historico como `comunidad-hispana-01`
- `player` en `/api/historical/player-profile` aceptando `stable_player_key`,
  `steam_id` o `source_player_id`

Ademas de los slugs fisicos de cada scoreboard, la capa historica acepta la
clave logica `all-servers` para devolver agregados globales sobre los tres
servidores de Comunidad Hispana sin tratarla como un origen CRCON real aparte.

La ventana temporal usa semana calendario UTC y solo considera partidas
cerradas con `ended_at` para no mezclar partidas aun en curso ni filas
historicas transitorias. El payload devuelve servidor, rango temporal,
jugador, kills semanales, posicion y numero de partidas consideradas.

`weekly-leaderboard` generaliza ese bloque para varias metricas semanales por
servidor usando el mismo filtro de partidas cerradas. Si la semana actual cae
entre lunes y miercoles UTC y todavia no acumula al menos `3` partidas
cerradas, el backend activa un fallback temporal a la semana cerrada anterior.
Metricas soportadas:

- `kills`
- `deaths`
- `support`
- `matches_over_100_kills`

El endpoint legacy `/api/historical/weekly-top-kills` se conserva como alias
compatible para la metrica `kills`.

`/api/historical/leaderboard` y `/api/historical/snapshots/leaderboard`
generalizan ese mismo contrato con `timeframe=weekly|monthly`. Para `monthly`,
la politica temporal usa el mes natural UTC en curso y hace fallback al mes
cerrado anterior solo cuando el mes actual todavia no tiene ningun cierre.
Ambas variantes exponen el rango real usado mediante `window_start`,
`window_end`, `window_kind`, `window_label` y `selection_reason`.

`recent-matches` devuelve cierres recientes por servidor con marcador, mapa y
conteo de jugadores. `server-summary` agrega volumen historico, jugadores
unicos, kills, mapas dominantes y rango temporal cubierto. `player-profile`
deja lista la base de consulta agregada por jugador para futuras vistas.

La familia `/api/historical/snapshots/*` lee directamente los archivos JSON
precalculados bajo `backend/data/snapshots/` y evita recalcular agregados
pesados en cada request. Estos endpoints devuelven payloads ligeros listos para
frontend con:

- `snapshot_status`
- `missing_reason`
- `request_path_policy`
- `generation_policy`
- `generated_at`
- `source_range_start`
- `source_range_end`
- `is_stale`
- `freshness`
- `found`
- `window_start`
- `window_end`
- `window_kind`
- `window_label`
- `uses_fallback`
- `selection_reason`
- `current_week_closed_matches`
- `previous_week_closed_matches`
- `sufficient_sample`

Si un snapshot todavia no existe en `backend/data/snapshots/`, la API responde
rapido con `found: false`, `snapshot_status: "missing"` y
`missing_reason: "snapshot-not-generated"`. La generacion y refresco de esos
artefactos debe ocurrir fuera del request path mediante `historical_ingestion`
o `historical_runner`; la lectura HTTP se mantiene como fast path de solo
lectura.

`/api/historical/snapshots/server-summary` devuelve `item` con el resumen del
servidor. `/api/historical/snapshots/weekly-leaderboard` devuelve `items` ya
precalculados para una metrica semanal y acepta `limit` para recortar el
payload ya persistido sin recalcularlo. `/api/historical/snapshots/recent-matches`
devuelve `items` de cierres recientes ya preparados y tambien acepta `limit`
para servir solo una parte del snapshot persistido.

La misma capa de snapshots guarda tambien `monthly-leaderboard` por servidor y
por agregado `all-servers`, con archivos como `monthly-kills.json` y
`monthly-support.json`.

Tambien persiste `monthly-mvp.json` por servidor y para `all-servers`, listo
para lectura rapida desde `/api/historical/monthly-mvp` y
`/api/historical/snapshots/monthly-mvp` sin recalculo pesado en request.

La misma operativa persiste tambien snapshots V2 de eventos de jugador para el
ultimo mes con datos disponible por servidor y para `all-servers`, listos para
lectura rapida sin consultas pesadas on-demand:

- `player-events-most-killed.json`
- `player-events-death-by.json`
- `player-events-duels.json`
- `player-events-weapon-kills.json`
- `player-events-teamkills.json`

Los endpoints `/api/historical/player-events` y
`/api/historical/snapshots/player-events` aceptan:

- `view=most-killed`
- `view=death-by`
- `view=duels`
- `view=weapon-kills`
- `view=teamkills`

La respuesta expone metadata operativa alineada con el resto de snapshots:

- `generated_at`
- `month_key`
- `source_range_start`
- `source_range_end`
- `found`
- `is_stale`

El backend incluye ademas el calculo interno de `monthly MVP V1` en
`app/monthly_mvp.py`, separado de los leaderboards mensuales simples por
metrica. Ese calculo:

- usa solo `kills`, `support`, `time_seconds`, `deaths` y `teamkills`
  persistidos
- recompone `KPM` y `KDA` desde totales mensuales
- aplica elegibilidad minima de `6` partidas cerradas y `6` horas
- soporta servidor individual y el agregado logico `all-servers`

En esta fase el ranking MVP queda listo para serializar en snapshots o payloads
sin reemplazar los leaderboards mensuales ya existentes por `kills`, `deaths`,
`support` y `matches_over_100_kills`.

La repo incluye tambien un calculo backend separado de `monthly MVP V2` en
`app/monthly_mvp_v2.py`, expuesto de momento por
`/api/historical/monthly-mvp-v2`.

Esta V2:

- convive sin reemplazar `monthly MVP V1`
- reusa la misma ventana mensual y la misma elegibilidad base
- anade `rivalry_edge` y `duel_control` derivados del ledger V2 de eventos
- aplica una penalizacion de teamkills mas estricta
- mantiene fuera del score el peso por arma o tipo de kill hasta validar mejor
  esas senales

Esa capacidad V2 se persiste tambien en snapshots dedicados
`monthly-mvp-v2.json` por servidor y para `all-servers`, leidos por:

- `/api/historical/monthly-mvp-v2`
- `/api/historical/snapshots/monthly-mvp-v2`

La lectura HTTP de V2 sigue asi la misma politica de fast path de solo lectura
que el resto de snapshots historicos, con metadata util como:

- `generated_at`
- `month_key`
- `found`
- `source_range_start`
- `source_range_end`
- `event_coverage`

## Ingesta historica CRCON

La ingesta historica no usa A2S ni scraping del HTML de `/games`. Consume la
capa JSON publica detectada en los scoreboards CRCON de Comunidad Hispana y
persiste el resultado en las tablas `historical_*`.

Fuentes configuradas:

- `https://scoreboard.comunidadhll.es`
- `https://scoreboard.comunidadhll.es:5443`
- `https://scoreboard.comunidadhll.es:3443`

Comandos manuales desde `backend/`:

```powershell
python -m app.historical_ingestion bootstrap
python -m app.historical_ingestion refresh
python -m app.historical_runner --interval 1800
```

Los mismos flujos desde Docker Compose:

```powershell
docker compose exec backend python -m app.historical_ingestion bootstrap
docker compose exec backend python -m app.historical_ingestion refresh
docker compose exec backend python -m app.historical_runner --interval 1800
```

Flags utiles:

- `--server comunidad-hispana-01` para limitar a un servidor
- `--server comunidad-hispana-02` para validar solo el segundo servidor activo
- `--overlap-hours 48` para releer una ventana reciente mayor sin relanzar bootstrap
- `--max-pages 2` para validacion local acotada
- `--page-size 25` para ajustar paginacion
- `--start-page 4` para forzar una pagina concreta en bootstraps largos
- `--detail-workers 16` para paralelizar el detalle por partida

La ejecucion `bootstrap` recorre paginas historicas hasta agotar resultados.
La ejecucion `refresh` usa una ventana de solape sobre la ultima partida
persistida por servidor para releer solo paginas recientes y absorber updates
tardios sin reimportar todo el historico. Cuando una ejecucion termina
correctamente, tambien recompone los snapshots historicos precalculados para el
servidor afectado o para todos los servidores si la ingesta fue global.
Si la recomposicion se lanza para un servidor fisico concreto, el backend
rehace tambien el agregado logico `all-servers` para mantener `Todos`
alineado con `#01` y `#02` aunque `#03` siga sin bootstrap.

En esta fase, el comando muestra progreso operativo util sin saturar stdout:

- intento primario RCON
- fuente finalmente seleccionada
- servidor actual
- pagina actual
- `match_ids_to_detail` de cada pagina

Si RCON no puede cubrir la operacion competitiva real, el fallback a
`public-scoreboard` queda visible tanto durante la ejecucion como en el JSON
final mediante:

- `primary_source`
- `selected_source`
- `fallback_used`
- `fallback_reason`
- `source_attempts`
- `primary_writer_result`

El comando devuelve ademas un resumen de cobertura persistida por servidor. Esto
ayuda a validar rapidamente cuantos matches reales quedaron importados, el rango
temporal cubierto y si la carga ya supera la ultima semana movil que usa la UI.
Ese resumen incluye tambien checkpoint y estado operativo de backfill por
servidor:

- `next_page`
- `last_completed_page`
- `discovered_total_matches`
- `discovered_total_pages`
- `archive_exhausted`
- `last_run`

Como la fuente CRCON publica expone un archivo muy profundo y puede devolver
errores `502` intermitentes bajo carga sostenida, el bootstrap completo debe
tratarse como una operacion reanudable. Flujo recomendado:

```powershell
python -m app.historical_ingestion bootstrap --detail-workers 16
python -m app.historical_ingestion bootstrap --detail-workers 16
```

La segunda invocacion reutiliza automaticamente el checkpoint persistido en
`historical_backfill_progress` y continua desde la siguiente pagina pendiente si
la sesion anterior se corta por tiempo disponible o por inestabilidad puntual
del origen. `--start-page` queda como override manual cuando se quiera
reprocesar o inspeccionar un tramo concreto.

Runbook operativo para overlap manual:

```powershell
python -m app.historical_ingestion refresh --overlap-hours 48
python -m app.historical_ingestion refresh --server comunidad-hispana-01 --overlap-hours 48 --max-pages 2
python -m app.historical_runner --max-runs 1
```

La primera pasada relee 48 horas sobre los tres servidores historicos ya
registrados. La segunda sirve para validar un solo servidor con alcance
acotado. La tercera recompone snapshots despues de una pasada manual cuando se
quiere confirmar que la capa precalculada vuelve a quedar alineada.

Interpretacion operativa recomendada:

- si aparece `historical-ingestion-rcon-primary-succeeded`, RCON se intento de
  verdad primero y la captura prospectiva quedo registrada
- si despues `selected_source` termina en `public-scoreboard`, eso significa
  que la reconstruccion del archivo competitivo `historical_*` siguio
  necesitando fallback clasico
- si RCON falla por red, auth o timeout, el motivo queda visible en
  `fallback_reason` y en `source_attempts`

Los reintentos de cada request JSON pueden ajustarse sin tocar codigo con:

- `HLL_HISTORICAL_CRCON_REQUEST_RETRIES`
- `HLL_HISTORICAL_CRCON_RETRY_DELAY_SECONDS`

El runner `python -m app.historical_runner` deja ahora una orquestacion
RCON-first lista para ejecucion local repetida sin depender de infraestructura
externa y mantiene calientes los snapshots historicos mas visibles cuando el
fallback clasico entra de forma controlada. Por defecto:

- intenta primero una captura prospectiva RCON en cada ciclo
- solo lanza el refresh historico clasico cuando RCON falla, cuando se pide un
  scope manual que sigue requiriendo cobertura competitiva, o en la cadencia
  periodica de fallback para mantener rankings y snapshots clasicos
- refresca cada `900` segundos
- prewarmea en cada ciclo:
  - `server-summary` para `comunidad-hispana-01`, `comunidad-hispana-02` y `all-servers`
  - `weekly-leaderboard` de la metrica por defecto `kills` para esos mismos alcances
  - `monthly-leaderboard` de la metrica por defecto `kills` para esos mismos alcances
  - `recent-matches` para esos mismos alcances
- recompone la matriz completa de snapshots cada `4` ciclos para mantener el resto de metricas al dia sin penalizar todos los refresh
- reintenta hasta `2` veces tras un fallo
- espera `30` segundos entre reintentos
- reutiliza el registro de `historical_ingestion_runs` para dejar trazabilidad
  de ultimo refresh, resultado y errores basicos
- persiste por servidor:
  - `server-summary`
  - `weekly-leaderboard` para `kills`, `deaths`, `support` y `matches_over_100_kills`
  - `monthly-leaderboard` para `kills`, `deaths`, `support` y `matches_over_100_kills`
  - `recent-matches`

Flags utiles del runner:

- `--server comunidad-hispana-01` para limitar a un servidor
- `--interval 900` para fijar la frecuencia recomendada de snapshots
- `--hourly` para fijar directamente un ciclo horario de `3600` segundos
- `--retries 1` para reducir reintentos
- `--retry-delay 10` para bajar la espera entre fallos
- `--max-runs 1` para una validacion puntual sin bucle indefinido

Para dejar automatizado el refresh historico horario en local, el comando
avanzado sigue disponible:

```powershell
python -m app.historical_runner --hourly
```

Sin `--server`, ese runner refresca:

- `comunidad-hispana-01`
- `comunidad-hispana-02`

Despues de cada fallback clasico correcto, recompone snapshots para los
servidores afectados y vuelve a alinear el agregado `all-servers`. Si el ciclo
RCON primario fue suficiente y no hizo falta el fallback clasico, el runner
deja constancia explicita de ese motivo en su salida JSON.

Para regenerar snapshots de forma puntual dentro del contenedor sin dejar un
bucle permanente, la validacion operativa minima es:

```powershell
docker compose exec backend python -m app.historical_runner --max-runs 1
```

Operativa local minima:

1. Desde `backend/`, arrancar la API con `python -m app.main`.
2. En otra terminal, dejar corriendo `python -m app.historical_runner --hourly`.
3. Verificar el proceso revisando la salida del runner: al arrancar imprime un
   bloque JSON con `event: "historical-refresh-loop-started"`, `server_scope`
   y `snapshot_scope`.
4. Confirmar que los snapshots siguen actualizandose revisando `generated_at`
   en archivos bajo `backend/data/snapshots/`, por ejemplo:
   - `backend/data/snapshots/comunidad-hispana-01/server-summary.json`
   - `backend/data/snapshots/comunidad-hispana-02/recent-matches.json`
   - `backend/data/snapshots/comunidad-hispana-02/weekly-kills.json`
   - `backend/data/snapshots/all-servers/monthly-kills.json`

Operativa avanzada con Docker Compose:

```powershell
docker compose --profile advanced up -d backend historical-runner frontend
```

El servicio `historical-runner` usa el mismo volumen persistente `./backend/data`
y ejecuta `python -m app.historical_runner --hourly` como bucle operativo
dedicado, sin mezclar el scheduler con el proceso HTTP principal. No forma
parte del despliegue normal, que queda limitado a `backend` + `frontend`.

En frontend, la landing ya no arranca con cards fake estaticas para servidores:

- el contenedor queda en estado de loading
- solo se renderizan cards con datos reales al hidratar
- si la API falla, se muestra una degradacion limpia en lugar de datos falsos

## Coordinacion single-writer para automatizaciones y CLI

Todos los procesos writer-oriented que comparten el mismo SQLite usan ahora un
lock comun derivado de `HLL_BACKEND_STORAGE_PATH` y persistido junto al volumen
de datos compartido. Ese lock coordina:

- `app.historical_ingestion`
- `app.historical_runner`
- `app.player_event_worker`
- `app.rcon_historical_worker`

Rutas HTTP read-only como `/api/historical/snapshots/*`, `/api/servers` en modo
cache local y el read model minimo RCON no adquieren este lock.

Variables operativas:

- `HLL_BACKEND_WRITER_LOCK_TIMEOUT_SECONDS`
- `HLL_BACKEND_WRITER_LOCK_POLL_INTERVAL_SECONDS`

Comportamiento:

- si un writer ya esta ejecutandose, el siguiente espera de forma controlada
  hasta agotar el timeout configurado
- si no puede adquirir el lock, falla con un error claro indicando:
  - lock path
  - holder
  - `started_at`
  - host
  - pid
- si el lock parece venir de un contenedor Docker ya parado, el backend puede
  recuperarlo automaticamente cuando:
  - el holder venia de un cwd tipo `/app`
  - el lock ya supero una gracia minima de seguridad
- la coordinacion principal es este single-writer lock; WAL y `busy_timeout`
  quedan como endurecimiento complementario, no como solucion unica

Runbook minimo:

- pasada manual del historico base mientras el runner automatico existe:

  ```powershell
  docker compose exec backend python -m app.historical_ingestion refresh --overlap-hours 48
  ```

  Si el lock esta ocupado, el comando esperara hasta el timeout configurado y,
  si no se libera, terminara con un mensaje claro de lock ocupado.

- pasada manual de player-events:

  ```powershell
  docker compose exec backend python -m app.player_event_worker refresh --overlap-hours 48
  ```

- pasada manual de captura prospectiva RCON:

  ```powershell
  docker compose exec backend python -m app.rcon_historical_worker capture
  ```

- convivencia recomendada con automatizaciones:
  - no hace falta parar contenedores por defecto
  - dejar que el lock coordine la exclusión mutua
  - usar `--max-runs 1` o comandos manuales puntuales cuando se quiera una
    pasada controlada

Comprobaciones utiles con Compose:

- `docker compose ps historical-runner`
- `docker compose logs -f historical-runner`
- `docker compose exec backend python -m app.historical_runner --max-runs 1`

Compose para captura prospectiva RCON:

```powershell
docker compose --profile advanced up -d rcon-historical-worker
docker compose logs -f rcon-historical-worker
docker compose exec backend python -m app.rcon_historical_worker capture
```

Variables utiles del runner:

- `HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS`
- `HLL_HISTORICAL_FULL_SNAPSHOT_EVERY_RUNS`
- `HLL_HISTORICAL_REFRESH_MAX_RETRIES`
- `HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS`
- `HLL_HISTORICAL_WEEKLY_FALLBACK_MIN_MATCHES`
- `HLL_HISTORICAL_WEEKLY_FALLBACK_MAX_WEEKDAY`

Al inicializar la persistencia local, el backend normaliza tambien la identidad
historica ya guardada:

- prioriza `steaminfo.profile.steamid` cuando existe
- si `player_id` ya parece un SteamID real, lo promueve igualmente a `steam:*`
- si no hay SteamID, usa `player_id` como clave `crcon-player:*`
- deja `steaminfo.id` como ultimo fallback cuando faltan las claves anteriores

La misma inicializacion fusiona filas duplicadas si una partida abierta quedo
guardada con un id sintetico y mas tarde CRCON la expone con un id numerico
definitivo. Esto evita que el ranking semanal cuente dos veces la misma sesion.

## CORS local minimo

El backend responde con `Access-Control-Allow-Origin` solo si la peticion llega
desde uno de los origenes permitidos en desarrollo local. No se habilita un
comodin global ni configuracion de produccion en esta fase.

La allowlist por defecto cubre `file://` mediante el origen `null` y los flujos
locales mas comunes del proyecto:

- `http://127.0.0.1:5500`
- `http://localhost:5500`
- `http://127.0.0.1`
- `http://127.0.0.1:8080`
- `http://localhost`
- `http://localhost:8080`

Las respuestas `GET` y `OPTIONS` incluyen `Access-Control-Allow-Origin` cuando
el origen esta permitido, suficiente para probar la landing contra la API local
sin tocar endpoints ni payloads.

Esta separacion mantiene el backend simple y deja una base clara para futuras tasks sin introducir integraciones reales todavia.

## Fuente y ledger de eventos de jugador V2

La repo incluye ahora una primera base V2 separada del historico `historical_*`
para preparar metricas avanzadas de duelos, armas y teamkills sin tocar todavia
la UI ni el scoring final.

Fuente minima elegida en esta fase:

- detalle de partida `GET /api/get_map_scoreboard?map_id={id}` del scoreboard CRCON

Importante:

- esta fuente no es un feed raw por kill
- el adaptador actual normaliza solo senales parciales ya visibles en el
  resumen de partida:
  - `most_killed`
  - `death_by`
  - `weapons`
  - `death_by_weapons`
  - `teamkills`
- `occurred_at` usa el timestamp de cierre o inicio de la partida, no el
  instante exacto del kill
- el ledger raw es append-only y deduplica por `event_id`
- la persistencia queda separada de `historical_matches` y
  `historical_player_match_stats` aunque comparte el mismo SQLite de desarrollo

Contrato minimo normalizado por evento:

- `event_id`
- `event_type`
- `occurred_at`
- `server_slug`
- `external_match_id`
- `source_kind`
- `source_ref`
- `killer_player_key`
- `victim_player_key`
- `weapon_name`
- `kill_category`
- `is_teamkill`
- `event_value`

Tablas nuevas:

- `player_event_raw_ledger`
- `player_event_ingestion_runs`
- `player_event_backfill_progress`

Comandos manuales desde `backend/`:

```powershell
python -m app.player_event_worker refresh
python -m app.player_event_worker refresh --server comunidad-hispana-01 --max-pages 1
python -m app.player_event_worker loop --interval 1800
```

Variables opcionales del worker:

- `HLL_PLAYER_EVENT_REFRESH_INTERVAL_SECONDS`
- `HLL_PLAYER_EVENT_REFRESH_OVERLAP_HOURS`
- `HLL_PLAYER_EVENT_REFRESH_MAX_RETRIES`
- `HLL_PLAYER_EVENT_REFRESH_RETRY_DELAY_SECONDS`

Flags utiles del worker:

- `--server comunidad-hispana-01` para validar un solo servidor
- `--overlap-hours 48` para releer una ventana reciente mayor
- `--max-pages 1` para una comprobacion acotada

Ejemplos operativos:

```powershell
python -m app.player_event_worker refresh --overlap-hours 48
python -m app.player_event_worker refresh --server comunidad-hispana-01 --overlap-hours 48 --max-pages 1
```

Politica operativa minima:

- el worker corre fuera del request path HTTP
- reusa la capa historica `public-scoreboard` solo como fuente de detalle
- persiste checkpoints por servidor y pagina
- la reejecucion es segura porque el ledger usa insercion idempotente por
  `event_id`

Agregados V2 ya disponibles desde codigo:

- `list_most_killed()`
- `list_death_by()`
- `list_net_duel_summaries()`
- `list_weapon_kills()`
- `list_teamkill_summaries()`

Limitaciones actuales de esta fase:

- no existe todavia un ledger raw por kill individual
- los agregados de duelos y armas son parciales, porque dependen del mejor
  resumen disponible por jugador en CRCON y no de todos los encounters del match
- la V2 no expone aun endpoints HTTP ni snapshots propios

## Historical Runtime Policy

El backend queda orientado a `RCON-first` tambien para historico:

- live:
  - `rcon` primero
  - `a2s` solo como fallback
- historico:
  - `rcon` primero tanto para lectura minima como para el writer path primario
    de `historical_ingestion`
  - `public-scoreboard` solo como fallback cuando RCON no cubre una operacion
    competitiva concreta, no tiene cobertura suficiente o falla la captura
    primaria

Metadata observable en payloads historicos:

- `primary_source`
- `selected_source`
- `fallback_used`
- `fallback_reason`
- `source_attempts`

Estado real a fecha de esta fase:

- el read model historico RCON soporta ya una capa competitiva primaria basada
  en ventanas derivadas desde persistencia `rcon_historical_*`
- `server-summary` y `recent-matches` pasan a usar esa capa RCON-backed como
  camino principal real
- en runtime, esas dos rutas solo se sirven como `rcon` cuando la capability
  sigue soportada y existe cobertura RCON util para el scope pedido
- cobertura util en esta frontera significa:
  - `server-summary`: al menos una fila con `coverage.status != "empty"` y
    `window_count` o `sample_count` mayor que cero
  - `recent-matches`: al menos una ventana con `match_id`, `closed_at` y
    `sample_count > 0`
- si el target persistido quedo con clave legacy `rcon:<host>:<port>` pero el
  runtime actual ya conoce su `external_server_id`, la capa read model intenta
  resolver ambos aliases antes de caer a fallback
- si no hay coverage suficiente o la lectura RCON falla, el backend mantiene
  fallback explicito a `public-scoreboard` con `fallback_used = true` y
  `fallback_reason` visible
- `historical_ingestion` intenta primero una captura writer-oriented por RCON y
  deja esa tentativa visible en su salida
- leaderboards semanales/mensuales, MVP V1/V2 y player-events siguen teniendo
  fallback a `public-scoreboard` mientras RCON no disponga de señal competitiva
  por jugador con paridad suficiente
- Elo/MMR permanece pausado y desacoplado del arranque del backend; cuando se
  reactive mediante una task explicita, debera respetar el contexto
  RCON-backed primario y usar `public-scoreboard` solo como suplemento/fallback
  para estadisticas por jugador sin paridad RCON

## PostgreSQL Phase 2 Displayed Data Migration

Cuando `HLL_BACKEND_DATABASE_URL` esta configurado, los endpoints visibles de
historico y el cache mostrado por `/api/servers` leen PostgreSQL. SQLite y los
JSON legacy quedan como fuente de migracion o fixture explicito con `db_path`.

Migracion idempotente:

```powershell
cd backend
python -m app.sqlite_to_postgres_migration
python -m app.storage_diagnostics
```

La salida JSON de `sqlite_to_postgres_migration` lista rutas fuente, dominios y
tablas migradas, filas leidas, insertadas, actualizadas, omitidas y errores.
La migracion conserva `external_match_id`, IDs legacy y `match_key` RCON para
que URLs de detalle existentes sigan resolviendo. Tambien copia candidatos y
URLs seguras de scoreboard; no vuelve a activar filas visibles de
`comunidad-hispana-03`.

Paridad minima a revisar en `storage_diagnostics`:

- `admin_log_events`, `materialized_matches`, `player_stats`
- `public_scoreboard_historical_matches`
- fuentes de rankings semanales y mensuales
- `server_summary_cache`, `server_snapshots`, `player_event_ledger`
- `scoreboard_candidates`
- ultimas partidas materializadas y ultimos eventos AdminLog `match_end`

Fuera de phase 2 quedan checkpoints/runs de ingesta publica que no se muestran
en frontend y Elo/MMR pausado. Si un endpoint de mantenimiento recibe un
`db_path` explicito, sigue trabajando contra SQLite para migracion, tests o
compatibilidad operativa controlada.

## Elo/MMR Monthly Ranking

Se añade una primera base operativa inspirada en el documento
`sistema_elo_mensual_hll.pdf`, pero adaptada a la telemetria real disponible.

Superficies nuevas:

- `python -m app.elo_mmr_engine rebuild`
- `python -m app.elo_mmr_engine leaderboard --server all-servers --limit 10`
- `python -m app.elo_mmr_engine player --server all-servers --player <stable_player_key>`
- `/api/historical/elo-mmr/leaderboard`
- `/api/historical/elo-mmr/player`

Persistencia nueva en SQLite:

- `elo_mmr_player_ratings`
- `elo_mmr_match_results`
- `elo_mmr_monthly_rankings`
- `elo_mmr_monthly_checkpoints`

Politica de exactitud:

- `exact`: outcome, combat, utility, disciplina por teamkills, MMR persistente
- `approximate`: role bucket, objective index, strength of schedule
- `not_available`: leadership y tacticas finas no persistidas

Cuando `historical_data_source=rcon`, el motor Elo/MMR deja visible una
frontera hibrida y honesta:

- `primary_source = rcon`
- `selected_source = hybrid-rcon-competitive-plus-public-scoreboard`
- `fallback_used = true`

Eso significa que la capa RCON-backed ya aporta el contexto competitivo de
cobertura y calidad de match, pero las estadisticas competitivas por jugador
siguen necesitando el suplemento clasico hasta que RCON tenga esa granularidad.

La especificacion detallada y el mapa de capabilities quedan en:

- `docs/elo-mmr-monthly-ranking-design.md`

## Alcance

Esta fase no implementa:

- logica real de Discord
- integraciones con servidores de juego
- base de datos
- autenticacion
- dependencias nuevas

La idea es dejar un esqueleto funcional, pequeno y coherente con `docs/frontend-backend-contract.md`.
