Add scoreboard-backed weekly historical stats
This commit is contained in:
@@ -67,5 +67,6 @@ Community website repository with a static landing in the current phase and a pl
|
||||
- The phased source strategy for that provisional block is documented in `docs/current-hll-servers-source-plan.md`.
|
||||
- The ingestion strategy for converting that provisional block into normalized server snapshots is documented in `docs/current-hll-data-ingestion-plan.md`.
|
||||
- The logical storage foundation for persisting server snapshots is documented in `docs/stats-database-schema-foundation.md`.
|
||||
- The historical domain model for scoreboard-based player and match statistics is documented in `docs/historical-stats-domain-model.md`.
|
||||
- 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`.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# TASK-028-historical-stats-source-and-domain-model
|
||||
|
||||
## Goal
|
||||
Definir la fuente real, el modelo de dominio y la estrategia técnica base para estadísticas históricas de los 2 servidores reales de la comunidad.
|
||||
|
||||
## Context
|
||||
Ya existen páginas de scoreboard/histórico para ambos servidores de la comunidad. El siguiente paso del proyecto es construir una capa histórica propia que permita consultas y rankings agregados, por ejemplo “jugadores con más kills de la última semana” por servidor. Antes de implementar ingesta o endpoints, hay que fijar claramente qué datos se van a extraer, cómo se modelan y cómo se relacionan con cada servidor.
|
||||
|
||||
## Steps
|
||||
1. Revisar las dos fuentes reales de histórico ya disponibles para los servidores de la comunidad.
|
||||
2. Definir qué entidades de dominio hacen falta como base. Incluir al menos:
|
||||
- servidor
|
||||
- partida
|
||||
- mapa
|
||||
- modo
|
||||
- jugador
|
||||
- participación de jugador en partida
|
||||
- métricas de partida por jugador
|
||||
3. Definir qué métricas históricas mínimas interesan en esta primera fase. Incluir al menos:
|
||||
- kills
|
||||
- muertes si está disponible
|
||||
- fecha/hora de partida
|
||||
- duración
|
||||
- mapa
|
||||
- servidor
|
||||
4. Definir la estrategia de identidad y deduplicación:
|
||||
- cómo identificar partidas
|
||||
- cómo identificar jugadores si el scoreboard no da un id perfecto
|
||||
- cómo evitar duplicados en la ingesta
|
||||
5. Definir el alcance inicial de analítica histórica. Incluir expresamente:
|
||||
- top kills de la última semana por servidor
|
||||
6. Documentar riesgos y límites:
|
||||
- estructura real del scoreboard
|
||||
- disponibilidad
|
||||
- scraping/control de cambios de HTML si aplica
|
||||
- granularidad de datos
|
||||
7. Actualizar la documentación técnica del repo con este modelo base.
|
||||
8. Al completar la implementación:
|
||||
- dejar el repositorio consistente
|
||||
- hacer commit
|
||||
- hacer push al remoto si el entorno lo permite
|
||||
|
||||
## Files to Read First
|
||||
- AGENTS.md
|
||||
- ai/repo-context.md
|
||||
- ai/architecture-index.md
|
||||
- docs/current-hll-servers-source-plan.md
|
||||
- docs/frontend-backend-contract.md
|
||||
- backend/README.md
|
||||
- cualquier código ya existente sobre histórico o scoreboard
|
||||
- las URLs reales de scoreboard ya usadas por la comunidad
|
||||
|
||||
## Expected Files to Modify
|
||||
- ai/architecture-index.md
|
||||
- docs/decisions.md
|
||||
- opcionalmente un nuevo documento técnico, por ejemplo:
|
||||
- docs/historical-stats-domain-model.md
|
||||
- opcionalmente backend/README.md si conviene reflejar el nuevo frente técnico
|
||||
|
||||
## Constraints
|
||||
- No implementar todavía scraping/ingesta completa en esta task.
|
||||
- No introducir UI histórica aún.
|
||||
- No hacer cambios destructivos.
|
||||
- Mantener el trabajo centrado en fuente, dominio y estrategia técnica.
|
||||
- Mantener foco en los 2 servidores reales de la comunidad.
|
||||
|
||||
## Validation
|
||||
- Existe un modelo de dominio claro para histórico.
|
||||
- Queda definida la fuente real de histórico para ambos servidores.
|
||||
- Queda definida la métrica inicial prioritaria: top kills de la última semana por servidor.
|
||||
- La documentación queda utilizable como base directa para las siguientes tasks.
|
||||
- Los cambios quedan committeados y se hace push si el entorno lo permite.
|
||||
|
||||
## Change Budget
|
||||
- Preferir menos de 5 archivos modificados o creados.
|
||||
- Preferir menos de 220 líneas cambiadas.
|
||||
|
||||
## Outcome
|
||||
- Se crea `docs/historical-stats-domain-model.md` como documento base para fuente real, entidades de dominio, identidad y deduplicación del histórico.
|
||||
- `docs/decisions.md` fija que el histórico de scoreboards reales queda separado del estado A2S actual.
|
||||
- `ai/architecture-index.md` y `backend/README.md` referencian explícitamente el nuevo frente histórico.
|
||||
|
||||
## Validation Result
|
||||
- Verificadas las dos fuentes reales de la comunidad en `https://scoreboard.comunidadhll.es/games` y `https://scoreboard.comunidadhll.es:5443/games`.
|
||||
- Verificado además que ambas cargan la misma SPA pública `Hell Let Loose Stats`, con referencias visibles a `games`, `players`, `kills`, `deaths`, `matches` y `server`.
|
||||
- Revisado `git diff --name-only`.
|
||||
- Resultado: el alcance documental queda limitado a `docs/historical-stats-domain-model.md`, `docs/decisions.md`, `ai/architecture-index.md` y `backend/README.md`.
|
||||
|
||||
## Decision Notes
|
||||
- Se documenta el scoreboard como fuente real del histórico y A2S como fuente del estado live para no mezclar dominios distintos.
|
||||
- La primera analítica prioritaria se fija en `top kills de la última semana por servidor` y no en un dashboard histórico genérico.
|
||||
@@ -0,0 +1,83 @@
|
||||
# TASK-029-historical-stats-ingestion-bootstrap
|
||||
|
||||
## Goal
|
||||
Preparar una base de ingesta histórica para los 2 servidores de la comunidad a partir de sus páginas de scoreboard, persistiendo datos suficientes para alimentar rankings semanales posteriores.
|
||||
|
||||
## Context
|
||||
Ya se ha definido o se va a definir el modelo histórico base. El siguiente paso técnico es arrancar la ingesta real desde las páginas de scoreboard de ambos servidores, guardando datos históricos estructurados de forma incremental y reutilizable.
|
||||
|
||||
## Steps
|
||||
1. Revisar el modelo de dominio/documentación histórica ya definida.
|
||||
2. Implementar una base mínima de ingesta para los 2 scoreboards reales de la comunidad.
|
||||
3. Extraer y persistir, como mínimo, datos suficientes sobre:
|
||||
- servidor
|
||||
- partida
|
||||
- fecha/hora
|
||||
- mapa
|
||||
- jugador
|
||||
- kills
|
||||
- otras métricas disponibles que puedan ser útiles y estables
|
||||
4. Diseñar la ingesta para poder ejecutarse varias veces sin duplicar datos.
|
||||
5. Mantener la solución preparada para refresco incremental futuro.
|
||||
6. Documentar cómo ejecutar la ingesta localmente.
|
||||
7. No implementar todavía dashboards o UI histórica.
|
||||
8. Al completar la implementación:
|
||||
- dejar el repositorio consistente
|
||||
- hacer commit
|
||||
- hacer push al remoto si el entorno lo permite
|
||||
|
||||
## Files to Read First
|
||||
- AGENTS.md
|
||||
- ai/repo-context.md
|
||||
- ai/architecture-index.md
|
||||
- docs/historical-stats-domain-model.md
|
||||
- backend/README.md
|
||||
- backend/app/config.py
|
||||
- cualquier almacenamiento o capa de persistencia ya existente
|
||||
- cualquier módulo collector existente
|
||||
- las URLs reales de scoreboard de ambos servidores
|
||||
|
||||
## Expected Files to Modify
|
||||
- backend/app/config.py
|
||||
- backend/README.md
|
||||
- uno o varios archivos nuevos de backend para ingesta histórica, por ejemplo:
|
||||
- backend/app/historical_ingestion.py
|
||||
- backend/app/historical_storage.py
|
||||
- backend/app/historical_parsers.py
|
||||
- opcionalmente archivos de datos o esquema local si la implementación lo requiere
|
||||
|
||||
## Constraints
|
||||
- No romper el flujo actual de estado en tiempo real.
|
||||
- No añadir UI histórica en esta task.
|
||||
- No introducir complejidad innecesaria.
|
||||
- No hacer cambios destructivos.
|
||||
- Mantener el trabajo centrado en ingesta, persistencia e idempotencia.
|
||||
|
||||
## Validation
|
||||
- Existe una ingesta histórica mínima para ambos servidores.
|
||||
- La ingesta persiste datos estructurados.
|
||||
- La ingesta puede reejecutarse sin duplicación grave.
|
||||
- La documentación explica cómo ejecutarla localmente.
|
||||
- Los cambios quedan committeados y se hace push al remoto si el entorno lo permite.
|
||||
|
||||
## Change Budget
|
||||
- Preferir menos de 8 archivos modificados o creados.
|
||||
- Preferir menos de 320 líneas cambiadas.
|
||||
|
||||
## Outcome
|
||||
- `backend/app/historical_ingestion.py` implementa una ingesta mínima sobre los 2 scoreboards reales usando `GET /api/get_public_info` y `GET /api/get_live_game_stats`.
|
||||
- `backend/app/historical_storage.py` añade persistencia idempotente en SQLite para partidas, jugadores y estadísticas por jugador en partida.
|
||||
- `backend/app/config.py` centraliza las dos fuentes reales de scoreboard.
|
||||
- `backend/README.md` documenta cómo ejecutar la ingesta localmente y qué tablas/payloads alimenta.
|
||||
|
||||
## Validation Result
|
||||
- Ejecutado desde `backend/`: `python -m app.historical_ingestion`.
|
||||
- Resultado: `capture_count: 2`, sin errores, con persistencia para `comunidad-hispana-01` y `comunidad-hispana-02`.
|
||||
- Revisada la SQLite local `backend/data/hll_vietnam_dev.sqlite3`.
|
||||
- Resultado: existen filas en `historical_matches`, `historical_players` y `historical_player_match_stats`.
|
||||
- Reejecutada la ingesta tras un ajuste menor de sanitización temporal.
|
||||
- Resultado: los upserts mantienen idempotencia básica y `time_seconds` queda no negativo en persistencia.
|
||||
|
||||
## Decision Notes
|
||||
- Se usa la API JSON real descubierta detrás del scoreboard en lugar de hacer scraping de la shell SPA.
|
||||
- La identidad de partida se basa en `server + start timestamp + map slug`, suficiente para la fase actual mientras no aparezca un `match id` público más fuerte.
|
||||
76
ai/tasks/done/TASK-030-weekly-top-kills-api.md
Normal file
76
ai/tasks/done/TASK-030-weekly-top-kills-api.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# TASK-030-weekly-top-kills-api
|
||||
|
||||
## Goal
|
||||
Exponer una primera API histórica útil que devuelva el ranking de jugadores con más kills de la última semana para cada uno de los 2 servidores de la comunidad.
|
||||
|
||||
## Context
|
||||
La primera necesidad histórica expresada para el proyecto es mostrar estadísticas como “jugadores con más kills de la última semana de cada servidor”. Una vez exista base mínima de ingesta y persistencia, la primera capa de valor debe ser un endpoint sencillo, estable y listo para alimentar futuras vistas o paneles.
|
||||
|
||||
## Steps
|
||||
1. Revisar la estructura histórica persistida y la documentación del modelo de dominio.
|
||||
2. Diseñar e implementar un endpoint o conjunto mínimo de endpoints para top kills semanales por servidor.
|
||||
3. Definir un payload claro que incluya, como mínimo:
|
||||
- servidor
|
||||
- rango
|
||||
- jugador
|
||||
- kills semanales
|
||||
- rango temporal usado
|
||||
4. Asegurar que la consulta se limita a la última semana real según la definición elegida por el proyecto.
|
||||
5. Mantener una implementación clara y preparada para futuras métricas históricas.
|
||||
6. Documentar el endpoint en backend.
|
||||
7. No implementar todavía UI histórica en esta task.
|
||||
8. Al completar la implementación:
|
||||
- dejar el repositorio consistente
|
||||
- hacer commit
|
||||
- hacer push al remoto si el entorno lo permite
|
||||
|
||||
## Files to Read First
|
||||
- AGENTS.md
|
||||
- ai/repo-context.md
|
||||
- ai/architecture-index.md
|
||||
- docs/historical-stats-domain-model.md
|
||||
- backend/README.md
|
||||
- backend/app/routes.py
|
||||
- backend/app/payloads.py
|
||||
- cualquier módulo de persistencia histórica creado en tasks previas
|
||||
|
||||
## Expected Files to Modify
|
||||
- backend/app/routes.py
|
||||
- backend/app/payloads.py
|
||||
- backend/README.md
|
||||
- opcionalmente nuevos módulos de consulta histórica en backend
|
||||
|
||||
## Constraints
|
||||
- No romper endpoints actuales.
|
||||
- No añadir UI histórica todavía.
|
||||
- No introducir librerías nuevas salvo necesidad muy justificada.
|
||||
- No hacer cambios destructivos.
|
||||
- Mantener el trabajo centrado en una primera API histórica útil y estable.
|
||||
|
||||
## Validation
|
||||
- Existe un endpoint histórico usable para top kills semanales por servidor.
|
||||
- El endpoint funciona para los 2 servidores reales de la comunidad.
|
||||
- El payload es claro y reutilizable.
|
||||
- La documentación backend queda alineada.
|
||||
- Los cambios quedan committeados y se hace push al remoto si el entorno lo permite.
|
||||
|
||||
## Change Budget
|
||||
- Preferir menos de 6 archivos modificados o creados.
|
||||
- Preferir menos de 240 líneas cambiadas.
|
||||
|
||||
## Outcome
|
||||
- `backend/app/historical_storage.py` añade la consulta agregada semanal de kills por servidor.
|
||||
- `backend/app/payloads.py` expone un payload estable para `top kills` semanales.
|
||||
- `backend/app/routes.py` añade `GET /api/historical/top-kills/weekly?limit=10` con soporte opcional para `server_id`.
|
||||
- `backend/README.md` documenta el endpoint y su payload.
|
||||
|
||||
## Validation Result
|
||||
- Ejecutado desde `backend/`: `python -` resolviendo `/api/historical/top-kills/weekly?limit=5` vía `resolve_get_payload(...)`.
|
||||
- Resultado: `HTTP 200` y rankings útiles para ambos servidores reales.
|
||||
- Ejemplo validado:
|
||||
- `comunidad-hispana-01` devuelve ranking encabezado por `[LCM] Vask0`
|
||||
- `comunidad-hispana-02` devuelve ranking encabezado por `Juanko`
|
||||
|
||||
## Decision Notes
|
||||
- Se expone primero un endpoint agregado por servidor para cubrir el caso de uso real expresado por el proyecto sin multiplicar rutas históricas prematuras.
|
||||
- La ventana temporal queda definida como rodante de 7 días usando UTC.
|
||||
@@ -109,6 +109,7 @@ normaliza espacios y barras finales para mantener la comparacion con el header
|
||||
- `GET /api/servers/latest`
|
||||
- `GET /api/servers/history?limit=20`
|
||||
- `GET /api/servers/{id}/history?limit=20`
|
||||
- `GET /api/historical/top-kills/weekly?limit=10`
|
||||
|
||||
`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
|
||||
@@ -397,6 +398,76 @@ consulta historica:
|
||||
persistido por el colector. El parametro opcional `limit` acepta valores entre
|
||||
`1` y `100`.
|
||||
|
||||
## Proximo frente historico
|
||||
|
||||
El siguiente frente backend ya documentado no reutiliza A2S como fuente de
|
||||
partidas. La base de estadisticas historicas por jugador y por partida parte de
|
||||
los scoreboards reales de Comunidad Hispana:
|
||||
|
||||
- `https://scoreboard.comunidadhll.es/games`
|
||||
- `https://scoreboard.comunidadhll.es:5443/games`
|
||||
|
||||
La definicion del modelo de dominio, identidad, deduplicacion y alcance inicial
|
||||
queda en `docs/historical-stats-domain-model.md`.
|
||||
|
||||
El descubrimiento tecnico realizado sobre el scoreboard publico confirma dos
|
||||
endpoints JSON utilizables para esta siguiente fase:
|
||||
|
||||
- `/api/get_public_info`
|
||||
- `/api/get_live_game_stats`
|
||||
|
||||
`get_public_info` aporta contexto de servidor, mapa y comienzo de la partida
|
||||
actual. `get_live_game_stats` aporta estadisticas por jugador de la partida en
|
||||
curso, incluyendo `player_id`, `kills`, `deaths` y `time_seconds`.
|
||||
|
||||
## Bootstrap de ingesta historica
|
||||
|
||||
El backend incluye una base minima de ingesta historica orientada al primer
|
||||
caso de uso de rankings semanales:
|
||||
|
||||
- `historical_ingestion.py` consulta los 2 scoreboards reales
|
||||
- `historical_storage.py` persiste partidas, jugadores y estadisticas por
|
||||
jugador en la misma SQLite local de desarrollo
|
||||
|
||||
Ejecucion manual desde `backend/`:
|
||||
|
||||
```powershell
|
||||
python -m app.historical_ingestion
|
||||
```
|
||||
|
||||
Ese comando:
|
||||
|
||||
- consulta `get_public_info` y `get_live_game_stats` en ambos scoreboards
|
||||
- construye una identidad de partida por `server + match_start + map`
|
||||
- hace upsert de jugadores por `player_id`
|
||||
- hace upsert de estadisticas por `match + player`, conservando el maximo valor
|
||||
observado de `kills`, `deaths` y `time_seconds`
|
||||
|
||||
Si se quiere validar sin escribir en SQLite:
|
||||
|
||||
```powershell
|
||||
python -m app.historical_ingestion --no-persist
|
||||
```
|
||||
|
||||
## Primera API historica util
|
||||
|
||||
Una vez exista al menos una ingesta historica persistida, el backend expone:
|
||||
|
||||
- `/api/historical/top-kills/weekly?limit=10`
|
||||
|
||||
Opcionalmente acepta:
|
||||
|
||||
- `server_id=comunidad-hispana-01`
|
||||
- `server_id=comunidad-hispana-02`
|
||||
|
||||
El payload devuelve:
|
||||
|
||||
- rango temporal usado
|
||||
- servidor
|
||||
- ranking
|
||||
- jugador
|
||||
- kills semanales agregadas
|
||||
|
||||
## CORS local minimo
|
||||
|
||||
El backend responde con `Access-Control-Allow-Origin` solo si la peticion llega
|
||||
|
||||
@@ -19,6 +19,18 @@ DEFAULT_ALLOWED_ORIGINS = (
|
||||
)
|
||||
DEFAULT_A2S_TARGETS_ENV_VAR = "HLL_BACKEND_A2S_TARGETS"
|
||||
DEFAULT_A2S_SOURCE_NAME = "community-hispana-a2s"
|
||||
DEFAULT_HISTORICAL_SCOREBOARD_SOURCES = (
|
||||
{
|
||||
"external_server_id": "comunidad-hispana-01",
|
||||
"display_name": "Comunidad Hispana #01",
|
||||
"scoreboard_base_url": "https://scoreboard.comunidadhll.es",
|
||||
},
|
||||
{
|
||||
"external_server_id": "comunidad-hispana-02",
|
||||
"display_name": "Comunidad Hispana #02",
|
||||
"scoreboard_base_url": "https://scoreboard.comunidadhll.es:5443",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_bind_address() -> tuple[str, int]:
|
||||
@@ -75,3 +87,8 @@ def get_a2s_targets_payload() -> str | None:
|
||||
|
||||
normalized = raw_payload.strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def get_historical_scoreboard_sources() -> tuple[dict[str, str], ...]:
|
||||
"""Return the real scoreboard sources used for historical player stats."""
|
||||
return tuple(dict(source) for source in DEFAULT_HISTORICAL_SCOREBOARD_SOURCES)
|
||||
|
||||
209
backend/app/historical_ingestion.py
Normal file
209
backend/app/historical_ingestion.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Historical scoreboard ingestion for the real Comunidad Hispana servers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from .config import get_historical_scoreboard_sources
|
||||
from .historical_storage import persist_historical_capture
|
||||
|
||||
|
||||
PUBLIC_INFO_PATH = "/api/get_public_info"
|
||||
LIVE_GAME_STATS_PATH = "/api/get_live_game_stats"
|
||||
DEFAULT_HTTP_TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
def collect_historical_stats(
|
||||
*,
|
||||
persist: bool = False,
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Collect the current historical-ready match state from both scoreboards."""
|
||||
captures: list[dict[str, object]] = []
|
||||
errors: list[dict[str, object]] = []
|
||||
|
||||
for source in get_historical_scoreboard_sources():
|
||||
try:
|
||||
capture = collect_server_historical_stats(source)
|
||||
if persist:
|
||||
capture["storage"] = persist_historical_capture(capture, db_path=db_path)
|
||||
captures.append(capture)
|
||||
except Exception as error: # noqa: BLE001 - keep ingestion failures isolated
|
||||
errors.append(
|
||||
{
|
||||
"external_server_id": source["external_server_id"],
|
||||
"scoreboard_base_url": source["scoreboard_base_url"],
|
||||
"message": str(error),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"captures": captures,
|
||||
"capture_count": len(captures),
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def collect_server_historical_stats(source: dict[str, str]) -> dict[str, object]:
|
||||
"""Collect one scoreboard snapshot with match and player metrics."""
|
||||
public_info_payload = _fetch_scoreboard_json(
|
||||
source["scoreboard_base_url"],
|
||||
PUBLIC_INFO_PATH,
|
||||
)
|
||||
live_game_stats_payload = _fetch_scoreboard_json(
|
||||
source["scoreboard_base_url"],
|
||||
LIVE_GAME_STATS_PATH,
|
||||
)
|
||||
|
||||
public_info = dict(public_info_payload.get("result") or {})
|
||||
live_game_stats = dict(live_game_stats_payload.get("result") or {})
|
||||
captured_at = _coerce_timestamp(live_game_stats.get("snapshot_timestamp"))
|
||||
|
||||
return {
|
||||
"external_server_id": source["external_server_id"],
|
||||
"display_name": source["display_name"],
|
||||
"scoreboard_base_url": source["scoreboard_base_url"],
|
||||
"captured_at": captured_at,
|
||||
"server_name": _extract_server_name(public_info, source["display_name"]),
|
||||
"match": _build_match_record(source, public_info, live_game_stats, captured_at),
|
||||
"players": _build_player_records(live_game_stats, captured_at),
|
||||
}
|
||||
|
||||
|
||||
def _fetch_scoreboard_json(base_url: str, path: str) -> dict[str, object]:
|
||||
request = Request(
|
||||
f"{base_url}{path}",
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "HLLVietnamBackend/0.1",
|
||||
},
|
||||
)
|
||||
with urlopen(request, timeout=DEFAULT_HTTP_TIMEOUT_SECONDS) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
error = payload.get("error")
|
||||
if error not in (None, [], [None]):
|
||||
raise RuntimeError(f"Scoreboard endpoint {path} returned error: {error}")
|
||||
|
||||
result = payload.get("result")
|
||||
if not isinstance(result, dict):
|
||||
raise RuntimeError(f"Scoreboard endpoint {path} did not return a result object.")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _extract_server_name(public_info: dict[str, object], fallback_name: str) -> str:
|
||||
server_name = public_info.get("name")
|
||||
if isinstance(server_name, dict):
|
||||
resolved_name = str(server_name.get("name") or "").strip()
|
||||
if resolved_name:
|
||||
return resolved_name
|
||||
|
||||
return fallback_name
|
||||
|
||||
|
||||
def _build_match_record(
|
||||
source: dict[str, str],
|
||||
public_info: dict[str, object],
|
||||
live_game_stats: dict[str, object],
|
||||
captured_at: str,
|
||||
) -> dict[str, object]:
|
||||
current_map_container = dict(public_info.get("current_map") or {})
|
||||
current_map = dict(current_map_container.get("map") or {})
|
||||
start_epoch = current_map_container.get("start")
|
||||
started_at = _coerce_timestamp(start_epoch)
|
||||
match_slug = str(current_map.get("id") or "unknown-match").strip() or "unknown-match"
|
||||
source_match_ref = f"{source['external_server_id']}:{int(start_epoch or 0)}:{match_slug}"
|
||||
|
||||
duration_seconds = None
|
||||
captured_at_dt = datetime.fromisoformat(captured_at.replace("Z", "+00:00"))
|
||||
started_at_dt = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
||||
if captured_at_dt >= started_at_dt:
|
||||
duration_seconds = int((captured_at_dt - started_at_dt).total_seconds())
|
||||
|
||||
return {
|
||||
"source_match_ref": source_match_ref,
|
||||
"started_at": started_at,
|
||||
"ended_at": None,
|
||||
"duration_seconds": duration_seconds,
|
||||
"map_slug": match_slug,
|
||||
"map_name": str(current_map.get("pretty_name") or "Mapa no disponible"),
|
||||
"mode_name": str(current_map.get("game_mode") or "unknown"),
|
||||
"server_name": _extract_server_name(public_info, source["display_name"]),
|
||||
}
|
||||
|
||||
|
||||
def _build_player_records(
|
||||
live_game_stats: dict[str, object],
|
||||
captured_at: str,
|
||||
) -> list[dict[str, object]]:
|
||||
player_rows = live_game_stats.get("stats")
|
||||
if not isinstance(player_rows, list):
|
||||
return []
|
||||
|
||||
players: list[dict[str, object]] = []
|
||||
for row in player_rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
|
||||
player_name = str(row.get("player") or "").strip()
|
||||
player_ref = str(row.get("player_id") or "").strip()
|
||||
if not player_name or not player_ref:
|
||||
continue
|
||||
|
||||
players.append(
|
||||
{
|
||||
"source_player_ref": player_ref,
|
||||
"canonical_name": player_name,
|
||||
"last_seen_name": player_name,
|
||||
"kills": _coerce_int(row.get("kills"), default=0),
|
||||
"deaths": _coerce_int(row.get("deaths")),
|
||||
"time_seconds": _coerce_int(row.get("time_seconds")),
|
||||
"captured_at": captured_at,
|
||||
}
|
||||
)
|
||||
|
||||
return players
|
||||
|
||||
|
||||
def _coerce_timestamp(value: object) -> str:
|
||||
if isinstance(value, (int, float)):
|
||||
resolved = datetime.fromtimestamp(value, tz=timezone.utc)
|
||||
return resolved.isoformat().replace("+00:00", "Z")
|
||||
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _coerce_int(value: object, *, default: int | None = None) -> int | None:
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
try:
|
||||
return max(0, int(float(value)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Allow manual historical ingestion execution during development."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Collect scoreboard-backed historical data for Comunidad Hispana.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-persist",
|
||||
action="store_true",
|
||||
help="Collect data without persisting it to the local SQLite database.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
payload = collect_historical_stats(persist=not args.no_persist)
|
||||
print(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
326
backend/app/historical_storage.py
Normal file
326
backend/app/historical_storage.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""Persistence and queries for scoreboard-backed historical player stats."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .config import get_storage_path
|
||||
|
||||
|
||||
def initialize_historical_storage(*, db_path: Path | None = None) -> Path:
|
||||
"""Ensure the local database contains the historical scoreboard tables."""
|
||||
resolved_path = db_path or get_storage_path()
|
||||
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with _connect(resolved_path) as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS historical_matches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
external_server_id TEXT NOT NULL,
|
||||
source_url TEXT NOT NULL,
|
||||
source_match_ref TEXT NOT NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT,
|
||||
duration_seconds INTEGER,
|
||||
map_slug TEXT,
|
||||
map_name TEXT NOT NULL,
|
||||
mode_name TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (external_server_id, source_match_ref)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS historical_players (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_player_ref TEXT NOT NULL UNIQUE,
|
||||
canonical_name TEXT NOT NULL,
|
||||
last_seen_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS historical_player_match_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
match_id INTEGER NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
player_name TEXT NOT NULL,
|
||||
kills INTEGER NOT NULL DEFAULT 0,
|
||||
deaths INTEGER,
|
||||
time_seconds INTEGER,
|
||||
captured_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (match_id, player_id),
|
||||
FOREIGN KEY (match_id) REFERENCES historical_matches(id),
|
||||
FOREIGN KEY (player_id) REFERENCES historical_players(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_historical_matches_server_started_at
|
||||
ON historical_matches(external_server_id, started_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_historical_player_match_stats_match_id
|
||||
ON historical_player_match_stats(match_id);
|
||||
"""
|
||||
)
|
||||
|
||||
return resolved_path
|
||||
|
||||
|
||||
def persist_historical_capture(
|
||||
capture: dict[str, object],
|
||||
*,
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Persist one scoreboard capture without duplicating match-player rows."""
|
||||
resolved_path = initialize_historical_storage(db_path=db_path)
|
||||
match = dict(capture.get("match") or {})
|
||||
players = list(capture.get("players") or [])
|
||||
|
||||
with _connect(resolved_path) as connection:
|
||||
match_id = _upsert_match(connection, capture, match)
|
||||
persisted_players = 0
|
||||
for player in players:
|
||||
player_id = _upsert_player(connection, player)
|
||||
_upsert_player_match_stats(connection, match_id, player_id, player)
|
||||
persisted_players += 1
|
||||
|
||||
return {
|
||||
"db_path": str(resolved_path),
|
||||
"external_server_id": capture.get("external_server_id"),
|
||||
"source_match_ref": match.get("source_match_ref"),
|
||||
"persisted_players": persisted_players,
|
||||
}
|
||||
|
||||
|
||||
def list_weekly_top_kills(
|
||||
*,
|
||||
limit: int = 10,
|
||||
server_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Return weekly aggregated kills grouped by server and player."""
|
||||
resolved_path = initialize_historical_storage(db_path=db_path)
|
||||
resolved_now = now or datetime.now(timezone.utc)
|
||||
window_start = resolved_now - timedelta(days=7)
|
||||
|
||||
clauses = ["historical_matches.started_at >= ?"]
|
||||
params: list[object] = [window_start.isoformat().replace("+00:00", "Z")]
|
||||
if server_id:
|
||||
clauses.append("historical_matches.external_server_id = ?")
|
||||
params.append(server_id)
|
||||
|
||||
where_clause = " AND ".join(clauses)
|
||||
query = f"""
|
||||
SELECT
|
||||
historical_matches.external_server_id,
|
||||
historical_matches.server_name,
|
||||
historical_players.source_player_ref AS player_ref,
|
||||
historical_players.last_seen_name AS player_name,
|
||||
SUM(historical_player_match_stats.kills) AS weekly_kills
|
||||
FROM historical_player_match_stats
|
||||
INNER JOIN historical_matches
|
||||
ON historical_matches.id = historical_player_match_stats.match_id
|
||||
INNER JOIN historical_players
|
||||
ON historical_players.id = historical_player_match_stats.player_id
|
||||
WHERE {where_clause}
|
||||
GROUP BY
|
||||
historical_matches.external_server_id,
|
||||
historical_matches.server_name,
|
||||
historical_players.id
|
||||
ORDER BY
|
||||
historical_matches.external_server_id ASC,
|
||||
weekly_kills DESC,
|
||||
player_name ASC
|
||||
"""
|
||||
|
||||
with _connect(resolved_path) as connection:
|
||||
rows = connection.execute(query, tuple(params)).fetchall()
|
||||
|
||||
grouped_items: list[dict[str, object]] = []
|
||||
current_server_id = None
|
||||
current_group: dict[str, object] | None = None
|
||||
|
||||
for row in rows:
|
||||
external_server_id = str(row["external_server_id"])
|
||||
if external_server_id != current_server_id:
|
||||
current_server_id = external_server_id
|
||||
current_group = {
|
||||
"external_server_id": external_server_id,
|
||||
"server_name": row["server_name"],
|
||||
"rankings": [],
|
||||
}
|
||||
grouped_items.append(current_group)
|
||||
|
||||
assert current_group is not None
|
||||
rankings = current_group["rankings"]
|
||||
if len(rankings) >= limit:
|
||||
continue
|
||||
|
||||
rankings.append(
|
||||
{
|
||||
"rank": len(rankings) + 1,
|
||||
"player_id": row["player_ref"],
|
||||
"player_name": row["player_name"],
|
||||
"weekly_kills": int(row["weekly_kills"] or 0),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"window_start": window_start.isoformat().replace("+00:00", "Z"),
|
||||
"window_end": resolved_now.isoformat().replace("+00:00", "Z"),
|
||||
"items": grouped_items,
|
||||
}
|
||||
|
||||
|
||||
def _connect(db_path: Path) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(db_path)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
|
||||
def _upsert_match(
|
||||
connection: sqlite3.Connection,
|
||||
capture: dict[str, object],
|
||||
match: dict[str, object],
|
||||
) -> int:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO historical_matches (
|
||||
external_server_id,
|
||||
source_url,
|
||||
source_match_ref,
|
||||
server_name,
|
||||
started_at,
|
||||
ended_at,
|
||||
duration_seconds,
|
||||
map_slug,
|
||||
map_name,
|
||||
mode_name
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(external_server_id, source_match_ref) DO UPDATE SET
|
||||
server_name = excluded.server_name,
|
||||
ended_at = excluded.ended_at,
|
||||
duration_seconds = CASE
|
||||
WHEN historical_matches.duration_seconds IS NULL THEN excluded.duration_seconds
|
||||
WHEN excluded.duration_seconds IS NULL THEN historical_matches.duration_seconds
|
||||
WHEN excluded.duration_seconds > historical_matches.duration_seconds
|
||||
THEN excluded.duration_seconds
|
||||
ELSE historical_matches.duration_seconds
|
||||
END,
|
||||
map_slug = excluded.map_slug,
|
||||
map_name = excluded.map_name,
|
||||
mode_name = excluded.mode_name,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(
|
||||
capture.get("external_server_id"),
|
||||
capture.get("scoreboard_base_url"),
|
||||
match.get("source_match_ref"),
|
||||
match.get("server_name"),
|
||||
match.get("started_at"),
|
||||
match.get("ended_at"),
|
||||
match.get("duration_seconds"),
|
||||
match.get("map_slug"),
|
||||
match.get("map_name"),
|
||||
match.get("mode_name"),
|
||||
),
|
||||
)
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM historical_matches
|
||||
WHERE external_server_id = ? AND source_match_ref = ?
|
||||
""",
|
||||
(capture.get("external_server_id"), match.get("source_match_ref")),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise RuntimeError("Failed to resolve historical match id.")
|
||||
|
||||
return int(row["id"])
|
||||
|
||||
|
||||
def _upsert_player(connection: sqlite3.Connection, player: dict[str, object]) -> int:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO historical_players (
|
||||
source_player_ref,
|
||||
canonical_name,
|
||||
last_seen_name
|
||||
) VALUES (?, ?, ?)
|
||||
ON CONFLICT(source_player_ref) DO UPDATE SET
|
||||
canonical_name = excluded.canonical_name,
|
||||
last_seen_name = excluded.last_seen_name,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(
|
||||
player.get("source_player_ref"),
|
||||
player.get("canonical_name"),
|
||||
player.get("last_seen_name"),
|
||||
),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT id FROM historical_players WHERE source_player_ref = ?",
|
||||
(player.get("source_player_ref"),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise RuntimeError("Failed to resolve historical player id.")
|
||||
|
||||
return int(row["id"])
|
||||
|
||||
|
||||
def _upsert_player_match_stats(
|
||||
connection: sqlite3.Connection,
|
||||
match_id: int,
|
||||
player_id: int,
|
||||
player: dict[str, object],
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO historical_player_match_stats (
|
||||
match_id,
|
||||
player_id,
|
||||
player_name,
|
||||
kills,
|
||||
deaths,
|
||||
time_seconds,
|
||||
captured_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(match_id, player_id) DO UPDATE SET
|
||||
player_name = excluded.player_name,
|
||||
kills = CASE
|
||||
WHEN excluded.kills > historical_player_match_stats.kills
|
||||
THEN excluded.kills
|
||||
ELSE historical_player_match_stats.kills
|
||||
END,
|
||||
deaths = CASE
|
||||
WHEN historical_player_match_stats.deaths IS NULL THEN excluded.deaths
|
||||
WHEN excluded.deaths IS NULL THEN historical_player_match_stats.deaths
|
||||
WHEN excluded.deaths > historical_player_match_stats.deaths
|
||||
THEN excluded.deaths
|
||||
ELSE historical_player_match_stats.deaths
|
||||
END,
|
||||
time_seconds = CASE
|
||||
WHEN historical_player_match_stats.time_seconds IS NULL THEN excluded.time_seconds
|
||||
WHEN excluded.time_seconds IS NULL THEN historical_player_match_stats.time_seconds
|
||||
WHEN excluded.time_seconds > historical_player_match_stats.time_seconds
|
||||
THEN excluded.time_seconds
|
||||
ELSE historical_player_match_stats.time_seconds
|
||||
END,
|
||||
captured_at = excluded.captured_at,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(
|
||||
match_id,
|
||||
player_id,
|
||||
player.get("last_seen_name"),
|
||||
player.get("kills"),
|
||||
player.get("deaths"),
|
||||
player.get("time_seconds"),
|
||||
player.get("captured_at"),
|
||||
),
|
||||
)
|
||||
@@ -15,6 +15,7 @@ from .payloads import (
|
||||
build_server_latest_payload,
|
||||
build_servers_payload,
|
||||
build_trailer_payload,
|
||||
build_weekly_top_kills_payload,
|
||||
)
|
||||
|
||||
|
||||
@@ -39,6 +40,13 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
|
||||
return HTTPStatus.OK, build_server_history_payload(limit=limit)
|
||||
|
||||
if parsed.path == "/api/historical/top-kills/weekly":
|
||||
limit = _parse_limit(parsed.query)
|
||||
if limit is None:
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
|
||||
server_id = parse_qs(parsed.query).get("server_id", [None])[0]
|
||||
return HTTPStatus.OK, build_weekly_top_kills_payload(limit=limit, server_id=server_id)
|
||||
|
||||
builder = GET_ROUTES.get(parsed.path)
|
||||
if builder is None:
|
||||
if parsed.path.startswith("/api/servers/") and parsed.path.endswith("/history"):
|
||||
|
||||
@@ -83,3 +83,22 @@ reutilizable para HLL actual y para futuras fuentes mas cercanas a HLL Vietnam.
|
||||
|
||||
El modelo base y las preguntas abiertas quedan documentados en
|
||||
`docs/stats-database-schema-foundation.md`.
|
||||
|
||||
## Decision 012: historico de scoreboards reales separado del estado A2S
|
||||
|
||||
La capa historica del proyecto debe tomar como fuente inicial las dos paginas de
|
||||
scoreboard reales ya usadas por la comunidad:
|
||||
|
||||
- `https://scoreboard.comunidadhll.es/games`
|
||||
- `https://scoreboard.comunidadhll.es:5443/games`
|
||||
|
||||
Esta fuente historica no debe mezclarse conceptualmente con los snapshots A2S de
|
||||
estado actual. A2S sigue siendo la fuente del panel live; el scoreboard pasa a
|
||||
ser la fuente base para partidas, jugadores y metricas agregadas.
|
||||
|
||||
La primera analitica prioritaria sera `top kills de la ultima semana por
|
||||
servidor`, por lo que el dominio historico debe organizarse alrededor de
|
||||
servidor, partida, jugador, participacion y metricas por jugador en partida.
|
||||
|
||||
La definicion de fuente, identidad, deduplicacion y riesgos queda documentada
|
||||
en `docs/historical-stats-domain-model.md`.
|
||||
|
||||
262
docs/historical-stats-domain-model.md
Normal file
262
docs/historical-stats-domain-model.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# Historical Stats Domain Model
|
||||
|
||||
## Objective
|
||||
|
||||
Definir la fuente real y el modelo de dominio minimo para construir una capa
|
||||
historica propia sobre los 2 servidores reales de Comunidad Hispana, sin
|
||||
implementar todavia la ingesta completa ni comprometer una base de datos
|
||||
productiva distinta de la base local ya usada en desarrollo.
|
||||
|
||||
## Real Historical Sources
|
||||
|
||||
Los 2 servidores reales ya exponen historico publico en:
|
||||
|
||||
- `https://scoreboard.comunidadhll.es/games`
|
||||
- `https://scoreboard.comunidadhll.es:5443/games`
|
||||
|
||||
Observaciones verificadas sobre ambas fuentes:
|
||||
|
||||
- ambas responden con una SPA publica titulada `Hell Let Loose Stats`
|
||||
- la descripcion publica indica `Hell Let Loose Statistics and Game History`
|
||||
- ambas cargan el mismo bundle frontend, por lo que la estructura funcional
|
||||
observable parece comun y cambia solo el dataset servido por cada dominio
|
||||
- en el bundle publico aparecen referencias a conceptos como `games`,
|
||||
`players`, `kills`, `deaths`, `matches` y `server`
|
||||
|
||||
Inferencia operativa:
|
||||
|
||||
- la fuente real no debe modelarse como una tabla HTML estable
|
||||
- la primera opcion tecnica futura debe ser descubrir y consumir un endpoint
|
||||
estructurado del scoreboard si existe
|
||||
- si no existe un endpoint publico reutilizable, la alternativa seria un parser
|
||||
controlado de HTML renderizado o del payload que hidrate la SPA
|
||||
|
||||
## Scope Of Historical Analytics Phase 1
|
||||
|
||||
La primera capacidad historica util del proyecto sera:
|
||||
|
||||
- top kills de la ultima semana por servidor
|
||||
|
||||
Por tanto, la ingesta base debe capturar como minimo:
|
||||
|
||||
- servidor
|
||||
- partida
|
||||
- fecha/hora de partida
|
||||
- duracion si esta disponible
|
||||
- mapa
|
||||
- modo
|
||||
- jugador
|
||||
- kills
|
||||
- muertes si estan disponibles
|
||||
|
||||
## Proposed Domain Entities
|
||||
|
||||
### `historical_servers`
|
||||
|
||||
Proposito:
|
||||
representar cada servidor real de la comunidad como origen de historico.
|
||||
|
||||
Campos minimos:
|
||||
|
||||
- `server_id`
|
||||
- `external_server_id`
|
||||
- `display_name`
|
||||
- `scoreboard_base_url`
|
||||
- `is_active`
|
||||
|
||||
Notas:
|
||||
|
||||
- esta entidad reutiliza la identidad ya establecida en `servers` cuando la
|
||||
implementacion llegue a persistencia
|
||||
- `scoreboard_base_url` permite mantener separado el origen historico por
|
||||
servidor sin hardcodear rutas dispersas
|
||||
|
||||
### `historical_matches`
|
||||
|
||||
Proposito:
|
||||
representar una partida individual extraida del scoreboard.
|
||||
|
||||
Campos minimos:
|
||||
|
||||
- `match_id`
|
||||
- `server_id`
|
||||
- `source_match_ref`
|
||||
- `started_at`
|
||||
- `ended_at` nullable
|
||||
- `duration_seconds` nullable
|
||||
- `map_id`
|
||||
- `mode_id` nullable
|
||||
- `source_fingerprint`
|
||||
|
||||
Notas:
|
||||
|
||||
- `source_match_ref` debe conservar el identificador nativo del scoreboard si
|
||||
la fuente lo expone
|
||||
- `source_fingerprint` cubre el caso en que no exista un id perfecto y haya que
|
||||
deduplicar por combinacion de campos observables
|
||||
|
||||
### `historical_maps`
|
||||
|
||||
Proposito:
|
||||
mantener identidad y nombre normalizado de mapa.
|
||||
|
||||
Campos minimos:
|
||||
|
||||
- `map_id`
|
||||
- `map_slug`
|
||||
- `display_name`
|
||||
- `raw_map_name`
|
||||
|
||||
Notas:
|
||||
|
||||
- el pipeline debe conservar el valor crudo y tambien un nombre normalizado
|
||||
- esto evita que los rankings historicos mezclen variantes tecnicas del mismo
|
||||
mapa
|
||||
|
||||
### `historical_modes`
|
||||
|
||||
Proposito:
|
||||
normalizar el modo de partida si la fuente lo ofrece.
|
||||
|
||||
Campos minimos:
|
||||
|
||||
- `mode_id`
|
||||
- `mode_slug`
|
||||
- `display_name`
|
||||
- `raw_mode_name`
|
||||
|
||||
### `historical_players`
|
||||
|
||||
Proposito:
|
||||
mantener una identidad de jugador lo bastante estable para consultas agregadas.
|
||||
|
||||
Campos minimos:
|
||||
|
||||
- `player_id`
|
||||
- `identity_kind`
|
||||
- `canonical_name`
|
||||
- `last_seen_name`
|
||||
- `source_player_ref` nullable
|
||||
- `identity_fingerprint`
|
||||
|
||||
Notas:
|
||||
|
||||
- si el scoreboard no da un id perfecto, la identidad inicial puede basarse en
|
||||
una huella controlada por servidor y nombre normalizado
|
||||
- la identidad debe ser revisable porque el nombre visible puede cambiar
|
||||
|
||||
### `historical_player_participations`
|
||||
|
||||
Proposito:
|
||||
representar que un jugador participo en una partida concreta.
|
||||
|
||||
Campos minimos:
|
||||
|
||||
- `participation_id`
|
||||
- `match_id`
|
||||
- `player_id`
|
||||
- `team` nullable
|
||||
- `joined_at` nullable
|
||||
- `left_at` nullable
|
||||
|
||||
Notas:
|
||||
|
||||
- esta capa separa la presencia del jugador de sus metricas
|
||||
- deja espacio para futuras metricas por equipo o duracion de participacion
|
||||
|
||||
### `historical_player_match_stats`
|
||||
|
||||
Proposito:
|
||||
guardar las metricas observadas por jugador dentro de una partida.
|
||||
|
||||
Campos minimos:
|
||||
|
||||
- `participation_id`
|
||||
- `kills`
|
||||
- `deaths` nullable
|
||||
- `assists` nullable
|
||||
- `score` nullable
|
||||
- `rank_in_match` nullable
|
||||
- `captured_at`
|
||||
|
||||
Notas:
|
||||
|
||||
- `kills` es la metrica obligatoria de la primera fase
|
||||
- `deaths` se persiste solo si la fuente la ofrece de forma estable
|
||||
|
||||
## Identity And Deduplication Strategy
|
||||
|
||||
### Match identity
|
||||
|
||||
Orden preferido:
|
||||
|
||||
1. `source_match_ref` del scoreboard si existe y es estable
|
||||
2. huella derivada de `server_id + started_at + map + mode + duration`
|
||||
|
||||
Regla:
|
||||
|
||||
- no insertar una partida nueva si ya existe la misma referencia nativa o la
|
||||
misma huella derivada
|
||||
|
||||
### Player identity
|
||||
|
||||
Orden preferido:
|
||||
|
||||
1. `source_player_ref` del scoreboard si existe
|
||||
2. identidad derivada de `server_id + normalized_player_name`
|
||||
|
||||
Riesgo:
|
||||
|
||||
- esto no resuelve por completo renombres o colisiones entre jugadores con el
|
||||
mismo nombre
|
||||
|
||||
Mitigacion inicial:
|
||||
|
||||
- conservar siempre `canonical_name`, `last_seen_name` y la referencia nativa
|
||||
cuando exista
|
||||
- documentar que los rankings de fase 1 pueden tener precision limitada si el
|
||||
scoreboard no expone un id fuerte
|
||||
|
||||
### Ingestion deduplication
|
||||
|
||||
Para poder reejecutar la ingesta sin duplicacion grave:
|
||||
|
||||
- upsert por `source_match_ref` o `source_fingerprint` en partidas
|
||||
- upsert por `source_player_ref` o `identity_fingerprint` en jugadores
|
||||
- unicidad por `match_id + player_id` en participaciones
|
||||
- unicidad por `participation_id` en metricas de partida por jugador
|
||||
|
||||
## Initial Query Shape
|
||||
|
||||
Consulta prioritaria:
|
||||
|
||||
- top kills de la ultima semana por servidor
|
||||
|
||||
Agrupacion minima necesaria:
|
||||
|
||||
- filtrar partidas del servidor cuya ventana temporal caiga dentro de los
|
||||
ultimos 7 dias segun `started_at` o `ended_at`
|
||||
- sumar `kills` por `player_id`
|
||||
- devolver nombre canonico del jugador, kills agregadas y rango
|
||||
|
||||
Definicion inicial de ventana:
|
||||
|
||||
- ventana rodante de 7 dias hacia atras desde el momento de consulta
|
||||
- usar UTC en backend para evitar ambiguedades
|
||||
|
||||
## Risks And Limits
|
||||
|
||||
- la estructura visible hoy es la de una SPA; el contrato interno del scoreboard
|
||||
puede cambiar sin aviso
|
||||
- no se ha fijado todavia un endpoint JSON publico y estable del scoreboard
|
||||
- si solo existe HTML cliente, el scraping sera mas fragil que una API
|
||||
- la identidad de jugador puede ser imperfecta si la fuente no expone un id
|
||||
- una misma partida podria aparecer con informacion parcial durante una captura
|
||||
- la disponibilidad del scoreboard no debe bloquear el backend principal
|
||||
|
||||
## Recommended Technical Direction For Following Tasks
|
||||
|
||||
1. Descubrir primero el endpoint estructurado o payload interno usado por la SPA.
|
||||
2. Implementar un parser backend desacoplado por servidor de scoreboard.
|
||||
3. Persistir solo entidades y metricas necesarias para top kills semanal.
|
||||
4. Mantener la ingesta idempotente antes de abrir endpoints historicos nuevos.
|
||||
Reference in New Issue
Block a user