Revert "Add scoreboard-backed weekly historical stats"

This reverts commit fc13715abc.
This commit is contained in:
devRaGonSa
2026-03-20 21:35:11 +01:00
parent fc13715abc
commit 687c7a926d
11 changed files with 0 additions and 1163 deletions

View File

@@ -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 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 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 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. - Frontend data consumption should remain progressive, endpoint by endpoint, with static fallbacks preserved during migration.
- The frontend integration strategy is documented in `docs/frontend-data-consumption-plan.md`. - The frontend integration strategy is documented in `docs/frontend-data-consumption-plan.md`.

View File

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

View File

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

View File

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

View File

@@ -109,7 +109,6 @@ normaliza espacios y barras finales para mantener la comparacion con el header
- `GET /api/servers/latest` - `GET /api/servers/latest`
- `GET /api/servers/history?limit=20` - `GET /api/servers/history?limit=20`
- `GET /api/servers/{id}/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 `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 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 persistido por el colector. El parametro opcional `limit` acepta valores entre
`1` y `100`. `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 ## CORS local minimo
El backend responde con `Access-Control-Allow-Origin` solo si la peticion llega El backend responde con `Access-Control-Allow-Origin` solo si la peticion llega

View File

@@ -19,18 +19,6 @@ DEFAULT_ALLOWED_ORIGINS = (
) )
DEFAULT_A2S_TARGETS_ENV_VAR = "HLL_BACKEND_A2S_TARGETS" DEFAULT_A2S_TARGETS_ENV_VAR = "HLL_BACKEND_A2S_TARGETS"
DEFAULT_A2S_SOURCE_NAME = "community-hispana-a2s" 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]: def get_bind_address() -> tuple[str, int]:
@@ -87,8 +75,3 @@ def get_a2s_targets_payload() -> str | None:
normalized = raw_payload.strip() normalized = raw_payload.strip()
return normalized or None 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)

View File

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

View File

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

View File

@@ -15,7 +15,6 @@ from .payloads import (
build_server_latest_payload, build_server_latest_payload,
build_servers_payload, build_servers_payload,
build_trailer_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.BAD_REQUEST, build_error_payload("Invalid limit parameter")
return HTTPStatus.OK, build_server_history_payload(limit=limit) 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) builder = GET_ROUTES.get(parsed.path)
if builder is None: if builder is None:
if parsed.path.startswith("/api/servers/") and parsed.path.endswith("/history"): if parsed.path.startswith("/api/servers/") and parsed.path.endswith("/history"):

View File

@@ -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 El modelo base y las preguntas abiertas quedan documentados en
`docs/stats-database-schema-foundation.md`. `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`.

View File

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