From 687c7a926dee19c45099878e17ba6b84f3b8630f Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Fri, 20 Mar 2026 21:35:11 +0100 Subject: [PATCH] Revert "Add scoreboard-backed weekly historical stats" This reverts commit fc13715abc9a5f9f723ef869ba2aad9924cdd133. --- 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 deletions(-) delete mode 100644 ai/tasks/done/TASK-028-historical-stats-source-and-domain-model.md delete mode 100644 ai/tasks/done/TASK-029-historical-stats-ingestion-bootstrap.md delete mode 100644 ai/tasks/done/TASK-030-weekly-top-kills-api.md delete mode 100644 backend/app/historical_ingestion.py delete mode 100644 backend/app/historical_storage.py delete mode 100644 docs/historical-stats-domain-model.md diff --git a/ai/architecture-index.md b/ai/architecture-index.md index e66a4af..d17de68 100644 --- a/ai/architecture-index.md +++ b/ai/architecture-index.md @@ -67,6 +67,5 @@ 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 deleted file mode 100644 index 2559f60..0000000 --- a/ai/tasks/done/TASK-028-historical-stats-source-and-domain-model.md +++ /dev/null @@ -1,91 +0,0 @@ -# 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 deleted file mode 100644 index 74830e8..0000000 --- a/ai/tasks/done/TASK-029-historical-stats-ingestion-bootstrap.md +++ /dev/null @@ -1,83 +0,0 @@ -# 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 deleted file mode 100644 index 2175cd2..0000000 --- a/ai/tasks/done/TASK-030-weekly-top-kills-api.md +++ /dev/null @@ -1,76 +0,0 @@ -# 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 933ddd8..c4966df 100644 --- a/backend/README.md +++ b/backend/README.md @@ -109,7 +109,6 @@ 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 @@ -398,76 +397,6 @@ 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 ef4c886..e4f309f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,18 +19,6 @@ 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]: @@ -87,8 +75,3 @@ 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 deleted file mode 100644 index 566d425..0000000 --- a/backend/app/historical_ingestion.py +++ /dev/null @@ -1,209 +0,0 @@ -"""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 deleted file mode 100644 index 3ad0ad5..0000000 --- a/backend/app/historical_storage.py +++ /dev/null @@ -1,326 +0,0 @@ -"""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 e819aa7..dda288d 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -15,7 +15,6 @@ from .payloads import ( build_server_latest_payload, build_servers_payload, build_trailer_payload, - build_weekly_top_kills_payload, ) @@ -40,13 +39,6 @@ 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 e06efce..6b6b304 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -83,22 +83,3 @@ 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 deleted file mode 100644 index bdaf4c8..0000000 --- a/docs/historical-stats-domain-model.md +++ /dev/null @@ -1,262 +0,0 @@ -# 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.