From fc13715abc9a5f9f723ef869ba2aad9924cdd133 Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Fri, 20 Mar 2026 21:27:45 +0100 Subject: [PATCH] Add scoreboard-backed weekly historical stats --- ai/architecture-index.md | 1 + ...istorical-stats-source-and-domain-model.md | 91 +++++ ...29-historical-stats-ingestion-bootstrap.md | 83 +++++ .../done/TASK-030-weekly-top-kills-api.md | 76 ++++ backend/README.md | 71 ++++ backend/app/config.py | 17 + backend/app/historical_ingestion.py | 209 +++++++++++ backend/app/historical_storage.py | 326 ++++++++++++++++++ backend/app/routes.py | 8 + docs/decisions.md | 19 + docs/historical-stats-domain-model.md | 262 ++++++++++++++ 11 files changed, 1163 insertions(+) create mode 100644 ai/tasks/done/TASK-028-historical-stats-source-and-domain-model.md create mode 100644 ai/tasks/done/TASK-029-historical-stats-ingestion-bootstrap.md create mode 100644 ai/tasks/done/TASK-030-weekly-top-kills-api.md create mode 100644 backend/app/historical_ingestion.py create mode 100644 backend/app/historical_storage.py create mode 100644 docs/historical-stats-domain-model.md diff --git a/ai/architecture-index.md b/ai/architecture-index.md index d17de68..e66a4af 100644 --- a/ai/architecture-index.md +++ b/ai/architecture-index.md @@ -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`. diff --git a/ai/tasks/done/TASK-028-historical-stats-source-and-domain-model.md b/ai/tasks/done/TASK-028-historical-stats-source-and-domain-model.md new file mode 100644 index 0000000..2559f60 --- /dev/null +++ b/ai/tasks/done/TASK-028-historical-stats-source-and-domain-model.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. diff --git a/ai/tasks/done/TASK-029-historical-stats-ingestion-bootstrap.md b/ai/tasks/done/TASK-029-historical-stats-ingestion-bootstrap.md new file mode 100644 index 0000000..74830e8 --- /dev/null +++ b/ai/tasks/done/TASK-029-historical-stats-ingestion-bootstrap.md @@ -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. diff --git a/ai/tasks/done/TASK-030-weekly-top-kills-api.md b/ai/tasks/done/TASK-030-weekly-top-kills-api.md new file mode 100644 index 0000000..2175cd2 --- /dev/null +++ b/ai/tasks/done/TASK-030-weekly-top-kills-api.md @@ -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. diff --git a/backend/README.md b/backend/README.md index c4966df..933ddd8 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py index e4f309f..ef4c886 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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) diff --git a/backend/app/historical_ingestion.py b/backend/app/historical_ingestion.py new file mode 100644 index 0000000..566d425 --- /dev/null +++ b/backend/app/historical_ingestion.py @@ -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() diff --git a/backend/app/historical_storage.py b/backend/app/historical_storage.py new file mode 100644 index 0000000..3ad0ad5 --- /dev/null +++ b/backend/app/historical_storage.py @@ -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"), + ), + ) diff --git a/backend/app/routes.py b/backend/app/routes.py index dda288d..e819aa7 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -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"): diff --git a/docs/decisions.md b/docs/decisions.md index 6b6b304..e06efce 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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`. diff --git a/docs/historical-stats-domain-model.md b/docs/historical-stats-domain-model.md new file mode 100644 index 0000000..bdaf4c8 --- /dev/null +++ b/docs/historical-stats-domain-model.md @@ -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.