commit 0cf98a1be95a5611800f41e443ae32689fab1635 Author: devRaGonSa Date: Tue Jun 2 16:23:16 2026 +0200 initial export diff --git a/.github/workflows/codex-worker.yml b/.github/workflows/codex-worker.yml new file mode 100644 index 0000000..2767fb5 --- /dev/null +++ b/.github/workflows/codex-worker.yml @@ -0,0 +1,33 @@ +name: Codex Worker + +on: + workflow_dispatch: + push: + paths: + - 'ai/tasks/pending/**' + - 'AGENTS.md' + - 'ai/**' + - 'scripts/codex-runner.ps1' + +jobs: + run-codex-worker: + runs-on: ubuntu-latest + if: ${{ secrets.OPENAI_API_KEY != '' }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Codex CLI + run: npm install -g @openai/codex + + - name: Run Codex Worker + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + codex --auto "Follow AGENTS.md, read the AI platform files in ai/, and process only the pending tasks within scoped repository rules." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31b24dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +.venv/ +__pycache__/ +*.pyc +node_modules/ +.idea/ +.vscode/ +dist/ +build/ +.DS_Store +Thumbs.db + +# Local AI worker/runtime artifacts +ai/worker.lock +ai/reports/*.md +!ai/reports/.gitkeep + +# Local backend runtime data +backend/runtime/ +backend/data/*.sqlite3 +backend/data/*.writer.lock +!backend/data/.gitkeep +backend/data/snapshots/** +!backend/data/snapshots/.gitkeep +.env +backend/data/*.sqlite3-shm +backend/data/*.sqlite3-wal diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d1c2fed --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,110 @@ +# HLL Vietnam Agent Operating Rules + +This repository uses an AI-driven task workflow adapted to HLL Vietnam. + +## Project Context + +- Product: HLL Vietnam +- Product type: community website +- Current frontend: HTML, CSS and vanilla JavaScript +- Planned backend: Python +- Current product scope: simple landing page and repository foundation +- Visual identity: military, Vietnam, tactical, sober + +## Task System + +Task locations: + +- Pending: `ai/tasks/pending` +- In progress: `ai/tasks/in-progress` +- Review: `ai/tasks/review` +- Blocked: `ai/tasks/blocked` +- Obsolete: `ai/tasks/obsolete` +- Done: `ai/tasks/done` + +Every new task must follow: + +- `ai/task-template.md` + +Local platform scripts should read repository-specific paths and worker settings from: + +- `ai-platform.json` + +## Core Workflow + +1. The orchestrator reviews repository context and relevant code. +2. The orchestrator writes or refines a task in `ai/tasks/pending`. +3. A worker moves the selected task to `ai/tasks/in-progress`. +4. The worker reads the files listed in `Files to Read First`. +5. The worker performs only the scoped change defined by the task. +6. The worker validates the change with the documented checks. +7. The worker moves completed work to `ai/tasks/done` when validation is complete, or to `ai/tasks/review` when human/orchestrator review is explicitly required. +8. The worker documents any relevant architectural or process decision. + +Codex must not act freely outside tasks except for repository inspection, platform maintenance, or explicitly requested integration work like this one. + +## Roles Used In This Repository + +- PM +- Analista +- Backend Senior +- Frontend Senior +- Arquitecto de Base de Datos +- Arquitecto Python +- Disenador grafico +- Experto en interfaz + +Role guidance is stored in: + +- `ai/orchestrator/` + +## Rules + +- Do not break repository structure without explicit technical justification. +- Do not make destructive changes without explicit justification. +- Keep changes small, verifiable and documented. +- Do not overwrite existing project context with generic template content. +- Preserve HLL Vietnam branding and product identity. +- Do not introduce unnecessary frameworks in the current phase. +- Do not build backend functionality until a task explicitly requires it. +- Do not modify unrelated files. + +## Technical Constraints + +- Frontend changes must remain compatible with direct browser opening when applicable. +- Backend architecture decisions must assume Python as the primary backend language. +- AI platform files are support infrastructure, not product features. +- If a template utility is copied from the platform template, it must remain clearly identified as platform infrastructure. + +## Planning Rules + +Before drafting or executing a task: + +1. Read `ai/architecture-index.md`. +2. Read `ai/repo-context.md`. +3. Read the relevant role file in `ai/orchestrator/`. +4. Read the small set of project files directly related to the requested change. + +When no pending product task exists: + +1. Do not invent a large backlog. +2. Only create a minimal technical validation task if needed to verify platform readiness. +3. Avoid feature planning that changes product scope without instruction. + +## Change Budget + +- Prefer fewer than 5 modified files per task. +- Prefer changes under 200 lines when feasible. +- Split work into follow-up tasks if the scope grows. + +## Validation + +Before marking a task as done: + +1. Run the validation listed in the task. +2. Review `git diff --name-only`. +3. Confirm that changed files match the expected scope. +4. Update documentation if the task changed workflow or architecture assumptions. + +If integration tests are relevant and `scripts/run-integration-tests.ps1` exists, use it. +If no integration tests are configured for the affected scope, document that explicitly in the task outcome. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ac8dbc --- /dev/null +++ b/README.md @@ -0,0 +1,261 @@ +# HLL Vietnam + +HLL Vietnam es la base inicial del repositorio para una futura web de comunidad enfocada en la comunidad hispana de Discord del juego HLL Vietnam. + +En esta primera fase, el proyecto se centra en una landing sencilla, limpia y profesional que sirva como punto de entrada para la comunidad. La implementacion actual utiliza HTML, CSS y JavaScript sin frameworks pesados para mantener una base facil de mantener y ampliar. + +## Estado actual + +- Landing inicial de comunidad. +- Estructura de repositorio preparada para crecer. +- Carpeta de backend reservada para una futura implementacion en Python. +- Carpeta `ai/` ya integrada como capa operativa para orquestacion por tasks y trabajo con Codex. + +## Estructura del repositorio + +```text +/ +|-- README.md +|-- .gitignore +|-- AGENTS.md +|-- docs/ +| |-- project-overview.md +| |-- roadmap.md +| `-- decisions.md +|-- frontend/ +| |-- index.html +| |-- historico.html +| |-- Dockerfile +| |-- .dockerignore +| `-- assets/ +| |-- css/ +| |-- js/ +| `-- img/ +|-- backend/ +| |-- README.md +| |-- requirements.txt +| |-- Dockerfile +| |-- .dockerignore +| |-- .env.example +| `-- app/ +| `-- __init__.py +|-- ai/ +| |-- README.md +| |-- architecture-index.md +| |-- repo-context.md +| |-- system-metrics.md +| |-- task-template.md +| |-- prompts/ +| | `-- plan-feature.md +| |-- orchestrator/ +| | `-- README.md +| `-- tasks/ +| |-- pending/ +| |-- in-progress/ +| `-- done/ +|-- docker-compose.yml +`-- scripts/ +``` + +## Backend futuro + +El backend principal esta previsto en Python, pero en esta fase no se introduce infraestructura final de produccion. La base actual prioriza un bootstrap pequeno, una persistencia local clara y una evolucion controlada. + +## Como abrir el frontend localmente + +1. Ve a la carpeta `frontend/`. +2. Abre `index.html` directamente en el navegador. + +No hace falta servidor para esta primera version. + +## Ejecucion con Docker + +El repositorio ya incluye: + +- `backend/Dockerfile` +- `frontend/Dockerfile` +- `docker-compose.yml` +- `backend/.env.example` + +Seleccion de proveedor por entorno hoy: + +- desarrollo: + - `HLL_BACKEND_LIVE_DATA_SOURCE=rcon` + - `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon` +- produccion realista en esta fase: + - `HLL_BACKEND_LIVE_DATA_SOURCE=rcon` + - `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon` + +Esto refleja la politica operativa actual: RCON es la fuente primaria para +live e historico. El scoreboard publico queda como fallback historico cuando +RCON falla, no cubre una operacion concreta o aun no tiene cobertura suficiente. + +Modo normal recomendado: + +- levantar solo `backend` + `frontend` +- mantener `historical-runner` y `rcon-historical-worker` como servicios + avanzados bajo demanda +- mantener Comunidad Hispana #03 fuera de los targets RCON por defecto +- dejar Elo/MMR y la materializacion historica compleja en pausa operativa + hasta una reintroduccion explicita + +Primer arranque: + +```powershell +docker compose up --build +``` + +Con la configuracion actual, ese comando levanta solo `backend` y `frontend`. +Los workers historicos estan en el perfil Compose `advanced` y no forman parte +del arranque normal. + +Accesos locales esperados: + +- frontend: `http://localhost:8080` +- backend: `http://localhost:8000` +- health del backend: `http://localhost:8000/health` + +Persistencia: + +- el SQLite historico se conserva en `backend/data/hll_vietnam_dev.sqlite3` +- los snapshots JSON se conservan en `backend/data/snapshots/` +- `docker-compose.yml` monta `./backend/data` dentro del contenedor en `/app/data` + +Reinicio normal: + +```powershell +docker compose up -d +``` + +Parada: + +```powershell +docker compose down +``` + +Recreacion de imagenes tras cambios: + +```powershell +docker compose up --build +``` + +## Runbook de proveedores + +Verificacion minima del proveedor activo: + +```powershell +Invoke-WebRequest http://localhost:8000/health | Select-Object -Expand Content +``` + +La respuesta incluye `live_data_source` y `historical_data_source`. + +Modo desarrollo recomendado: + +```powershell +docker compose up --build +``` + +Modo live con RCON en Docker Compose: + +```powershell +$env:HLL_BACKEND_LIVE_DATA_SOURCE='rcon' +$env:HLL_BACKEND_HISTORICAL_DATA_SOURCE='rcon' +$env:HLL_BACKEND_RCON_TARGETS='[ + { + "name": "Comunidad Hispana #01", + "host": "203.0.113.10", + "port": 28015, + "password": "replace-me", + "external_server_id": "comunidad-hispana-01", + "region": "ES", + "game_port": 7777, + "query_port": 7778 + } +]' +docker compose up -d backend frontend +``` + +Buenas practicas: + +- no versionar credenciales reales en `backend/.env.example` +- preferir exportarlas como variables de entorno del host o del secreto del + despliegue +- mantener `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon` como valor normal y usar + `public-scoreboard` solo como fallback historico controlado +- no reintroducir Comunidad Hispana #03 en `HLL_BACKEND_RCON_TARGETS` salvo que + una task nueva valide su disponibilidad + +## Operaciones historicas avanzadas con Docker + +Estas operaciones quedan disponibles para uso explicito, pero no son parte del +arranque recomendado. La ruta normal de despliegue es `backend` + `frontend`. +El codigo, las migraciones, los snapshots historicos y Elo/MMR se conservan en +la repo, pero Elo/MMR y la materializacion historica compleja quedan pausados +operativamente en esta fase. + +Refresh historico puntual dentro del contenedor backend: + +```powershell +docker compose exec backend python -m app.historical_ingestion refresh +``` + +Bootstrap o backfill historico: + +```powershell +docker compose exec backend python -m app.historical_ingestion bootstrap +``` + +Regeneracion puntual de snapshots mediante refresh controlado: + +```powershell +docker compose exec backend python -m app.historical_runner --max-runs 1 +``` + +Automatizacion horaria avanzada: + +```powershell +docker compose --profile advanced up -d backend historical-runner frontend +``` + +`historical-runner` es un servicio Compose separado que ejecuta +`python -m app.historical_runner --hourly`. Sigue disponible para tareas +historicas explicitas, pero no se recomienda como requisito normal de +despliegue. Los targets RCON por defecto solo incluyen `comunidad-hispana-01` +y `comunidad-hispana-02`; `comunidad-hispana-03` queda deshabilitado en la +configuracion por defecto porque ya no es una fuente operativa vigente. + +Verificacion minima: + +- `docker compose ps historical-runner` +- `docker compose logs -f historical-runner` +- revisar `generated_at` en `backend/data/snapshots/` + +## Arquitectura historica RCON-first + +La linea historica actual usa RCON como fuente primaria. El flujo previsto es: + +- captura de sesiones RCON para cobertura, frescura y ventanas competitivas +- ingesta de AdminLog mediante `app.rcon_admin_log_ingestion` +- parsing de eventos AdminLog hacia eventos normalizados +- almacenamiento en tablas `rcon_admin_log_*` y `rcon_historical_*` +- materializacion de partidas cerradas y estadisticas de jugador desde eventos RCON +- enriquecimiento opcional con snapshots de perfil de jugador, sin tratarlos + como hechos autoritativos de una partida + +El scoreboard publico queda limitado a enriquecimiento, links confiables o +fallback historico cuando RCON falla, no tiene cobertura suficiente o no cubre +una operacion concreta. Elo/MMR sigue pausado y Comunidad Hispana #03 permanece +fuera de los targets RCON por defecto. + +Comandos manuales RCON dentro del contenedor backend: + +```powershell +docker compose exec backend python -m app.rcon_admin_log_ingestion --minutes 1440 +docker compose exec backend python -m app.rcon_historical_worker capture +``` + +Si se prefiere operar fuera de Docker, el backend sigue pudiendo arrancar localmente con `python -m app.main` desde `backend/`. + +## Evolucion prevista + +La capa inspirada en `ai-dev-platform-template` ya esta integrada y adaptada al contexto real de HLL Vietnam. Las siguientes iteraciones deben centrarse en usarla para planificar y ejecutar tasks reales del producto sin ampliar alcance fuera de ese flujo. diff --git a/ai-platform.json b/ai-platform.json new file mode 100644 index 0000000..cd80052 --- /dev/null +++ b/ai-platform.json @@ -0,0 +1,34 @@ +{ + "schema_version": 1, + "project": { + "name": "HLL Vietnam", + "type": "community website", + "identity": "Spanish-speaking HLL Vietnam Discord community", + "visual_direction": "military, Vietnam, tactical, sober" + }, + "workflow": { + "orchestrator": "ChatGPT coordinates with the human client/product owner and prepares scoped tasks.", + "worker": "Codex CLI workers execute only explicit tasks and follow AGENTS.md.", + "task_template": "ai/task-template.md", + "task_paths": { + "pending": "ai/tasks/pending", + "in_progress": "ai/tasks/in-progress", + "review": "ai/tasks/review", + "blocked": "ai/tasks/blocked", + "obsolete": "ai/tasks/obsolete", + "done": "ai/tasks/done" + } + }, + "runner": { + "lock_file": "ai/worker.lock", + "metrics_file": "ai/system-metrics.md", + "reports_path": "ai/reports", + "integration_tests_script": "scripts/run-integration-tests.ps1", + "codex_prompt": "Follow AGENTS.md, read the platform context in ai/, and process the pending tasks without acting outside task scope." + }, + "constraints": { + "frontend": "Keep HTML, CSS and vanilla JavaScript compatible with direct browser opening when applicable.", + "backend": "Python is the planned backend baseline. Do not add backend behavior without a task.", + "scope": "Preserve HLL Vietnam context. Do not expand Elo/MMR, historical workers or RCON server #03 handling from platform tasks." + } +} diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..50e1456 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd +data/*.sqlite3 +data/snapshots/** +!data/.gitkeep +!data/snapshots/.gitkeep diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..c252827 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,21 @@ +HLL_BACKEND_HOST=0.0.0.0 +HLL_BACKEND_PORT=8000 +HLL_BACKEND_STORAGE_PATH=/app/data/hll_vietnam_dev.sqlite3 +HLL_BACKEND_DATABASE_URL=postgresql://hll_vietnam:hll_vietnam_dev@postgres:5432/hll_vietnam +HLL_BACKEND_ALLOWED_ORIGINS=http://127.0.0.1,http://127.0.0.1:8080,http://localhost,http://localhost:8080 +HLL_BACKEND_REFRESH_INTERVAL_SECONDS=120 +HLL_BACKEND_LIVE_DATA_SOURCE=rcon +HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon +HLL_BACKEND_RCON_TIMEOUT_SECONDS=20 +HLL_BACKEND_RCON_TARGETS=[{"name":"Comunidad Hispana #01","slug":"comunidad-hispana-01","external_server_id":"comunidad-hispana-01","host":"152.114.195.174","port":7779,"password":"replace-me-01","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #02","slug":"comunidad-hispana-02","external_server_id":"comunidad-hispana-02","host":"152.114.195.150","port":7879,"password":"replace-me-02","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null}] +HLL_HISTORICAL_CRCON_PAGE_SIZE=50 +HLL_HISTORICAL_CRCON_TIMEOUT_SECONDS=15 +HLL_HISTORICAL_CRCON_DETAIL_WORKERS=8 +HLL_HISTORICAL_CRCON_REQUEST_RETRIES=3 +HLL_HISTORICAL_CRCON_RETRY_DELAY_SECONDS=0.5 +HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS=900 +HLL_HISTORICAL_FULL_SNAPSHOT_EVERY_RUNS=4 +HLL_HISTORICAL_REFRESH_MAX_RETRIES=2 +HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS=30 +HLL_HISTORICAL_WEEKLY_FALLBACK_MIN_MATCHES=3 +HLL_HISTORICAL_WEEKLY_FALLBACK_MAX_WEEKDAY=2 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f1ff44b --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + HLL_BACKEND_HOST=0.0.0.0 \ + HLL_BACKEND_PORT=8000 \ + HLL_BACKEND_STORAGE_PATH=/app/data/hll_vietnam_dev.sqlite3 + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir --requirement requirements.txt + +COPY app ./app + +RUN mkdir -p /app/data/snapshots + +EXPOSE 8000 + +CMD ["python", "-m", "app.main"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..25e74b9 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,1685 @@ +# Backend + +Esta carpeta contiene el bootstrap minimo del futuro backend principal en Python para HLL Vietnam. + +## Objetivo en esta fase + +- dejar un punto de entrada claro para la aplicacion +- validar que el backend puede arrancar localmente +- exponer rutas placeholder coherentes con el contrato frontend-backend + +## Stack actual del bootstrap + +- Python 3 +- libreria estandar de Python (`http.server`, sin frameworks ni dependencias externas) + +## Estructura minima + +```text +backend/ +|-- README.md +|-- requirements.txt +`-- app/ + |-- a2s_client.py + |-- __init__.py + |-- collector.py + |-- main.py + |-- historical_ingestion.py + |-- historical_models.py + |-- historical_runner.py + |-- historical_storage.py + |-- normalizers.py + |-- payloads.py + |-- routes.py + |-- server_targets.py + `-- snapshots.py +``` + +La persistencia local de desarrollo se crea bajo `backend/data/` cuando el +colector la necesita por primera vez. + +`app` es el paquete Python del backend. El archivo correcto del paquete es +`backend/app/__init__.py`; no debe existir una variante `init.py`. + +## Punto de entrada + +El entrypoint real del backend es el modulo `app.main`, ubicado en +`backend/app/main.py`. + +Desde la carpeta `backend/`, se puede arrancar localmente con: + +```powershell +python -m app.main +``` + +Ese comando usa imports relativos de paquete (`from .routes import ...`), por lo +que la forma soportada de arranque es por modulo y no ejecutando el archivo como +script suelto. + +Por defecto escuchara en `127.0.0.1:8000`. + +Variables opcionales: + +- `HLL_BACKEND_HOST` +- `HLL_BACKEND_PORT` +- `HLL_BACKEND_ALLOWED_ORIGINS` +- `HLL_BACKEND_REFRESH_INTERVAL_SECONDS` +- `HLL_BACKEND_LIVE_DATA_SOURCE` +- `HLL_BACKEND_HISTORICAL_DATA_SOURCE` +- `HLL_BACKEND_RCON_TIMEOUT_SECONDS` +- `HLL_BACKEND_RCON_TARGETS` +- `HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS` +- `HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES` +- `HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS` +- `HLL_RCON_BACKFILL_CHUNK_HOURS` +- `HLL_RCON_BACKFILL_SLEEP_SECONDS` +- `HLL_RCON_BACKFILL_MAX_DAYS_BACK` +- `HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES` +- `HLL_BACKEND_SQLITE_WRITER_TIMEOUT_SECONDS` +- `HLL_BACKEND_SQLITE_BUSY_TIMEOUT_MS` +- `HLL_BACKEND_WRITER_LOCK_TIMEOUT_SECONDS` +- `HLL_BACKEND_WRITER_LOCK_POLL_INTERVAL_SECONDS` +- `HLL_HISTORICAL_CRCON_PAGE_SIZE` +- `HLL_HISTORICAL_CRCON_TIMEOUT_SECONDS` +- `HLL_HISTORICAL_CRCON_DETAIL_WORKERS` +- `HLL_HISTORICAL_CRCON_REQUEST_RETRIES` +- `HLL_HISTORICAL_CRCON_RETRY_DELAY_SECONDS` +- `HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS` +- `HLL_HISTORICAL_REFRESH_OVERLAP_HOURS` +- `HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS` +- `HLL_HISTORICAL_FULL_SNAPSHOT_EVERY_RUNS` +- `HLL_HISTORICAL_REFRESH_MAX_RETRIES` +- `HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS` +- `HLL_BACKEND_SQLITE_WRITER_TIMEOUT_SECONDS` +- `HLL_BACKEND_SQLITE_BUSY_TIMEOUT_MS` +- `HLL_BACKEND_WRITER_LOCK_TIMEOUT_SECONDS` +- `HLL_BACKEND_WRITER_LOCK_POLL_INTERVAL_SECONDS` +- `HLL_HISTORICAL_WEEKLY_FALLBACK_MIN_MATCHES` +- `HLL_HISTORICAL_WEEKLY_FALLBACK_MAX_WEEKDAY` + +Variables especialmente relevantes para Docker y Compose: + +- `HLL_BACKEND_HOST` +- `HLL_BACKEND_PORT` +- `HLL_BACKEND_DATABASE_URL` +- `HLL_BACKEND_STORAGE_PATH` +- `HLL_BACKEND_ALLOWED_ORIGINS` +- `HLL_BACKEND_LIVE_DATA_SOURCE` +- `HLL_BACKEND_HISTORICAL_DATA_SOURCE` +- `HLL_BACKEND_RCON_TIMEOUT_SECONDS` +- `HLL_BACKEND_RCON_TARGETS` +- `HLL_HISTORICAL_CRCON_PAGE_SIZE` +- `HLL_HISTORICAL_CRCON_TIMEOUT_SECONDS` +- `HLL_HISTORICAL_CRCON_DETAIL_WORKERS` +- `HLL_HISTORICAL_CRCON_REQUEST_RETRIES` +- `HLL_HISTORICAL_CRCON_RETRY_DELAY_SECONDS` +- `HLL_HISTORICAL_REFRESH_OVERLAP_HOURS` +- `HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS` +- `HLL_HISTORICAL_FULL_SNAPSHOT_EVERY_RUNS` +- `HLL_HISTORICAL_REFRESH_MAX_RETRIES` +- `HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS` + +Para ejecucion containerizada, el repositorio incluye tambien: + +- `backend/Dockerfile` +- `backend/.dockerignore` +- `backend/.env.example` + +El contenedor usa el mismo entrypoint real del proyecto: + +```powershell +python -m app.main +``` + +Dentro del contenedor arranca por defecto con: + +- `HLL_BACKEND_HOST=0.0.0.0` +- `HLL_BACKEND_PORT=8000` +- `HLL_BACKEND_STORAGE_PATH=/app/data/hll_vietnam_dev.sqlite3` + +Compose configura ademas `HLL_BACKEND_DATABASE_URL` para que PostgreSQL sea el +almacenamiento autoritativo de la fase 1 RCON: muestras/ventanas de captura, +AdminLog, snapshots de perfil y partidas/estadisticas materializadas. Sin esa +variable, la ejecucion local mantiene fallback SQLite para esos dominios. + +Diagnostico rapido del backend activo: + +```powershell +python -m app.storage_diagnostics +``` + +La salida lista el backend RCON activo, counts de las tablas migradas, la ultima +partida materializada por servidor y que superficies siguen temporalmente en +SQLite en esta fase. + +Build local: + +```powershell +docker build -t hll-vietnam-backend ./backend +``` + +Ejecucion local con persistencia bind-mounted: + +```powershell +docker run --rm ` + -p 8000:8000 ` + --env-file backend/.env.example ` + -v ${PWD}\backend\data:/app/data ` + hll-vietnam-backend +``` + +Si se prefiere no usar `--env-file`, el contenedor puede arrancar solo con sus +defaults para host, puerto y path de SQLite. El bind mount de `/app/data` sigue +siendo la forma recomendada de no perder persistencia al recrear el contenedor. + +El `frontend/index.html` viene preparado para volver a consultar el bloque de +servidores cada `120000` ms (`120s`) sin recargar la pagina completa. La landing +lee ese valor desde `data-server-refresh-ms`, por lo que puede ajustarse en el +HTML si una demo local necesita un intervalo distinto. + +Valor por defecto de `HLL_BACKEND_ALLOWED_ORIGINS`: + +- `null` +- `http://127.0.0.1` +- `http://127.0.0.1:5500` +- `http://127.0.0.1:8080` +- `http://localhost` +- `http://localhost:5500` +- `http://localhost:8080` + +Esto cubre el caso de abrir `frontend/index.html` directamente desde `file://` +y los puertos locales mas habituales cuando el frontend se sirve con un +servidor sencillo. + +Prueba local recomendada para validar frontend y backend juntos: + +1. En una terminal, desde `backend/`, arrancar el backend: + + ```powershell + python -m app.main + ``` + +2. En otra terminal, desde `frontend/`, servir la landing: + + ```powershell + python -m http.server 8080 + ``` + +3. Abrir `http://localhost:8080`. + +Si se necesita otra combinacion de origenes locales, puede sobrescribirse +`HLL_BACKEND_ALLOWED_ORIGINS` con una lista separada por comas. El backend +normaliza espacios y barras finales para mantener la comparacion con el header +`Origin` del navegador. + +## Endpoints placeholder disponibles + +- `GET /health` +- `GET /api/community` +- `GET /api/trailer` +- `GET /api/discord` +- `GET /api/servers` +- `GET /api/servers/latest` +- `GET /api/servers/history?limit=20` +- `GET /api/servers/{id}/history?limit=20` +- `GET /api/historical/weekly-top-kills?limit=10&server=comunidad-hispana-01` +- `GET /api/historical/weekly-leaderboard?metric=kills&limit=10&server=comunidad-hispana-01` +- `GET /api/historical/leaderboard?timeframe=monthly&metric=kills&limit=10&server=comunidad-hispana-01` +- `GET /api/historical/monthly-mvp?limit=10&server=comunidad-hispana-01` +- `GET /api/historical/monthly-mvp-v2?limit=10&server=comunidad-hispana-01` +- `GET /api/historical/player-events?view=most-killed&limit=10&server=comunidad-hispana-01` +- `GET /api/historical/recent-matches?limit=20&server=comunidad-hispana-01` +- `GET /api/historical/server-summary?server=comunidad-hispana-01` +- `GET /api/historical/snapshots/server-summary?server=comunidad-hispana-01` +- `GET /api/historical/snapshots/weekly-leaderboard?metric=kills&limit=10&server=comunidad-hispana-01` +- `GET /api/historical/snapshots/leaderboard?timeframe=monthly&metric=kills&limit=10&server=comunidad-hispana-01` +- `GET /api/historical/snapshots/monthly-mvp?limit=10&server=comunidad-hispana-01` +- `GET /api/historical/snapshots/monthly-mvp-v2?limit=10&server=comunidad-hispana-01` +- `GET /api/historical/snapshots/player-events?view=most-killed&limit=10&server=comunidad-hispana-01` +- `GET /api/historical/snapshots/recent-matches?limit=6&server=comunidad-hispana-01` +- `GET /api/historical/player-profile?player=steam%3A76561198000000000` + +`GET /health` expone tambien: + +- `live_data_source` +- `historical_data_source` + +`GET /api/servers` trata el ultimo snapshot persistido como cache local y lo +reutiliza solo si sigue dentro del objetivo de `120` segundos. Si ese snapshot +esta vencido, el endpoint intenta primero una consulta RCON real inmediata +contra los targets configurados. Solo si RCON falla o no devuelve snapshots +utilizables, cae a A2S de forma controlada antes de responder. + +La respuesta incluye metadata de frescura pensada para frontend: + +- `last_snapshot_at` +- `snapshot_age_seconds` +- `snapshot_age_minutes` +- `max_snapshot_age_seconds` +- `is_stale` +- `freshness` +- `source` +- `refresh_attempted` +- `refresh_status` +- `primary_source` +- `selected_source` +- `fallback_used` +- `fallback_reason` +- `source_attempts` + +Si la consulta real falla, `/api/servers` devuelve el ultimo snapshot valido +disponible marcado como stale. Si no existe ningun snapshot valido, responde +`items: []` en lugar de reintroducir servidores de respaldo ajenos a la +comunidad. Cada respuesta deja tambien trazabilidad de arbitraje de fuente para +que sea visible si se sirvio RCON directo o si hubo fallback a A2S. + +Los endpoints historicos leen la persistencia local SQLite creada por el +colector. Si todavia no hay snapshots guardados, responden `status: "ok"` con +`items: []` para mantener un contrato simple en desarrollo. + +## Seleccion de fuente de datos + +El backend separa ahora la fuente de datos del contrato HTTP del producto. +Esto permite cambiar proveedores por entorno sin tocar `routes.py`, payloads de +UI ni el formato consumido por frontend. + +Variables nuevas: + +- `HLL_BACKEND_LIVE_DATA_SOURCE` +- `HLL_BACKEND_HISTORICAL_DATA_SOURCE` + +Valores soportados en esta fase: + +- live: + - `rcon` como camino primario recomendado + - `a2s` como fallback legacy o override explicito +- historico: + - `rcon` como camino primario recomendado para captura y writer path primario + - `public-scoreboard` como fallback legacy controlado + +Defaults actuales: + +- `HLL_BACKEND_LIVE_DATA_SOURCE=rcon` +- `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon` + +La seleccion efectiva se resuelve en `app/data_sources.py` y en adapters +dedicados dentro de `app/providers/`: + +- `get_live_data_source()` entrega el proveedor usado por `payloads.py` + cuando `/api/servers` necesita un refresh real +- `get_historical_data_source()` entrega el proveedor usado por + `historical_ingestion.py` para bootstrap y refresh incremental +- `providers/public_scoreboard_provider.py` encapsula la semantica actual del + scoreboard/CRCON publico bajo el contrato historico +- `providers/rcon_provider.py` encapsula el proveedor live basado en comandos + RCON HLL v2 mediante `ServerConnect`, `Login` y `GetServerInformation` + +Proveedores operativos en esta fase: + +- live `rcon` +- live `a2s` +- historico `rcon` solo para read model minimo y captura prospectiva +- historico `public-scoreboard` como fallback para cobertura competitiva sin paridad RCON + +Politica funcional actual: + +- live: + - RCON primero + - A2S solo si RCON falla o no devuelve snapshots utilizables +- historico/recopilacion: + - RCON primero + - `public-scoreboard` solo si RCON no soporta aun esa operacion concreta o + falla la captura primaria + +Estado real de "historico por RCON" en esta repo: + +- no existe backfill retroactivo por RCON con el cliente actual +- la viabilidad documentada hoy es solo para captura prospectiva separada +- `historical_ingestion.py` intenta primero el writer path prospectivo RCON +- la persistencia competitiva `historical_*` sigue necesitando fallback a + `public-scoreboard` mientras RCON no exponga pagina historica/detalle de + match cerrada con paridad suficiente +- el diseno tecnico de esa linea prospectiva queda en + `docs/rcon-historical-ingestion-design.md` + +Variables especificas de RCON live: + +- `HLL_BACKEND_RCON_TIMEOUT_SECONDS` +- `HLL_BACKEND_RCON_TARGETS` + +Variables especificas de captura historica prospectiva RCON: + +- `HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS` +- `HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES` +- `HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS` + +`HLL_BACKEND_RCON_TARGETS` acepta un array JSON con: + +- `name` +- `slug` opcional como alias legacy +- `host` +- `port` +- `password` +- `external_server_id` opcional +- `region` opcional +- `game_port` opcional +- `query_port` opcional +- `source_name` opcional + +Compatibilidad operativa del loader: + +- si llega `slug` pero no `external_server_id`, el backend reutiliza `slug` + como `external_server_id` +- si falta `name` pero existe `slug`, el backend genera un nombre razonable a + partir del slug en vez de dejar `Unnamed RCON target` +- los errores de validacion indican el campo que falta y las claves + efectivamente recibidas + +Timeout recomendado por defecto: + +- `HLL_BACKEND_RCON_TIMEOUT_SECONDS=20` + +Diagnostico operativo del cliente RCON: + +- el cliente informa ahora el stage exacto del fallo cuando puede distinguirlo +- stages observables: + - `tcp_connect` + - `server_connect_request` + - `server_connect_response` + - `xor_key_decode` + - `login_request` + - `login_response` + - `get_server_information_request` + - `get_server_information_response` + - `payload_decode` + - `unexpected_response` + - `timeout` +- esto mejora el diagnostico del protocolo, pero no resuelve por si solo la + conectividad real si el servidor acepta TCP y luego no responde al handshake + RCON o al comando `GetServerInformation` + +Ejemplo: + +```powershell +$env:HLL_BACKEND_RCON_TARGETS='[ + { + "name": "Comunidad Hispana #01", + "slug": "comunidad-hispana-01", + "host": "152.114.195.174", + "port": 7779, + "password": "replace-me", + "external_server_id": "comunidad-hispana-01", + "region": "ES", + "game_port": null, + "query_port": null, + "source_name": "community-hispana-rcon" + } +]' +``` + +Runbook operativo minimo: + +- modo recomendado por defecto: + - `HLL_BACKEND_LIVE_DATA_SOURCE=rcon` + - `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon` + - usar solo `comunidad-hispana-01` y `comunidad-hispana-02` en los targets + RCON por defecto +- modo historico/RCON avanzado: + - iniciar workers solo de forma explicita + - no reintroducir `comunidad-hispana-03` salvo validacion nueva +- override legacy live/A2S: + - `HLL_BACKEND_LIVE_DATA_SOURCE=a2s` + - `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon` + +Verificacion minima del proveedor activo: + +```powershell +Invoke-WebRequest http://127.0.0.1:8000/health | Select-Object -Expand Content +``` + +La respuesta incluye `live_data_source` y `historical_data_source`, util para +confirmar si la instancia esta usando `a2s` o `rcon` para live. + +Captura historica prospectiva por RCON: + +- se ejecuta fuera del request path HTTP +- persiste muestras live hacia delante en tablas `rcon_historical_*` +- usa RCON como camino historico primario y mantiene `public-scoreboard` solo + como fallback para operaciones competitivas que aun no tienen paridad RCON +- no promete backfill retroactivo de matches ya perdidos + +Arquitectura RCON-first de datos historicos: + +- `app.rcon_historical_worker` captura sesiones RCON y mantiene ventanas + competitivas prospectivas. +- `app.rcon_admin_log_ingestion` ingiere AdminLog para el periodo solicitado. +- `app.rcon_admin_log_parser` normaliza eventos como inicio/cierre de partida, + kills, cambios de equipo, chat y mensajes de perfil. +- `app.rcon_admin_log_storage` persiste eventos AdminLog deduplicados y + snapshots de perfil de jugador. +- `app.rcon_admin_log_materialization` materializa partidas cerradas y + estadisticas por jugador desde eventos RCON. +- `app.rcon_historical_read_model` expone las lecturas historicas actuales y + solo recurre a `public-scoreboard` como fallback/enriquecimiento cuando RCON + no cubre la operacion. + +Comandos manuales equivalentes dentro de Docker Compose: + +```powershell +docker compose exec backend python -m app.rcon_admin_log_ingestion --minutes 1440 +docker compose exec backend python -m app.rcon_historical_worker capture +``` + +Backfill historico RCON/AdminLog: + +- runbook: `docs/historical-rcon-backfill.md` +- ejemplo seco: + + ```powershell + docker compose run --rm rcon-historical-worker python -m app.rcon_historical_backfill --ensure-recent-matches 100 --servers comunidad-hispana-01,comunidad-hispana-02 --dry-run + ``` + +Comandos manuales desde `backend/`: + +```powershell +python -m app.rcon_historical_worker capture +python -m app.rcon_historical_worker capture --target comunidad-hispana-01 +python -m app.rcon_historical_worker loop --interval 120 +``` + +Runbook minimo: + +- una pasada manual sobre todos los targets RCON configurados: + + ```powershell + python -m app.rcon_historical_worker capture + ``` + +- una validacion acotada sobre un target concreto: + + ```powershell + python -m app.rcon_historical_worker capture --target comunidad-hispana-01 + ``` + +- un worker local en bucle: + + ```powershell + python -m app.rcon_historical_worker loop --interval 120 --max-runs 1 + ``` + +La salida del worker incluye: + +- `target_scope` +- `captured_at` +- `targets` +- `errors` +- `storage_status` + +Cuando una captura falla, cada error incluye como minimo: + +- `target_key` +- `external_server_id` +- `name` +- `host` +- `port` +- `timeout_seconds` +- `error_type` +- `error_stage` +- `message` + +`error_type` intenta clasificar al menos: + +- `timeout` +- `auth/login` +- `connection-refused` +- `payload-invalid` +- `other-error` + +La persistencia queda separada del historico `historical_*` actual y usa: + +- `rcon_historical_targets` +- `rcon_historical_capture_runs` +- `rcon_historical_samples` +- `rcon_historical_checkpoints` + +Lectura historica minima cuando `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon`: + +- endpoints soportados hoy directamente por RCON persistido: + - `GET /api/historical/server-summary` + - `GET /api/historical/recent-matches` +- lo que devuelven: + - cobertura por target RCON configurado + - frescura del ultimo capture exitoso + - actividad reciente persistida +- endpoints que hoy caen automaticamente a `public-scoreboard` para mantener el + contrato completo: + - `GET /api/historical/weekly-top-kills` + - `GET /api/historical/weekly-leaderboard` + - `GET /api/historical/leaderboard` + - `GET /api/historical/monthly-mvp` + - `GET /api/historical/monthly-mvp-v2` + - `GET /api/historical/player-events` + - `GET /api/historical/player-profile` + - `GET /api/historical/snapshots/*` + +Cuando esos endpoints se consultan con `historical_data_source=rcon`, el backend +intenta primero RCON para la operacion soportada y, si no hay cobertura o la +capacidad aun no existe, cae automaticamente a `public-scoreboard`. La +respuesta deja trazabilidad con `primary_source`, `selected_source`, +`fallback_used`, `fallback_reason` y `source_attempts`. + +## Criterio de estructura + +- `__init__.py` declara el paquete `app` y reexporta las utilidades publicas + minimas del bootstrap. +- `collector.py` define el flujo minimo de captura para desarrollo usando una + fuente controlada. +- `a2s_client.py` encapsula una consulta minima A2S_INFO por UDP para probar + servidores reales sin acoplar todavia el backend a una fuente mas compleja. +- `rcon_client.py` encapsula una conexion minima HLL RCON v2 por TCP con + `ServerConnect`, XOR key base64, `authToken` y `GetServerInformation` para + consultas live de produccion. +- `config.py` centraliza host, puerto y allowlist minima de origenes locales. +- `data_sources.py` define los contratos y la seleccion por entorno para live e historico. +- `historical_ingestion.py` intenta primero el writer path RCON y, si hace falta + poblar `historical_*`, cae de forma explicita a la capa JSON publica de CRCON. +- `historical_models.py` fija las entidades historicas minimas del dominio. +- `historical_snapshots.py` fija los tipos y selectores validos de snapshots historicos precalculados. +- `historical_snapshot_storage.py` persiste snapshots historicos precalculados listos para lectura rapida. +- `historical_runner.py` ejecuta refresh incremental periodico con reintentos basicos. +- `historical_storage.py` prepara la persistencia `historical_*` y las consultas agregadas iniciales. +- `main.py` contiene el entrypoint HTTP y la creacion del servidor. +- `normalizers.py` transforma registros crudos o respuestas A2S a un modelo + comun del colector. +- `routes.py` resuelve las rutas GET soportadas. +- `payloads.py` centraliza respuestas placeholder y mock. +- `server_targets.py` registra targets A2S de prueba de forma desacoplada del + flujo principal del colector. +- `snapshots.py` construye snapshots consistentes con timestamp comun de + captura. +- `storage.py` prepara una persistencia local minima en SQLite para + `game_sources`, `servers` y `server_snapshots`. + +## Persistencia local minima + +El backend ya puede guardar snapshots en un SQLite local de desarrollo usando +solo libreria estandar de Python. Esta base minima sigue el modelo logico de: + +- `game_sources` +- `servers` +- `server_snapshots` +- `historical_servers` +- `historical_maps` +- `historical_matches` +- `historical_players` +- `historical_player_match_stats` +- `historical_ingestion_runs` + +Por defecto el archivo se crea en: + +```text +backend/data/hll_vietnam_dev.sqlite3 +``` + +En Docker, ese mismo rol de persistencia debe montarse fuera del contenedor en: + +```text +/app/data/hll_vietnam_dev.sqlite3 +``` + +Politica comun SQLite para writers: + +- `timeout` explicito compartido +- `PRAGMA foreign_keys = ON` +- `PRAGMA journal_mode = WAL` +- `PRAGMA busy_timeout` +- `row_factory = sqlite3.Row` + +Esta politica se aplica de forma uniforme a las capas writer-capable que +comparten el mismo SQLite, incluyendo: + +- `historical_storage.py` +- `player_event_storage.py` +- `rcon_historical_storage.py` +- `storage.py` + +Politica read-only para historico: + +- las rutas de lectura de `historical_storage.py` no ejecutan ya + `initialize_historical_storage()` +- si el SQLite historico todavia no existe, esas lecturas devuelven resultados + vacios o defaults estables sin crear archivo ni correr seed/migraciones +- cuando el archivo ya existe, esas lecturas abren `mode=ro` con + `row_factory = sqlite3.Row` y `PRAGMA busy_timeout` +- la inicializacion, migraciones, seed y normalizaciones siguen reservadas al + writer path explicito + +Variable opcional: + +- `HLL_BACKEND_STORAGE_PATH` +- `HLL_BACKEND_A2S_TARGETS` + +La base logica sigue documentada en +`docs/stats-database-schema-foundation.md` para snapshots live y en +`docs/historical-domain-model.md` para el historico CRCON. Esta implementacion +no introduce ORM, migraciones ni una decision de almacenamiento productivo. + +## Snapshots historicos precalculados + +La capa historica persiste ahora los snapshots precalculados orientados a UI +como archivos JSON independientes en disco, separados del SQLite del historico +bruto. Esta capa esta preparada para guardar: + +- `server-summary` +- `weekly-leaderboard` con metricas `kills`, `deaths`, `support` y `matches_over_100_kills` +- `monthly-leaderboard` con las mismas metricas semanticas +- `monthly-mvp` +- `recent-matches` + +Por defecto se escriben bajo: + +```text +backend/data/snapshots// +``` + +En Docker, estos snapshots deben persistirse bajo: + +```text +/app/data/snapshots// +``` + +Ejemplos: + +- `backend/data/snapshots/comunidad-hispana-01/server-summary.json` +- `backend/data/snapshots/comunidad-hispana-01/weekly-kills.json` +- `backend/data/snapshots/comunidad-hispana-02/recent-matches.json` +- `backend/data/snapshots/all-servers/weekly-support.json` +- `backend/data/snapshots/all-servers/monthly-mvp.json` + +Cada archivo conserva metadatos operativos minimos: + +- `server_key` +- `snapshot_type` +- `metric` +- `window` +- `payload` +- `generated_at` +- `source_range_start` +- `source_range_end` +- `is_stale` + +La persistencia usa una identidad de archivo estable por combinacion de +servidor, tipo y metrica para que cada refresh reemplace el artefacto anterior +sin mezclarlo con el historico bruto. + +Resumen de persistencia recomendada para contenedor: + +- montar `/app/data` +- conservar el SQLite historico en `/app/data/hll_vietnam_dev.sqlite3` +- conservar los snapshots JSON en `/app/data/snapshots/` + +Con `docker compose`, esa persistencia ya queda montada desde: + +- `./backend/data -> /app/data` + +## Bootstrap del colector + +El backend incluye un bootstrap minimo para el futuro flujo de snapshots: + +- `fetch_controlled_server_source()` obtiene datos controlados de desarrollo +- `query_server_info()` permite consultar metadata basica real por A2S_INFO +- `fetch_a2s_probe()` adapta una consulta A2S real al modelo interno del colector +- `fetch_configured_a2s_probes()` consulta la lista configurada de targets A2S +- `normalize_server_record()` reduce los registros a una forma comun +- `normalize_a2s_server_info()` reduce una respuesta A2S al mismo contrato interno +- `build_server_snapshot()` y `build_snapshot_batch()` generan snapshots con + `captured_at` +- `collect_server_snapshots()` orquesta captura, normalizacion, ensamblado y + persistencia opcional +- `persist_snapshot_batch()` escribe el lote en SQLite y mantiene identidad de + servidor separada del historico + +Ejecucion manual desde `backend/`: + +```powershell +python -m app.collector --source auto +``` + +Ese comando intenta consultar primero los targets A2S configurados. Si ninguno +responde y no se ha desactivado el fallback, usa la fuente controlada de +desarrollo para no romper el flujo local. El resultado imprime el modo usado, +los errores de consulta y el lote de snapshots persistido en SQLite. + +Si se quiere forzar solo A2S real: + +```powershell +python -m app.collector --source a2s --no-fallback +``` + +Ese flujo es la validacion local minima extremo a extremo para los targets +reales configurados de Comunidad Hispana. El timeout por defecto del cliente +A2S es `6.0s` para tolerar mejor latencia puntual entre multiples consultas +reales consecutivas. Cuando responden ambos targets por defecto, el comando +debe devolver: + +- `collection_mode: "a2s"` +- `target_count: 2` +- `success_count: 2` +- un snapshot con `external_server_id: "comunidad-hispana-01"` +- un snapshot con `external_server_id: "comunidad-hispana-02"` +- `source_name: "community-hispana-a2s"` +- `snapshot_origin: "real-a2s"` en ambos +- `source_ref: "a2s://152.114.195.174:7778"` +- `source_ref: "a2s://152.114.195.150:7878"` +- persistencia en `backend/data/hll_vietnam_dev.sqlite3` + +Si la consulta se ejecuta desde un entorno con red restringida, sin salida UDP +o con latencia puntual alta, el cliente puede devolver timeout aunque el target +este sano. En ese caso el resultado conserva errores controlados por target y +puede acabar con `success_count` parcial o `0` segun cuantas consultas fallen. + +Los snapshots persistidos y los endpoints historicos exponen ademas: + +- `snapshot_origin` para distinguir `real-a2s` frente a `controlled-fallback` +- `source_ref` para conservar una referencia de procedencia util en historico + +Si se quiere seguir usando solo datos controlados: + +```powershell +python -m app.collector --source controlled +``` + +## Refresco local periodico de snapshots + +Para evitar lanzar el colector manualmente en cada captura, el backend incluye +un bucle local de refresco periodico pensado solo para desarrollo: + +```powershell +python -m app.scheduler +``` + +Ese comando ejecuta capturas persistidas de forma repetida usando el mismo +flujo del colector y la base SQLite local. Por defecto: + +- usa `--source auto` +- espera `120` segundos entre ejecuciones +- permite fallback controlado si A2S no responde +- sigue en ejecucion hasta que se detiene manualmente + +Se puede detener de forma segura con `Ctrl+C`. + +Variables y flags utiles: + +- `HLL_BACKEND_REFRESH_INTERVAL_SECONDS` para cambiar el intervalo por defecto +- `--interval 120` para fijar el intervalo en segundos en una ejecucion concreta +- `--source a2s --no-fallback` para forzar solo capturas reales +- `--max-runs 3` para limitar el numero de ciclos y evitar un bucle indefinido + +Ejemplos: + +```powershell +python -m app.scheduler --interval 120 +python -m app.scheduler --source a2s --no-fallback --max-runs 2 +``` + +Flujo local recomendado para ver datos vivos en la landing: + +1. Desde `backend/`, arrancar la API: + + ```powershell + python -m app.main + ``` + +2. En otra terminal, dejar el scheduler corriendo: + + ```powershell + python -m app.scheduler + ``` + +3. Servir `frontend/` con un servidor local sencillo y abrir la landing. El + frontend volvera a pedir `/api/servers` cada `120` segundos, por lo que los + cambios de mapa o poblacion apareceran sin recarga manual cuando existan + snapshots nuevos. + +Este mecanismo deja el refresco desacoplado del servidor HTTP y es facil de +reemplazar mas adelante por un scheduler mas serio sin rehacer el colector. + +Prueba manual minima de A2S desde `backend/`: + +```powershell +python -m app.a2s_client 203.0.113.10 27015 +``` + +Ese comando lanza una consulta `A2S_INFO` por UDP y devuelve JSON con nombre de +servidor, mapa, jugadores y capacidad maxima cuando el query port responde. +Tambien puede reutilizarse desde Python con `query_server_info()` o +`fetch_a2s_probe()`. Si el servidor no responde o el puerto es incorrecto, el +cliente eleva errores controlados de timeout o protocolo para que la siguiente +task pueda integrarlo en el pipeline de snapshots sin romper el backend. + +## Registro local de targets A2S + +La lista de targets A2S vive en `app/server_targets.py`. Por defecto el backend +registra solo el primer target real verificado del proyecto: + +- `Comunidad Hispana #01` +- host/IP: `152.114.195.174` +- `query_port`: `7778` +- `game_port`: `7777` +- `source_name`: `community-hispana-a2s` +- `external_server_id`: `comunidad-hispana-01` + +`query_port` es el puerto usado para `A2S_INFO`; `game_port` se conserva por +separado para documentar el puerto de juego real sin mezclar ambos conceptos en +la configuracion. + +El registro por defecto incluye dos targets reales verificados: + +- `Comunidad Hispana #01` + - host/IP: `152.114.195.174` + - `query_port`: `7778` + - `game_port`: `7777` + - `external_server_id`: `comunidad-hispana-01` +- `Comunidad Hispana #02` + - host/IP: `152.114.195.150` + - `query_port`: `7878` + - `game_port`: `7877` + - `external_server_id`: `comunidad-hispana-02` + +Si se quiere cambiar la lista sin editar codigo, puede definirse +`HLL_BACKEND_A2S_TARGETS` como un array JSON: + +```powershell +$env:HLL_BACKEND_A2S_TARGETS='[ + { + "name": "Comunidad Hispana #01", + "host": "152.114.195.174", + "query_port": 7778, + "game_port": 7777, + "source_name": "community-hispana-a2s", + "external_server_id": "comunidad-hispana-01", + "region": "ES" + }, + { + "name": "Comunidad Hispana #02", + "host": "152.114.195.150", + "query_port": 7878, + "game_port": 7877, + "source_name": "community-hispana-a2s", + "external_server_id": "comunidad-hispana-02", + "region": "ES" + } +]' +``` + +Cada target soporta: + +- `name` +- `host` +- `query_port` +- `game_port` opcional +- `source_name` +- `external_server_id` opcional +- `region` opcional + +El colector puede resolver esos targets con `load_a2s_targets()` o +`fetch_configured_a2s_probes()` sin depender de constantes dispersas. + +## Consulta historica minima + +Una vez existen snapshots persistidos, el backend expone una primera capa de +consulta historica: + +- `/api/servers/latest` devuelve el ultimo snapshot conocido por servidor +- `/api/servers/history` devuelve snapshots recientes agregados +- `/api/servers/{id}/history` devuelve el historial reciente de un servidor + +`{id}` acepta el `server_id` numerico interno o el `external_server_id` +persistido por el colector. El parametro opcional `limit` acepta valores entre +`1` y `100`. + +La capa historica propia expone: + +- `/api/historical/weekly-top-kills` +- `/api/historical/weekly-leaderboard` +- `/api/historical/leaderboard` +- `/api/historical/recent-matches` +- `/api/historical/server-summary` +- `/api/historical/snapshots/server-summary` +- `/api/historical/snapshots/weekly-leaderboard` +- `/api/historical/snapshots/leaderboard` +- `/api/historical/snapshots/recent-matches` +- `/api/historical/player-profile` + +Parametros opcionales: + +- `limit` entre `1` y `100` +- `server` con slug historico como `comunidad-hispana-01` +- `player` en `/api/historical/player-profile` aceptando `stable_player_key`, + `steam_id` o `source_player_id` + +Ademas de los slugs fisicos de cada scoreboard, la capa historica acepta la +clave logica `all-servers` para devolver agregados globales sobre los tres +servidores de Comunidad Hispana sin tratarla como un origen CRCON real aparte. + +La ventana temporal usa semana calendario UTC y solo considera partidas +cerradas con `ended_at` para no mezclar partidas aun en curso ni filas +historicas transitorias. El payload devuelve servidor, rango temporal, +jugador, kills semanales, posicion y numero de partidas consideradas. + +`weekly-leaderboard` generaliza ese bloque para varias metricas semanales por +servidor usando el mismo filtro de partidas cerradas. Si la semana actual cae +entre lunes y miercoles UTC y todavia no acumula al menos `3` partidas +cerradas, el backend activa un fallback temporal a la semana cerrada anterior. +Metricas soportadas: + +- `kills` +- `deaths` +- `support` +- `matches_over_100_kills` + +El endpoint legacy `/api/historical/weekly-top-kills` se conserva como alias +compatible para la metrica `kills`. + +`/api/historical/leaderboard` y `/api/historical/snapshots/leaderboard` +generalizan ese mismo contrato con `timeframe=weekly|monthly`. Para `monthly`, +la politica temporal usa el mes natural UTC en curso y hace fallback al mes +cerrado anterior solo cuando el mes actual todavia no tiene ningun cierre. +Ambas variantes exponen el rango real usado mediante `window_start`, +`window_end`, `window_kind`, `window_label` y `selection_reason`. + +`recent-matches` devuelve cierres recientes por servidor con marcador, mapa y +conteo de jugadores. `server-summary` agrega volumen historico, jugadores +unicos, kills, mapas dominantes y rango temporal cubierto. `player-profile` +deja lista la base de consulta agregada por jugador para futuras vistas. + +La familia `/api/historical/snapshots/*` lee directamente los archivos JSON +precalculados bajo `backend/data/snapshots/` y evita recalcular agregados +pesados en cada request. Estos endpoints devuelven payloads ligeros listos para +frontend con: + +- `snapshot_status` +- `missing_reason` +- `request_path_policy` +- `generation_policy` +- `generated_at` +- `source_range_start` +- `source_range_end` +- `is_stale` +- `freshness` +- `found` +- `window_start` +- `window_end` +- `window_kind` +- `window_label` +- `uses_fallback` +- `selection_reason` +- `current_week_closed_matches` +- `previous_week_closed_matches` +- `sufficient_sample` + +Si un snapshot todavia no existe en `backend/data/snapshots/`, la API responde +rapido con `found: false`, `snapshot_status: "missing"` y +`missing_reason: "snapshot-not-generated"`. La generacion y refresco de esos +artefactos debe ocurrir fuera del request path mediante `historical_ingestion` +o `historical_runner`; la lectura HTTP se mantiene como fast path de solo +lectura. + +`/api/historical/snapshots/server-summary` devuelve `item` con el resumen del +servidor. `/api/historical/snapshots/weekly-leaderboard` devuelve `items` ya +precalculados para una metrica semanal y acepta `limit` para recortar el +payload ya persistido sin recalcularlo. `/api/historical/snapshots/recent-matches` +devuelve `items` de cierres recientes ya preparados y tambien acepta `limit` +para servir solo una parte del snapshot persistido. + +La misma capa de snapshots guarda tambien `monthly-leaderboard` por servidor y +por agregado `all-servers`, con archivos como `monthly-kills.json` y +`monthly-support.json`. + +Tambien persiste `monthly-mvp.json` por servidor y para `all-servers`, listo +para lectura rapida desde `/api/historical/monthly-mvp` y +`/api/historical/snapshots/monthly-mvp` sin recalculo pesado en request. + +La misma operativa persiste tambien snapshots V2 de eventos de jugador para el +ultimo mes con datos disponible por servidor y para `all-servers`, listos para +lectura rapida sin consultas pesadas on-demand: + +- `player-events-most-killed.json` +- `player-events-death-by.json` +- `player-events-duels.json` +- `player-events-weapon-kills.json` +- `player-events-teamkills.json` + +Los endpoints `/api/historical/player-events` y +`/api/historical/snapshots/player-events` aceptan: + +- `view=most-killed` +- `view=death-by` +- `view=duels` +- `view=weapon-kills` +- `view=teamkills` + +La respuesta expone metadata operativa alineada con el resto de snapshots: + +- `generated_at` +- `month_key` +- `source_range_start` +- `source_range_end` +- `found` +- `is_stale` + +El backend incluye ademas el calculo interno de `monthly MVP V1` en +`app/monthly_mvp.py`, separado de los leaderboards mensuales simples por +metrica. Ese calculo: + +- usa solo `kills`, `support`, `time_seconds`, `deaths` y `teamkills` + persistidos +- recompone `KPM` y `KDA` desde totales mensuales +- aplica elegibilidad minima de `6` partidas cerradas y `6` horas +- soporta servidor individual y el agregado logico `all-servers` + +En esta fase el ranking MVP queda listo para serializar en snapshots o payloads +sin reemplazar los leaderboards mensuales ya existentes por `kills`, `deaths`, +`support` y `matches_over_100_kills`. + +La repo incluye tambien un calculo backend separado de `monthly MVP V2` en +`app/monthly_mvp_v2.py`, expuesto de momento por +`/api/historical/monthly-mvp-v2`. + +Esta V2: + +- convive sin reemplazar `monthly MVP V1` +- reusa la misma ventana mensual y la misma elegibilidad base +- anade `rivalry_edge` y `duel_control` derivados del ledger V2 de eventos +- aplica una penalizacion de teamkills mas estricta +- mantiene fuera del score el peso por arma o tipo de kill hasta validar mejor + esas senales + +Esa capacidad V2 se persiste tambien en snapshots dedicados +`monthly-mvp-v2.json` por servidor y para `all-servers`, leidos por: + +- `/api/historical/monthly-mvp-v2` +- `/api/historical/snapshots/monthly-mvp-v2` + +La lectura HTTP de V2 sigue asi la misma politica de fast path de solo lectura +que el resto de snapshots historicos, con metadata util como: + +- `generated_at` +- `month_key` +- `found` +- `source_range_start` +- `source_range_end` +- `event_coverage` + +## Ingesta historica CRCON + +La ingesta historica no usa A2S ni scraping del HTML de `/games`. Consume la +capa JSON publica detectada en los scoreboards CRCON de Comunidad Hispana y +persiste el resultado en las tablas `historical_*`. + +Fuentes configuradas: + +- `https://scoreboard.comunidadhll.es` +- `https://scoreboard.comunidadhll.es:5443` +- `https://scoreboard.comunidadhll.es:3443` + +Comandos manuales desde `backend/`: + +```powershell +python -m app.historical_ingestion bootstrap +python -m app.historical_ingestion refresh +python -m app.historical_runner --interval 1800 +``` + +Los mismos flujos desde Docker Compose: + +```powershell +docker compose exec backend python -m app.historical_ingestion bootstrap +docker compose exec backend python -m app.historical_ingestion refresh +docker compose exec backend python -m app.historical_runner --interval 1800 +``` + +Flags utiles: + +- `--server comunidad-hispana-01` para limitar a un servidor +- `--server comunidad-hispana-02` para validar solo el segundo servidor activo +- `--overlap-hours 48` para releer una ventana reciente mayor sin relanzar bootstrap +- `--max-pages 2` para validacion local acotada +- `--page-size 25` para ajustar paginacion +- `--start-page 4` para forzar una pagina concreta en bootstraps largos +- `--detail-workers 16` para paralelizar el detalle por partida + +La ejecucion `bootstrap` recorre paginas historicas hasta agotar resultados. +La ejecucion `refresh` usa una ventana de solape sobre la ultima partida +persistida por servidor para releer solo paginas recientes y absorber updates +tardios sin reimportar todo el historico. Cuando una ejecucion termina +correctamente, tambien recompone los snapshots historicos precalculados para el +servidor afectado o para todos los servidores si la ingesta fue global. +Si la recomposicion se lanza para un servidor fisico concreto, el backend +rehace tambien el agregado logico `all-servers` para mantener `Todos` +alineado con `#01` y `#02` aunque `#03` siga sin bootstrap. + +En esta fase, el comando muestra progreso operativo util sin saturar stdout: + +- intento primario RCON +- fuente finalmente seleccionada +- servidor actual +- pagina actual +- `match_ids_to_detail` de cada pagina + +Si RCON no puede cubrir la operacion competitiva real, el fallback a +`public-scoreboard` queda visible tanto durante la ejecucion como en el JSON +final mediante: + +- `primary_source` +- `selected_source` +- `fallback_used` +- `fallback_reason` +- `source_attempts` +- `primary_writer_result` + +El comando devuelve ademas un resumen de cobertura persistida por servidor. Esto +ayuda a validar rapidamente cuantos matches reales quedaron importados, el rango +temporal cubierto y si la carga ya supera la ultima semana movil que usa la UI. +Ese resumen incluye tambien checkpoint y estado operativo de backfill por +servidor: + +- `next_page` +- `last_completed_page` +- `discovered_total_matches` +- `discovered_total_pages` +- `archive_exhausted` +- `last_run` + +Como la fuente CRCON publica expone un archivo muy profundo y puede devolver +errores `502` intermitentes bajo carga sostenida, el bootstrap completo debe +tratarse como una operacion reanudable. Flujo recomendado: + +```powershell +python -m app.historical_ingestion bootstrap --detail-workers 16 +python -m app.historical_ingestion bootstrap --detail-workers 16 +``` + +La segunda invocacion reutiliza automaticamente el checkpoint persistido en +`historical_backfill_progress` y continua desde la siguiente pagina pendiente si +la sesion anterior se corta por tiempo disponible o por inestabilidad puntual +del origen. `--start-page` queda como override manual cuando se quiera +reprocesar o inspeccionar un tramo concreto. + +Runbook operativo para overlap manual: + +```powershell +python -m app.historical_ingestion refresh --overlap-hours 48 +python -m app.historical_ingestion refresh --server comunidad-hispana-01 --overlap-hours 48 --max-pages 2 +python -m app.historical_runner --max-runs 1 +``` + +La primera pasada relee 48 horas sobre los tres servidores historicos ya +registrados. La segunda sirve para validar un solo servidor con alcance +acotado. La tercera recompone snapshots despues de una pasada manual cuando se +quiere confirmar que la capa precalculada vuelve a quedar alineada. + +Interpretacion operativa recomendada: + +- si aparece `historical-ingestion-rcon-primary-succeeded`, RCON se intento de + verdad primero y la captura prospectiva quedo registrada +- si despues `selected_source` termina en `public-scoreboard`, eso significa + que la reconstruccion del archivo competitivo `historical_*` siguio + necesitando fallback clasico +- si RCON falla por red, auth o timeout, el motivo queda visible en + `fallback_reason` y en `source_attempts` + +Los reintentos de cada request JSON pueden ajustarse sin tocar codigo con: + +- `HLL_HISTORICAL_CRCON_REQUEST_RETRIES` +- `HLL_HISTORICAL_CRCON_RETRY_DELAY_SECONDS` + +El runner `python -m app.historical_runner` deja ahora una orquestacion +RCON-first lista para ejecucion local repetida sin depender de infraestructura +externa y mantiene calientes los snapshots historicos mas visibles cuando el +fallback clasico entra de forma controlada. Por defecto: + +- intenta primero una captura prospectiva RCON en cada ciclo +- solo lanza el refresh historico clasico cuando RCON falla, cuando se pide un + scope manual que sigue requiriendo cobertura competitiva, o en la cadencia + periodica de fallback para mantener rankings y snapshots clasicos +- refresca cada `900` segundos +- prewarmea en cada ciclo: + - `server-summary` para `comunidad-hispana-01`, `comunidad-hispana-02` y `all-servers` + - `weekly-leaderboard` de la metrica por defecto `kills` para esos mismos alcances + - `monthly-leaderboard` de la metrica por defecto `kills` para esos mismos alcances + - `recent-matches` para esos mismos alcances +- recompone la matriz completa de snapshots cada `4` ciclos para mantener el resto de metricas al dia sin penalizar todos los refresh +- reintenta hasta `2` veces tras un fallo +- espera `30` segundos entre reintentos +- reutiliza el registro de `historical_ingestion_runs` para dejar trazabilidad + de ultimo refresh, resultado y errores basicos +- persiste por servidor: + - `server-summary` + - `weekly-leaderboard` para `kills`, `deaths`, `support` y `matches_over_100_kills` + - `monthly-leaderboard` para `kills`, `deaths`, `support` y `matches_over_100_kills` + - `recent-matches` + +Flags utiles del runner: + +- `--server comunidad-hispana-01` para limitar a un servidor +- `--interval 900` para fijar la frecuencia recomendada de snapshots +- `--hourly` para fijar directamente un ciclo horario de `3600` segundos +- `--retries 1` para reducir reintentos +- `--retry-delay 10` para bajar la espera entre fallos +- `--max-runs 1` para una validacion puntual sin bucle indefinido + +Para dejar automatizado el refresh historico horario en local, el comando +avanzado sigue disponible: + +```powershell +python -m app.historical_runner --hourly +``` + +Sin `--server`, ese runner refresca: + +- `comunidad-hispana-01` +- `comunidad-hispana-02` + +Despues de cada fallback clasico correcto, recompone snapshots para los +servidores afectados y vuelve a alinear el agregado `all-servers`. Si el ciclo +RCON primario fue suficiente y no hizo falta el fallback clasico, el runner +deja constancia explicita de ese motivo en su salida JSON. + +Para regenerar snapshots de forma puntual dentro del contenedor sin dejar un +bucle permanente, la validacion operativa minima es: + +```powershell +docker compose exec backend python -m app.historical_runner --max-runs 1 +``` + +Operativa local minima: + +1. Desde `backend/`, arrancar la API con `python -m app.main`. +2. En otra terminal, dejar corriendo `python -m app.historical_runner --hourly`. +3. Verificar el proceso revisando la salida del runner: al arrancar imprime un + bloque JSON con `event: "historical-refresh-loop-started"`, `server_scope` + y `snapshot_scope`. +4. Confirmar que los snapshots siguen actualizandose revisando `generated_at` + en archivos bajo `backend/data/snapshots/`, por ejemplo: + - `backend/data/snapshots/comunidad-hispana-01/server-summary.json` + - `backend/data/snapshots/comunidad-hispana-02/recent-matches.json` + - `backend/data/snapshots/comunidad-hispana-02/weekly-kills.json` + - `backend/data/snapshots/all-servers/monthly-kills.json` + +Operativa avanzada con Docker Compose: + +```powershell +docker compose --profile advanced up -d backend historical-runner frontend +``` + +El servicio `historical-runner` usa el mismo volumen persistente `./backend/data` +y ejecuta `python -m app.historical_runner --hourly` como bucle operativo +dedicado, sin mezclar el scheduler con el proceso HTTP principal. No forma +parte del despliegue normal, que queda limitado a `backend` + `frontend`. + +En frontend, la landing ya no arranca con cards fake estaticas para servidores: + +- el contenedor queda en estado de loading +- solo se renderizan cards con datos reales al hidratar +- si la API falla, se muestra una degradacion limpia en lugar de datos falsos + +## Coordinacion single-writer para automatizaciones y CLI + +Todos los procesos writer-oriented que comparten el mismo SQLite usan ahora un +lock comun derivado de `HLL_BACKEND_STORAGE_PATH` y persistido junto al volumen +de datos compartido. Ese lock coordina: + +- `app.historical_ingestion` +- `app.historical_runner` +- `app.player_event_worker` +- `app.rcon_historical_worker` + +Rutas HTTP read-only como `/api/historical/snapshots/*`, `/api/servers` en modo +cache local y el read model minimo RCON no adquieren este lock. + +Variables operativas: + +- `HLL_BACKEND_WRITER_LOCK_TIMEOUT_SECONDS` +- `HLL_BACKEND_WRITER_LOCK_POLL_INTERVAL_SECONDS` + +Comportamiento: + +- si un writer ya esta ejecutandose, el siguiente espera de forma controlada + hasta agotar el timeout configurado +- si no puede adquirir el lock, falla con un error claro indicando: + - lock path + - holder + - `started_at` + - host + - pid +- si el lock parece venir de un contenedor Docker ya parado, el backend puede + recuperarlo automaticamente cuando: + - el holder venia de un cwd tipo `/app` + - el lock ya supero una gracia minima de seguridad +- la coordinacion principal es este single-writer lock; WAL y `busy_timeout` + quedan como endurecimiento complementario, no como solucion unica + +Runbook minimo: + +- pasada manual del historico base mientras el runner automatico existe: + + ```powershell + docker compose exec backend python -m app.historical_ingestion refresh --overlap-hours 48 + ``` + + Si el lock esta ocupado, el comando esperara hasta el timeout configurado y, + si no se libera, terminara con un mensaje claro de lock ocupado. + +- pasada manual de player-events: + + ```powershell + docker compose exec backend python -m app.player_event_worker refresh --overlap-hours 48 + ``` + +- pasada manual de captura prospectiva RCON: + + ```powershell + docker compose exec backend python -m app.rcon_historical_worker capture + ``` + +- convivencia recomendada con automatizaciones: + - no hace falta parar contenedores por defecto + - dejar que el lock coordine la exclusión mutua + - usar `--max-runs 1` o comandos manuales puntuales cuando se quiera una + pasada controlada + +Comprobaciones utiles con Compose: + +- `docker compose ps historical-runner` +- `docker compose logs -f historical-runner` +- `docker compose exec backend python -m app.historical_runner --max-runs 1` + +Compose para captura prospectiva RCON: + +```powershell +docker compose --profile advanced up -d rcon-historical-worker +docker compose logs -f rcon-historical-worker +docker compose exec backend python -m app.rcon_historical_worker capture +``` + +Variables utiles del runner: + +- `HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS` +- `HLL_HISTORICAL_FULL_SNAPSHOT_EVERY_RUNS` +- `HLL_HISTORICAL_REFRESH_MAX_RETRIES` +- `HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS` +- `HLL_HISTORICAL_WEEKLY_FALLBACK_MIN_MATCHES` +- `HLL_HISTORICAL_WEEKLY_FALLBACK_MAX_WEEKDAY` + +Al inicializar la persistencia local, el backend normaliza tambien la identidad +historica ya guardada: + +- prioriza `steaminfo.profile.steamid` cuando existe +- si `player_id` ya parece un SteamID real, lo promueve igualmente a `steam:*` +- si no hay SteamID, usa `player_id` como clave `crcon-player:*` +- deja `steaminfo.id` como ultimo fallback cuando faltan las claves anteriores + +La misma inicializacion fusiona filas duplicadas si una partida abierta quedo +guardada con un id sintetico y mas tarde CRCON la expone con un id numerico +definitivo. Esto evita que el ranking semanal cuente dos veces la misma sesion. + +## CORS local minimo + +El backend responde con `Access-Control-Allow-Origin` solo si la peticion llega +desde uno de los origenes permitidos en desarrollo local. No se habilita un +comodin global ni configuracion de produccion en esta fase. + +La allowlist por defecto cubre `file://` mediante el origen `null` y los flujos +locales mas comunes del proyecto: + +- `http://127.0.0.1:5500` +- `http://localhost:5500` +- `http://127.0.0.1` +- `http://127.0.0.1:8080` +- `http://localhost` +- `http://localhost:8080` + +Las respuestas `GET` y `OPTIONS` incluyen `Access-Control-Allow-Origin` cuando +el origen esta permitido, suficiente para probar la landing contra la API local +sin tocar endpoints ni payloads. + +Esta separacion mantiene el backend simple y deja una base clara para futuras tasks sin introducir integraciones reales todavia. + +## Fuente y ledger de eventos de jugador V2 + +La repo incluye ahora una primera base V2 separada del historico `historical_*` +para preparar metricas avanzadas de duelos, armas y teamkills sin tocar todavia +la UI ni el scoring final. + +Fuente minima elegida en esta fase: + +- detalle de partida `GET /api/get_map_scoreboard?map_id={id}` del scoreboard CRCON + +Importante: + +- esta fuente no es un feed raw por kill +- el adaptador actual normaliza solo senales parciales ya visibles en el + resumen de partida: + - `most_killed` + - `death_by` + - `weapons` + - `death_by_weapons` + - `teamkills` +- `occurred_at` usa el timestamp de cierre o inicio de la partida, no el + instante exacto del kill +- el ledger raw es append-only y deduplica por `event_id` +- la persistencia queda separada de `historical_matches` y + `historical_player_match_stats` aunque comparte el mismo SQLite de desarrollo + +Contrato minimo normalizado por evento: + +- `event_id` +- `event_type` +- `occurred_at` +- `server_slug` +- `external_match_id` +- `source_kind` +- `source_ref` +- `killer_player_key` +- `victim_player_key` +- `weapon_name` +- `kill_category` +- `is_teamkill` +- `event_value` + +Tablas nuevas: + +- `player_event_raw_ledger` +- `player_event_ingestion_runs` +- `player_event_backfill_progress` + +Comandos manuales desde `backend/`: + +```powershell +python -m app.player_event_worker refresh +python -m app.player_event_worker refresh --server comunidad-hispana-01 --max-pages 1 +python -m app.player_event_worker loop --interval 1800 +``` + +Variables opcionales del worker: + +- `HLL_PLAYER_EVENT_REFRESH_INTERVAL_SECONDS` +- `HLL_PLAYER_EVENT_REFRESH_OVERLAP_HOURS` +- `HLL_PLAYER_EVENT_REFRESH_MAX_RETRIES` +- `HLL_PLAYER_EVENT_REFRESH_RETRY_DELAY_SECONDS` + +Flags utiles del worker: + +- `--server comunidad-hispana-01` para validar un solo servidor +- `--overlap-hours 48` para releer una ventana reciente mayor +- `--max-pages 1` para una comprobacion acotada + +Ejemplos operativos: + +```powershell +python -m app.player_event_worker refresh --overlap-hours 48 +python -m app.player_event_worker refresh --server comunidad-hispana-01 --overlap-hours 48 --max-pages 1 +``` + +Politica operativa minima: + +- el worker corre fuera del request path HTTP +- reusa la capa historica `public-scoreboard` solo como fuente de detalle +- persiste checkpoints por servidor y pagina +- la reejecucion es segura porque el ledger usa insercion idempotente por + `event_id` + +Agregados V2 ya disponibles desde codigo: + +- `list_most_killed()` +- `list_death_by()` +- `list_net_duel_summaries()` +- `list_weapon_kills()` +- `list_teamkill_summaries()` + +Limitaciones actuales de esta fase: + +- no existe todavia un ledger raw por kill individual +- los agregados de duelos y armas son parciales, porque dependen del mejor + resumen disponible por jugador en CRCON y no de todos los encounters del match +- la V2 no expone aun endpoints HTTP ni snapshots propios + +## Historical Runtime Policy + +El backend queda orientado a `RCON-first` tambien para historico: + +- live: + - `rcon` primero + - `a2s` solo como fallback +- historico: + - `rcon` primero tanto para lectura minima como para el writer path primario + de `historical_ingestion` + - `public-scoreboard` solo como fallback cuando RCON no cubre una operacion + competitiva concreta, no tiene cobertura suficiente o falla la captura + primaria + +Metadata observable en payloads historicos: + +- `primary_source` +- `selected_source` +- `fallback_used` +- `fallback_reason` +- `source_attempts` + +Estado real a fecha de esta fase: + +- el read model historico RCON soporta ya una capa competitiva primaria basada + en ventanas derivadas desde persistencia `rcon_historical_*` +- `server-summary` y `recent-matches` pasan a usar esa capa RCON-backed como + camino principal real +- en runtime, esas dos rutas solo se sirven como `rcon` cuando la capability + sigue soportada y existe cobertura RCON util para el scope pedido +- cobertura util en esta frontera significa: + - `server-summary`: al menos una fila con `coverage.status != "empty"` y + `window_count` o `sample_count` mayor que cero + - `recent-matches`: al menos una ventana con `match_id`, `closed_at` y + `sample_count > 0` +- si el target persistido quedo con clave legacy `rcon::` pero el + runtime actual ya conoce su `external_server_id`, la capa read model intenta + resolver ambos aliases antes de caer a fallback +- si no hay coverage suficiente o la lectura RCON falla, el backend mantiene + fallback explicito a `public-scoreboard` con `fallback_used = true` y + `fallback_reason` visible +- `historical_ingestion` intenta primero una captura writer-oriented por RCON y + deja esa tentativa visible en su salida +- leaderboards semanales/mensuales, MVP V1/V2 y player-events siguen teniendo + fallback a `public-scoreboard` mientras RCON no disponga de señal competitiva + por jugador con paridad suficiente +- Elo/MMR permanece pausado y desacoplado del arranque del backend; cuando se + reactive mediante una task explicita, debera respetar el contexto + RCON-backed primario y usar `public-scoreboard` solo como suplemento/fallback + para estadisticas por jugador sin paridad RCON + +## PostgreSQL Phase 2 Displayed Data Migration + +Cuando `HLL_BACKEND_DATABASE_URL` esta configurado, los endpoints visibles de +historico y el cache mostrado por `/api/servers` leen PostgreSQL. SQLite y los +JSON legacy quedan como fuente de migracion o fixture explicito con `db_path`. + +Migracion idempotente: + +```powershell +cd backend +python -m app.sqlite_to_postgres_migration +python -m app.storage_diagnostics +``` + +La salida JSON de `sqlite_to_postgres_migration` lista rutas fuente, dominios y +tablas migradas, filas leidas, insertadas, actualizadas, omitidas y errores. +La migracion conserva `external_match_id`, IDs legacy y `match_key` RCON para +que URLs de detalle existentes sigan resolviendo. Tambien copia candidatos y +URLs seguras de scoreboard; no vuelve a activar filas visibles de +`comunidad-hispana-03`. + +Paridad minima a revisar en `storage_diagnostics`: + +- `admin_log_events`, `materialized_matches`, `player_stats` +- `public_scoreboard_historical_matches` +- fuentes de rankings semanales y mensuales +- `server_summary_cache`, `server_snapshots`, `player_event_ledger` +- `scoreboard_candidates` +- ultimas partidas materializadas y ultimos eventos AdminLog `match_end` + +Fuera de phase 2 quedan checkpoints/runs de ingesta publica que no se muestran +en frontend y Elo/MMR pausado. Si un endpoint de mantenimiento recibe un +`db_path` explicito, sigue trabajando contra SQLite para migracion, tests o +compatibilidad operativa controlada. + +## Elo/MMR Monthly Ranking + +Se añade una primera base operativa inspirada en el documento +`sistema_elo_mensual_hll.pdf`, pero adaptada a la telemetria real disponible. + +Superficies nuevas: + +- `python -m app.elo_mmr_engine rebuild` +- `python -m app.elo_mmr_engine leaderboard --server all-servers --limit 10` +- `python -m app.elo_mmr_engine player --server all-servers --player ` +- `/api/historical/elo-mmr/leaderboard` +- `/api/historical/elo-mmr/player` + +Persistencia nueva en SQLite: + +- `elo_mmr_player_ratings` +- `elo_mmr_match_results` +- `elo_mmr_monthly_rankings` +- `elo_mmr_monthly_checkpoints` + +Politica de exactitud: + +- `exact`: outcome, combat, utility, disciplina por teamkills, MMR persistente +- `approximate`: role bucket, objective index, strength of schedule +- `not_available`: leadership y tacticas finas no persistidas + +Cuando `historical_data_source=rcon`, el motor Elo/MMR deja visible una +frontera hibrida y honesta: + +- `primary_source = rcon` +- `selected_source = hybrid-rcon-competitive-plus-public-scoreboard` +- `fallback_used = true` + +Eso significa que la capa RCON-backed ya aporta el contexto competitivo de +cobertura y calidad de match, pero las estadisticas competitivas por jugador +siguen necesitando el suplemento clasico hasta que RCON tenga esa granularidad. + +La especificacion detallada y el mapa de capabilities quedan en: + +- `docs/elo-mmr-monthly-ranking-design.md` + +## Alcance + +Esta fase no implementa: + +- logica real de Discord +- integraciones con servidores de juego +- base de datos +- autenticacion +- dependencias nuevas + +La idea es dejar un esqueleto funcional, pequeno y coherente con `docs/frontend-backend-contract.md`. diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..2ed7cc2 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,59 @@ +"""Minimal bootstrap package for the HLL Vietnam Python backend.""" + +from .config import get_allowed_origins, get_bind_address +from .main import create_server, run +from .normalizers import normalize_a2s_server_info, normalize_server_record +from .payloads import build_health_payload +from .routes import resolve_get_payload +from .snapshots import build_server_snapshot, build_snapshot_batch, utc_now +from .storage import initialize_storage, persist_snapshot_batch + + +def collect_server_snapshots(*args: object, **kwargs: object) -> dict[str, object]: + """Proxy collector access without importing the module during package init.""" + from .collector import collect_server_snapshots as _collect_server_snapshots + + return _collect_server_snapshots(*args, **kwargs) + + +def fetch_a2s_probe(*args: object, **kwargs: object) -> dict[str, object]: + """Proxy A2S probe access without importing the collector during package init.""" + from .collector import fetch_a2s_probe as _fetch_a2s_probe + + return _fetch_a2s_probe(*args, **kwargs) + + +def query_server_info(*args: object, **kwargs: object) -> object: + """Proxy A2S info queries without importing the module during package init.""" + from .a2s_client import query_server_info as _query_server_info + + return _query_server_info(*args, **kwargs) + + +def fetch_controlled_server_source() -> tuple[dict[str, object], ...]: + """Proxy the controlled source without importing the module during package init.""" + from .collector import ( + fetch_controlled_server_source as _fetch_controlled_server_source, + ) + + return tuple(_fetch_controlled_server_source()) + +__all__ = [ + "build_health_payload", + "build_server_snapshot", + "build_snapshot_batch", + "collect_server_snapshots", + "create_server", + "fetch_a2s_probe", + "fetch_controlled_server_source", + "get_allowed_origins", + "get_bind_address", + "initialize_storage", + "normalize_a2s_server_info", + "normalize_server_record", + "persist_snapshot_batch", + "query_server_info", + "resolve_get_payload", + "run", + "utc_now", +] diff --git a/backend/app/a2s_client.py b/backend/app/a2s_client.py new file mode 100644 index 0000000..f839599 --- /dev/null +++ b/backend/app/a2s_client.py @@ -0,0 +1,176 @@ +"""Minimal Steam A2S info client for development-time HLL server probes.""" + +from __future__ import annotations + +import argparse +import json +import socket +import struct +from dataclasses import asdict, dataclass + + +DEFAULT_A2S_TIMEOUT = 6.0 +_A2S_PREFIX = b"\xFF\xFF\xFF\xFF" +_A2S_INFO_REQUEST = _A2S_PREFIX + b"\x54Source Engine Query\x00" +_A2S_CHALLENGE_RESPONSE = 0x41 +_A2S_INFO_RESPONSE = 0x49 + + +class A2SError(RuntimeError): + """Base error for A2S query failures.""" + + +class A2STimeoutError(A2SError): + """Raised when an A2S query does not complete before the timeout.""" + + +class A2SProtocolError(A2SError): + """Raised when an A2S server returns an unexpected payload.""" + + +@dataclass(frozen=True, slots=True) +class A2SServerInfo: + """Minimal metadata returned by an A2S info query.""" + + host: str + query_port: int + server_name: str + map_name: str | None + players: int + max_players: int + protocol: int + folder: str | None = None + game: str | None = None + version: str | None = None + + +def query_server_info( + host: str, + query_port: int, + *, + timeout: float = DEFAULT_A2S_TIMEOUT, +) -> A2SServerInfo: + """Query one server using A2S_INFO and return minimal reusable metadata.""" + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp_socket: + udp_socket.settimeout(timeout) + address = (host, query_port) + + try: + udp_socket.sendto(_A2S_INFO_REQUEST, address) + payload = _receive_packet(udp_socket) + if _is_challenge_packet(payload): + challenge = payload[5:9] + udp_socket.sendto(_A2S_INFO_REQUEST + challenge, address) + payload = _receive_packet(udp_socket) + except socket.timeout as error: + raise A2STimeoutError( + f"A2S query to {host}:{query_port} timed out after {timeout:.1f}s." + ) from error + except OSError as error: + raise A2SError( + f"A2S query to {host}:{query_port} failed: {error}." + ) from error + + return _parse_info_payload(payload, host=host, query_port=query_port) + + +def main() -> None: + """Allow a direct development-time probe of one A2S target.""" + parser = argparse.ArgumentParser(description="Probe one server with A2S_INFO.") + parser.add_argument("host", help="Server hostname or IPv4 address.") + parser.add_argument("query_port", type=int, help="Server Steam query port.") + parser.add_argument( + "--timeout", + type=float, + default=DEFAULT_A2S_TIMEOUT, + help="Socket timeout in seconds.", + ) + args = parser.parse_args() + + payload = asdict( + query_server_info(args.host, args.query_port, timeout=args.timeout) + ) + print(json.dumps(payload, indent=2)) + + +def _receive_packet(udp_socket: socket.socket) -> bytes: + payload, _ = udp_socket.recvfrom(4096) + return payload + + +def _is_challenge_packet(payload: bytes) -> bool: + return ( + len(payload) >= 9 + and payload.startswith(_A2S_PREFIX) + and payload[4] == _A2S_CHALLENGE_RESPONSE + ) + + +def _parse_info_payload( + payload: bytes, + *, + host: str, + query_port: int, +) -> A2SServerInfo: + if len(payload) < 6 or not payload.startswith(_A2S_PREFIX): + raise A2SProtocolError("A2S response did not include the expected packet header.") + if payload[4] != _A2S_INFO_RESPONSE: + raise A2SProtocolError( + f"A2S response type {payload[4]!r} is not an info response." + ) + + protocol = payload[5] + offset = 6 + server_name, offset = _read_c_string(payload, offset) + map_name, offset = _read_c_string(payload, offset) + folder, offset = _read_c_string(payload, offset) + game, offset = _read_c_string(payload, offset) + offset += 2 # app id + players = _read_byte(payload, offset) + max_players = _read_byte(payload, offset + 1) + offset += 6 # players, max, bots, server type, environment, visibility + offset += 1 # vac + version, offset = _read_c_string(payload, offset) + + if offset < len(payload): + extra_data_flag = payload[offset] + offset += 1 + if extra_data_flag & 0x80: + offset += 2 + if extra_data_flag & 0x10: + _, offset = _read_c_string(payload, offset) + if extra_data_flag & 0x40: + offset += 2 + offset += 8 + if extra_data_flag & 0x20: + offset += 8 + + return A2SServerInfo( + host=host, + query_port=query_port, + server_name=server_name or "Unknown server", + map_name=map_name or None, + players=players, + max_players=max_players, + protocol=protocol, + folder=folder or None, + game=game or None, + version=version or None, + ) + + +def _read_c_string(payload: bytes, offset: int) -> tuple[str, int]: + end = payload.find(b"\x00", offset) + if end == -1: + raise A2SProtocolError("A2S response ended before a null-terminated string.") + return payload[offset:end].decode("utf-8", errors="replace"), end + 1 + + +def _read_byte(payload: bytes, offset: int) -> int: + if offset >= len(payload): + raise A2SProtocolError("A2S response ended before expected integer fields.") + return struct.unpack_from(" Sequence[Mapping[str, object]]: + """Return the controlled development source used by the collector bootstrap.""" + return CONTROLLED_RAW_SERVER_SOURCE + + +def fetch_a2s_probe( + host: str, + query_port: int, + *, + timeout: float = DEFAULT_A2S_TIMEOUT, + source_name: str = "a2s-info", + external_server_id: str | None = None, + region: str | None = None, +) -> dict[str, object]: + """Probe one A2S target and normalize its metadata for the collector model.""" + server_info = query_server_info(host, query_port, timeout=timeout) + return normalize_a2s_server_info( + server_info, + source_name=source_name, + external_server_id=external_server_id, + region=region, + ) + + +def fetch_configured_a2s_probes( + *, + timeout: float = DEFAULT_A2S_TIMEOUT, + probe_target: TargetProbe | None = None, +) -> tuple[dict[str, object], ...]: + """Probe the configured A2S targets without hardcoding them in collector logic.""" + probe = probe_target or _probe_configured_target + return tuple( + dict(probe(target, timeout)) + for target in load_a2s_targets() + ) + + +def collect_server_snapshots( + *, + fetch_raw_source: RawSourceFetcher = fetch_controlled_server_source, + source_name: str = "controlled-placeholder", + source_mode: str = "controlled", + timeout: float = DEFAULT_A2S_TIMEOUT, + allow_controlled_fallback: bool = True, + probe_target: TargetProbe | None = None, + persist: bool = False, + db_path: Path | None = None, +) -> dict[str, object]: + """Collect snapshot batches from controlled data, A2S, or auto mode.""" + normalized_records, collection_details = _collect_normalized_records( + fetch_raw_source=fetch_raw_source, + source_name=source_name, + source_mode=source_mode, + timeout=timeout, + allow_controlled_fallback=allow_controlled_fallback, + probe_target=probe_target, + ) + captured_at = utc_now() + + payload = { + "source_name": collection_details["source_name"], + "collection_mode": collection_details["collection_mode"], + "fallback_used": collection_details["fallback_used"], + "target_count": collection_details["target_count"], + "success_count": collection_details["success_count"], + "errors": collection_details["errors"], + "captured_at": captured_at.isoformat().replace("+00:00", "Z"), + "snapshots": build_snapshot_batch( + normalized_records, + captured_at=captured_at, + ), + } + if persist: + payload["storage"] = persist_snapshot_batch( + payload["snapshots"], + source_name=payload["source_name"], + captured_at=payload["captured_at"], + db_path=db_path, + ) + + return payload + + +def main() -> None: + """Allow manual collector execution during development.""" + parser = argparse.ArgumentParser(description="Collect development server snapshots.") + parser.add_argument( + "--source", + choices=("controlled", "a2s", "auto"), + default="auto", + help="Choose controlled data, configured A2S targets, or auto with fallback.", + ) + parser.add_argument( + "--timeout", + type=float, + default=DEFAULT_A2S_TIMEOUT, + help="Socket timeout in seconds for A2S probes.", + ) + parser.add_argument( + "--no-fallback", + action="store_true", + help="Disable fallback to controlled data when A2S fails.", + ) + args = parser.parse_args() + + payload = collect_server_snapshots( + source_mode=args.source, + timeout=args.timeout, + allow_controlled_fallback=not args.no_fallback, + persist=True, + ) + print(json.dumps(payload, indent=2)) + + +def _collect_normalized_records( + *, + fetch_raw_source: RawSourceFetcher, + source_name: str, + source_mode: str, + timeout: float, + allow_controlled_fallback: bool, + probe_target: TargetProbe | None, +) -> tuple[list[dict[str, object]], dict[str, object]]: + if source_mode == "controlled": + raw_records = fetch_raw_source() + return ( + [ + normalize_server_record(record, source_name=source_name) + for record in raw_records + ], + { + "source_name": source_name, + "collection_mode": "controlled", + "fallback_used": False, + "target_count": 0, + "success_count": 0, + "errors": [], + }, + ) + + configured_targets = load_a2s_targets() + records: list[dict[str, object]] = [] + errors: list[dict[str, object]] = [] + probe = probe_target or _probe_configured_target + + for target in configured_targets: + try: + records.append(dict(probe(target, timeout))) + except Exception as error: # noqa: BLE001 - keep collector failures controlled + errors.append( + { + "target": target.name, + "host": target.host, + "query_port": target.query_port, + "message": str(error), + } + ) + + if records: + return ( + records, + { + "source_name": "a2s-info", + "collection_mode": "a2s", + "fallback_used": False, + "target_count": len(configured_targets), + "success_count": len(records), + "errors": errors, + }, + ) + + if source_mode == "a2s" or not allow_controlled_fallback: + return ( + [], + { + "source_name": "a2s-info", + "collection_mode": "a2s", + "fallback_used": False, + "target_count": len(configured_targets), + "success_count": 0, + "errors": errors, + }, + ) + + raw_records = fetch_raw_source() + normalized_records = [ + normalize_server_record(record, source_name=source_name) + for record in raw_records + ] + return ( + normalized_records, + { + "source_name": source_name, + "collection_mode": "controlled-fallback", + "fallback_used": True, + "target_count": len(configured_targets), + "success_count": 0, + "errors": errors, + }, + ) + + +def _probe_configured_target( + target: A2SServerTarget, + timeout: float, +) -> dict[str, object]: + return fetch_a2s_probe( + target.host, + target.query_port, + timeout=timeout, + source_name=target.source_name, + external_server_id=target.external_server_id, + region=target.region, + ) + + +if __name__ == "__main__": + main() diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..49aefcb --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,604 @@ +"""Local development configuration for the HLL Vietnam backend bootstrap.""" + +from __future__ import annotations + +import os +from pathlib import Path + + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8000 +DEFAULT_STORAGE_FILENAME = "hll_vietnam_dev.sqlite3" +DEFAULT_REFRESH_INTERVAL_SECONDS = 300 +DEFAULT_LIVE_DATA_SOURCE = "rcon" +DEFAULT_HISTORICAL_DATA_SOURCE = "rcon" +DEFAULT_RCON_TIMEOUT_SECONDS = 20.0 +DEFAULT_HISTORICAL_CRCON_PAGE_SIZE = 50 +DEFAULT_HISTORICAL_CRCON_TIMEOUT_SECONDS = 15.0 +DEFAULT_HISTORICAL_CRCON_DETAIL_WORKERS = 8 +DEFAULT_HISTORICAL_CRCON_REQUEST_RETRIES = 3 +DEFAULT_HISTORICAL_CRCON_RETRY_DELAY_SECONDS = 0.5 +DEFAULT_HISTORICAL_REFRESH_INTERVAL_SECONDS = 1800 +DEFAULT_HISTORICAL_REFRESH_OVERLAP_HOURS = 12 +DEFAULT_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS = 900 +DEFAULT_HISTORICAL_REFRESH_MAX_RETRIES = 2 +DEFAULT_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS = 30 +DEFAULT_HISTORICAL_FULL_SNAPSHOT_EVERY_RUNS = 4 +DEFAULT_HISTORICAL_ELO_MMR_REBUILD_INTERVAL_MINUTES = 180 +DEFAULT_HISTORICAL_ELO_MMR_MIN_NEW_SAMPLES = 12 +DEFAULT_HISTORICAL_WEEKLY_FALLBACK_MIN_MATCHES = 3 +DEFAULT_HISTORICAL_WEEKLY_FALLBACK_MAX_WEEKDAY = 2 +DEFAULT_PLAYER_EVENT_REFRESH_INTERVAL_SECONDS = 1800 +DEFAULT_PLAYER_EVENT_REFRESH_OVERLAP_HOURS = 12 +DEFAULT_PLAYER_EVENT_REFRESH_MAX_RETRIES = 2 +DEFAULT_PLAYER_EVENT_REFRESH_RETRY_DELAY_SECONDS = 30 +DEFAULT_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS = 600 +DEFAULT_RCON_HISTORICAL_CAPTURE_MAX_RETRIES = 2 +DEFAULT_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS = 15 +DEFAULT_RCON_BACKFILL_CHUNK_HOURS = 6 +DEFAULT_RCON_BACKFILL_SLEEP_SECONDS = 1.0 +DEFAULT_RCON_BACKFILL_MAX_DAYS_BACK = 45 +DEFAULT_RECENT_MATCHES_KEEP = 100 +DEFAULT_ADMIN_LOG_NONCRITICAL_RETENTION_DAYS = 30 +DEFAULT_ADMIN_LOG_CRITICAL_RETENTION_DAYS = 90 +DEFAULT_SERVER_SNAPSHOT_RETENTION_DAYS = 14 +DEFAULT_DB_MAINTENANCE_BATCH_SIZE = 5000 +DEFAULT_DB_MAINTENANCE_ENABLED = False +DEFAULT_DB_MAINTENANCE_INTERVAL_SECONDS = 43200 +DEFAULT_SQLITE_WRITER_TIMEOUT_SECONDS = 30.0 +DEFAULT_SQLITE_BUSY_TIMEOUT_MS = 30000 +DEFAULT_WRITER_LOCK_TIMEOUT_SECONDS = 120.0 +DEFAULT_WRITER_LOCK_POLL_INTERVAL_SECONDS = 1.0 +DEFAULT_ALLOWED_ORIGINS = ( + "null", + "http://127.0.0.1", + "http://127.0.0.1:5500", + "http://127.0.0.1:8080", + "http://localhost", + "http://localhost:5500", + "http://localhost:8080", +) +DEFAULT_A2S_TARGETS_ENV_VAR = "HLL_BACKEND_A2S_TARGETS" +DEFAULT_A2S_SOURCE_NAME = "community-hispana-a2s" +DEFAULT_RCON_TARGETS_ENV_VAR = "HLL_BACKEND_RCON_TARGETS" +DEFAULT_RCON_SOURCE_NAME = "community-hispana-rcon" + + +def get_bind_address() -> tuple[str, int]: + """Return the host and port used by the local backend bootstrap.""" + host = os.getenv("HLL_BACKEND_HOST", DEFAULT_HOST) + port = int(os.getenv("HLL_BACKEND_PORT", str(DEFAULT_PORT))) + return host, port + + +def get_allowed_origins() -> tuple[str, ...]: + """Return the small allowlist used for local frontend development.""" + raw_origins = os.getenv( + "HLL_BACKEND_ALLOWED_ORIGINS", + ",".join(DEFAULT_ALLOWED_ORIGINS), + ) + origins = [] + for origin in raw_origins.split(","): + normalized_origin = _normalize_origin(origin) + if normalized_origin: + origins.append(normalized_origin) + return tuple(origins) or DEFAULT_ALLOWED_ORIGINS + + +def _normalize_origin(origin: str) -> str: + """Normalize configured origins so env overrides match browser Origin values.""" + return origin.strip().rstrip("/") + + +def get_storage_path() -> Path: + """Return the local SQLite path used for development snapshot persistence.""" + default_path = Path(__file__).resolve().parent.parent / "data" / DEFAULT_STORAGE_FILENAME + configured_path = os.getenv("HLL_BACKEND_STORAGE_PATH") + return Path(configured_path) if configured_path else default_path + + +def get_database_url() -> str | None: + """Return the optional PostgreSQL URL for migrated backend storage domains.""" + configured_url = os.getenv("HLL_BACKEND_DATABASE_URL") + if configured_url is None: + return None + normalized_url = configured_url.strip() + return normalized_url or None + + +def use_postgres_rcon_storage(*, explicit_sqlite_path: Path | None = None) -> bool: + """Return whether phase-1 RCON storage should use PostgreSQL.""" + return explicit_sqlite_path is None and get_database_url() is not None + + +def get_sqlite_writer_timeout_seconds() -> float: + """Return the SQLite connection timeout shared by writer-capable storage layers.""" + configured_value = os.getenv( + "HLL_BACKEND_SQLITE_WRITER_TIMEOUT_SECONDS", + str(DEFAULT_SQLITE_WRITER_TIMEOUT_SECONDS), + ) + timeout_seconds = float(configured_value) + if timeout_seconds <= 0: + raise ValueError("HLL_BACKEND_SQLITE_WRITER_TIMEOUT_SECONDS must be positive.") + return timeout_seconds + + +def get_sqlite_busy_timeout_ms() -> int: + """Return the SQLite busy_timeout shared by writer-capable storage layers.""" + configured_value = os.getenv( + "HLL_BACKEND_SQLITE_BUSY_TIMEOUT_MS", + str(DEFAULT_SQLITE_BUSY_TIMEOUT_MS), + ) + busy_timeout_ms = int(configured_value) + if busy_timeout_ms <= 0: + raise ValueError("HLL_BACKEND_SQLITE_BUSY_TIMEOUT_MS must be positive.") + return busy_timeout_ms + + +def get_writer_lock_timeout_seconds() -> float: + """Return how long writer jobs should wait for the shared backend writer lock.""" + configured_value = os.getenv( + "HLL_BACKEND_WRITER_LOCK_TIMEOUT_SECONDS", + str(DEFAULT_WRITER_LOCK_TIMEOUT_SECONDS), + ) + timeout_seconds = float(configured_value) + if timeout_seconds < 0: + raise ValueError("HLL_BACKEND_WRITER_LOCK_TIMEOUT_SECONDS must be zero or positive.") + return timeout_seconds + + +def get_writer_lock_poll_interval_seconds() -> float: + """Return how often writer jobs should poll the shared backend writer lock.""" + configured_value = os.getenv( + "HLL_BACKEND_WRITER_LOCK_POLL_INTERVAL_SECONDS", + str(DEFAULT_WRITER_LOCK_POLL_INTERVAL_SECONDS), + ) + poll_interval_seconds = float(configured_value) + if poll_interval_seconds <= 0: + raise ValueError( + "HLL_BACKEND_WRITER_LOCK_POLL_INTERVAL_SECONDS must be positive." + ) + return poll_interval_seconds + + +def get_refresh_interval_seconds() -> int: + """Return the default interval used by the local refresh loop.""" + configured_value = os.getenv( + "HLL_BACKEND_REFRESH_INTERVAL_SECONDS", + str(DEFAULT_REFRESH_INTERVAL_SECONDS), + ) + interval_seconds = int(configured_value) + if interval_seconds <= 0: + raise ValueError("HLL_BACKEND_REFRESH_INTERVAL_SECONDS must be positive.") + + return interval_seconds + + +def get_historical_crcon_page_size() -> int: + """Return the default page size used for CRCON historical ingestion.""" + configured_value = os.getenv( + "HLL_HISTORICAL_CRCON_PAGE_SIZE", + str(DEFAULT_HISTORICAL_CRCON_PAGE_SIZE), + ) + page_size = int(configured_value) + if page_size <= 0: + raise ValueError("HLL_HISTORICAL_CRCON_PAGE_SIZE must be positive.") + + return page_size + + +def get_historical_crcon_request_timeout_seconds() -> float: + """Return the timeout used for CRCON historical JSON requests.""" + configured_value = os.getenv( + "HLL_HISTORICAL_CRCON_TIMEOUT_SECONDS", + str(DEFAULT_HISTORICAL_CRCON_TIMEOUT_SECONDS), + ) + timeout_seconds = float(configured_value) + if timeout_seconds <= 0: + raise ValueError("HLL_HISTORICAL_CRCON_TIMEOUT_SECONDS must be positive.") + + return timeout_seconds + + +def get_historical_crcon_detail_workers() -> int: + """Return the worker count used for CRCON historical detail requests.""" + configured_value = os.getenv( + "HLL_HISTORICAL_CRCON_DETAIL_WORKERS", + str(DEFAULT_HISTORICAL_CRCON_DETAIL_WORKERS), + ) + worker_count = int(configured_value) + if worker_count <= 0: + raise ValueError("HLL_HISTORICAL_CRCON_DETAIL_WORKERS must be positive.") + + return worker_count + + +def get_historical_crcon_request_retries() -> int: + """Return the retry count used for CRCON historical JSON requests.""" + configured_value = os.getenv( + "HLL_HISTORICAL_CRCON_REQUEST_RETRIES", + str(DEFAULT_HISTORICAL_CRCON_REQUEST_RETRIES), + ) + retry_count = int(configured_value) + if retry_count <= 0: + raise ValueError("HLL_HISTORICAL_CRCON_REQUEST_RETRIES must be positive.") + + return retry_count + + +def get_historical_crcon_retry_delay_seconds() -> float: + """Return the base delay used between CRCON request retries.""" + configured_value = os.getenv( + "HLL_HISTORICAL_CRCON_RETRY_DELAY_SECONDS", + str(DEFAULT_HISTORICAL_CRCON_RETRY_DELAY_SECONDS), + ) + retry_delay_seconds = float(configured_value) + if retry_delay_seconds < 0: + raise ValueError( + "HLL_HISTORICAL_CRCON_RETRY_DELAY_SECONDS must be zero or positive." + ) + + return retry_delay_seconds + + +def get_historical_refresh_interval_seconds() -> int: + """Return the default interval used by the historical refresh loop.""" + return _read_int_env( + "HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS", + os.getenv( + "HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS", + str(DEFAULT_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS), + ), + minimum=1, + ) + + +def _read_int_env(name: str, default_value: str, *, minimum: int) -> int: + """Read one integer env var and keep validation errors actionable.""" + configured_value = os.getenv(name, default_value) + try: + value = int(configured_value) + except (TypeError, ValueError) as error: + raise ValueError(f"{name} must be an integer.") from error + if value < minimum: + qualifier = "positive" if minimum == 1 else f"at least {minimum}" + raise ValueError(f"{name} must be {qualifier}.") + return value + + +def _read_float_env(name: str, default_value: str, *, minimum: float) -> float: + """Read one float env var and keep validation errors actionable.""" + configured_value = os.getenv(name, default_value) + try: + value = float(configured_value) + except (TypeError, ValueError) as error: + raise ValueError(f"{name} must be a number.") from error + if value < minimum: + qualifier = "zero or positive" if minimum == 0 else f"at least {minimum}" + raise ValueError(f"{name} must be {qualifier}.") + return value + + +def get_historical_refresh_overlap_hours() -> int: + """Return the overlap window used by incremental historical refreshes.""" + configured_value = os.getenv( + "HLL_HISTORICAL_REFRESH_OVERLAP_HOURS", + str(DEFAULT_HISTORICAL_REFRESH_OVERLAP_HOURS), + ) + overlap_hours = int(configured_value) + if overlap_hours < 0: + raise ValueError("HLL_HISTORICAL_REFRESH_OVERLAP_HOURS must be zero or positive.") + + return overlap_hours + + +def get_live_data_source_kind() -> str: + """Return the live provider kind selected for the current environment.""" + source_kind = os.getenv("HLL_BACKEND_LIVE_DATA_SOURCE", DEFAULT_LIVE_DATA_SOURCE).strip() + if source_kind not in {"a2s", "rcon"}: + raise ValueError("HLL_BACKEND_LIVE_DATA_SOURCE must be 'a2s' or 'rcon'.") + return source_kind + + +def get_historical_data_source_kind() -> str: + """Return the historical provider kind selected for the current environment.""" + source_kind = os.getenv( + "HLL_BACKEND_HISTORICAL_DATA_SOURCE", + DEFAULT_HISTORICAL_DATA_SOURCE, + ).strip() + if source_kind not in {"public-scoreboard", "rcon"}: + raise ValueError( + "HLL_BACKEND_HISTORICAL_DATA_SOURCE must be 'public-scoreboard' or 'rcon'." + ) + return source_kind + + +def get_rcon_request_timeout_seconds() -> float: + """Return the timeout used for HLL RCON TCP requests.""" + configured_value = os.getenv( + "HLL_BACKEND_RCON_TIMEOUT_SECONDS", + str(DEFAULT_RCON_TIMEOUT_SECONDS), + ) + timeout_seconds = float(configured_value) + if timeout_seconds <= 0: + raise ValueError("HLL_BACKEND_RCON_TIMEOUT_SECONDS must be positive.") + return timeout_seconds + + +def get_historical_refresh_max_retries() -> int: + """Return the retry count used by the historical refresh loop.""" + return _read_int_env( + "HLL_HISTORICAL_REFRESH_MAX_RETRIES", + str(DEFAULT_HISTORICAL_REFRESH_MAX_RETRIES), + minimum=0, + ) + + +def get_historical_refresh_retry_delay_seconds() -> float: + """Return the wait time between historical refresh retries.""" + return _read_float_env( + "HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS", + str(DEFAULT_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS), + minimum=0, + ) + + +def get_historical_full_snapshot_every_runs() -> int: + """Return how often the runner should rebuild the full snapshot matrix.""" + configured_value = os.getenv( + "HLL_HISTORICAL_FULL_SNAPSHOT_EVERY_RUNS", + str(DEFAULT_HISTORICAL_FULL_SNAPSHOT_EVERY_RUNS), + ) + run_count = int(configured_value) + if run_count <= 0: + raise ValueError("HLL_HISTORICAL_FULL_SNAPSHOT_EVERY_RUNS must be positive.") + + return run_count + + +def get_historical_elo_mmr_rebuild_interval_minutes() -> int: + """Return the minimum minutes between automatic Elo/MMR rebuilds.""" + configured_value = os.getenv( + "HLL_HISTORICAL_ELO_MMR_REBUILD_INTERVAL_MINUTES", + str(DEFAULT_HISTORICAL_ELO_MMR_REBUILD_INTERVAL_MINUTES), + ) + interval_minutes = int(configured_value) + if interval_minutes <= 0: + raise ValueError("HLL_HISTORICAL_ELO_MMR_REBUILD_INTERVAL_MINUTES must be positive.") + return interval_minutes + + +def get_historical_elo_mmr_min_new_samples() -> int: + """Return the minimum new RCON samples required for an automatic Elo/MMR rebuild.""" + configured_value = os.getenv( + "HLL_HISTORICAL_ELO_MMR_MIN_NEW_SAMPLES", + str(DEFAULT_HISTORICAL_ELO_MMR_MIN_NEW_SAMPLES), + ) + min_samples = int(configured_value) + if min_samples <= 0: + raise ValueError("HLL_HISTORICAL_ELO_MMR_MIN_NEW_SAMPLES must be positive.") + return min_samples + + +def get_historical_weekly_fallback_min_matches() -> int: + """Return the minimum closed matches required to trust the current week.""" + configured_value = os.getenv( + "HLL_HISTORICAL_WEEKLY_FALLBACK_MIN_MATCHES", + str(DEFAULT_HISTORICAL_WEEKLY_FALLBACK_MIN_MATCHES), + ) + min_matches = int(configured_value) + if min_matches <= 0: + raise ValueError("HLL_HISTORICAL_WEEKLY_FALLBACK_MIN_MATCHES must be positive.") + + return min_matches + + +def get_historical_weekly_fallback_max_weekday() -> int: + """Return the last weekday index where weekly fallback may still apply.""" + configured_value = os.getenv( + "HLL_HISTORICAL_WEEKLY_FALLBACK_MAX_WEEKDAY", + str(DEFAULT_HISTORICAL_WEEKLY_FALLBACK_MAX_WEEKDAY), + ) + max_weekday = int(configured_value) + if max_weekday < 0 or max_weekday > 6: + raise ValueError("HLL_HISTORICAL_WEEKLY_FALLBACK_MAX_WEEKDAY must be between 0 and 6.") + + return max_weekday + + +def get_player_event_refresh_interval_seconds() -> int: + """Return the default interval used by the player event refresh loop.""" + configured_value = os.getenv( + "HLL_PLAYER_EVENT_REFRESH_INTERVAL_SECONDS", + str(DEFAULT_PLAYER_EVENT_REFRESH_INTERVAL_SECONDS), + ) + interval_seconds = int(configured_value) + if interval_seconds <= 0: + raise ValueError("HLL_PLAYER_EVENT_REFRESH_INTERVAL_SECONDS must be positive.") + return interval_seconds + + +def get_player_event_refresh_overlap_hours() -> int: + """Return the overlap window used by player event refresh runs.""" + configured_value = os.getenv( + "HLL_PLAYER_EVENT_REFRESH_OVERLAP_HOURS", + str(DEFAULT_PLAYER_EVENT_REFRESH_OVERLAP_HOURS), + ) + overlap_hours = int(configured_value) + if overlap_hours < 0: + raise ValueError("HLL_PLAYER_EVENT_REFRESH_OVERLAP_HOURS must be zero or positive.") + return overlap_hours + + +def get_player_event_refresh_max_retries() -> int: + """Return the retry count used by the player event refresh loop.""" + configured_value = os.getenv( + "HLL_PLAYER_EVENT_REFRESH_MAX_RETRIES", + str(DEFAULT_PLAYER_EVENT_REFRESH_MAX_RETRIES), + ) + max_retries = int(configured_value) + if max_retries < 0: + raise ValueError("HLL_PLAYER_EVENT_REFRESH_MAX_RETRIES must be zero or positive.") + return max_retries + + +def get_player_event_refresh_retry_delay_seconds() -> int: + """Return the wait time between player event refresh retries.""" + configured_value = os.getenv( + "HLL_PLAYER_EVENT_REFRESH_RETRY_DELAY_SECONDS", + str(DEFAULT_PLAYER_EVENT_REFRESH_RETRY_DELAY_SECONDS), + ) + retry_delay_seconds = int(configured_value) + if retry_delay_seconds < 0: + raise ValueError( + "HLL_PLAYER_EVENT_REFRESH_RETRY_DELAY_SECONDS must be zero or positive." + ) + return retry_delay_seconds + + +def get_rcon_historical_capture_interval_seconds() -> int: + """Return the default interval used by the prospective RCON capture loop.""" + configured_value = os.getenv( + "HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS", + str(DEFAULT_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS), + ) + interval_seconds = int(configured_value) + if interval_seconds <= 0: + raise ValueError("HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS must be positive.") + return interval_seconds + + +def get_rcon_historical_capture_max_retries() -> int: + """Return the retry count used by the prospective RCON capture loop.""" + configured_value = os.getenv( + "HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES", + str(DEFAULT_RCON_HISTORICAL_CAPTURE_MAX_RETRIES), + ) + max_retries = int(configured_value) + if max_retries < 0: + raise ValueError("HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES must be zero or positive.") + return max_retries + + +def get_rcon_historical_capture_retry_delay_seconds() -> int: + """Return the wait time between failed prospective RCON capture attempts.""" + configured_value = os.getenv( + "HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS", + str(DEFAULT_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS), + ) + retry_delay_seconds = int(configured_value) + if retry_delay_seconds < 0: + raise ValueError( + "HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS must be zero or positive." + ) + return retry_delay_seconds + + +def get_rcon_backfill_chunk_hours() -> int: + """Return the AdminLog backfill chunk size in hours.""" + return _read_int_env( + "HLL_RCON_BACKFILL_CHUNK_HOURS", + str(DEFAULT_RCON_BACKFILL_CHUNK_HOURS), + minimum=1, + ) + + +def get_rcon_backfill_sleep_seconds() -> float: + """Return the delay between AdminLog backfill RCON requests.""" + return _read_float_env( + "HLL_RCON_BACKFILL_SLEEP_SECONDS", + str(DEFAULT_RCON_BACKFILL_SLEEP_SECONDS), + minimum=0, + ) + + +def get_rcon_backfill_max_days_back() -> int: + """Return the maximum AdminLog backfill lookback horizon in days.""" + return _read_int_env( + "HLL_RCON_BACKFILL_MAX_DAYS_BACK", + str(DEFAULT_RCON_BACKFILL_MAX_DAYS_BACK), + minimum=1, + ) + + +def get_recent_matches_keep() -> int: + """Return how many recent closed materialized matches maintenance must protect.""" + return _read_int_env( + "HLL_RECENT_MATCHES_KEEP", + str(DEFAULT_RECENT_MATCHES_KEEP), + minimum=1, + ) + + +def get_admin_log_noncritical_retention_days() -> int: + """Return retention days for non-critical AdminLog events.""" + return _read_int_env( + "HLL_ADMIN_LOG_NONCRITICAL_RETENTION_DAYS", + str(DEFAULT_ADMIN_LOG_NONCRITICAL_RETENTION_DAYS), + minimum=1, + ) + + +def get_admin_log_critical_retention_days() -> int: + """Return retention days for critical AdminLog events.""" + return _read_int_env( + "HLL_ADMIN_LOG_CRITICAL_RETENTION_DAYS", + str(DEFAULT_ADMIN_LOG_CRITICAL_RETENTION_DAYS), + minimum=1, + ) + + +def get_server_snapshot_retention_days() -> int: + """Return retention days for live server snapshots.""" + return _read_int_env( + "HLL_SERVER_SNAPSHOT_RETENTION_DAYS", + str(DEFAULT_SERVER_SNAPSHOT_RETENTION_DAYS), + minimum=1, + ) + + +def get_db_maintenance_batch_size() -> int: + """Return the delete batch size used by database maintenance.""" + return _read_int_env( + "HLL_DB_MAINTENANCE_BATCH_SIZE", + str(DEFAULT_DB_MAINTENANCE_BATCH_SIZE), + minimum=1, + ) + + +def get_db_maintenance_enabled() -> bool: + """Return whether scheduled database maintenance is enabled.""" + normalized = os.getenv( + "HLL_DB_MAINTENANCE_ENABLED", + "true" if DEFAULT_DB_MAINTENANCE_ENABLED else "false", + ).strip().lower() + return normalized in {"1", "true", "yes", "on"} + + +def get_db_maintenance_interval_seconds() -> int: + """Return the scheduled database maintenance interval in seconds.""" + return _read_int_env( + "HLL_DB_MAINTENANCE_INTERVAL_SECONDS", + str(DEFAULT_DB_MAINTENANCE_INTERVAL_SECONDS), + minimum=1, + ) + + +def get_a2s_targets_payload() -> str | None: + """Return the optional JSON payload that overrides local A2S targets.""" + raw_payload = os.getenv(DEFAULT_A2S_TARGETS_ENV_VAR) + if raw_payload is None: + return None + + normalized = raw_payload.strip() + return normalized or None + + +def get_rcon_targets_payload() -> str | None: + """Return the optional JSON payload that defines live RCON targets.""" + raw_payload = os.getenv(DEFAULT_RCON_TARGETS_ENV_VAR) + if raw_payload is None: + return None + + normalized = raw_payload.strip() + return normalized or None diff --git a/backend/app/data_sources.py b/backend/app/data_sources.py new file mode 100644 index 0000000..f8846b0 --- /dev/null +++ b/backend/app/data_sources.py @@ -0,0 +1,446 @@ +"""Data source selection and contracts for live and historical backend flows.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from .collector import collect_server_snapshots +from .config import get_historical_data_source_kind, get_live_data_source_kind +from .providers.public_scoreboard_provider import PublicScoreboardHistoricalDataSource +from .providers.rcon_provider import RconLiveDataSource +from .rcon_historical_read_model import ( + describe_rcon_historical_read_model, + list_rcon_historical_recent_activity, + list_rcon_historical_server_summaries, +) +from .server_targets import A2SServerTarget, load_a2s_targets + + +LIVE_SOURCE_A2S = "a2s" +SOURCE_KIND_PUBLIC_SCOREBOARD = "public-scoreboard" +SOURCE_KIND_RCON = "rcon" + + +class HistoricalDataSource(Protocol): + """Contract for historical providers used by ingestion flows.""" + + source_kind: str + + def fetch_public_info(self, *, base_url: str) -> dict[str, object]: + """Fetch provider metadata for one historical source.""" + + def fetch_match_page(self, *, base_url: str, page: int, limit: int) -> dict[str, object]: + """Fetch one page of historical matches.""" + + def fetch_match_details( + self, + *, + base_url: str, + match_ids: list[str], + max_workers: int, + ) -> list[dict[str, object]]: + """Fetch detailed payloads for one batch of matches.""" + + +class LiveDataSource(Protocol): + """Contract for live providers used by API payload builders.""" + + source_kind: str + + def collect_snapshots(self, *, persist: bool) -> dict[str, object]: + """Collect one live snapshot batch.""" + + def build_target_index(self) -> dict[str | None, object]: + """Return optional server connection metadata keyed by external id.""" + + +@dataclass(frozen=True, slots=True) +class A2SLiveDataSource: + """Live provider backed by the existing A2S collector flow.""" + + source_kind: str = LIVE_SOURCE_A2S + + def collect_snapshots(self, *, persist: bool) -> dict[str, object]: + return collect_server_snapshots( + source_mode="a2s", + allow_controlled_fallback=False, + persist=persist, + ) + + def build_target_index(self) -> dict[str | None, A2SServerTarget]: + return { + target.external_server_id: target + for target in load_a2s_targets() + if target.external_server_id + } + + +@dataclass(frozen=True, slots=True) +class RconFirstLiveDataSource: + """Live source arbitration with RCON as primary and A2S as controlled fallback.""" + + primary_source: RconLiveDataSource = RconLiveDataSource() + fallback_source: A2SLiveDataSource = A2SLiveDataSource() + source_kind: str = SOURCE_KIND_RCON + + def collect_snapshots(self, *, persist: bool) -> dict[str, object]: + attempts: list[dict[str, object]] = [] + fallback_reason: str | None = None + + try: + primary_payload = self.primary_source.collect_snapshots(persist=persist) + except Exception as error: # noqa: BLE001 - source arbitration keeps fallback controlled + attempts.append( + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="error", + reason="rcon-live-request-failed", + message=str(error), + ) + ) + fallback_reason = "rcon-live-request-failed" + else: + primary_success_count = int(primary_payload.get("success_count") or 0) + primary_snapshots = list(primary_payload.get("snapshots") or []) + if primary_success_count > 0 and primary_snapshots: + attempts.append( + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="success", + ) + ) + return attach_source_policy( + primary_payload, + build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source=SOURCE_KIND_RCON, + source_attempts=attempts, + ), + ) + + attempts.append( + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="empty", + reason="rcon-live-returned-no-usable-snapshots", + message=f"success_count={primary_success_count}", + ) + ) + fallback_reason = "rcon-live-returned-no-usable-snapshots" + + try: + fallback_payload = self.fallback_source.collect_snapshots(persist=persist) + except Exception as error: # noqa: BLE001 - keep combined failure explicit + attempts.append( + build_source_attempt( + source=LIVE_SOURCE_A2S, + role="fallback", + status="error", + reason="a2s-live-fallback-failed", + message=str(error), + ) + ) + raise RuntimeError( + "RCON-first live collection failed and A2S fallback also failed." + ) from error + + attempts.append( + build_source_attempt( + source=LIVE_SOURCE_A2S, + role="fallback", + status="success", + ) + ) + return attach_source_policy( + fallback_payload, + build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source=LIVE_SOURCE_A2S, + fallback_used=True, + fallback_reason=fallback_reason, + source_attempts=attempts, + ), + ) + + def build_target_index(self) -> dict[str | None, object]: + target_index = dict(self.fallback_source.build_target_index()) + target_index.update(self.primary_source.build_target_index()) + return target_index + + +@dataclass(frozen=True, slots=True) +class RconHistoricalDataSource: + """Persisted RCON-backed historical read model over captured competitive windows.""" + + source_kind: str = SOURCE_KIND_RCON + + def fetch_public_info(self, *, base_url: str) -> dict[str, object]: + raise RuntimeError( + "RCON historical read mode does not support CRCON ingestion operations." + ) + + def fetch_match_page(self, *, base_url: str, page: int, limit: int) -> dict[str, object]: + raise RuntimeError( + "RCON historical read mode does not support CRCON ingestion operations." + ) + + def fetch_match_details( + self, + *, + base_url: str, + match_ids: list[str], + max_workers: int, + ) -> list[dict[str, object]]: + raise RuntimeError( + "RCON historical read mode does not support CRCON ingestion operations." + ) + + def list_server_summaries(self, *, server_key: str | None = None) -> list[dict[str, object]]: + """Return coverage and freshness from persisted RCON-backed competitive history.""" + return list_rcon_historical_server_summaries(server_key=server_key) + + def list_recent_activity( + self, + *, + server_key: str | None = None, + limit: int = 20, + ) -> list[dict[str, object]]: + """Return recent RCON-backed competitive history without on-demand network calls.""" + return list_rcon_historical_recent_activity(server_key=server_key, limit=limit) + + def has_server_summary_coverage(self, items: list[dict[str, object]]) -> bool: + """Return whether RCON summaries contain usable historical coverage.""" + for item in items: + coverage = item.get("coverage") if isinstance(item, dict) else None + if not isinstance(coverage, dict): + continue + if coverage.get("status") == "available": + return True + if int(coverage.get("sample_count") or 0) > 0: + return True + if int(coverage.get("window_count") or 0) > 0: + return True + if coverage.get("last_sample_at"): + return True + return False + + def has_recent_activity_coverage(self, items: list[dict[str, object]]) -> bool: + """Return whether RCON recent activity contains at least one usable item.""" + for item in items: + if not isinstance(item, dict): + continue + if item.get("closed_at") or item.get("ended_at") or item.get("started_at"): + return True + if int(item.get("sample_count") or 0) > 0: + return True + return False + + def describe_capabilities(self) -> dict[str, object]: + """Describe the supported RCON historical read surface.""" + return describe_rcon_historical_read_model() + + +def get_historical_data_source() -> HistoricalDataSource: + """Select the historical provider configured for the current environment.""" + source_kind = get_historical_data_source_kind() + if source_kind == SOURCE_KIND_PUBLIC_SCOREBOARD: + return PublicScoreboardHistoricalDataSource() + if source_kind == SOURCE_KIND_RCON: + return RconHistoricalDataSource() + raise ValueError(f"Unsupported historical data source: {source_kind}") + + +def get_live_data_source() -> LiveDataSource: + """Select the live provider configured for the current environment.""" + source_kind = get_live_data_source_kind() + if source_kind == LIVE_SOURCE_A2S: + return A2SLiveDataSource() + if source_kind == SOURCE_KIND_RCON: + return RconFirstLiveDataSource() + raise ValueError(f"Unsupported live data source: {source_kind}") + + +def get_rcon_historical_read_model() -> RconHistoricalDataSource | None: + """Return the persisted RCON-backed historical read model when selected.""" + if get_historical_data_source_kind() != SOURCE_KIND_RCON: + return None + return RconHistoricalDataSource() + + +def describe_historical_runtime_policy() -> dict[str, object]: + """Describe the effective historical runtime policy for the current environment.""" + if get_historical_data_source_kind() != SOURCE_KIND_RCON: + return { + "mode": "public-scoreboard-primary", + "primary_source": SOURCE_KIND_PUBLIC_SCOREBOARD, + "fallback_source": None, + "summary": "Historical runtime uses public-scoreboard directly.", + } + return { + "mode": "rcon-first-with-public-scoreboard-fallback", + "primary_source": SOURCE_KIND_RCON, + "fallback_source": SOURCE_KIND_PUBLIC_SCOREBOARD, + "summary": ( + "Historical runtime attempts the persisted RCON-backed competitive model first " + "and falls back to public-scoreboard when the requested operation is unsupported, has " + "no coverage yet, or the primary path fails." + ), + } + + +def build_historical_runtime_source_policy( + *, + operation: str, + rcon_status: str, + fallback_reason: str | None = None, + selected_source: str | None = None, + rcon_message: str | None = None, +) -> dict[str, object]: + """Build one normalized source-policy block for historical runtime reads.""" + configured_kind = get_historical_data_source_kind() + if configured_kind != SOURCE_KIND_RCON: + return build_source_policy( + primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD, + selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD, + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_PUBLIC_SCOREBOARD, + role="primary", + status="success", + reason=f"{operation}-served-by-public-scoreboard", + ) + ], + ) + + if rcon_status == "success": + return build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source=selected_source or SOURCE_KIND_RCON, + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="success", + reason=f"{operation}-served-by-rcon", + ) + ], + ) + + return build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source=selected_source or SOURCE_KIND_PUBLIC_SCOREBOARD, + fallback_used=True, + fallback_reason=fallback_reason, + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status=rcon_status, + reason=fallback_reason, + message=rcon_message, + ), + build_source_attempt( + source=SOURCE_KIND_PUBLIC_SCOREBOARD, + role="fallback", + status="success", + reason=f"{operation}-served-by-public-scoreboard-fallback", + ), + ], + ) + + +def resolve_historical_ingestion_data_source() -> tuple[HistoricalDataSource, dict[str, object]]: + """Resolve the fallback provider used when classic scoreboard import is required.""" + configured_kind = get_historical_data_source_kind() + if configured_kind in {SOURCE_KIND_PUBLIC_SCOREBOARD, SOURCE_KIND_RCON}: + primary_source = ( + SOURCE_KIND_PUBLIC_SCOREBOARD + if configured_kind == SOURCE_KIND_PUBLIC_SCOREBOARD + else SOURCE_KIND_RCON + ) + fallback_used = configured_kind == SOURCE_KIND_RCON + fallback_reason = ( + "classic-historical-import-requires-public-scoreboard-fallback" + if fallback_used + else None + ) + attempts = [] + if configured_kind == SOURCE_KIND_RCON: + attempts.append( + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="deferred", + reason="rcon-primary-writer-attempt-is-handled-by-historical-ingestion", + ) + ) + attempts.append( + build_source_attempt( + source=SOURCE_KIND_PUBLIC_SCOREBOARD, + role="fallback" if fallback_used else "primary", + status="ready", + reason="classic-historical-import-provider-ready", + ) + ) + return ( + PublicScoreboardHistoricalDataSource(), + build_source_policy( + primary_source=primary_source, + selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD, + fallback_used=fallback_used, + fallback_reason=fallback_reason, + source_attempts=attempts, + ), + ) + + raise ValueError(f"Unsupported historical data source: {configured_kind}") + + +def build_source_attempt( + *, + source: str, + role: str, + status: str, + reason: str | None = None, + message: str | None = None, +) -> dict[str, object]: + """Build one normalized trace entry for source arbitration.""" + return { + "source": source, + "role": role, + "status": status, + "reason": reason, + "message": message, + } + + +def build_source_policy( + *, + primary_source: str, + selected_source: str, + fallback_used: bool = False, + fallback_reason: str | None = None, + source_attempts: list[dict[str, object]] | None = None, +) -> dict[str, object]: + """Build one small source-policy block for API responses and worker output.""" + return { + "primary_source": primary_source, + "selected_source": selected_source, + "fallback_used": fallback_used, + "fallback_reason": fallback_reason, + "source_attempts": list(source_attempts or []), + } + + +def attach_source_policy( + payload: dict[str, object], + source_policy: dict[str, object], +) -> dict[str, object]: + """Attach normalized source-policy metadata to an existing payload.""" + enriched = dict(payload) + enriched.update(source_policy) + return enriched diff --git a/backend/app/database_maintenance.py b/backend/app/database_maintenance.py new file mode 100644 index 0000000..6cce252 --- /dev/null +++ b/backend/app/database_maintenance.py @@ -0,0 +1,638 @@ +"""Application-level database maintenance for bounded historical storage.""" + +from __future__ import annotations + +import argparse +import json +import sqlite3 +from contextlib import closing +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Iterable, Sequence + +from .config import ( + get_admin_log_critical_retention_days, + get_admin_log_noncritical_retention_days, + get_database_url, + get_db_maintenance_batch_size, + get_historical_weekly_fallback_min_matches, + get_recent_matches_keep, + get_server_snapshot_retention_days, +) +from .rcon_admin_log_materialization import MATCH_RESULT_SOURCE +from .sqlite_utils import connect_sqlite_writer +from .writer_lock import backend_writer_lock, build_writer_lock_holder + +CRITICAL_ADMIN_LOG_EVENT_TYPES = frozenset({"kill", "match_start", "match_end"}) + + +@dataclass(frozen=True, slots=True) +class MaintenanceOptions: + apply: bool + recent_matches_keep: int + admin_log_noncritical_retention_days: int + admin_log_critical_retention_days: int + server_snapshot_retention_days: int + batch_size: int + vacuum_analyze: bool + now: datetime + + +def run_database_maintenance_cleanup( + *, + apply: bool = False, + recent_matches_keep: int | None = None, + admin_log_noncritical_retention_days: int | None = None, + admin_log_critical_retention_days: int | None = None, + server_snapshot_retention_days: int | None = None, + batch_size: int | None = None, + vacuum_analyze: bool = False, + now: str | datetime | None = None, + db_path: Path | None = None, +) -> dict[str, object]: + """Plan or apply safe bounded cleanup for supported storage tables.""" + options = MaintenanceOptions( + apply=apply, + recent_matches_keep=recent_matches_keep or get_recent_matches_keep(), + admin_log_noncritical_retention_days=( + admin_log_noncritical_retention_days or get_admin_log_noncritical_retention_days() + ), + admin_log_critical_retention_days=( + admin_log_critical_retention_days or get_admin_log_critical_retention_days() + ), + server_snapshot_retention_days=( + server_snapshot_retention_days or get_server_snapshot_retention_days() + ), + batch_size=batch_size or get_db_maintenance_batch_size(), + vacuum_analyze=vacuum_analyze, + now=_resolve_now(now), + ) + _emit_json_log( + { + "event": "database-maintenance-started", + "mode": "apply" if options.apply else "dry-run", + "database_backend": _database_backend_name(db_path=db_path), + "database_url_configured": bool(get_database_url()) and db_path is None, + "db_path": str(db_path) if db_path is not None else None, + "recent_matches_keep": options.recent_matches_keep, + "admin_log_noncritical_retention_days": options.admin_log_noncritical_retention_days, + "admin_log_critical_retention_days": options.admin_log_critical_retention_days, + "server_snapshot_retention_days": options.server_snapshot_retention_days, + "batch_size": options.batch_size, + "vacuum_analyze": options.vacuum_analyze, + "now": _to_iso(options.now), + } + ) + + try: + if options.apply: + with backend_writer_lock( + holder=build_writer_lock_holder("app.database_maintenance cleanup"), + storage_path=db_path, + ): + payload = _run_cleanup(options=options, db_path=db_path) + else: + payload = _run_cleanup(options=options, db_path=db_path) + _emit_json_log( + { + "event": "database-maintenance-completed", + **payload, + } + ) + return payload + except Exception as exc: # noqa: BLE001 - CLI reports structured diagnostics + error_payload = { + "status": "error", + "mode": "apply" if options.apply else "dry-run", + "error_type": type(exc).__name__, + "error": str(exc), + } + _emit_json_log({"event": "database-maintenance-error", **error_payload}) + return error_payload + + +def _run_cleanup(*, options: MaintenanceOptions, db_path: Path | None) -> dict[str, object]: + with _connect_maintenance(db_path=db_path) as connection: + existing_tables = _existing_table_names(connection) + plan = _build_cleanup_plan(connection, existing_tables=existing_tables, options=options) + _emit_json_log( + { + "event": "database-maintenance-plan", + **plan["summary"], + } + ) + + deleted_counts = { + "rcon_match_player_stats": 0, + "rcon_materialized_matches": 0, + "rcon_admin_log_events": 0, + "server_snapshots": 0, + } + if options.apply: + deleted_counts["rcon_match_player_stats"] = _delete_match_player_stats( + connection, + matches=plan["candidate_matches"], + batch_size=options.batch_size, + ) + deleted_counts["rcon_materialized_matches"] = _delete_ids_in_batches( + connection, + table_name="rcon_materialized_matches", + ids=[int(row["id"]) for row in plan["candidate_matches"]], + batch_size=options.batch_size, + ) + deleted_counts["rcon_admin_log_events"] = _delete_ids_in_batches( + connection, + table_name="rcon_admin_log_events", + ids=plan["candidate_admin_log_ids"], + batch_size=options.batch_size, + ) + deleted_counts["server_snapshots"] = _delete_ids_in_batches( + connection, + table_name="server_snapshots", + ids=plan["candidate_server_snapshot_ids"], + batch_size=options.batch_size, + ) + if options.vacuum_analyze: + _run_vacuum_analyze(connection) + + return { + "status": "ok", + "mode": "apply" if options.apply else "dry-run", + "deleted_counts": deleted_counts, + "plan": plan["summary"], + } + + +def _build_cleanup_plan( + connection: sqlite3.Connection | Any, + *, + existing_tables: set[str], + options: MaintenanceOptions, +) -> dict[str, object]: + candidate_server_snapshot_ids: list[int] = [] + candidate_admin_log_ids: list[int] = [] + candidate_matches: list[dict[str, object]] = [] + protected_match_keys: list[str] = [] + skipped_tables: list[str] = [] + + if "server_snapshots" not in existing_tables: + skipped_tables.append("server_snapshots") + _emit_skip("server_snapshots", "table-missing") + else: + cutoff = options.now - timedelta(days=options.server_snapshot_retention_days) + for row in connection.execute( + "SELECT id, captured_at FROM server_snapshots ORDER BY id ASC" + ).fetchall(): + captured_at = _parse_datetime(row["captured_at"]) + if captured_at is None: + continue + if captured_at < cutoff: + candidate_server_snapshot_ids.append(int(row["id"])) + + protected_ranges: dict[str, list[tuple[int, int]]] = {} + if "rcon_materialized_matches" not in existing_tables: + skipped_tables.append("rcon_materialized_matches") + _emit_skip("rcon_materialized_matches", "table-missing") + else: + ( + candidate_matches, + protected_matches, + protected_ranges, + protection_summary, + ) = _plan_materialized_match_cleanup(connection, options=options) + protected_match_keys = [str(row["match_key"]) for row in protected_matches] + if "rcon_match_player_stats" not in existing_tables: + skipped_tables.append("rcon_match_player_stats") + _emit_skip("rcon_match_player_stats", "table-missing") + + if "rcon_admin_log_events" not in existing_tables: + skipped_tables.append("rcon_admin_log_events") + _emit_skip("rcon_admin_log_events", "table-missing") + else: + candidate_admin_log_ids = _plan_admin_log_cleanup( + connection, + options=options, + protected_ranges=protected_ranges, + ) + + candidate_player_stat_rows = 0 + if candidate_matches and "rcon_match_player_stats" in existing_tables: + candidate_player_stat_rows = _count_candidate_player_stats(connection, candidate_matches) + + summary = { + "status": "ok", + "protected_match_count": len(protected_match_keys), + "candidate_match_count": len(candidate_matches), + "candidate_match_player_stat_count": candidate_player_stat_rows, + "candidate_admin_log_event_count": len(candidate_admin_log_ids), + "candidate_server_snapshot_count": len(candidate_server_snapshot_ids), + "skipped_tables": skipped_tables, + "protected_match_keys_preview": protected_match_keys[:10], + } + if "protection_summary" in locals(): + summary["protection_summary"] = protection_summary + + return { + "candidate_server_snapshot_ids": candidate_server_snapshot_ids, + "candidate_admin_log_ids": candidate_admin_log_ids, + "candidate_matches": candidate_matches, + "summary": summary, + } + + +def _plan_materialized_match_cleanup( + connection: sqlite3.Connection | Any, + *, + options: MaintenanceOptions, +) -> tuple[list[dict[str, object]], list[dict[str, object]], dict[str, list[tuple[int, int]]], dict[str, object]]: + rows = [ + dict(row) + for row in connection.execute( + """ + SELECT id, target_key, match_key, started_at, ended_at, + started_server_time, ended_server_time, source_basis + FROM rcon_materialized_matches + WHERE source_basis = ? + """, + (MATCH_RESULT_SOURCE,), + ).fetchall() + ] + closed_rows: list[dict[str, object]] = [] + protected_rows: list[dict[str, object]] = [] + current_month_start = options.now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + previous_month_start = (current_month_start - timedelta(days=1)).replace(day=1) + current_week_start = (options.now - timedelta(days=options.now.weekday())).replace( + hour=0, + minute=0, + second=0, + microsecond=0, + ) + previous_week_start = current_week_start - timedelta(days=7) + + for row in rows: + closed_at = _parse_datetime(row.get("ended_at") or row.get("started_at")) + if closed_at is None: + row["_protect_reason"] = "unparseable-closed-at" + protected_rows.append(row) + continue + row["_closed_at"] = closed_at + closed_rows.append(row) + + closed_rows.sort( + key=lambda row: ( + row["_closed_at"], + _coerce_int(row.get("ended_server_time")) or _coerce_int(row.get("started_server_time")) or 0, + _coerce_int(row.get("id")) or 0, + ), + reverse=True, + ) + latest_ids = {int(row["id"]) for row in closed_rows[: options.recent_matches_keep]} + current_week_count = sum( + 1 for row in closed_rows if current_week_start <= row["_closed_at"] < options.now + ) + previous_week_count = sum( + 1 for row in closed_rows if previous_week_start <= row["_closed_at"] < current_week_start + ) + protect_previous_week = ( + current_week_count < get_historical_weekly_fallback_min_matches() + and previous_week_count > 0 + ) + protect_previous_month = options.now.day <= 7 + + candidate_rows: list[dict[str, object]] = [] + protected_ranges: dict[str, list[tuple[int, int]]] = {} + for row in closed_rows: + closed_at = row["_closed_at"] + should_protect = False + if int(row["id"]) in latest_ids: + should_protect = True + elif closed_at >= current_month_start: + should_protect = True + elif protect_previous_month and previous_month_start <= closed_at < current_month_start: + should_protect = True + elif closed_at >= current_week_start: + should_protect = True + elif protect_previous_week and previous_week_start <= closed_at < current_week_start: + should_protect = True + + if should_protect: + protected_rows.append(row) + lower = _coerce_int(row.get("started_server_time")) + upper = _coerce_int(row.get("ended_server_time")) + if lower is not None and upper is not None: + protected_ranges.setdefault(str(row["target_key"]), []).append((lower, upper)) + else: + candidate_rows.append(row) + + return ( + candidate_rows, + protected_rows, + protected_ranges, + { + "recent_matches_keep": options.recent_matches_keep, + "current_week_closed_matches": current_week_count, + "previous_week_closed_matches": previous_week_count, + "protect_previous_week": protect_previous_week, + "protect_previous_month": protect_previous_month, + "current_week_start": _to_iso(current_week_start), + "previous_week_start": _to_iso(previous_week_start), + "current_month_start": _to_iso(current_month_start), + "previous_month_start": _to_iso(previous_month_start), + }, + ) + + +def _plan_admin_log_cleanup( + connection: sqlite3.Connection | Any, + *, + options: MaintenanceOptions, + protected_ranges: dict[str, list[tuple[int, int]]], +) -> list[int]: + noncritical_cutoff = options.now - timedelta(days=options.admin_log_noncritical_retention_days) + critical_cutoff = options.now - timedelta(days=options.admin_log_critical_retention_days) + candidate_ids: list[int] = [] + rows = connection.execute( + """ + SELECT id, target_key, event_type, event_timestamp, server_time + FROM rcon_admin_log_events + ORDER BY id ASC + """ + ).fetchall() + for row in rows: + event_type = str(row["event_type"] or "").strip() + event_time = _parse_datetime(row["event_timestamp"]) + if event_time is None: + continue + if event_type in CRITICAL_ADMIN_LOG_EVENT_TYPES: + if event_time >= critical_cutoff: + continue + server_time = _coerce_int(row["server_time"]) + if server_time is None: + continue + if _server_time_is_protected( + target_key=str(row["target_key"] or ""), + server_time=server_time, + protected_ranges=protected_ranges, + ): + continue + candidate_ids.append(int(row["id"])) + continue + if event_time < noncritical_cutoff: + candidate_ids.append(int(row["id"])) + return candidate_ids + + +def _count_candidate_player_stats( + connection: sqlite3.Connection | Any, + matches: Sequence[dict[str, object]], +) -> int: + count = 0 + for batch in _chunked(list(matches), 250): + clause, params = _match_pair_clause(batch) + row = connection.execute( + f"SELECT COUNT(*) AS count FROM rcon_match_player_stats WHERE {clause}", + params, + ).fetchone() + count += int(row["count"] or 0) + return count + + +def _delete_match_player_stats( + connection: sqlite3.Connection | Any, + *, + matches: Sequence[dict[str, object]], + batch_size: int, +) -> int: + deleted = 0 + for batch in _chunked(list(matches), max(1, min(batch_size, 250))): + clause, params = _match_pair_clause(batch) + deleted_in_batch = int( + connection.execute( + f"DELETE FROM rcon_match_player_stats WHERE {clause}", + params, + ).rowcount + or 0 + ) + _commit(connection) + deleted += deleted_in_batch + _emit_json_log( + { + "event": "database-maintenance-delete-batch", + "table": "rcon_match_player_stats", + "deleted_rows": deleted_in_batch, + "batch_size": len(batch), + } + ) + return deleted + + +def _delete_ids_in_batches( + connection: sqlite3.Connection | Any, + *, + table_name: str, + ids: Sequence[int], + batch_size: int, +) -> int: + deleted = 0 + for batch in _chunked(list(ids), batch_size): + placeholders = ",".join("?" for _ in batch) + deleted_in_batch = int( + connection.execute( + f"DELETE FROM {table_name} WHERE id IN ({placeholders})", + batch, + ).rowcount + or 0 + ) + _commit(connection) + deleted += deleted_in_batch + _emit_json_log( + { + "event": "database-maintenance-delete-batch", + "table": table_name, + "deleted_rows": deleted_in_batch, + "batch_size": len(batch), + } + ) + return deleted + + +def _run_vacuum_analyze(connection: sqlite3.Connection | Any) -> None: + raw_connection = _raw_connection(connection) + if isinstance(raw_connection, sqlite3.Connection): + raw_connection.execute("VACUUM") + raw_connection.execute("ANALYZE") + raw_connection.commit() + return + raw_connection.commit() + raw_connection.autocommit = True + try: + raw_connection.execute("VACUUM ANALYZE") + finally: + raw_connection.autocommit = False + + +def _match_pair_clause(matches: Sequence[dict[str, object]]) -> tuple[str, list[object]]: + clauses: list[str] = [] + params: list[object] = [] + for row in matches: + clauses.append("(target_key = ? AND match_key = ?)") + params.extend([row["target_key"], row["match_key"]]) + return " OR ".join(clauses), params + + +def _existing_table_names(connection: sqlite3.Connection | Any) -> set[str]: + raw_connection = _raw_connection(connection) + if isinstance(raw_connection, sqlite3.Connection): + rows = connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ).fetchall() + return {str(row["name"]) for row in rows} + rows = raw_connection.execute( + """ + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + """ + ).fetchall() + return {str(row["table_name"]) for row in rows} + + +def _emit_skip(table_name: str, reason: str) -> None: + _emit_json_log( + { + "event": "database-maintenance-table-skipped", + "table": table_name, + "reason": reason, + } + ) + + +def _server_time_is_protected( + *, + target_key: str, + server_time: int, + protected_ranges: dict[str, list[tuple[int, int]]], +) -> bool: + for lower, upper in protected_ranges.get(target_key, []): + if lower <= server_time <= upper: + return True + return False + + +def _connect_maintenance(*, db_path: Path | None): + if get_database_url() and db_path is None: + from .postgres_rcon_storage import connect_postgres_compat + + return connect_postgres_compat() + resolved_path = db_path or Path.cwd() / "backend" / "data" / "hll_vietnam_dev.sqlite3" + resolved_path.parent.mkdir(parents=True, exist_ok=True) + return closing(connect_sqlite_writer(resolved_path)) + + +def _commit(connection: sqlite3.Connection | Any) -> None: + _raw_connection(connection).commit() + + +def _raw_connection(connection: sqlite3.Connection | Any) -> sqlite3.Connection | Any: + return connection.connection if hasattr(connection, "connection") else connection + + +def _database_backend_name(*, db_path: Path | None) -> str: + return "postgres" if get_database_url() and db_path is None else "sqlite" + + +def _resolve_now(value: str | datetime | None) -> datetime: + if value is None: + return datetime.now(timezone.utc) + if isinstance(value, datetime): + return value.astimezone(timezone.utc) if value.tzinfo else value.replace(tzinfo=timezone.utc) + parsed = _parse_datetime(value) + if parsed is None: + raise ValueError("--now must be an ISO 8601 timestamp or date.") + return parsed + + +def _parse_datetime(value: object) -> datetime | None: + text = str(value or "").strip() + if not text: + return None + if len(text) == 10: + text = f"{text}T00:00:00+00:00" + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + return parsed.astimezone(timezone.utc) if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +def _to_iso(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _coerce_int(value: object) -> int | None: + try: + return None if value is None else int(value) + except (TypeError, ValueError): + return None + + +def _chunked(values: Sequence[Any], size: int) -> Iterable[list[Any]]: + for index in range(0, len(values), size): + yield list(values[index : index + size]) + + +def _emit_json_log(payload: dict[str, object]) -> None: + print(json.dumps(payload, ensure_ascii=True, default=str), flush=True) + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Database maintenance for HLL Vietnam.") + subparsers = parser.add_subparsers(dest="command", required=True) + cleanup_parser = subparsers.add_parser("cleanup") + cleanup_parser.add_argument("--dry-run", action="store_true") + cleanup_parser.add_argument("--apply", action="store_true") + cleanup_parser.add_argument("--recent-matches-keep", type=int, default=get_recent_matches_keep()) + cleanup_parser.add_argument( + "--admin-log-noncritical-retention-days", + type=int, + default=get_admin_log_noncritical_retention_days(), + ) + cleanup_parser.add_argument( + "--admin-log-critical-retention-days", + type=int, + default=get_admin_log_critical_retention_days(), + ) + cleanup_parser.add_argument( + "--server-snapshot-retention-days", + type=int, + default=get_server_snapshot_retention_days(), + ) + cleanup_parser.add_argument("--batch-size", type=int, default=get_db_maintenance_batch_size()) + cleanup_parser.add_argument("--vacuum-analyze", action="store_true") + cleanup_parser.add_argument("--now", default=None) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_arg_parser() + args = parser.parse_args(list(argv) if argv is not None else None) + if args.command != "cleanup": + raise ValueError("Unsupported command.") + if args.apply and args.dry_run: + raise ValueError("--apply and --dry-run are mutually exclusive.") + payload = run_database_maintenance_cleanup( + apply=bool(args.apply), + recent_matches_keep=args.recent_matches_keep, + admin_log_noncritical_retention_days=args.admin_log_noncritical_retention_days, + admin_log_critical_retention_days=args.admin_log_critical_retention_days, + server_snapshot_retention_days=args.server_snapshot_retention_days, + batch_size=args.batch_size, + vacuum_analyze=bool(args.vacuum_analyze), + now=args.now, + ) + return 0 if payload.get("status") == "ok" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/app/elo_mmr_engine.py b/backend/app/elo_mmr_engine.py new file mode 100644 index 0000000..0a47a43 --- /dev/null +++ b/backend/app/elo_mmr_engine.py @@ -0,0 +1,1013 @@ +"""Core Elo/MMR rebuild engine backed by real historical signals.""" + +from __future__ import annotations + +import argparse +import json +from collections import defaultdict +from datetime import datetime, timezone +from statistics import pstdev +from typing import Iterable + +from .config import get_historical_data_source_kind +from .data_sources import ( + SOURCE_KIND_PUBLIC_SCOREBOARD, + SOURCE_KIND_RCON, + build_source_attempt, + build_source_policy, + get_rcon_historical_read_model, +) +from .elo_mmr_models import ( + CAPABILITY_APPROXIMATE, + CAPABILITY_EXACT, + CAPABILITY_UNAVAILABLE, + DEFAULT_BASE_MMR, + ELO_K_FACTOR, + FULL_QUALITY_DURATION_SECONDS, + FULL_QUALITY_PLAYER_COUNT, + MIN_VALID_MATCH_DURATION_SECONDS, + MIN_VALID_PLAYER_PARTICIPATION_RATIO, + MIN_VALID_PLAYER_PARTICIPATION_SECONDS, + MIN_VALID_MATCH_PLAYERS, + MONTHLY_ACTIVITY_TARGET_HOURS, + MONTHLY_ACTIVITY_TARGET_MATCHES, + MONTHLY_MIN_TIME_SECONDS, + MONTHLY_MIN_VALID_MATCHES, + build_signal, + summarize_accuracy, +) +from .elo_mmr_storage import ( + get_elo_mmr_player_profile, + initialize_elo_mmr_storage, + list_elo_mmr_monthly_rankings, + replace_elo_mmr_state, +) +from .historical_storage import ALL_SERVERS_SLUG, initialize_historical_storage +from .rcon_historical_read_model import get_rcon_historical_competitive_match_context +from .sqlite_utils import connect_sqlite_readonly +from .writer_lock import backend_writer_lock, build_writer_lock_holder + + +SCOPE_ALL_SERVERS = ALL_SERVERS_SLUG +QUALITY_BUCKET_HIGH = "high" +QUALITY_BUCKET_MEDIUM = "medium" +QUALITY_BUCKET_LOW = "low" +ROLE_BUCKET_SUPPORT = "support" +ROLE_BUCKET_OFFENSE = "offense" +ROLE_BUCKET_DEFENSE = "defense" +ROLE_BUCKET_COMBAT = "combat" +ROLE_BUCKET_GENERALIST = "generalist" +MONTHLY_MIN_AVG_PARTICIPATION_RATIO = 0.45 +MONTHLY_RANK_WEIGHT_COMPETITIVE_GAIN = 0.70 +MONTHLY_RANK_WEIGHT_MATCH_SCORE = 0.14 +MONTHLY_RANK_WEIGHT_STRENGTH_OF_SCHEDULE = 0.05 +MONTHLY_RANK_WEIGHT_CONSISTENCY = 0.04 +MONTHLY_RANK_WEIGHT_CONFIDENCE = 0.04 +MONTHLY_RANK_WEIGHT_ACTIVITY = 0.03 +EXACT_MODIFIER_K_SHARE = 0.06 +PROXY_MODIFIER_K_SHARE = 0.02 + +ROLE_WEIGHTS = { + ROLE_BUCKET_SUPPORT: {"combat": 0.18, "objective": 0.18, "utility": 0.42, "discipline": 0.22}, + ROLE_BUCKET_OFFENSE: {"combat": 0.38, "objective": 0.30, "utility": 0.10, "discipline": 0.22}, + ROLE_BUCKET_DEFENSE: {"combat": 0.26, "objective": 0.34, "utility": 0.16, "discipline": 0.24}, + ROLE_BUCKET_COMBAT: {"combat": 0.48, "objective": 0.14, "utility": 0.14, "discipline": 0.24}, + ROLE_BUCKET_GENERALIST: {"combat": 0.34, "objective": 0.22, "utility": 0.20, "discipline": 0.24}, +} + + +def rebuild_elo_mmr_models(*, db_path=None) -> dict[str, object]: + """Rebuild persistent player ratings and monthly rankings from scratch.""" + with backend_writer_lock(holder=build_writer_lock_holder("app.elo_mmr_engine rebuild")): + resolved_path = initialize_historical_storage(db_path=db_path) + initialize_elo_mmr_storage(db_path=resolved_path) + historical_source_policy = _build_historical_source_policy_for_elo() + rcon_read_model = get_rcon_historical_read_model() + match_rows = _load_closed_match_rows(db_path=resolved_path) + grouped_matches = _group_match_rows(match_rows) + rcon_match_context_cache: dict[tuple[str, str | None, str | None], dict[str, object] | None] = {} + + ratings_by_scope: dict[str, dict[str, dict[str, object]]] = {SCOPE_ALL_SERVERS: {}} + player_ratings: list[dict[str, object]] = [] + match_results: list[dict[str, object]] = [] + monthly_checkpoints: list[dict[str, object]] = [] + + for match_group in grouped_matches: + server_scope = match_group["server_slug"] + ratings_by_scope.setdefault(server_scope, {}) + rcon_match_context = None + if rcon_read_model is not None: + cache_key = ( + str(match_group["server_slug"]), + str(match_group.get("ended_at")) if match_group.get("ended_at") is not None else None, + str(match_group.get("map_pretty_name") or match_group.get("map_name") or "") + or None, + ) + if cache_key not in rcon_match_context_cache: + rcon_match_context_cache[cache_key] = get_rcon_historical_competitive_match_context( + server_key=str(match_group["server_slug"]), + ended_at=match_group.get("ended_at"), + map_name=match_group.get("map_pretty_name") or match_group.get("map_name"), + ) + rcon_match_context = rcon_match_context_cache[cache_key] + for scope_key in (server_scope, SCOPE_ALL_SERVERS): + match_results.extend( + _score_match_for_scope( + match_group=match_group, + scope_key=scope_key, + ratings_by_scope=ratings_by_scope[scope_key], + rcon_match_context=rcon_match_context, + ) + ) + + for scope_ratings in ratings_by_scope.values(): + player_ratings.extend(scope_ratings.values()) + + monthly_rankings = _build_monthly_rankings(match_results) + checkpoint_groups: dict[tuple[str, str], list[dict[str, object]]] = defaultdict(list) + for row in monthly_rankings: + checkpoint_groups[(row["scope_key"], row["month_key"])].append(row) + generated_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + for (scope_key, month_key), rows in checkpoint_groups.items(): + eligible_count = sum(1 for row in rows if row["eligible"]) + exact_ratio = round( + sum(float(row["capabilities"]["exact_ratio"]) for row in rows) / max(1, len(rows)), + 3, + ) + approximate_ratio = round( + sum(float(row["capabilities"]["approximate_ratio"]) for row in rows) / max(1, len(rows)), + 3, + ) + unavailable_ratio = round( + sum(float(row["capabilities"]["unavailable_ratio"]) for row in rows) / max(1, len(rows)), + 3, + ) + partial_count = sum(1 for row in rows if row["accuracy_mode"] == "partial") + monthly_checkpoints.append( + { + "scope_key": scope_key, + "month_key": month_key, + "generated_at": generated_at, + "player_count": len(rows), + "eligible_player_count": eligible_count, + "source_policy": historical_source_policy, + "capabilities_summary": { + "accuracy_mode": "partial" if partial_count > 0 else "approximate" if approximate_ratio > 0 else "exact", + "exact_ratio": exact_ratio, + "approximate_ratio": approximate_ratio, + "unavailable_ratio": unavailable_ratio, + "partial_count": partial_count, + "notes": [ + "Outcome, combat, utility, match validity and player participation use real stored signals.", + "ObjectiveIndex, role bucket, discipline and strength of schedule rely partly on honest proxies.", + "LeadershipIndex is not available with the current repository telemetry.", + ], + }, + } + ) + + replace_elo_mmr_state( + player_ratings=player_ratings, + match_results=match_results, + monthly_rankings=monthly_rankings, + monthly_checkpoints=monthly_checkpoints, + db_path=resolved_path, + ) + latest_month_by_scope = { + checkpoint["scope_key"]: checkpoint["month_key"] for checkpoint in monthly_checkpoints + } + return { + "status": "ok", + "historical_source_policy": historical_source_policy, + "totals": { + "matches_scored": len({(row["scope_key"], row["external_match_id"]) for row in match_results}), + "player_ratings": len(player_ratings), + "match_results": len(match_results), + "monthly_rankings": len(monthly_rankings), + "monthly_checkpoints": len(monthly_checkpoints), + }, + "latest_month_by_scope": latest_month_by_scope, + } + + +def list_elo_mmr_leaderboard_payload(*, server_id: str | None, limit: int) -> dict[str, object]: + """Return the current monthly Elo/MMR leaderboard for one scope.""" + scope_key = _normalize_scope_key(server_id) + result = list_elo_mmr_monthly_rankings(scope_key=scope_key, limit=limit) + return { + "scope_key": scope_key, + "month_key": result["month_key"], + "found": result["found"], + "generated_at": result["generated_at"], + "items": result["items"], + "source_policy": result["source_policy"] or _build_historical_source_policy_for_elo(), + "capabilities_summary": result["capabilities_summary"], + } + + +def get_elo_mmr_player_payload(*, player_id: str, server_id: str | None) -> dict[str, object] | None: + """Return one Elo/MMR player profile.""" + return get_elo_mmr_player_profile( + player_id=player_id, + scope_key=_normalize_scope_key(server_id), + ) + + +def build_arg_parser() -> argparse.ArgumentParser: + """Build the CLI parser for Elo/MMR maintenance.""" + parser = argparse.ArgumentParser( + description="Rebuild or inspect the Elo/MMR monthly ranking system.", + ) + parser.add_argument( + "mode", + choices=("rebuild", "leaderboard", "player"), + help="rebuild recomputes all persisted Elo/MMR state; leaderboard and player inspect the read model", + ) + parser.add_argument("--server", dest="server_id", help="optional server scope") + parser.add_argument("--limit", type=int, default=10, help="max rows for leaderboard mode") + parser.add_argument("--player", dest="player_id", help="player id or steam id for player mode") + return parser + + +def main(argv: Iterable[str] | None = None) -> int: + """Run the Elo/MMR CLI.""" + parser = build_arg_parser() + args = parser.parse_args(list(argv) if argv is not None else None) + if args.mode == "rebuild": + print(json.dumps(rebuild_elo_mmr_models(), indent=2)) + return 0 + if args.mode == "leaderboard": + print(json.dumps(list_elo_mmr_leaderboard_payload(server_id=args.server_id, limit=args.limit), indent=2)) + return 0 + if not args.player_id: + parser.error("--player is required in player mode") + print(json.dumps(get_elo_mmr_player_payload(player_id=args.player_id, server_id=args.server_id), indent=2)) + return 0 + + +def _load_closed_match_rows(*, db_path) -> list[dict[str, object]]: + with connect_sqlite_readonly(db_path) as connection: + rows = connection.execute( + """ + SELECT + historical_servers.slug AS server_slug, + historical_servers.display_name AS server_name, + historical_matches.external_match_id, + historical_matches.started_at, + historical_matches.ended_at, + historical_matches.game_mode, + historical_matches.allied_score, + historical_matches.axis_score, + historical_players.stable_player_key, + historical_players.display_name AS player_name, + historical_players.steam_id, + historical_player_match_stats.team_side, + historical_player_match_stats.kills, + historical_player_match_stats.deaths, + historical_player_match_stats.teamkills, + historical_player_match_stats.time_seconds, + historical_player_match_stats.combat, + historical_player_match_stats.offense, + historical_player_match_stats.defense, + historical_player_match_stats.support + FROM historical_player_match_stats + INNER JOIN historical_matches + ON historical_matches.id = historical_player_match_stats.historical_match_id + INNER JOIN historical_servers + ON historical_servers.id = historical_matches.historical_server_id + INNER JOIN historical_players + ON historical_players.id = historical_player_match_stats.historical_player_id + WHERE historical_matches.ended_at IS NOT NULL + ORDER BY historical_matches.ended_at ASC, historical_matches.id ASC, historical_players.id ASC + """ + ).fetchall() + return [dict(row) for row in rows] + + +def _group_match_rows(rows: list[dict[str, object]]) -> list[dict[str, object]]: + grouped: dict[tuple[str, str], list[dict[str, object]]] = defaultdict(list) + for row in rows: + grouped[(str(row["server_slug"]), str(row["external_match_id"]))].append(row) + items: list[dict[str, object]] = [] + for (server_slug, match_id), players in grouped.items(): + first = players[0] + items.append( + { + "server_slug": server_slug, + "server_name": first["server_name"], + "external_match_id": match_id, + "started_at": first["started_at"], + "ended_at": first["ended_at"], + "game_mode": first["game_mode"], + "allied_score": _safe_int(first["allied_score"]), + "axis_score": _safe_int(first["axis_score"]), + "players": players, + } + ) + return items + + +def _score_match_for_scope( + *, + match_group: dict[str, object], + scope_key: str, + ratings_by_scope: dict[str, dict[str, object]], + rcon_match_context: dict[str, object] | None = None, +) -> list[dict[str, object]]: + players = list(match_group["players"]) + duration_seconds, duration_mode = _resolve_match_duration( + match_group, + players, + rcon_match_context=rcon_match_context, + ) + quality_factor = _build_quality_factor( + player_count=max(len(players), int(rcon_match_context.get("peak_players") or 0)) + if rcon_match_context is not None + else len(players), + duration_seconds=duration_seconds, + has_score=match_group.get("allied_score") is not None and match_group.get("axis_score") is not None, + ) + quality_bucket = _classify_quality_bucket(quality_factor) + match_valid = duration_seconds >= MIN_VALID_MATCH_DURATION_SECONDS and len(players) >= MIN_VALID_MATCH_PLAYERS + month_key = str(match_group["ended_at"])[:7] + max_kills = max(max(_safe_int(player.get("kills")), 0) for player in players) or 1 + max_support = max(max(_safe_int(player.get("support")), 0) for player in players) or 1 + max_combat = max(max(_safe_int(player.get("combat")), 0) for player in players) or 1 + max_objective = max( + max(_safe_int(player.get("offense")) + _safe_int(player.get("defense")), 0) + for player in players + ) or 1 + results: list[dict[str, object]] = [] + rating_before_by_player = { + str(player["stable_player_key"]): float( + ratings_by_scope.get(str(player["stable_player_key"]), {}).get("current_mmr", DEFAULT_BASE_MMR) + ) + for player in players + } + + for player in players: + stable_player_key = str(player["stable_player_key"]) + rating_row = ratings_by_scope.setdefault( + stable_player_key, + { + "scope_key": scope_key, + "stable_player_key": stable_player_key, + "player_name": player["player_name"], + "steam_id": player.get("steam_id"), + "current_mmr": DEFAULT_BASE_MMR, + "matches_processed": 0, + "wins": 0, + "draws": 0, + "losses": 0, + "last_match_id": None, + "last_match_ended_at": None, + "accuracy_mode": "partial", + "capabilities": summarize_accuracy([]), + }, + ) + signals: list[dict[str, object]] = [] + time_seconds = _safe_int(player.get("time_seconds")) + participation_ratio = _build_participation_ratio( + time_seconds=time_seconds, + duration_seconds=duration_seconds, + ) + player_match_valid = match_valid and _is_player_match_eligible( + time_seconds=time_seconds, + participation_ratio=participation_ratio, + ) + team_outcome = _resolve_team_outcome( + team_side=str(player.get("team_side") or ""), + allied_score=_safe_int(match_group.get("allied_score")), + axis_score=_safe_int(match_group.get("axis_score")), + ) + outcome_score = _build_outcome_score( + team_outcome=team_outcome, + allied_score=_safe_int(match_group.get("allied_score")), + axis_score=_safe_int(match_group.get("axis_score")), + ) + signals.append(build_signal("OutcomeScore", CAPABILITY_EXACT, "Derived from team side and final match score.")) + signals.append(build_signal("MatchValidity", CAPABILITY_EXACT, "Uses closed match state, duration and lobby size thresholds.")) + if duration_seconds > 0: + signals.append(build_signal("PlayerParticipation", CAPABILITY_EXACT, "Uses persisted player time_seconds relative to match duration.")) + + kills = _safe_int(player.get("kills")) + deaths = max(1, _safe_int(player.get("deaths"))) + combat_raw = _safe_int(player.get("combat")) + combat_index = round( + (40.0 * (kills / max_kills)) + + (35.0 * min(1.0, (kills / deaths) / 3.0)) + + (25.0 * (combat_raw / max_combat)), + 3, + ) + signals.append(build_signal("CombatIndex", CAPABILITY_EXACT, "Uses kills, KDA proxy and persisted combat score.")) + + support = _safe_int(player.get("support")) + utility_index = round(100.0 * (support / max_support), 3) if max_support > 0 else 0.0 + signals.append(build_signal("UtilityIndex", CAPABILITY_EXACT, "Uses persisted support points.")) + + objective_proxy = _safe_int(player.get("offense")) + _safe_int(player.get("defense")) + objective_index = round(100.0 * (objective_proxy / max_objective), 3) if max_objective > 0 else 0.0 + signals.append(build_signal("ObjectiveIndex", CAPABILITY_APPROXIMATE, "Approximated from offense and defense scoreboard points because no tactical event feed exists yet.")) + + teamkills = _safe_int(player.get("teamkills")) + completion_component = round(participation_ratio * 100.0, 3) + discipline_index = round( + max( + 0.0, + (88.0 - (teamkills * 18.0)) + (0.12 * completion_component), + ), + 3, + ) + signals.append(build_signal("DisciplineIndex", CAPABILITY_APPROXIMATE, "Uses exact teamkills plus participation as an honest proxy for leave or AFK risk because direct discipline telemetry is unavailable.")) + leadership_index = None + signals.append(build_signal("LeadershipIndex", CAPABILITY_UNAVAILABLE, "No leadership-specific telemetry is stored in the repository yet.")) + + role_bucket = _resolve_role_bucket(player) + signals.append(build_signal("role_bucket", CAPABILITY_APPROXIMATE, "Inferred from the dominant combat/offense/defense/support axis because literal player role is unavailable.")) + if duration_mode == CAPABILITY_EXACT: + signals.append(build_signal("quality_duration", CAPABILITY_EXACT, "Duration computed from match timestamps.")) + else: + signals.append(build_signal("quality_duration", CAPABILITY_APPROXIMATE, "Duration approximated from the maximum persisted player time.")) + if rcon_match_context is not None: + signals.append( + build_signal( + "RconCompetitiveWindow", + CAPABILITY_APPROXIMATE, + "Uses the closest RCON-backed competitive window for match duration and lobby density when coverage exists.", + ) + ) + + weights = ROLE_WEIGHTS.get(role_bucket, ROLE_WEIGHTS[ROLE_BUCKET_GENERALIST]) + impact_score = round( + sum( + { + "combat": combat_index, + "objective": objective_index, + "utility": utility_index, + "discipline": discipline_index, + }[key] + * weight + for key, weight in weights.items() + ), + 3, + ) + team_side = str(player.get("team_side") or "") + strength_of_schedule_match = _build_strength_of_schedule_match( + stable_player_key=stable_player_key, + team_side=team_side, + players=players, + rating_before_by_player=rating_before_by_player, + quality_factor=quality_factor, + ) + signals.append(build_signal("StrengthOfScheduleMatch", CAPABILITY_APPROXIMATE, "Approximated from opponent average MMR pressure plus match quality because no full roster graph is stored.")) + exact_modifier_index = _build_weighted_modifier_index( + left_value=combat_index, + right_value=utility_index, + left_weight=weights["combat"], + right_weight=weights["utility"], + ) + proxy_modifier_index = _build_weighted_modifier_index( + left_value=objective_index, + right_value=discipline_index, + left_weight=weights["objective"], + right_weight=weights["discipline"], + ) + effective_score = round( + ( + (0.60 * outcome_score) + + (0.25 * impact_score) + + (0.10 * strength_of_schedule_match) + + (0.05 * discipline_index) + ) + * participation_ratio, + 3, + ) + if not player_match_valid: + delta_mmr = 0.0 + match_score = 0.0 + expected_result = 0.0 + actual_result = 0.0 + elo_core_delta = 0.0 + performance_modifier_delta = 0.0 + proxy_modifier_delta = 0.0 + else: + expected_result = _build_expected_result( + player_rating=rating_before_by_player.get(stable_player_key, DEFAULT_BASE_MMR), + opponent_average_rating=_resolve_opponent_average_rating( + stable_player_key=stable_player_key, + team_side=team_side, + players=players, + rating_before_by_player=rating_before_by_player, + ), + ) + actual_result = _build_actual_result( + team_outcome=team_outcome, + allied_score=_safe_int(match_group.get("allied_score")), + axis_score=_safe_int(match_group.get("axis_score")), + participation_ratio=participation_ratio, + ) + exact_modifier_edge = _build_centered_modifier_edge( + exact_modifier_index, + participation_ratio=participation_ratio, + ) + proxy_modifier_edge = _build_centered_modifier_edge( + proxy_modifier_index, + participation_ratio=participation_ratio, + ) + elo_core_delta = round( + ELO_K_FACTOR * quality_factor * (actual_result - expected_result), + 3, + ) + exact_modifier_delta = round( + ELO_K_FACTOR * quality_factor * EXACT_MODIFIER_K_SHARE * exact_modifier_edge, + 3, + ) + proxy_modifier_delta = round( + ELO_K_FACTOR * quality_factor * PROXY_MODIFIER_K_SHARE * proxy_modifier_edge, + 3, + ) + performance_modifier_delta = round( + exact_modifier_delta + proxy_modifier_delta, + 3, + ) + delta_mmr = round(elo_core_delta + performance_modifier_delta, 3) + match_score = round(effective_score * quality_factor, 3) + signals.append(build_signal("DeltaMMR", CAPABILITY_APPROXIMATE, "Uses Elo-like expected-vs-actual movement plus bounded HLL performance modifiers and honest proxy boundaries.")) + signals.append(build_signal("MatchScore", CAPABILITY_APPROXIMATE, "Uses outcome-first competitive scoring with bounded HLL impact and schedule context, then scales by match quality.")) + capability_summary = summarize_accuracy(signals) + rating_before = float(rating_row["current_mmr"]) + rating_after = round(rating_before + delta_mmr, 3) + results.append( + { + "scope_key": scope_key, + "month_key": month_key, + "external_match_id": match_group["external_match_id"], + "stable_player_key": stable_player_key, + "player_name": player["player_name"], + "steam_id": player.get("steam_id"), + "server_slug": match_group["server_slug"], + "server_name": match_group["server_name"], + "match_ended_at": match_group["ended_at"], + "match_valid": player_match_valid, + "quality_factor": quality_factor, + "quality_bucket": quality_bucket, + "role_bucket": role_bucket, + "role_bucket_mode": CAPABILITY_APPROXIMATE, + "outcome_score": outcome_score, + "combat_index": combat_index, + "objective_index": objective_index, + "objective_index_mode": CAPABILITY_APPROXIMATE, + "utility_index": utility_index, + "utility_index_mode": CAPABILITY_EXACT, + "leadership_index": leadership_index, + "leadership_index_mode": CAPABILITY_UNAVAILABLE, + "discipline_index": discipline_index, + "discipline_index_mode": CAPABILITY_APPROXIMATE, + "impact_score": impact_score, + "delta_mmr": delta_mmr, + "mmr_before": rating_before, + "mmr_after": rating_after, + "match_score": match_score, + "penalty_points": round((teamkills * 2.0) + max(0.0, (0.5 - participation_ratio) * 8.0), 3), + "capabilities": capability_summary, + "time_seconds": time_seconds, + "participation_ratio": participation_ratio, + "strength_of_schedule_match": strength_of_schedule_match, + "team_outcome": team_outcome, + "expected_result": expected_result, + "actual_result": actual_result, + "elo_core_delta": elo_core_delta, + "performance_modifier_delta": performance_modifier_delta, + "proxy_modifier_delta": proxy_modifier_delta, + } + ) + rating_row["current_mmr"] = rating_after + rating_row["matches_processed"] = int(rating_row["matches_processed"]) + 1 + rating_row["last_match_id"] = match_group["external_match_id"] + rating_row["last_match_ended_at"] = match_group["ended_at"] + rating_row["accuracy_mode"] = capability_summary["accuracy_mode"] + rating_row["capabilities"] = capability_summary + if team_outcome == "win": + rating_row["wins"] = int(rating_row["wins"]) + 1 + elif team_outcome == "draw": + rating_row["draws"] = int(rating_row["draws"]) + 1 + else: + rating_row["losses"] = int(rating_row["losses"]) + 1 + return results + + +def _build_monthly_rankings(match_results: list[dict[str, object]]) -> list[dict[str, object]]: + grouped: dict[tuple[str, str, str], list[dict[str, object]]] = defaultdict(list) + for row in match_results: + grouped[(row["scope_key"], row["month_key"], row["stable_player_key"])].append(row) + + rankings: list[dict[str, object]] = [] + grouped_by_scope_month: dict[tuple[str, str], list[dict[str, object]]] = defaultdict(list) + for (scope_key, month_key, stable_player_key), rows in grouped.items(): + rows.sort(key=lambda item: (item["match_ended_at"], item["external_match_id"])) + valid_rows = [row for row in rows if row["match_valid"]] + total_time_seconds = sum(int(row["time_seconds"] or 0) for row in rows) + penalty_points = round(sum(float(row["penalty_points"]) for row in rows), 3) + capability_rows = [row["capabilities"] for row in rows] + exact_ratio = round(sum(float(item["exact_ratio"]) for item in capability_rows) / max(1, len(capability_rows)), 3) + approximate_ratio = round(sum(float(item["approximate_ratio"]) for item in capability_rows) / max(1, len(capability_rows)), 3) + unavailable_ratio = round(sum(float(item["unavailable_ratio"]) for item in capability_rows) / max(1, len(capability_rows)), 3) + accuracy_mode = "partial" if unavailable_ratio > 0 else "approximate" if approximate_ratio > 0 else "exact" + avg_match_score = round(sum(float(row["match_score"]) for row in valid_rows) / max(1, len(valid_rows)), 3) + baseline_mmr = round(float(rows[0]["mmr_before"]), 3) + current_mmr = round(float(rows[-1]["mmr_after"]), 3) + mmr_gain = round(current_mmr - baseline_mmr, 3) + elo_core_gain = round(sum(float(row.get("elo_core_delta") or 0.0) for row in rows), 3) + performance_modifier_gain = round( + sum(float(row.get("performance_modifier_delta") or 0.0) for row in rows), + 3, + ) + proxy_modifier_gain = round( + sum(float(row.get("proxy_modifier_delta") or 0.0) for row in rows), + 3, + ) + avg_participation_ratio = round( + sum(float(row.get("participation_ratio") or 0.0) for row in rows) / max(1, len(rows)), + 3, + ) + strength_of_schedule = round( + sum(float(row.get("strength_of_schedule_match") or 0.0) for row in valid_rows) / max(1, len(valid_rows)), + 3, + ) + consistency = _build_consistency_score(valid_rows) + activity = _build_activity_score(valid_rows, total_time_seconds) + confidence = round( + min( + 100.0, + (len(valid_rows) / MONTHLY_MIN_VALID_MATCHES) * 35.0 + + (total_time_seconds / MONTHLY_MIN_TIME_SECONDS) * 30.0 + + (avg_participation_ratio * 20.0) + + (exact_ratio * 15.0), + ), + 3, + ) + eligible = ( + len(valid_rows) >= MONTHLY_MIN_VALID_MATCHES + and total_time_seconds >= MONTHLY_MIN_TIME_SECONDS + and avg_participation_ratio >= MONTHLY_MIN_AVG_PARTICIPATION_RATIO + ) + eligibility_reason = _build_monthly_eligibility_reason( + valid_match_count=len(valid_rows), + total_time_seconds=total_time_seconds, + avg_participation_ratio=avg_participation_ratio, + ) + grouped_by_scope_month[(scope_key, month_key)].append( + { + "scope_key": scope_key, + "month_key": month_key, + "stable_player_key": stable_player_key, + "player_name": rows[-1]["player_name"], + "steam_id": rows[-1].get("steam_id"), + "current_mmr": current_mmr, + "baseline_mmr": baseline_mmr, + "mmr_gain": mmr_gain, + "avg_match_score": avg_match_score, + "strength_of_schedule": strength_of_schedule, + "consistency": consistency, + "activity": activity, + "confidence": confidence, + "penalty_points": penalty_points, + "monthly_rank_score": 0.0, + "valid_matches": len(valid_rows), + "total_matches": len(rows), + "total_time_seconds": total_time_seconds, + "avg_participation_ratio": avg_participation_ratio, + "eligible": eligible, + "eligibility_reason": eligibility_reason, + "accuracy_mode": accuracy_mode, + "capabilities": { + "accuracy_mode": accuracy_mode, + "exact_ratio": exact_ratio, + "approximate_ratio": approximate_ratio, + "unavailable_ratio": unavailable_ratio, + "signals": [ + build_signal("OutcomeScore", CAPABILITY_EXACT, "Uses final scores and team side."), + build_signal("CombatIndex", CAPABILITY_EXACT, "Uses historical player stats."), + build_signal("ObjectiveIndex", CAPABILITY_APPROXIMATE, "Uses offense and defense scores as a tactical proxy."), + build_signal("UtilityIndex", CAPABILITY_EXACT, "Uses support points."), + build_signal("LeadershipIndex", CAPABILITY_UNAVAILABLE, "No leadership telemetry exists yet."), + build_signal("DisciplineIndex", CAPABILITY_APPROXIMATE, "Uses teamkills exactly plus participation as a leave-risk proxy."), + build_signal("StrengthOfSchedule", CAPABILITY_APPROXIMATE, "Uses opponent average MMR pressure plus match quality, not a full roster graph."), + build_signal("MonthlyEligibility", CAPABILITY_EXACT, "Uses persisted valid-match count, playtime and participation thresholds."), + ], + }, + "component_scores": { + "model_version": "elo-v3-competitive", + "ranking_formula_version": "elo-v3-competitive-balanced-v1", + "avg_match_score": avg_match_score, + "mmr_gain_raw": mmr_gain, + "elo_core_gain": elo_core_gain, + "performance_modifier_gain": performance_modifier_gain, + "proxy_modifier_gain": proxy_modifier_gain, + "competitive_gain": round( + elo_core_gain + + (0.25 * performance_modifier_gain) + + (0.10 * proxy_modifier_gain), + 3, + ), + "strength_of_schedule": strength_of_schedule, + "consistency": consistency, + "activity": activity, + "confidence": confidence, + "avg_participation_ratio": avg_participation_ratio, + "penalty_points": penalty_points, + }, + } + ) + + for rows in grouped_by_scope_month.values(): + max_avg = max((row["avg_match_score"] for row in rows), default=1.0) or 1.0 + max_competitive_gain = max( + (max(0.0, float(row["component_scores"].get("competitive_gain") or 0.0)) for row in rows), + default=1.0, + ) or 1.0 + max_sos = max((row["strength_of_schedule"] for row in rows), default=1.0) or 1.0 + max_consistency = max((row["consistency"] for row in rows), default=1.0) or 1.0 + max_activity = max((row["activity"] for row in rows), default=1.0) or 1.0 + max_confidence = max((row["confidence"] for row in rows), default=1.0) or 1.0 + for row in rows: + competitive_gain = max(0.0, float(row["component_scores"].get("competitive_gain") or 0.0)) + normalized_gain = competitive_gain / max_competitive_gain if max_competitive_gain > 0 else 0.0 + row["component_scores"]["normalized_mmr_gain"] = round(normalized_gain * 100.0, 3) + row["monthly_rank_score"] = round( + (MONTHLY_RANK_WEIGHT_COMPETITIVE_GAIN * normalized_gain * 100.0) + + (MONTHLY_RANK_WEIGHT_MATCH_SCORE * (row["avg_match_score"] / max_avg) * 100.0) + + (MONTHLY_RANK_WEIGHT_STRENGTH_OF_SCHEDULE * (row["strength_of_schedule"] / max_sos) * 100.0) + + (MONTHLY_RANK_WEIGHT_CONSISTENCY * (row["consistency"] / max_consistency) * 100.0) + + (MONTHLY_RANK_WEIGHT_ACTIVITY * (row["activity"] / max_activity) * 100.0) + + (MONTHLY_RANK_WEIGHT_CONFIDENCE * (row["confidence"] / max_confidence) * 100.0) + - row["penalty_points"], + 3, + ) + rankings.append(row) + return rankings + + +def _build_historical_source_policy_for_elo() -> dict[str, object]: + if get_historical_data_source_kind() != SOURCE_KIND_RCON: + return build_source_policy( + primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD, + selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD, + source_attempts=[build_source_attempt(source=SOURCE_KIND_PUBLIC_SCOREBOARD, role="primary", status="success")], + ) + return build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source="hybrid-rcon-competitive-plus-public-scoreboard", + fallback_used=True, + fallback_reason="rcon-competitive-context-primary-but-player-stats-still-require-public-scoreboard-supplement", + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="partial", + reason="rcon-competitive-context-used-for-match-coverage-and-quality", + ), + build_source_attempt( + source=SOURCE_KIND_PUBLIC_SCOREBOARD, + role="supplemental-fallback", + status="success", + reason="public-scoreboard-still-provides-player-level-competitive-stats", + ), + ], + ) + + +def _resolve_match_duration( + match_group: dict[str, object], + players: list[dict[str, object]], + *, + rcon_match_context: dict[str, object] | None = None, +) -> tuple[int, str]: + if rcon_match_context and int(rcon_match_context.get("duration_seconds") or 0) > 0: + return int(rcon_match_context["duration_seconds"]), CAPABILITY_APPROXIMATE + started_at = _parse_optional_timestamp(match_group.get("started_at")) + ended_at = _parse_optional_timestamp(match_group.get("ended_at")) + if started_at and ended_at and ended_at >= started_at: + return int((ended_at - started_at).total_seconds()), CAPABILITY_EXACT + return max((_safe_int(player.get("time_seconds")) for player in players), default=0), CAPABILITY_APPROXIMATE + + +def _build_quality_factor(*, player_count: int, duration_seconds: int, has_score: bool) -> float: + player_component = min(1.0, player_count / FULL_QUALITY_PLAYER_COUNT) + duration_component = min(1.0, duration_seconds / FULL_QUALITY_DURATION_SECONDS) + score_component = 1.0 if has_score else 0.7 + return round((0.4 * player_component) + (0.4 * duration_component) + (0.2 * score_component), 3) + + +def _build_actual_result( + *, + team_outcome: str, + allied_score: int | None, + axis_score: int | None, + participation_ratio: float, +) -> float: + if team_outcome == "draw": + base_result = 0.5 + elif team_outcome == "win": + base_result = 1.0 + else: + base_result = 0.0 + if allied_score is None or axis_score is None: + margin_adjustment = 0.0 + else: + total_score = max(1, allied_score + axis_score) + margin_ratio = abs(allied_score - axis_score) / total_score + margin_adjustment = min(0.08, margin_ratio * 0.12) + if team_outcome == "win": + adjusted = min(1.0, base_result + margin_adjustment) + elif team_outcome == "loss": + adjusted = max(0.0, base_result - margin_adjustment) + else: + adjusted = base_result + return round(0.5 + ((adjusted - 0.5) * participation_ratio), 4) + + +def _build_weighted_modifier_index( + *, + left_value: float, + right_value: float, + left_weight: float, + right_weight: float, +) -> float: + total_weight = max(0.001, left_weight + right_weight) + return round(((left_value * left_weight) + (right_value * right_weight)) / total_weight, 3) + + +def _build_centered_modifier_edge(index_value: float, *, participation_ratio: float) -> float: + centered = (index_value - 50.0) / 50.0 + return round(max(-1.0, min(1.0, centered * participation_ratio)), 4) + + +def _build_participation_ratio(*, time_seconds: int, duration_seconds: int) -> float: + if duration_seconds <= 0: + return 0.0 + return round(min(1.0, max(0.0, time_seconds / duration_seconds)), 3) + + +def _is_player_match_eligible(*, time_seconds: int, participation_ratio: float) -> bool: + return ( + time_seconds >= MIN_VALID_PLAYER_PARTICIPATION_SECONDS + and participation_ratio >= MIN_VALID_PLAYER_PARTICIPATION_RATIO + ) + + +def _build_outcome_score(*, team_outcome: str, allied_score: int | None, axis_score: int | None) -> float: + if allied_score is None or axis_score is None: + return 50.0 if team_outcome == "draw" else 65.0 if team_outcome == "win" else 35.0 + total_score = max(1, allied_score + axis_score) + margin_ratio = abs(allied_score - axis_score) / total_score + if team_outcome == "draw": + return 50.0 + if team_outcome == "win": + return round(min(100.0, 68.0 + (margin_ratio * 32.0)), 3) + return round(max(0.0, 32.0 - (margin_ratio * 32.0)), 3) + + +def _resolve_opponent_average_rating( + *, + stable_player_key: str, + team_side: str, + players: list[dict[str, object]], + rating_before_by_player: dict[str, float], +) -> float: + normalized_team_side = str(team_side or "").strip().lower() + opponent_ratings = [ + rating_before_by_player.get(str(player["stable_player_key"]), DEFAULT_BASE_MMR) + for player in players + if str(player["stable_player_key"]) != stable_player_key + and _is_same_team(str(player.get("team_side") or ""), normalized_team_side) is False + ] + if not opponent_ratings: + return DEFAULT_BASE_MMR + return round(sum(opponent_ratings) / len(opponent_ratings), 3) + + +def _build_strength_of_schedule_match( + *, + stable_player_key: str, + team_side: str, + players: list[dict[str, object]], + rating_before_by_player: dict[str, float], + quality_factor: float, +) -> float: + opponent_average = _resolve_opponent_average_rating( + stable_player_key=stable_player_key, + team_side=team_side, + players=players, + rating_before_by_player=rating_before_by_player, + ) + mmr_pressure = 50.0 + ((opponent_average - DEFAULT_BASE_MMR) / 8.0) + quality_pressure = quality_factor * 35.0 + return round(min(100.0, max(0.0, mmr_pressure + quality_pressure)), 3) + + +def _build_expected_result(*, player_rating: float, opponent_average_rating: float) -> float: + exponent = (opponent_average_rating - player_rating) / 400.0 + return round(1.0 / (1.0 + (10.0**exponent)), 4) + + +def _build_monthly_eligibility_reason( + *, + valid_match_count: int, + total_time_seconds: int, + avg_participation_ratio: float, +) -> str | None: + if valid_match_count < MONTHLY_MIN_VALID_MATCHES: + return "minimum-valid-matches-not-met" + if total_time_seconds < MONTHLY_MIN_TIME_SECONDS: + return "minimum-playtime-not-met" + if avg_participation_ratio < MONTHLY_MIN_AVG_PARTICIPATION_RATIO: + return "minimum-participation-ratio-not-met" + return None + + +def _classify_quality_bucket(quality_factor: float) -> str: + if quality_factor >= 0.8: + return QUALITY_BUCKET_HIGH + if quality_factor >= 0.55: + return QUALITY_BUCKET_MEDIUM + return QUALITY_BUCKET_LOW + + +def _resolve_team_outcome(*, team_side: str, allied_score: int | None, axis_score: int | None) -> str: + if allied_score is None or axis_score is None or allied_score == axis_score: + return "draw" + normalized = team_side.strip().lower() + allied_won = allied_score > axis_score + if normalized.startswith("all"): + return "win" if allied_won else "loss" + if normalized.startswith("ax"): + return "win" if not allied_won else "loss" + return "draw" + + +def _is_same_team(team_side: str, normalized_team_side: str) -> bool: + candidate = team_side.strip().lower() + if normalized_team_side.startswith("all"): + return candidate.startswith("all") + if normalized_team_side.startswith("ax"): + return candidate.startswith("ax") + return candidate == normalized_team_side + + +def _resolve_role_bucket(player: dict[str, object]) -> str: + axes = { + ROLE_BUCKET_SUPPORT: _safe_int(player.get("support")), + ROLE_BUCKET_OFFENSE: _safe_int(player.get("offense")), + ROLE_BUCKET_DEFENSE: _safe_int(player.get("defense")), + ROLE_BUCKET_COMBAT: _safe_int(player.get("combat")), + } + top_bucket, top_value = max(axes.items(), key=lambda item: item[1]) + sorted_values = sorted(axes.values(), reverse=True) + if top_value <= 0 or (len(sorted_values) >= 2 and sorted_values[0] == sorted_values[1]): + return ROLE_BUCKET_GENERALIST + return top_bucket + + +def _build_consistency_score(rows: list[dict[str, object]]) -> float: + if len(rows) <= 1: + return 100.0 if rows else 0.0 + values = [float(row["match_score"]) for row in rows] + average = sum(values) / len(values) + if average <= 0: + return 0.0 + return round(100.0 * (1.0 - min(1.0, pstdev(values) / max(average, 1.0))), 3) + + +def _build_activity_score(rows: list[dict[str, object]], total_time_seconds: int) -> float: + match_component = min(1.0, len(rows) / MONTHLY_ACTIVITY_TARGET_MATCHES) + hour_component = min(1.0, (total_time_seconds / 3600.0) / MONTHLY_ACTIVITY_TARGET_HOURS) + return round(((0.6 * match_component) + (0.4 * hour_component)) * 100.0, 3) + + +def _normalize_scope_key(server_id: str | None) -> str: + normalized = str(server_id or SCOPE_ALL_SERVERS).strip() + return normalized or SCOPE_ALL_SERVERS + + +def _parse_optional_timestamp(value: object) -> datetime | None: + if not value: + return None + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _safe_int(value: object) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/app/elo_mmr_models.py b/backend/app/elo_mmr_models.py new file mode 100644 index 0000000..e07d2e3 --- /dev/null +++ b/backend/app/elo_mmr_models.py @@ -0,0 +1,74 @@ +"""Contracts and capability helpers for the Elo/MMR monthly ranking system.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass + + +CAPABILITY_EXACT = "exact" +CAPABILITY_APPROXIMATE = "approximate" +CAPABILITY_UNAVAILABLE = "not_available" + +ACCURACY_EXACT = "exact" +ACCURACY_APPROXIMATE = "approximate" +ACCURACY_PARTIAL = "partial" + +DEFAULT_BASE_MMR = 1000.0 +ELO_K_FACTOR = 60.0 +MIN_VALID_MATCH_DURATION_SECONDS = 900 +MIN_VALID_MATCH_PLAYERS = 20 +MIN_VALID_PLAYER_PARTICIPATION_SECONDS = 900 +MIN_VALID_PLAYER_PARTICIPATION_RATIO = 0.45 +FULL_QUALITY_PLAYER_COUNT = 70 +FULL_QUALITY_DURATION_SECONDS = 3600 +MONTHLY_MIN_VALID_MATCHES = 5 +MONTHLY_MIN_TIME_SECONDS = 21600 +MONTHLY_ACTIVITY_TARGET_MATCHES = 12 +MONTHLY_ACTIVITY_TARGET_HOURS = 20.0 +DEFAULT_MONTHLY_SCOREBOARD_MIN_MATCHES = 3 + + +@dataclass(frozen=True, slots=True) +class EloSignalAvailability: + """Normalized availability state for one scoring input.""" + + name: str + status: str + detail: str + + def to_dict(self) -> dict[str, object]: + """Return the availability entry as a serializable mapping.""" + return asdict(self) + + +def build_signal(name: str, status: str, detail: str) -> dict[str, object]: + """Create a normalized availability block for one signal.""" + return EloSignalAvailability(name=name, status=status, detail=detail).to_dict() + + +def summarize_accuracy(signals: list[dict[str, object]]) -> dict[str, object]: + """Summarize exact, approximate and unavailable signals for one calculation.""" + exact_count = sum(1 for signal in signals if signal.get("status") == CAPABILITY_EXACT) + approximate_count = sum( + 1 for signal in signals if signal.get("status") == CAPABILITY_APPROXIMATE + ) + unavailable_count = sum( + 1 for signal in signals if signal.get("status") == CAPABILITY_UNAVAILABLE + ) + if unavailable_count > 0: + accuracy_mode = ACCURACY_PARTIAL + elif approximate_count > 0: + accuracy_mode = ACCURACY_APPROXIMATE + else: + accuracy_mode = ACCURACY_EXACT + total = max(1, len(signals)) + return { + "accuracy_mode": accuracy_mode, + "exact_count": exact_count, + "approximate_count": approximate_count, + "unavailable_count": unavailable_count, + "exact_ratio": round(exact_count / total, 3), + "approximate_ratio": round(approximate_count / total, 3), + "unavailable_ratio": round(unavailable_count / total, 3), + "signals": list(signals), + } diff --git a/backend/app/elo_mmr_storage.py b/backend/app/elo_mmr_storage.py new file mode 100644 index 0000000..4f3a4f0 --- /dev/null +++ b/backend/app/elo_mmr_storage.py @@ -0,0 +1,578 @@ +"""SQLite storage for persistent Elo/MMR and monthly ranking results.""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime +from pathlib import Path + +from .config import get_storage_path +from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer + + +def initialize_elo_mmr_storage(*, db_path: Path | None = None) -> Path: + """Create the Elo/MMR persistence tables in the shared backend SQLite.""" + resolved_path = _resolve_db_path(db_path) + resolved_path.parent.mkdir(parents=True, exist_ok=True) + with _connect_writer(resolved_path) as connection: + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS elo_mmr_player_ratings ( + scope_key TEXT NOT NULL, + stable_player_key TEXT NOT NULL, + player_name TEXT NOT NULL, + steam_id TEXT, + current_mmr REAL NOT NULL, + matches_processed INTEGER NOT NULL DEFAULT 0, + wins INTEGER NOT NULL DEFAULT 0, + draws INTEGER NOT NULL DEFAULT 0, + losses INTEGER NOT NULL DEFAULT 0, + last_match_id TEXT, + last_match_ended_at TEXT, + accuracy_mode TEXT NOT NULL, + capabilities_json TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (scope_key, stable_player_key) + ); + + CREATE TABLE IF NOT EXISTS elo_mmr_match_results ( + scope_key TEXT NOT NULL, + month_key TEXT NOT NULL, + external_match_id TEXT NOT NULL, + stable_player_key TEXT NOT NULL, + player_name TEXT NOT NULL, + steam_id TEXT, + server_slug TEXT NOT NULL, + server_name TEXT NOT NULL, + match_ended_at TEXT NOT NULL, + match_valid INTEGER NOT NULL, + quality_factor REAL NOT NULL, + quality_bucket TEXT NOT NULL, + role_bucket TEXT NOT NULL, + role_bucket_mode TEXT NOT NULL, + outcome_score REAL NOT NULL, + combat_index REAL NOT NULL, + objective_index REAL, + objective_index_mode TEXT NOT NULL, + utility_index REAL, + utility_index_mode TEXT NOT NULL, + leadership_index REAL, + leadership_index_mode TEXT NOT NULL, + discipline_index REAL, + discipline_index_mode TEXT NOT NULL, + impact_score REAL NOT NULL, + delta_mmr REAL NOT NULL, + mmr_before REAL NOT NULL, + mmr_after REAL NOT NULL, + match_score REAL NOT NULL, + penalty_points REAL NOT NULL, + capabilities_json TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (scope_key, external_match_id, stable_player_key) + ); + + CREATE TABLE IF NOT EXISTS elo_mmr_monthly_rankings ( + scope_key TEXT NOT NULL, + month_key TEXT NOT NULL, + stable_player_key TEXT NOT NULL, + player_name TEXT NOT NULL, + steam_id TEXT, + current_mmr REAL NOT NULL, + baseline_mmr REAL NOT NULL, + mmr_gain REAL NOT NULL, + avg_match_score REAL NOT NULL, + strength_of_schedule REAL NOT NULL, + consistency REAL NOT NULL, + activity REAL NOT NULL, + confidence REAL NOT NULL, + penalty_points REAL NOT NULL, + monthly_rank_score REAL NOT NULL, + valid_matches INTEGER NOT NULL, + total_matches INTEGER NOT NULL, + total_time_seconds INTEGER NOT NULL, + eligible INTEGER NOT NULL, + eligibility_reason TEXT, + accuracy_mode TEXT NOT NULL, + capabilities_json TEXT NOT NULL, + component_scores_json TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (scope_key, month_key, stable_player_key) + ); + + CREATE TABLE IF NOT EXISTS elo_mmr_monthly_checkpoints ( + scope_key TEXT NOT NULL, + month_key TEXT NOT NULL, + generated_at TEXT NOT NULL, + player_count INTEGER NOT NULL, + eligible_player_count INTEGER NOT NULL, + source_policy_json TEXT NOT NULL, + capabilities_summary_json TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (scope_key, month_key) + ); + + CREATE INDEX IF NOT EXISTS idx_elo_mmr_monthly_rankings_scope_month + ON elo_mmr_monthly_rankings(scope_key, month_key, eligible, monthly_rank_score DESC); + + CREATE INDEX IF NOT EXISTS idx_elo_mmr_player_ratings_scope + ON elo_mmr_player_ratings(scope_key, current_mmr DESC); + """ + ) + return resolved_path + + +def replace_elo_mmr_state( + *, + player_ratings: list[dict[str, object]], + match_results: list[dict[str, object]], + monthly_rankings: list[dict[str, object]], + monthly_checkpoints: list[dict[str, object]], + db_path: Path | None = None, +) -> Path: + """Replace the persisted Elo/MMR state with a freshly rebuilt dataset.""" + resolved_path = initialize_elo_mmr_storage(db_path=db_path) + with _connect_writer(resolved_path) as connection: + connection.execute("DELETE FROM elo_mmr_monthly_checkpoints") + connection.execute("DELETE FROM elo_mmr_monthly_rankings") + connection.execute("DELETE FROM elo_mmr_match_results") + connection.execute("DELETE FROM elo_mmr_player_ratings") + + connection.executemany( + """ + INSERT INTO elo_mmr_player_ratings ( + scope_key, + stable_player_key, + player_name, + steam_id, + current_mmr, + matches_processed, + wins, + draws, + losses, + last_match_id, + last_match_ended_at, + accuracy_mode, + capabilities_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + [ + ( + row["scope_key"], + row["stable_player_key"], + row["player_name"], + row.get("steam_id"), + row["current_mmr"], + row["matches_processed"], + row["wins"], + row["draws"], + row["losses"], + row.get("last_match_id"), + row.get("last_match_ended_at"), + row["accuracy_mode"], + json.dumps(row["capabilities"], ensure_ascii=True, separators=(",", ":")), + ) + for row in player_ratings + ], + ) + + connection.executemany( + """ + INSERT INTO elo_mmr_match_results ( + scope_key, + month_key, + external_match_id, + stable_player_key, + player_name, + steam_id, + server_slug, + server_name, + match_ended_at, + match_valid, + quality_factor, + quality_bucket, + role_bucket, + role_bucket_mode, + outcome_score, + combat_index, + objective_index, + objective_index_mode, + utility_index, + utility_index_mode, + leadership_index, + leadership_index_mode, + discipline_index, + discipline_index_mode, + impact_score, + delta_mmr, + mmr_before, + mmr_after, + match_score, + penalty_points, + capabilities_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + [ + ( + row["scope_key"], + row["month_key"], + row["external_match_id"], + row["stable_player_key"], + row["player_name"], + row.get("steam_id"), + row["server_slug"], + row["server_name"], + row["match_ended_at"], + 1 if row["match_valid"] else 0, + row["quality_factor"], + row["quality_bucket"], + row["role_bucket"], + row["role_bucket_mode"], + row["outcome_score"], + row["combat_index"], + row.get("objective_index"), + row["objective_index_mode"], + row.get("utility_index"), + row["utility_index_mode"], + row.get("leadership_index"), + row["leadership_index_mode"], + row.get("discipline_index"), + row["discipline_index_mode"], + row["impact_score"], + row["delta_mmr"], + row["mmr_before"], + row["mmr_after"], + row["match_score"], + row["penalty_points"], + json.dumps(row["capabilities"], ensure_ascii=True, separators=(",", ":")), + ) + for row in match_results + ], + ) + + connection.executemany( + """ + INSERT INTO elo_mmr_monthly_rankings ( + scope_key, + month_key, + stable_player_key, + player_name, + steam_id, + current_mmr, + baseline_mmr, + mmr_gain, + avg_match_score, + strength_of_schedule, + consistency, + activity, + confidence, + penalty_points, + monthly_rank_score, + valid_matches, + total_matches, + total_time_seconds, + eligible, + eligibility_reason, + accuracy_mode, + capabilities_json, + component_scores_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + [ + ( + row["scope_key"], + row["month_key"], + row["stable_player_key"], + row["player_name"], + row.get("steam_id"), + row["current_mmr"], + row["baseline_mmr"], + row["mmr_gain"], + row["avg_match_score"], + row["strength_of_schedule"], + row["consistency"], + row["activity"], + row["confidence"], + row["penalty_points"], + row["monthly_rank_score"], + row["valid_matches"], + row["total_matches"], + row["total_time_seconds"], + 1 if row["eligible"] else 0, + row.get("eligibility_reason"), + row["accuracy_mode"], + json.dumps(row["capabilities"], ensure_ascii=True, separators=(",", ":")), + json.dumps(row["component_scores"], ensure_ascii=True, separators=(",", ":")), + ) + for row in monthly_rankings + ], + ) + + connection.executemany( + """ + INSERT INTO elo_mmr_monthly_checkpoints ( + scope_key, + month_key, + generated_at, + player_count, + eligible_player_count, + source_policy_json, + capabilities_summary_json + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + [ + ( + row["scope_key"], + row["month_key"], + row["generated_at"], + row["player_count"], + row["eligible_player_count"], + json.dumps(row["source_policy"], ensure_ascii=True, separators=(",", ":")), + json.dumps( + row["capabilities_summary"], + ensure_ascii=True, + separators=(",", ":"), + ), + ) + for row in monthly_checkpoints + ], + ) + return resolved_path + + +def list_elo_mmr_monthly_rankings( + *, + scope_key: str, + limit: int = 10, + month_key: str | None = None, + eligible_only: bool = True, + db_path: Path | None = None, +) -> dict[str, object]: + """Return the persisted monthly Elo/MMR leaderboard for one scope.""" + resolved_path = _resolve_db_path(db_path) + resolved_month_key = month_key or get_latest_elo_mmr_month_key(scope_key=scope_key, db_path=resolved_path) + if not resolved_month_key: + return { + "month_key": None, + "found": False, + "generated_at": None, + "items": [], + "source_policy": None, + "capabilities_summary": None, + } + + where_clauses = ["scope_key = ?", "month_key = ?"] + params: list[object] = [scope_key, resolved_month_key] + if eligible_only: + where_clauses.append("eligible = 1") + params.append(limit) + try: + with _connect_readonly(resolved_path) as connection: + checkpoint_row = connection.execute( + """ + SELECT generated_at, source_policy_json, capabilities_summary_json + FROM elo_mmr_monthly_checkpoints + WHERE scope_key = ? AND month_key = ? + """, + (scope_key, resolved_month_key), + ).fetchone() + rows = connection.execute( + f""" + SELECT * + FROM elo_mmr_monthly_rankings + WHERE {" AND ".join(where_clauses)} + ORDER BY monthly_rank_score DESC, current_mmr DESC, player_name COLLATE NOCASE ASC + LIMIT ? + """, + params, + ).fetchall() + except sqlite3.OperationalError: + return { + "month_key": None, + "found": False, + "generated_at": None, + "items": [], + "source_policy": None, + "capabilities_summary": None, + } + items = [] + for index, row in enumerate(rows, start=1): + items.append( + { + "ranking_position": index, + "player": { + "stable_player_key": row["stable_player_key"], + "name": row["player_name"], + "steam_id": row["steam_id"], + }, + "persistent_rating": { + "mmr": round(float(row["current_mmr"] or 0.0), 3), + "baseline_mmr": round(float(row["baseline_mmr"] or 0.0), 3), + "mmr_gain": round(float(row["mmr_gain"] or 0.0), 3), + }, + "monthly_rank_score": round(float(row["monthly_rank_score"] or 0.0), 3), + "components": json.loads(row["component_scores_json"]), + "valid_matches": int(row["valid_matches"] or 0), + "total_matches": int(row["total_matches"] or 0), + "total_time_seconds": int(row["total_time_seconds"] or 0), + "eligible": bool(row["eligible"]), + "eligibility_reason": row["eligibility_reason"], + "accuracy_mode": row["accuracy_mode"], + "capabilities": json.loads(row["capabilities_json"]), + } + ) + return { + "month_key": resolved_month_key, + "found": bool(items), + "generated_at": checkpoint_row["generated_at"] if checkpoint_row else None, + "items": items, + "source_policy": json.loads(checkpoint_row["source_policy_json"]) + if checkpoint_row + else None, + "capabilities_summary": json.loads(checkpoint_row["capabilities_summary_json"]) + if checkpoint_row + else None, + } + + +def get_elo_mmr_player_profile( + *, + player_id: str, + scope_key: str, + month_key: str | None = None, + db_path: Path | None = None, +) -> dict[str, object] | None: + """Return the persisted rating and monthly ranking profile for one player.""" + resolved_player_id = player_id.strip() + if not resolved_player_id: + return None + resolved_path = _resolve_db_path(db_path) + resolved_month_key = month_key or get_latest_elo_mmr_month_key(scope_key=scope_key, db_path=resolved_path) + try: + with _connect_readonly(resolved_path) as connection: + rating_row = connection.execute( + """ + SELECT * + FROM elo_mmr_player_ratings + WHERE scope_key = ? + AND (stable_player_key = ? OR steam_id = ?) + ORDER BY updated_at DESC + LIMIT 1 + """, + (scope_key, resolved_player_id, resolved_player_id), + ).fetchone() + monthly_row = None + if resolved_month_key: + monthly_row = connection.execute( + """ + SELECT * + FROM elo_mmr_monthly_rankings + WHERE scope_key = ? + AND month_key = ? + AND (stable_player_key = ? OR steam_id = ?) + ORDER BY updated_at DESC + LIMIT 1 + """, + (scope_key, resolved_month_key, resolved_player_id, resolved_player_id), + ).fetchone() + except sqlite3.OperationalError: + return None + if rating_row is None and monthly_row is None: + return None + return { + "scope_key": scope_key, + "month_key": resolved_month_key, + "player": { + "stable_player_key": ( + rating_row["stable_player_key"] if rating_row else monthly_row["stable_player_key"] + ), + "name": rating_row["player_name"] if rating_row else monthly_row["player_name"], + "steam_id": rating_row["steam_id"] if rating_row else monthly_row["steam_id"], + }, + "persistent_rating": ( + { + "mmr": round(float(rating_row["current_mmr"] or 0.0), 3), + "matches_processed": int(rating_row["matches_processed"] or 0), + "wins": int(rating_row["wins"] or 0), + "draws": int(rating_row["draws"] or 0), + "losses": int(rating_row["losses"] or 0), + "last_match_id": rating_row["last_match_id"], + "last_match_ended_at": rating_row["last_match_ended_at"], + "accuracy_mode": rating_row["accuracy_mode"], + "capabilities": json.loads(rating_row["capabilities_json"]), + } + if rating_row + else None + ), + "monthly_ranking": ( + { + "monthly_rank_score": round(float(monthly_row["monthly_rank_score"] or 0.0), 3), + "current_mmr": round(float(monthly_row["current_mmr"] or 0.0), 3), + "baseline_mmr": round(float(monthly_row["baseline_mmr"] or 0.0), 3), + "mmr_gain": round(float(monthly_row["mmr_gain"] or 0.0), 3), + "valid_matches": int(monthly_row["valid_matches"] or 0), + "total_matches": int(monthly_row["total_matches"] or 0), + "total_time_seconds": int(monthly_row["total_time_seconds"] or 0), + "eligible": bool(monthly_row["eligible"]), + "eligibility_reason": monthly_row["eligibility_reason"], + "accuracy_mode": monthly_row["accuracy_mode"], + "components": json.loads(monthly_row["component_scores_json"]), + "capabilities": json.loads(monthly_row["capabilities_json"]), + } + if monthly_row + else None + ), + } + + +def get_latest_elo_mmr_month_key( + *, + scope_key: str, + db_path: Path | None = None, +) -> str | None: + """Return the latest month key available for one Elo/MMR scope.""" + resolved_path = _resolve_db_path(db_path) + try: + with _connect_readonly(resolved_path) as connection: + row = connection.execute( + """ + SELECT MAX(month_key) AS latest_month_key + FROM elo_mmr_monthly_checkpoints + WHERE scope_key = ? + """, + (scope_key,), + ).fetchone() + except sqlite3.OperationalError: + return None + return str(row["latest_month_key"]) if row and row["latest_month_key"] else None + + +def get_latest_elo_mmr_generated_at(*, db_path: Path | None = None) -> datetime | None: + """Return the latest persisted Elo/MMR checkpoint generation time, if any.""" + resolved_path = _resolve_db_path(db_path) + try: + with _connect_readonly(resolved_path) as connection: + row = connection.execute( + """ + SELECT MAX(generated_at) AS latest_generated_at + FROM elo_mmr_monthly_checkpoints + """ + ).fetchone() + except sqlite3.OperationalError: + return None + latest_generated_at = str(row["latest_generated_at"] or "").strip() if row else "" + if not latest_generated_at: + return None + return datetime.fromisoformat(latest_generated_at.replace("Z", "+00:00")) + + +def _connect_writer(db_path: Path): + return connect_sqlite_writer(db_path) + + +def _connect_readonly(db_path: Path): + return connect_sqlite_readonly(db_path) + + +def _resolve_db_path(db_path: Path | None) -> Path: + return db_path or get_storage_path() diff --git a/backend/app/historical_ingestion.py b/backend/app/historical_ingestion.py new file mode 100644 index 0000000..7598173 --- /dev/null +++ b/backend/app/historical_ingestion.py @@ -0,0 +1,714 @@ +"""Historical CRCON ingestion bootstrap and incremental refresh.""" + +from __future__ import annotations + +import argparse +import json +from dataclasses import dataclass +from typing import Callable, Iterable + +from .config import ( + get_historical_crcon_detail_workers, + get_historical_crcon_page_size, + get_historical_data_source_kind, + get_historical_refresh_overlap_hours, +) +from .data_sources import ( + SOURCE_KIND_PUBLIC_SCOREBOARD, + SOURCE_KIND_RCON, + HistoricalDataSource, + build_historical_runtime_source_policy, + resolve_historical_ingestion_data_source, +) +from .elo_mmr_engine import rebuild_elo_mmr_models +from .historical_snapshots import generate_and_persist_historical_snapshots +from .historical_storage import ( + finalize_backfill_progress, + finalize_ingestion_run, + get_backfill_resume_page, + get_refresh_cutoff_for_server, + initialize_historical_storage, + list_historical_coverage_report, + list_historical_servers, + mark_backfill_progress_page_completed, + mark_backfill_progress_started, + start_ingestion_run, + upsert_historical_match, +) +from .rcon_historical_worker import run_rcon_historical_capture_unlocked +from .writer_lock import backend_writer_lock, build_writer_lock_holder + + +ProgressCallback = Callable[[dict[str, object]], None] + + +@dataclass(slots=True) +class IngestionStats: + """Mutable counters for one ingestion execution.""" + + pages_processed: int = 0 + matches_seen: int = 0 + matches_inserted: int = 0 + matches_updated: int = 0 + player_rows_inserted: int = 0 + player_rows_updated: int = 0 + + def apply(self, delta: dict[str, int]) -> None: + self.matches_inserted += delta.get("matches_inserted", 0) + self.matches_updated += delta.get("matches_updated", 0) + self.player_rows_inserted += delta.get("player_rows_inserted", 0) + self.player_rows_updated += delta.get("player_rows_updated", 0) + + +def run_bootstrap( + *, + server_slug: str | None = None, + max_pages: int | None = None, + page_size: int | None = None, + start_page: int | None = None, + detail_workers: int | None = None, + rebuild_snapshots: bool = True, + progress_callback: ProgressCallback | None = None, +) -> dict[str, object]: + """Run a first full historical import against one or all configured servers.""" + with backend_writer_lock( + holder=build_writer_lock_holder( + f"app.historical_ingestion bootstrap:{server_slug or 'all-servers'}" + ) + ): + return _run_ingestion( + mode="bootstrap", + server_slug=server_slug, + max_pages=max_pages, + page_size=page_size, + start_page=start_page, + detail_workers=detail_workers, + overlap_hours=None, + incremental=False, + rebuild_snapshots=rebuild_snapshots, + progress_callback=progress_callback, + ) + + +def run_incremental_refresh( + *, + server_slug: str | None = None, + max_pages: int | None = None, + page_size: int | None = None, + start_page: int | None = None, + detail_workers: int | None = None, + overlap_hours: int | None = None, + rebuild_snapshots: bool = True, + progress_callback: ProgressCallback | None = None, +) -> dict[str, object]: + """Refresh recent historical pages without replaying the whole archive.""" + with backend_writer_lock( + holder=build_writer_lock_holder( + f"app.historical_ingestion refresh:{server_slug or 'all-servers'}" + ) + ): + return _run_ingestion( + mode="incremental", + server_slug=server_slug, + max_pages=max_pages, + page_size=page_size, + start_page=start_page, + detail_workers=detail_workers, + overlap_hours=overlap_hours, + incremental=True, + rebuild_snapshots=rebuild_snapshots, + progress_callback=progress_callback, + ) + + +def _run_ingestion( + *, + mode: str, + server_slug: str | None, + max_pages: int | None, + page_size: int | None, + start_page: int | None, + detail_workers: int | None, + overlap_hours: int | None, + incremental: bool, + rebuild_snapshots: bool, + progress_callback: ProgressCallback | None, +) -> dict[str, object]: + initialize_historical_storage() + stats = IngestionStats() + fallback_data_source, fallback_source_policy = resolve_historical_ingestion_data_source() + selected_servers = _select_servers(server_slug) + processed_servers: list[dict[str, object]] = [] + active_runs: dict[str, int] = {} + resolved_overlap_hours = ( + get_historical_refresh_overlap_hours() + if overlap_hours is None + else overlap_hours + ) + if resolved_overlap_hours < 0: + raise ValueError("--overlap-hours must be zero or positive.") + + primary_writer_result = _attempt_primary_rcon_writer( + mode=mode, + server_slug=server_slug, + selected_servers=selected_servers, + progress_callback=progress_callback, + ) + source_policy = _resolve_ingestion_source_policy( + fallback_source_policy=fallback_source_policy, + primary_writer_result=primary_writer_result, + ) + use_classic_fallback = _should_use_classic_fallback(primary_writer_result) + _emit_progress( + progress_callback, + { + "event": "historical-ingestion-source-selected", + "mode": mode, + "primary_source": source_policy.get("primary_source"), + "selected_source": source_policy.get("selected_source"), + "fallback_used": bool(source_policy.get("fallback_used")), + "fallback_reason": source_policy.get("fallback_reason"), + }, + ) + + try: + if use_classic_fallback: + for server in selected_servers: + run_id = start_ingestion_run(mode=mode, target_server_slug=str(server["slug"])) + active_runs[str(server["slug"])] = run_id + mark_backfill_progress_started( + server_slug=str(server["slug"]), + mode=mode, + run_id=run_id, + ) + cutoff = ( + get_refresh_cutoff_for_server( + str(server["slug"]), + overlap_hours=resolved_overlap_hours, + ) + if incremental + else None + ) + resolved_start_page = _resolve_start_page( + start_page=start_page, + server_slug=str(server["slug"]), + mode=mode, + ) + server_stats = _ingest_server( + server=server, + mode=mode, + run_id=run_id, + stats=stats, + data_source=fallback_data_source, + max_pages=max_pages, + page_size=page_size, + start_page=resolved_start_page, + detail_workers=detail_workers, + cutoff=cutoff, + progress_callback=progress_callback, + source_policy=source_policy, + ) + processed_servers.append(server_stats) + finalize_ingestion_run( + run_id, + status="success", + pages_processed=server_stats["pages_processed"], + matches_seen=server_stats["matches_seen"], + matches_inserted=server_stats["matches_inserted"], + matches_updated=server_stats["matches_updated"], + player_rows_inserted=server_stats["player_rows_inserted"], + player_rows_updated=server_stats["player_rows_updated"], + notes=f"public_name={server_stats['public_name']}", + ) + finalize_backfill_progress( + server_slug=str(server["slug"]), + mode=mode, + run_id=run_id, + status="success", + archive_exhausted=bool(server_stats["archive_exhausted"]), + ) + active_runs.pop(str(server["slug"]), None) + if rebuild_snapshots: + snapshot_result = generate_and_persist_historical_snapshots(server_key=server_slug) + elo_mmr_result = rebuild_elo_mmr_models() + else: + snapshot_result = { + "status": "skipped", + "reason": "snapshot-rebuild-disabled", + "generation_policy": "handled-by-caller", + } + elo_mmr_result = { + "status": "skipped", + "reason": "snapshot-rebuild-disabled", + } + except Exception as exc: + for active_server_slug, run_id in active_runs.items(): + finalize_ingestion_run( + run_id, + status="failed", + pages_processed=stats.pages_processed, + matches_seen=stats.matches_seen, + matches_inserted=stats.matches_inserted, + matches_updated=stats.matches_updated, + player_rows_inserted=stats.player_rows_inserted, + player_rows_updated=stats.player_rows_updated, + notes=str(exc), + ) + finalize_backfill_progress( + server_slug=active_server_slug, + mode=mode, + run_id=run_id, + status="failed", + error_message=str(exc), + ) + raise + + return { + "status": "ok", + "mode": mode, + "source_provider": source_policy.get("selected_source"), + "source_policy": source_policy, + "primary_writer_result": primary_writer_result, + "page_size": page_size or get_historical_crcon_page_size(), + "start_page": start_page, + "detail_workers": detail_workers or get_historical_crcon_detail_workers(), + "overlap_hours": resolved_overlap_hours if incremental else None, + "servers": processed_servers, + "coverage": list_historical_coverage_report(server_slug=server_slug), + "snapshot_result": snapshot_result, + "elo_mmr_result": elo_mmr_result, + "totals": { + "pages_processed": stats.pages_processed, + "matches_seen": stats.matches_seen, + "matches_inserted": stats.matches_inserted, + "matches_updated": stats.matches_updated, + "player_rows_inserted": stats.player_rows_inserted, + "player_rows_updated": stats.player_rows_updated, + }, + } + + +def _ingest_server( + *, + server: dict[str, object], + mode: str, + run_id: int, + stats: IngestionStats, + data_source: HistoricalDataSource, + max_pages: int | None, + page_size: int | None, + start_page: int, + detail_workers: int | None, + cutoff: str | None, + progress_callback: ProgressCallback | None, + source_policy: dict[str, object], +) -> dict[str, object]: + resolved_page_size = page_size or get_historical_crcon_page_size() + resolved_detail_workers = detail_workers or get_historical_crcon_detail_workers() + page_limit = max_pages or 1000000 + start_page = max(1, start_page) + local_stats = IngestionStats() + public_info = data_source.fetch_public_info(base_url=str(server["scoreboard_base_url"])) + discovered_total_matches: int | None = None + last_page_processed: int | None = None + archive_exhausted = False + _emit_progress( + progress_callback, + { + "event": "historical-ingestion-server-started", + "mode": mode, + "server_slug": server["slug"], + "selected_source": source_policy.get("selected_source"), + "fallback_used": bool(source_policy.get("fallback_used")), + "start_page": start_page, + "cutoff": cutoff, + }, + ) + + for page_number in range(start_page, start_page + page_limit): + payload = data_source.fetch_match_page( + base_url=str(server["scoreboard_base_url"]), + page=page_number, + limit=resolved_page_size, + ) + if discovered_total_matches is None: + discovered_total_matches = _coerce_int(payload.get("total")) + page_matches = _coerce_match_list(payload.get("maps")) + if not page_matches: + archive_exhausted = True + break + + local_stats.pages_processed += 1 + stats.pages_processed += 1 + last_page_processed = page_number + stop_after_page = False + match_ids_to_fetch: list[str] = [] + + for match_summary in page_matches: + local_stats.matches_seen += 1 + stats.matches_seen += 1 + + reference_timestamp = _pick_match_timestamp(match_summary) + if cutoff and reference_timestamp and reference_timestamp < cutoff: + stop_after_page = True + continue + + match_id = _stringify(match_summary.get("id")) + if match_id: + match_ids_to_fetch.append(match_id) + + _emit_progress( + progress_callback, + { + "event": "historical-ingestion-page-loaded", + "mode": mode, + "server_slug": server["slug"], + "page": page_number, + "selected_source": source_policy.get("selected_source"), + "match_ids_to_detail": len(match_ids_to_fetch), + "page_matches": len(page_matches), + "cutoff_reached": stop_after_page, + }, + ) + + for detail_payload in data_source.fetch_match_details( + base_url=str(server["scoreboard_base_url"]), + match_ids=match_ids_to_fetch, + max_workers=resolved_detail_workers, + ): + delta = upsert_historical_match( + server_slug=str(server["slug"]), + match_payload=detail_payload, + ) + local_stats.apply(delta) + stats.apply(delta) + + mark_backfill_progress_page_completed( + server_slug=str(server["slug"]), + mode=mode, + page_number=page_number, + page_size=resolved_page_size, + run_id=run_id, + discovered_total_matches=discovered_total_matches, + ) + + if stop_after_page: + break + + return { + "server_slug": server["slug"], + "public_name": _extract_public_name(public_info), + "server_number": public_info.get("server_number") or server.get("server_number"), + "source_provider": data_source.source_kind, + "pages_processed": local_stats.pages_processed, + "matches_seen": local_stats.matches_seen, + "discovered_total_matches": discovered_total_matches, + "matches_inserted": local_stats.matches_inserted, + "matches_updated": local_stats.matches_updated, + "player_rows_inserted": local_stats.player_rows_inserted, + "player_rows_updated": local_stats.player_rows_updated, + "start_page": start_page, + "last_page_processed": last_page_processed, + "cutoff": cutoff, + "archive_exhausted": archive_exhausted, + } + + +def _resolve_start_page( + *, + start_page: int | None, + server_slug: str, + mode: str, +) -> int: + if start_page is not None: + return max(1, start_page) + if mode != "bootstrap": + return 1 + return get_backfill_resume_page(server_slug, mode=mode) + + +def _attempt_primary_rcon_writer( + *, + mode: str, + server_slug: str | None, + selected_servers: list[dict[str, object]], + progress_callback: ProgressCallback | None, +) -> dict[str, object]: + configured_kind = get_historical_data_source_kind() + if configured_kind != SOURCE_KIND_RCON: + result = { + "attempted": False, + "status": "skipped", + "primary_source": SOURCE_KIND_PUBLIC_SCOREBOARD, + "selected_source": SOURCE_KIND_PUBLIC_SCOREBOARD, + "fallback_used": False, + "fallback_reason": None, + "source_attempts": [], + } + _emit_progress( + progress_callback, + { + "event": "historical-ingestion-rcon-primary-skipped", + "mode": mode, + "reason": "historical-data-source-configured-for-public-scoreboard", + }, + ) + return result + + target_scope = server_slug or "all-configured-rcon-targets" + _emit_progress( + progress_callback, + { + "event": "historical-ingestion-rcon-primary-started", + "mode": mode, + "target_scope": target_scope, + "servers": [str(server["slug"]) for server in selected_servers], + }, + ) + try: + capture_result = run_rcon_historical_capture_unlocked(target_key=server_slug) + except Exception as exc: # noqa: BLE001 - fallback remains explicit and controlled + result = { + "attempted": True, + "status": "error", + "primary_source": SOURCE_KIND_RCON, + "selected_source": SOURCE_KIND_PUBLIC_SCOREBOARD, + "fallback_used": True, + "fallback_reason": "rcon-historical-writer-request-failed", + "message": str(exc), + } + _emit_progress( + progress_callback, + { + "event": "historical-ingestion-rcon-primary-failed", + "mode": mode, + "target_scope": target_scope, + "message": str(exc), + }, + ) + return result + + capture_run_status = str(capture_result.get("run_status") or capture_result.get("status") or "unknown") + targets = list(capture_result.get("targets") or []) + errors = list(capture_result.get("errors") or []) + if targets: + result = { + "attempted": True, + "status": "partial", + "primary_source": SOURCE_KIND_RCON, + "selected_source": SOURCE_KIND_PUBLIC_SCOREBOARD, + "fallback_used": True, + "fallback_reason": "rcon-primary-writer-succeeded-but-classic-match-archive-still-needs-fallback", + "capture_result": capture_result, + } + _emit_progress( + progress_callback, + { + "event": "historical-ingestion-rcon-primary-succeeded", + "mode": mode, + "target_scope": target_scope, + "captured_targets": len(targets), + "run_status": capture_run_status, + "next_step": "classic-public-scoreboard-fallback-required", + }, + ) + return result + + result = { + "attempted": True, + "status": "empty", + "primary_source": SOURCE_KIND_RCON, + "selected_source": SOURCE_KIND_PUBLIC_SCOREBOARD, + "fallback_used": True, + "fallback_reason": "rcon-historical-writer-returned-no-usable-samples", + "capture_result": capture_result, + "message": json.dumps(errors, separators=(",", ":")) if errors else None, + } + _emit_progress( + progress_callback, + { + "event": "historical-ingestion-rcon-primary-empty", + "mode": mode, + "target_scope": target_scope, + "run_status": capture_run_status, + "errors": len(errors), + }, + ) + return result + + +def _should_use_classic_fallback(primary_writer_result: dict[str, object]) -> bool: + selected_source = str(primary_writer_result.get("selected_source") or "") + return selected_source == SOURCE_KIND_PUBLIC_SCOREBOARD + + +def _resolve_ingestion_source_policy( + *, + fallback_source_policy: dict[str, object], + primary_writer_result: dict[str, object], +) -> dict[str, object]: + configured_kind = get_historical_data_source_kind() + if configured_kind != SOURCE_KIND_RCON: + return fallback_source_policy + + status = str(primary_writer_result.get("status") or "error") + selected_source = str( + primary_writer_result.get("selected_source") or SOURCE_KIND_PUBLIC_SCOREBOARD + ) + fallback_reason = primary_writer_result.get("fallback_reason") + message = primary_writer_result.get("message") + if ( + fallback_reason + == "rcon-primary-writer-succeeded-but-classic-match-archive-still-needs-fallback" + ): + message = ( + "RCON prospective capture succeeded first, but the classic historical_* " + "archive still requires public-scoreboard for match-page import." + ) + return build_historical_runtime_source_policy( + operation="historical-ingestion", + rcon_status=status, + fallback_reason=str(fallback_reason) if fallback_reason else None, + selected_source=selected_source, + rcon_message=message if isinstance(message, str) else None, + ) + + +def _emit_progress( + callback: ProgressCallback | None, + payload: dict[str, object], +) -> None: + if callback is None: + return + callback(payload) + + +def _select_servers(server_slug: str | None) -> list[dict[str, object]]: + servers = list_historical_servers() + if server_slug is None: + return servers + + normalized = server_slug.strip() + selected = [server for server in servers if server["slug"] == normalized] + if not selected: + raise ValueError(f"Unknown historical server slug: {server_slug}") + return selected + + +def _coerce_match_list(payload: object) -> list[dict[str, object]]: + if not isinstance(payload, list): + return [] + return [item for item in payload if isinstance(item, dict)] + + +def _pick_match_timestamp(match_payload: dict[str, object]) -> str | None: + for key in ("end", "start", "creation_time"): + value = match_payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _extract_public_name(public_info: dict[str, object]) -> str | None: + name_value = public_info.get("name") + if isinstance(name_value, str): + return name_value + if isinstance(name_value, dict): + raw_name = name_value.get("name") + return raw_name.strip() if isinstance(raw_name, str) and raw_name.strip() else None + return None + + +def _stringify(value: object) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + +def _coerce_int(value: object) -> int | None: + if value in (None, ""): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def build_arg_parser() -> argparse.ArgumentParser: + """Create the CLI parser for manual historical ingestion runs.""" + parser = argparse.ArgumentParser( + description="Historical CRCON ingestion for HLL Vietnam.", + ) + parser.add_argument( + "mode", + choices=("bootstrap", "refresh"), + help="bootstrap imports the archive, refresh only recent pages", + ) + parser.add_argument( + "--server", + dest="server_slug", + help="optional historical server slug", + ) + parser.add_argument( + "--max-pages", + type=int, + help="optional page cap for local validation", + ) + parser.add_argument( + "--page-size", + type=int, + help="override CRCON page size", + ) + parser.add_argument( + "--start-page", + type=int, + help="override the resume page; bootstrap uses persisted progress when omitted", + ) + parser.add_argument( + "--detail-workers", + type=int, + help="parallel worker count for per-match detail requests", + ) + parser.add_argument( + "--overlap-hours", + type=int, + help="override the incremental overlap window in hours", + ) + return parser + + +def main(argv: Iterable[str] | None = None) -> int: + """Run the historical ingestion CLI.""" + parser = build_arg_parser() + args = parser.parse_args(list(argv) if argv is not None else None) + + def _print_progress(payload: dict[str, object]) -> None: + print(json.dumps(payload, ensure_ascii=True)) + + if args.mode == "bootstrap": + result = run_bootstrap( + server_slug=args.server_slug, + max_pages=args.max_pages, + page_size=args.page_size, + start_page=args.start_page, + detail_workers=args.detail_workers, + progress_callback=_print_progress, + ) + else: + result = run_incremental_refresh( + server_slug=args.server_slug, + max_pages=args.max_pages, + page_size=args.page_size, + start_page=args.start_page, + detail_workers=args.detail_workers, + overlap_hours=args.overlap_hours, + progress_callback=_print_progress, + ) + + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/app/historical_models.py b/backend/app/historical_models.py new file mode 100644 index 0000000..8566f82 --- /dev/null +++ b/backend/app/historical_models.py @@ -0,0 +1,126 @@ +"""Historical domain models for persisted CRCON scoreboard data.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True, slots=True) +class HistoricalServerDefinition: + """Stable identity for one historical CRCON source.""" + + slug: str + display_name: str + scoreboard_base_url: str + server_number: int | None + source_kind: str = "crcon-scoreboard-json" + + +@dataclass(frozen=True, slots=True) +class HistoricalMapRecord: + """Normalized map metadata reused across historical matches.""" + + external_map_id: str | None + map_name: str | None + pretty_name: str | None + game_mode: str | None + image_name: str | None + + +@dataclass(frozen=True, slots=True) +class HistoricalMatchRecord: + """Normalized match identity and summary.""" + + external_match_id: str + server_slug: str + created_at: datetime | None + started_at: datetime | None + ended_at: datetime | None + map_name: str | None + map_pretty_name: str | None + map_external_id: str | None + game_mode: str | None + image_name: str | None + allied_score: int | None + axis_score: int | None + + +@dataclass(frozen=True, slots=True) +class HistoricalPlayerIdentity: + """Stable player identity across historical match stats.""" + + stable_player_key: str + display_name: str + steam_id: str | None + source_player_id: str | None + + +@dataclass(frozen=True, slots=True) +class HistoricalPlayerMatchStats: + """Metrics persisted per player and match.""" + + stable_player_key: str + match_player_ref: str | None + team_side: str | None + level: int | None + kills: int | None + deaths: int | None + teamkills: int | None + time_seconds: int | None + kills_per_minute: float | None + deaths_per_minute: float | None + kill_death_ratio: float | None + combat: int | None + offense: int | None + defense: int | None + support: int | None + + +@dataclass(frozen=True, slots=True) +class HistoricalIngestionRunSummary: + """Outcome metadata recorded for one ingestion execution.""" + + mode: str + started_at: datetime + completed_at: datetime | None + status: str + pages_processed: int + matches_seen: int + matches_inserted: int + matches_updated: int + player_rows_inserted: int + player_rows_updated: int + + +@dataclass(frozen=True, slots=True) +class HistoricalBackfillProgressSummary: + """Persisted resume checkpoint and last attempt metadata per server.""" + + server_slug: str + mode: str + next_page: int + last_completed_page: int | None + discovered_total_matches: int | None + discovered_total_pages: int | None + archive_exhausted: bool + last_run_id: int | None + last_run_status: str | None + last_run_started_at: datetime | None + last_run_completed_at: datetime | None + last_error: str | None + + +@dataclass(frozen=True, slots=True) +class HistoricalSnapshotRecord: + """Persisted precomputed historical snapshot ready for lightweight reads.""" + + server_key: str + snapshot_type: str + metric: str | None + window: str | None + payload_json: str + generated_at: datetime + source_range_start: datetime | None + source_range_end: datetime | None + is_stale: bool diff --git a/backend/app/historical_runner.py b/backend/app/historical_runner.py new file mode 100644 index 0000000..10edced --- /dev/null +++ b/backend/app/historical_runner.py @@ -0,0 +1,529 @@ +"""Local development loop for periodic historical CRCON refreshes.""" + +from __future__ import annotations + +import argparse +import json +import time +import traceback +from datetime import datetime, timezone +from typing import Any + +from .config import ( + DEFAULT_DB_MAINTENANCE_INTERVAL_SECONDS, + get_db_maintenance_enabled, + get_db_maintenance_interval_seconds, + get_historical_full_snapshot_every_runs, + get_historical_elo_mmr_min_new_samples, + get_historical_elo_mmr_rebuild_interval_minutes, + get_historical_refresh_interval_seconds, + get_historical_refresh_max_retries, + get_historical_refresh_retry_delay_seconds, + get_historical_data_source_kind, +) +from .database_maintenance import run_database_maintenance_cleanup +from .elo_mmr_engine import rebuild_elo_mmr_models +from .elo_mmr_storage import get_latest_elo_mmr_generated_at +from .historical_ingestion import run_incremental_refresh +from .historical_snapshots import ( + generate_and_persist_historical_snapshots, + generate_and_persist_priority_historical_snapshots, +) +from .rcon_historical_storage import count_rcon_historical_samples_since +from .rcon_historical_worker import run_rcon_historical_capture +from .writer_lock import backend_writer_lock, build_writer_lock_holder + +HOURLY_INTERVAL_SECONDS = 3600 +DEFAULT_HISTORICAL_SERVER_SCOPE = ( + "comunidad-hispana-01", + "comunidad-hispana-02", +) +_LAST_DATABASE_MAINTENANCE_RUN_AT: datetime | None = None + + +def run_periodic_historical_refresh( + *, + interval_seconds: int, + max_retries: int, + retry_delay_seconds: float, + server_slug: str | None = None, + max_pages: int | None = None, + page_size: int | None = None, + max_runs: int | None = None, +) -> None: + """Run periodic historical refreshes and rebuild persisted snapshots.""" + completed_runs = 0 + print( + json.dumps( + { + "event": "historical-refresh-loop-started", + "interval_seconds": interval_seconds, + "max_retries": max_retries, + "retry_delay_seconds": retry_delay_seconds, + "server_scope": _describe_refresh_scope(server_slug), + "snapshot_scope": _describe_snapshot_scope(server_slug), + }, + indent=2, + ) + ) + print("Press Ctrl+C to stop.") + + try: + while max_runs is None or completed_runs < max_runs: + completed_runs += 1 + payload = _run_refresh_with_retries( + max_retries=max_retries, + retry_delay_seconds=retry_delay_seconds, + server_slug=server_slug, + max_pages=max_pages, + page_size=page_size, + run_number=completed_runs, + ) + _emit_json_log({"run": completed_runs, **payload}) + + if max_runs is not None and completed_runs >= max_runs: + break + + time.sleep(interval_seconds) + except KeyboardInterrupt: + print("\nHistorical refresh loop stopped by user.") + + +def _run_refresh_with_retries( + *, + max_retries: int, + retry_delay_seconds: float, + server_slug: str | None, + max_pages: int | None, + page_size: int | None, + run_number: int, +) -> dict[str, Any]: + attempt = 0 + while True: + attempt += 1 + try: + with backend_writer_lock( + holder=build_writer_lock_holder( + f"app.historical_runner refresh:{server_slug or 'all-servers'}" + ) + ): + rcon_capture_result = _run_primary_rcon_capture() + should_run_classic_fallback, classic_fallback_reason = ( + _resolve_classic_fallback_policy( + server_slug=server_slug, + run_number=run_number, + rcon_capture_result=rcon_capture_result, + ) + ) + if should_run_classic_fallback: + refresh_result = run_incremental_refresh( + server_slug=server_slug, + max_pages=max_pages, + page_size=page_size, + rebuild_snapshots=False, + ) + snapshot_result = generate_historical_snapshots( + server_slug=server_slug, + run_number=run_number, + ) + elo_mmr_result = rebuild_elo_mmr_models() + else: + should_generate_snapshots = _rcon_capture_has_new_useful_data( + rcon_capture_result + ) + refresh_result = { + "status": "skipped", + "reason": "rcon-primary-cycle-no-classic-fallback-needed", + } + if should_generate_snapshots: + snapshot_result = generate_historical_snapshots( + server_slug=server_slug, + run_number=run_number, + ) + snapshot_result = { + **snapshot_result, + "generation_policy": "rcon-primary-useful-cycle", + "reason": "rcon-primary-cycle-produced-new-useful-coverage", + } + elo_policy = _build_elo_mmr_rebuild_policy( + rcon_capture_result=rcon_capture_result + ) + if bool(elo_policy["due"]): + elo_mmr_result = { + **rebuild_elo_mmr_models(), + "generation_policy": "rcon-primary-useful-cycle-elo-rebuild-due", + "reason": "rcon-primary-useful-cycle-met-elo-rebuild-threshold", + **elo_policy, + } + else: + elo_mmr_result = { + "status": "skipped", + "reason": "rcon-primary-useful-cycle-elo-rebuild-throttled", + "generation_policy": "rcon-primary-useful-cycle-elo-rebuild-throttled", + **elo_policy, + } + else: + snapshot_result = { + "status": "skipped", + "reason": "rcon-primary-cycle-had-no-new-useful-data", + "generation_policy": "rcon-primary-no-new-useful-data", + } + elo_mmr_result = { + "status": "skipped", + "reason": "rcon-primary-cycle-had-no-new-useful-data", + "generation_policy": "rcon-primary-no-new-useful-data", + **_build_elo_mmr_rebuild_policy( + rcon_capture_result=rcon_capture_result + ), + } + maintenance_result = _maybe_run_database_maintenance() + return { + "status": "ok", + "attempts_used": attempt, + "max_retries": max_retries, + "rcon_capture_result": rcon_capture_result, + "classic_fallback_used": should_run_classic_fallback, + "classic_fallback_reason": classic_fallback_reason, + "refresh_result": refresh_result, + "snapshot_result": snapshot_result, + "elo_mmr_result": elo_mmr_result, + "database_maintenance_result": maintenance_result, + } + except Exception as exc: + failure_payload = { + "event": "historical-refresh-attempt-failed", + "attempt": attempt, + "max_retries": max_retries, + "server_scope": _describe_refresh_scope(server_slug), + "snapshot_scope": _describe_snapshot_scope(server_slug), + "error_type": type(exc).__name__, + "error": str(exc), + "traceback": traceback.format_exc(), + } + _emit_json_log(failure_payload) + if attempt > max_retries: + return { + "status": "error", + "attempts_used": attempt, + "max_retries": max_retries, + "error_type": type(exc).__name__, + "error": str(exc), + "traceback": failure_payload["traceback"], + } + if retry_delay_seconds > 0: + time.sleep(retry_delay_seconds) + + +def generate_historical_snapshots( + *, + server_slug: str | None = None, + run_number: int = 1, +) -> dict[str, Any]: + """Build priority prewarm snapshots on every run and the full matrix on cadence.""" + generated_at = datetime.now(timezone.utc) + full_snapshot_every_runs = get_historical_full_snapshot_every_runs() + should_run_full_refresh = bool(server_slug) or run_number % full_snapshot_every_runs == 0 + _emit_json_log( + { + "event": "historical-snapshot-refresh-started", + "run_number": run_number, + "snapshot_step": "full-matrix" if should_run_full_refresh else "priority-prewarm", + "server_slug": server_slug, + "snapshot_scope": _describe_snapshot_scope(server_slug), + } + ) + if should_run_full_refresh: + result = generate_and_persist_historical_snapshots( + server_key=server_slug, + generated_at=generated_at, + ) + else: + result = generate_and_persist_priority_historical_snapshots( + generated_at=generated_at, + ) + return { + **result, + "run_number": run_number, + "full_snapshot_every_runs": full_snapshot_every_runs, + "prewarm_only": not should_run_full_refresh, + "refresh_interval_seconds": get_historical_refresh_interval_seconds(), + "includes_monthly_mvp_v2": True, + } + + +def _emit_json_log(payload: dict[str, Any]) -> None: + """Print JSON logs that remain safe for Compose and log collectors.""" + print(json.dumps(payload, ensure_ascii=True, default=str), flush=True) + + +def _maybe_run_database_maintenance(*, now: datetime | None = None) -> dict[str, Any]: + """Optionally run scheduled database maintenance without crashing the runner.""" + global _LAST_DATABASE_MAINTENANCE_RUN_AT + + anchor = now.astimezone(timezone.utc) if now else datetime.now(timezone.utc) + if not get_db_maintenance_enabled(): + result = {"status": "skipped", "reason": "disabled", "enabled": False} + _emit_json_log({"event": "database-maintenance-scheduler-skipped-disabled", **result}) + return result + + interval_seconds, interval_source = _resolve_db_maintenance_interval_seconds() + if _LAST_DATABASE_MAINTENANCE_RUN_AT is not None: + elapsed_seconds = max( + 0, + int((anchor - _LAST_DATABASE_MAINTENANCE_RUN_AT).total_seconds()), + ) + if elapsed_seconds < interval_seconds: + result = { + "status": "skipped", + "reason": "not-due", + "enabled": True, + "interval_seconds": interval_seconds, + "interval_source": interval_source, + "elapsed_seconds": elapsed_seconds, + "last_run_at": _LAST_DATABASE_MAINTENANCE_RUN_AT.isoformat().replace( + "+00:00", "Z" + ), + } + _emit_json_log({"event": "database-maintenance-scheduler-skipped-not-due", **result}) + return result + + _emit_json_log( + { + "event": "database-maintenance-scheduler-started", + "enabled": True, + "interval_seconds": interval_seconds, + "interval_source": interval_source, + "scheduled_at": anchor.isoformat().replace("+00:00", "Z"), + } + ) + try: + result = run_database_maintenance_cleanup(apply=True, now=anchor) + except Exception as exc: # noqa: BLE001 - scheduler must not crash the runner + result = { + "status": "error", + "error_type": type(exc).__name__, + "error": str(exc), + "enabled": True, + "interval_seconds": interval_seconds, + "interval_source": interval_source, + } + _emit_json_log({"event": "database-maintenance-scheduler-failed", **result}) + return result + + if result.get("status") == "ok": + _LAST_DATABASE_MAINTENANCE_RUN_AT = anchor + _emit_json_log( + { + "event": "database-maintenance-scheduler-completed", + "enabled": True, + "interval_seconds": interval_seconds, + "interval_source": interval_source, + "result": result, + } + ) + return result + + failed_result = { + "enabled": True, + "interval_seconds": interval_seconds, + "interval_source": interval_source, + "result": result, + } + _emit_json_log({"event": "database-maintenance-scheduler-failed", **failed_result}) + return result + + +def _resolve_db_maintenance_interval_seconds() -> tuple[int, str]: + """Return a safe maintenance interval even if env configuration is invalid.""" + try: + return get_db_maintenance_interval_seconds(), "env" + except ValueError: + return DEFAULT_DB_MAINTENANCE_INTERVAL_SECONDS, "default-invalid-env-fallback" + + +def _describe_refresh_scope(server_slug: str | None) -> list[str]: + if server_slug: + return [server_slug] + return list(DEFAULT_HISTORICAL_SERVER_SCOPE) + + +def _describe_snapshot_scope(server_slug: str | None) -> list[str]: + if server_slug: + return [server_slug, "all-servers"] + return [*DEFAULT_HISTORICAL_SERVER_SCOPE, "all-servers"] + + +def _run_primary_rcon_capture() -> dict[str, Any]: + if get_historical_data_source_kind() != "rcon": + return { + "status": "skipped", + "reason": "historical-data-source-configured-without-rcon-primary", + } + return run_rcon_historical_capture() + + +def _resolve_classic_fallback_policy( + *, + server_slug: str | None, + run_number: int, + rcon_capture_result: dict[str, Any], +) -> tuple[bool, str]: + if get_historical_data_source_kind() != "rcon": + return True, "public-scoreboard-configured-as-primary-historical-source" + + if not _rcon_capture_has_usable_results(rcon_capture_result): + return True, "rcon-historical-capture-failed-or-returned-no-usable-targets" + + if server_slug: + return True, "manual-server-scope-still-needs-classic-historical-fallback" + + if run_number % get_historical_full_snapshot_every_runs() == 0: + return True, "periodic-classic-fallback-for-competitive-historical-coverage" + + return False, "rcon-primary-cycle-succeeded-without-needing-classic-fallback" + + +def _rcon_capture_has_usable_results(rcon_capture_result: dict[str, Any]) -> bool: + if rcon_capture_result.get("status") != "ok": + return False + targets = rcon_capture_result.get("targets") + return isinstance(targets, list) and len(targets) > 0 + + +def _rcon_capture_has_new_useful_data(rcon_capture_result: dict[str, Any]) -> bool: + if rcon_capture_result.get("status") != "ok": + return False + totals = rcon_capture_result.get("totals") + if isinstance(totals, dict) and int(totals.get("samples_inserted") or 0) > 0: + return True + if isinstance(totals, dict) and int(totals.get("admin_log_events_inserted") or 0) > 0: + return True + if isinstance(totals, dict) and int(totals.get("materialized_matches_inserted") or 0) > 0: + return True + targets = rcon_capture_result.get("targets") + if not isinstance(targets, list): + return False + return any(bool(target.get("sample_inserted")) for target in targets if isinstance(target, dict)) + + +def _build_elo_mmr_rebuild_policy( + *, + rcon_capture_result: dict[str, Any], +) -> dict[str, Any]: + interval_minutes = get_historical_elo_mmr_rebuild_interval_minutes() + min_new_samples = get_historical_elo_mmr_min_new_samples() + last_generated_at = get_latest_elo_mmr_generated_at() + last_generated_at_iso = ( + last_generated_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + if last_generated_at is not None + else None + ) + minutes_since_last_rebuild = None + if last_generated_at is not None: + minutes_since_last_rebuild = int( + max( + 0, + ( + datetime.now(timezone.utc) - last_generated_at.astimezone(timezone.utc) + ).total_seconds() // 60, + ) + ) + samples_since_last_rebuild = count_rcon_historical_samples_since(last_generated_at_iso) + due = ( + _rcon_capture_has_new_useful_data(rcon_capture_result) + and samples_since_last_rebuild >= min_new_samples + and ( + last_generated_at is None + or minutes_since_last_rebuild is None + or minutes_since_last_rebuild >= interval_minutes + ) + ) + return { + "policy": "min-new-rcon-samples-and-minutes-since-last-successful-rebuild", + "due": due, + "last_generated_at": last_generated_at_iso, + "samples_since_last_rebuild": samples_since_last_rebuild, + "minutes_since_last_rebuild": minutes_since_last_rebuild, + "rebuild_interval_minutes": interval_minutes, + "min_new_samples": min_new_samples, + } + + +def main() -> None: + """Allow local scheduled historical refresh execution without external infra.""" + parser = argparse.ArgumentParser( + description="Run periodic historical refreshes and regenerate snapshots for HLL Vietnam.", + ) + parser.add_argument( + "--interval", + type=int, + default=get_historical_refresh_interval_seconds(), + help="Seconds to wait between refresh-plus-snapshot runs.", + ) + parser.add_argument( + "--hourly", + action="store_true", + help="Shortcut for running the refresh loop every 3600 seconds.", + ) + parser.add_argument( + "--retries", + type=int, + default=get_historical_refresh_max_retries(), + help="Retry attempts after a failed incremental refresh.", + ) + parser.add_argument( + "--retry-delay", + type=float, + default=get_historical_refresh_retry_delay_seconds(), + help="Seconds to wait between failed attempts.", + ) + parser.add_argument( + "--server", + dest="server_slug", + help="Optional historical server slug.", + ) + parser.add_argument( + "--max-pages", + type=int, + default=None, + help="Optional page cap for local validation.", + ) + parser.add_argument( + "--page-size", + type=int, + default=None, + help="Optional override for CRCON page size.", + ) + parser.add_argument( + "--max-runs", + type=int, + default=None, + help="Optional safety limit for the number of refresh cycles to execute.", + ) + args = parser.parse_args() + + if args.hourly: + args.interval = HOURLY_INTERVAL_SECONDS + + if args.interval <= 0: + raise ValueError("--interval must be a positive integer.") + if args.retries < 0: + raise ValueError("--retries must be zero or positive.") + if args.retry_delay < 0: + raise ValueError("--retry-delay must be zero or positive.") + if args.max_runs is not None and args.max_runs <= 0: + raise ValueError("--max-runs must be positive when provided.") + + run_periodic_historical_refresh( + interval_seconds=args.interval, + max_retries=args.retries, + retry_delay_seconds=args.retry_delay, + server_slug=args.server_slug, + max_pages=args.max_pages, + page_size=args.page_size, + max_runs=args.max_runs, + ) + + +if __name__ == "__main__": + main() diff --git a/backend/app/historical_snapshot_storage.py b/backend/app/historical_snapshot_storage.py new file mode 100644 index 0000000..d81706c --- /dev/null +++ b/backend/app/historical_snapshot_storage.py @@ -0,0 +1,370 @@ +"""File-based persistence for precomputed historical snapshots.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +from .config import get_storage_path, use_postgres_rcon_storage +from .historical_models import HistoricalSnapshotRecord +from .historical_snapshots import validate_snapshot_identity + + +SNAPSHOT_DIRECTORY_NAME = "snapshots" + + +def resolve_historical_snapshot_storage_path(*, db_path: Path | None = None) -> Path: + """Resolve the snapshot directory location without touching SQLite state.""" + resolved_db_path = db_path or get_storage_path() + return resolved_db_path.parent / SNAPSHOT_DIRECTORY_NAME + + +def initialize_historical_snapshot_storage(*, db_path: Path | None = None) -> Path: + """Create the snapshot directory used by precomputed historical payloads.""" + snapshots_root = resolve_historical_snapshot_storage_path(db_path=db_path) + snapshots_root.mkdir(parents=True, exist_ok=True) + return snapshots_root + + +def persist_historical_snapshot( + *, + server_key: str, + snapshot_type: str, + payload: dict[str, object] | list[object], + metric: str | None = None, + window: str | None = None, + generated_at: datetime | None = None, + source_range_start: datetime | None = None, + source_range_end: datetime | None = None, + is_stale: bool = False, + db_path: Path | None = None, +) -> HistoricalSnapshotRecord: + """Insert or replace one persisted historical snapshot JSON file.""" + normalized_server_key = server_key.strip() + if not normalized_server_key: + raise ValueError("server_key is required for historical snapshots.") + + validate_snapshot_identity(snapshot_type=snapshot_type, metric=metric) + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import persist_snapshot_record + + return persist_snapshot_record( + { + "server_key": normalized_server_key, + "snapshot_type": snapshot_type, + "metric": metric, + "window": window, + "generated_at": generated_at or datetime.now(timezone.utc), + "source_range_start": source_range_start, + "source_range_end": source_range_end, + "is_stale": is_stale, + "payload": payload, + } + ) + snapshots_root = initialize_historical_snapshot_storage(db_path=db_path) + generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc)) + payload_json = json.dumps(payload, ensure_ascii=True) + snapshot_path = _build_snapshot_path( + snapshots_root=snapshots_root, + server_key=normalized_server_key, + snapshot_type=snapshot_type, + metric=metric, + ) + snapshot_path.parent.mkdir(parents=True, exist_ok=True) + existing_document = _read_snapshot_document(snapshot_path) + + if _should_preserve_existing_snapshot( + incoming_payload=payload, + snapshot_type=snapshot_type, + existing_document=existing_document, + ): + preserved_payload = existing_document.get("payload") if existing_document else payload + return HistoricalSnapshotRecord( + server_key=normalized_server_key, + snapshot_type=snapshot_type, + metric=metric, + window=window, + payload_json=json.dumps(preserved_payload, ensure_ascii=True), + generated_at=_parse_optional_datetime(existing_document.get("generated_at")) + if existing_document + else generated_at_value, + source_range_start=_parse_optional_datetime( + existing_document.get("source_range_start") + ) + if existing_document + else _as_utc(source_range_start), + source_range_end=_parse_optional_datetime(existing_document.get("source_range_end")) + if existing_document + else _as_utc(source_range_end), + is_stale=bool(existing_document.get("is_stale", False)) if existing_document else is_stale, + ) + + snapshot_document = { + "server_key": normalized_server_key, + "snapshot_type": snapshot_type, + "metric": metric, + "window": window, + "generated_at": _to_iso(generated_at_value), + "source_range_start": _to_iso(source_range_start), + "source_range_end": _to_iso(source_range_end), + "is_stale": is_stale, + "payload": payload, + } + snapshot_path.write_text( + json.dumps(snapshot_document, ensure_ascii=True, indent=2) + "\n", + encoding="utf-8", + ) + + return HistoricalSnapshotRecord( + server_key=normalized_server_key, + snapshot_type=snapshot_type, + metric=metric, + window=window, + payload_json=payload_json, + generated_at=generated_at_value, + source_range_start=_as_utc(source_range_start), + source_range_end=_as_utc(source_range_end), + is_stale=is_stale, + ) + + +def persist_historical_snapshot_batch( + snapshots: list[dict[str, object]], + *, + db_path: Path | None = None, +) -> list[HistoricalSnapshotRecord]: + """Persist a batch of snapshots generated in one runner cycle.""" + records: list[HistoricalSnapshotRecord] = [] + for snapshot in snapshots: + records.append( + persist_historical_snapshot( + server_key=str(snapshot["server_key"]), + snapshot_type=str(snapshot["snapshot_type"]), + payload=snapshot["payload"], + metric=snapshot.get("metric"), + window=snapshot.get("window"), + generated_at=snapshot.get("generated_at"), + source_range_start=snapshot.get("source_range_start"), + source_range_end=snapshot.get("source_range_end"), + is_stale=bool(snapshot.get("is_stale", False)), + db_path=db_path, + ) + ) + return records + + +def get_historical_snapshot( + *, + server_key: str, + snapshot_type: str, + metric: str | None = None, + window: str | None = None, + db_path: Path | None = None, +) -> dict[str, object] | None: + """Return one persisted snapshot and decoded payload, if present.""" + validate_snapshot_identity(snapshot_type=snapshot_type, metric=metric) + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import get_snapshot + + return get_snapshot( + server_key=server_key, + snapshot_type=snapshot_type, + metric=metric, + window=window, + ) + snapshots_root = resolve_historical_snapshot_storage_path(db_path=db_path) + snapshot_path = _build_snapshot_path( + snapshots_root=snapshots_root, + server_key=server_key, + snapshot_type=snapshot_type, + metric=metric, + ) + if not snapshot_path.exists(): + return None + + document = json.loads(snapshot_path.read_text(encoding="utf-8")) + return { + "server_key": document.get("server_key"), + "snapshot_type": document.get("snapshot_type"), + "metric": document.get("metric"), + "window": document.get("window"), + "generated_at": document.get("generated_at"), + "source_range_start": document.get("source_range_start"), + "source_range_end": document.get("source_range_end"), + "is_stale": bool(document.get("is_stale", False)), + "payload": document.get("payload"), + } + + +def list_historical_snapshots( + *, + server_key: str | None = None, + snapshot_type: str | None = None, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """List persisted snapshots for validation and operational inspection.""" + snapshots_root = resolve_historical_snapshot_storage_path(db_path=db_path) + if not snapshots_root.exists(): + return [] + if snapshot_type: + validate_snapshot_identity(snapshot_type=snapshot_type) + + rows: list[dict[str, object]] = [] + for snapshot_path in snapshots_root.glob("*/*.json"): + try: + document = json.loads(snapshot_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + + if server_key and document.get("server_key") != server_key: + continue + if snapshot_type and document.get("snapshot_type") != snapshot_type: + continue + + rows.append( + { + "server_key": document.get("server_key"), + "snapshot_type": document.get("snapshot_type"), + "metric": document.get("metric"), + "window": document.get("window"), + "generated_at": document.get("generated_at"), + "source_range_start": document.get("source_range_start"), + "source_range_end": document.get("source_range_end"), + "is_stale": bool(document.get("is_stale", False)), + } + ) + + return sorted( + rows, + key=lambda item: ( + str(item.get("server_key") or ""), + str(item.get("snapshot_type") or ""), + str(item.get("metric") or ""), + str(item.get("generated_at") or ""), + ), + ) + + +def _should_preserve_existing_snapshot( + *, + incoming_payload: dict[str, object] | list[object], + snapshot_type: str, + existing_document: dict[str, object] | None, +) -> bool: + if not _is_effectively_empty_snapshot_payload(snapshot_type, incoming_payload): + return False + if existing_document and not _is_effectively_empty_snapshot_payload( + snapshot_type, + existing_document.get("payload"), + ): + return True + return False + + +def _is_effectively_empty_snapshot_payload( + snapshot_type: str, + payload: object, +) -> bool: + if not isinstance(payload, dict): + return not payload + + if snapshot_type == "server-summary": + item = payload.get("item") + if not isinstance(item, dict): + return True + matches_count = item.get("imported_matches_count", item.get("matches_count", 0)) + return int(matches_count or 0) <= 0 + + if snapshot_type == "recent-matches": + items = payload.get("items") + return not isinstance(items, list) or len(items) == 0 + + if snapshot_type in { + "weekly-leaderboard", + "monthly-leaderboard", + "monthly-mvp", + "monthly-mvp-v2", + }: + items = payload.get("items") + return not isinstance(items, list) or len(items) == 0 + + return False +def _read_snapshot_document(snapshot_path: Path) -> dict[str, object] | None: + if not snapshot_path.exists(): + return None + try: + return json.loads(snapshot_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + + +def _build_snapshot_path( + *, + snapshots_root: Path, + server_key: str, + snapshot_type: str, + metric: str | None, +) -> Path: + return snapshots_root / server_key / _build_snapshot_filename( + snapshot_type=snapshot_type, + metric=metric, + ) + + +def _build_snapshot_filename(*, snapshot_type: str, metric: str | None) -> str: + if snapshot_type == "server-summary": + return "server-summary.json" + if snapshot_type == "recent-matches": + return "recent-matches.json" + if snapshot_type == "monthly-mvp-v2": + return "monthly-mvp-v2.json" + if snapshot_type == "player-event-most-killed": + return "player-events-most-killed.json" + if snapshot_type == "player-event-death-by": + return "player-events-death-by.json" + if snapshot_type == "player-event-duels": + return "player-events-duels.json" + if snapshot_type == "player-event-weapon-kills": + return "player-events-weapon-kills.json" + if snapshot_type == "player-event-teamkills": + return "player-events-teamkills.json" + if snapshot_type == "weekly-leaderboard": + metric_suffix = "matches-over-100-kills" if metric == "matches_over_100_kills" else _slugify(metric or "unknown") + return f"weekly-{metric_suffix}.json" + if snapshot_type == "monthly-leaderboard": + metric_suffix = "matches-over-100-kills" if metric == "matches_over_100_kills" else _slugify(metric or "unknown") + return f"monthly-{metric_suffix}.json" + if snapshot_type == "monthly-mvp": + return "monthly-mvp.json" + metric_suffix = _slugify(metric or "") + base_name = _slugify(snapshot_type) + return f"{base_name}-{metric_suffix}.json" if metric_suffix else f"{base_name}.json" + + +def _slugify(value: str) -> str: + return value.strip().replace("_", "-").replace(" ", "-").lower() + + +def _to_iso(value: datetime | None) -> str | None: + if value is None: + return None + return _as_utc(value).isoformat().replace("+00:00", "Z") + + +def _as_utc(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _parse_optional_datetime(value: object) -> datetime | None: + if not isinstance(value, str) or not value.strip(): + return None + normalized = value.strip().replace("Z", "+00:00") + parsed = datetime.fromisoformat(normalized) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) diff --git a/backend/app/historical_snapshots.py b/backend/app/historical_snapshots.py new file mode 100644 index 0000000..f54c0dd --- /dev/null +++ b/backend/app/historical_snapshots.py @@ -0,0 +1,842 @@ +"""Definitions for persisted precomputed historical snapshots.""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +from .config import get_database_url, get_historical_data_source_kind +from .data_sources import SOURCE_KIND_RCON, get_rcon_historical_read_model +from .historical_storage import ( + ALL_SERVERS_SLUG, + list_historical_server_summaries, + list_historical_servers, + list_monthly_leaderboard, + list_monthly_mvp_ranking, + list_monthly_mvp_v2_ranking, + list_recent_historical_matches, + list_weekly_leaderboard, +) +from .player_event_aggregates import ( + list_death_by, + list_most_killed, + list_net_duel_summaries, + list_teamkill_summaries, + list_weapon_kills, +) +from .player_event_storage import initialize_player_event_storage + +SNAPSHOT_TYPE_SERVER_SUMMARY = "server-summary" +SNAPSHOT_TYPE_WEEKLY_LEADERBOARD = "weekly-leaderboard" +SNAPSHOT_TYPE_MONTHLY_LEADERBOARD = "monthly-leaderboard" +SNAPSHOT_TYPE_MONTHLY_MVP = "monthly-mvp" +SNAPSHOT_TYPE_MONTHLY_MVP_V2 = "monthly-mvp-v2" +SNAPSHOT_TYPE_RECENT_MATCHES = "recent-matches" +SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED = "player-event-most-killed" +SNAPSHOT_TYPE_PLAYER_EVENT_DEATH_BY = "player-event-death-by" +SNAPSHOT_TYPE_PLAYER_EVENT_DUELS = "player-event-duels" +SNAPSHOT_TYPE_PLAYER_EVENT_WEAPON_KILLS = "player-event-weapon-kills" +SNAPSHOT_TYPE_PLAYER_EVENT_TEAMKILLS = "player-event-teamkills" + +SUPPORTED_SNAPSHOT_TYPES = frozenset( + { + SNAPSHOT_TYPE_SERVER_SUMMARY, + SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, + SNAPSHOT_TYPE_MONTHLY_LEADERBOARD, + SNAPSHOT_TYPE_MONTHLY_MVP, + SNAPSHOT_TYPE_MONTHLY_MVP_V2, + SNAPSHOT_TYPE_RECENT_MATCHES, + SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED, + SNAPSHOT_TYPE_PLAYER_EVENT_DEATH_BY, + SNAPSHOT_TYPE_PLAYER_EVENT_DUELS, + SNAPSHOT_TYPE_PLAYER_EVENT_WEAPON_KILLS, + SNAPSHOT_TYPE_PLAYER_EVENT_TEAMKILLS, + } +) + +SUPPORTED_LEADERBOARD_METRICS = frozenset( + { + "kills", + "deaths", + "support", + "matches_over_100_kills", + } +) +PREWARM_SNAPSHOT_SERVER_KEYS = ( + "comunidad-hispana-01", + "comunidad-hispana-02", + ALL_SERVERS_SLUG, +) +PREWARM_LEADERBOARD_METRICS = ("kills",) +SNAPSHOT_LEADERBOARD_METRICS = ( + "kills", + "deaths", + "matches_over_100_kills", + "support", +) +PLAYER_EVENT_SNAPSHOT_TYPES = ( + SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED, + SNAPSHOT_TYPE_PLAYER_EVENT_DEATH_BY, + SNAPSHOT_TYPE_PLAYER_EVENT_DUELS, + SNAPSHOT_TYPE_PLAYER_EVENT_WEAPON_KILLS, + SNAPSHOT_TYPE_PLAYER_EVENT_TEAMKILLS, +) + +DEFAULT_SNAPSHOT_WINDOW = "all-time" +DEFAULT_WEEKLY_SNAPSHOT_WINDOW = "7d" +DEFAULT_MONTHLY_SNAPSHOT_WINDOW = "month" +DEFAULT_WEEKLY_LEADERBOARD_LIMIT = 10 +DEFAULT_RECENT_MATCHES_LIMIT = 20 + + +def validate_snapshot_identity( + *, + snapshot_type: str, + metric: str | None = None, +) -> None: + """Validate the persisted snapshot selectors accepted by the storage layer.""" + if snapshot_type not in SUPPORTED_SNAPSHOT_TYPES: + raise ValueError(f"Unsupported historical snapshot type: {snapshot_type}") + + if snapshot_type in { + SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, + SNAPSHOT_TYPE_MONTHLY_LEADERBOARD, + }: + if metric not in SUPPORTED_LEADERBOARD_METRICS: + raise ValueError(f"Unsupported historical snapshot metric: {metric}") + return + + if metric is not None: + raise ValueError( + "Metric is only supported for weekly-leaderboard and monthly-leaderboard." + ) + + +def list_snapshot_server_keys(*, db_path: Path | None = None) -> list[str]: + """Return the historical server slugs that should receive persisted snapshots.""" + server_keys = [ + str(item["slug"]) + for item in list_historical_servers(db_path=db_path) + if item.get("slug") + ] + server_keys.append(ALL_SERVERS_SLUG) + return server_keys + + +def build_historical_server_snapshots( + *, + server_key: str, + generated_at: datetime | None = None, + leaderboard_limit: int = DEFAULT_WEEKLY_LEADERBOARD_LIMIT, + recent_matches_limit: int = DEFAULT_RECENT_MATCHES_LIMIT, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """Build all precomputed historical snapshots required for one server.""" + generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc)) + leaderboard_limit = _normalize_snapshot_limit("leaderboard_limit", leaderboard_limit) + recent_matches_limit = _normalize_snapshot_limit( + "recent_matches_limit", + recent_matches_limit, + ) + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_SERVER_SUMMARY) + snapshots = [_build_server_summary_snapshot(server_key, generated_at_value, db_path=db_path)] + + for metric in SNAPSHOT_LEADERBOARD_METRICS: + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, metric=metric) + snapshots.append( + _build_weekly_leaderboard_snapshot( + server_key, + metric, + generated_at_value, + limit=leaderboard_limit, + db_path=db_path, + ) + ) + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_LEADERBOARD, metric=metric) + snapshots.append( + _build_monthly_leaderboard_snapshot( + server_key, + metric, + generated_at_value, + limit=leaderboard_limit, + db_path=db_path, + ) + ) + + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP) + snapshots.append( + _build_monthly_mvp_snapshot( + server_key, + generated_at_value, + limit=leaderboard_limit, + db_path=db_path, + ) + ) + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP_V2) + snapshots.append( + _build_monthly_mvp_v2_snapshot( + server_key, + generated_at_value, + limit=leaderboard_limit, + db_path=db_path, + ) + ) + for snapshot_type in PLAYER_EVENT_SNAPSHOT_TYPES: + _log_snapshot_build_started(server_key, snapshot_type) + snapshots.append( + _build_player_event_snapshot( + server_key, + snapshot_type, + generated_at_value, + limit=leaderboard_limit, + db_path=db_path, + ) + ) + + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_RECENT_MATCHES) + snapshots.append( + _build_recent_matches_snapshot( + server_key, + generated_at_value, + limit=recent_matches_limit, + db_path=db_path, + ) + ) + return snapshots + + +def build_priority_historical_snapshots( + *, + server_keys: tuple[str, ...] = PREWARM_SNAPSHOT_SERVER_KEYS, + generated_at: datetime | None = None, + leaderboard_limit: int = DEFAULT_WEEKLY_LEADERBOARD_LIMIT, + recent_matches_limit: int = DEFAULT_RECENT_MATCHES_LIMIT, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """Build the minimum warm snapshot set required by the historical UI.""" + generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc)) + leaderboard_limit = _normalize_snapshot_limit("leaderboard_limit", leaderboard_limit) + recent_matches_limit = _normalize_snapshot_limit( + "recent_matches_limit", + recent_matches_limit, + ) + snapshots: list[dict[str, object]] = [] + for server_key in server_keys: + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_SERVER_SUMMARY) + snapshots.append( + _build_server_summary_snapshot(server_key, generated_at_value, db_path=db_path) + ) + for metric in PREWARM_LEADERBOARD_METRICS: + _log_snapshot_build_started( + server_key, + SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, + metric=metric, + ) + snapshots.append( + _build_weekly_leaderboard_snapshot( + server_key, + metric, + generated_at_value, + limit=leaderboard_limit, + db_path=db_path, + ) + ) + _log_snapshot_build_started( + server_key, + SNAPSHOT_TYPE_MONTHLY_LEADERBOARD, + metric=metric, + ) + snapshots.append( + _build_monthly_leaderboard_snapshot( + server_key, + metric, + generated_at_value, + limit=leaderboard_limit, + db_path=db_path, + ) + ) + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP) + snapshots.append( + _build_monthly_mvp_snapshot( + server_key, + generated_at_value, + limit=leaderboard_limit, + db_path=db_path, + ) + ) + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP_V2) + snapshots.append( + _build_monthly_mvp_v2_snapshot( + server_key, + generated_at_value, + limit=leaderboard_limit, + db_path=db_path, + ) + ) + for snapshot_type in PLAYER_EVENT_SNAPSHOT_TYPES: + _log_snapshot_build_started(server_key, snapshot_type) + snapshots.append( + _build_player_event_snapshot( + server_key, + snapshot_type, + generated_at_value, + limit=leaderboard_limit, + db_path=db_path, + ) + ) + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_RECENT_MATCHES) + snapshots.append( + _build_recent_matches_snapshot( + server_key, + generated_at_value, + limit=recent_matches_limit, + db_path=db_path, + ) + ) + return snapshots + + +def build_all_historical_snapshots( + *, + server_key: str | None = None, + generated_at: datetime | None = None, + leaderboard_limit: int = DEFAULT_WEEKLY_LEADERBOARD_LIMIT, + recent_matches_limit: int = DEFAULT_RECENT_MATCHES_LIMIT, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """Build the full snapshot set for one server or for all configured servers.""" + target_server_keys = _resolve_snapshot_target_keys(server_key=server_key, db_path=db_path) + snapshots: list[dict[str, object]] = [] + for target_server_key in target_server_keys: + snapshots.extend( + build_historical_server_snapshots( + server_key=target_server_key, + generated_at=generated_at, + leaderboard_limit=leaderboard_limit, + recent_matches_limit=recent_matches_limit, + db_path=db_path, + ) + ) + return snapshots + + +def generate_and_persist_historical_snapshots( + *, + server_key: str | None = None, + generated_at: datetime | None = None, + leaderboard_limit: int = DEFAULT_WEEKLY_LEADERBOARD_LIMIT, + recent_matches_limit: int = DEFAULT_RECENT_MATCHES_LIMIT, + db_path: Path | None = None, +) -> dict[str, object]: + """Build and persist precomputed snapshots for one server or all servers.""" + from .historical_snapshot_storage import persist_historical_snapshot_batch + + generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc)) + snapshots = build_all_historical_snapshots( + server_key=server_key, + generated_at=generated_at_value, + leaderboard_limit=leaderboard_limit, + recent_matches_limit=recent_matches_limit, + db_path=db_path, + ) + persisted_records = persist_historical_snapshot_batch(snapshots, db_path=db_path) + snapshots_by_server: dict[str, int] = {} + for record in persisted_records: + snapshots_by_server.setdefault(record.server_key, 0) + snapshots_by_server[record.server_key] += 1 + + return { + "generated_at": _to_iso(generated_at_value), + "server_slug": server_key, + "snapshot_policy": "full-matrix", + "snapshot_count": len(persisted_records), + "servers_processed": len(snapshots_by_server), + "snapshots_by_server": snapshots_by_server, + } + + +def generate_and_persist_priority_historical_snapshots( + *, + generated_at: datetime | None = None, + leaderboard_limit: int = DEFAULT_WEEKLY_LEADERBOARD_LIMIT, + recent_matches_limit: int = DEFAULT_RECENT_MATCHES_LIMIT, + db_path: Path | None = None, +) -> dict[str, object]: + """Build and persist the priority snapshot set used for prewarm.""" + from .historical_snapshot_storage import persist_historical_snapshot_batch + + generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc)) + snapshots = build_priority_historical_snapshots( + generated_at=generated_at_value, + leaderboard_limit=leaderboard_limit, + recent_matches_limit=recent_matches_limit, + db_path=db_path, + ) + persisted_records = persist_historical_snapshot_batch(snapshots, db_path=db_path) + snapshots_by_server: dict[str, int] = {} + for record in persisted_records: + snapshots_by_server.setdefault(record.server_key, 0) + snapshots_by_server[record.server_key] += 1 + + return { + "generated_at": _to_iso(generated_at_value), + "server_slug": None, + "snapshot_policy": "priority-prewarm", + "prewarm_server_keys": list(PREWARM_SNAPSHOT_SERVER_KEYS), + "prewarm_metrics": list(PREWARM_LEADERBOARD_METRICS), + "snapshot_count": len(persisted_records), + "servers_processed": len(snapshots_by_server), + "snapshots_by_server": snapshots_by_server, + } + + +def _build_server_summary_snapshot( + server_key: str, + generated_at: datetime, + *, + db_path: Path | None = None, +) -> dict[str, object]: + if get_historical_data_source_kind() == SOURCE_KIND_RCON: + data_source = get_rcon_historical_read_model() + summary_items = ( + data_source.list_server_summaries(server_key=server_key) + if data_source is not None + else [] + ) + else: + summary_items = list_historical_server_summaries(server_slug=server_key, db_path=db_path) + summary_item = summary_items[0] if summary_items else {} + time_range = summary_item.get("time_range") if isinstance(summary_item, dict) else {} + return { + "server_key": server_key, + "snapshot_type": SNAPSHOT_TYPE_SERVER_SUMMARY, + "metric": None, + "window": DEFAULT_SNAPSHOT_WINDOW, + "generated_at": generated_at, + "source_range_start": _parse_optional_timestamp(time_range.get("start")), + "source_range_end": _parse_optional_timestamp(time_range.get("end")), + "is_stale": False, + "payload": { + "server_key": server_key, + "generated_at": _to_iso(generated_at), + "item": summary_item, + }, + } + + +def _build_weekly_leaderboard_snapshot( + server_key: str, + metric: str, + generated_at: datetime, + *, + limit: int, + db_path: Path | None = None, +) -> dict[str, object]: + if get_historical_data_source_kind() == SOURCE_KIND_RCON: + from .rcon_historical_leaderboards import list_rcon_materialized_leaderboard + + leaderboard_result = list_rcon_materialized_leaderboard( + limit=limit, + server_key=server_key, + metric=metric, + timeframe="weekly", + db_path=db_path, + now=generated_at, + ) + else: + leaderboard_result = list_weekly_leaderboard( + limit=limit, + server_id=server_key, + metric=metric, + db_path=db_path, + ) + return { + "server_key": server_key, + "snapshot_type": SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, + "metric": metric, + "window": DEFAULT_WEEKLY_SNAPSHOT_WINDOW, + "generated_at": generated_at, + "source_range_start": _parse_optional_timestamp(leaderboard_result.get("window_start")), + "source_range_end": _parse_optional_timestamp(leaderboard_result.get("window_end")), + "is_stale": False, + "payload": { + "server_key": server_key, + "metric": metric, + "limit": limit, + "generated_at": _to_iso(generated_at), + **leaderboard_result, + }, + } + + +def _build_monthly_leaderboard_snapshot( + server_key: str, + metric: str, + generated_at: datetime, + *, + limit: int, + db_path: Path | None = None, +) -> dict[str, object]: + if get_historical_data_source_kind() == SOURCE_KIND_RCON: + from .rcon_historical_leaderboards import list_rcon_materialized_leaderboard + + leaderboard_result = list_rcon_materialized_leaderboard( + limit=limit, + server_key=server_key, + metric=metric, + timeframe="monthly", + db_path=db_path, + now=generated_at, + ) + else: + leaderboard_result = list_monthly_leaderboard( + limit=limit, + server_id=server_key, + metric=metric, + db_path=db_path, + ) + return { + "server_key": server_key, + "snapshot_type": SNAPSHOT_TYPE_MONTHLY_LEADERBOARD, + "metric": metric, + "window": DEFAULT_MONTHLY_SNAPSHOT_WINDOW, + "generated_at": generated_at, + "source_range_start": _parse_optional_timestamp(leaderboard_result.get("window_start")), + "source_range_end": _parse_optional_timestamp(leaderboard_result.get("window_end")), + "is_stale": False, + "payload": { + "server_key": server_key, + "metric": metric, + "limit": limit, + "generated_at": _to_iso(generated_at), + **leaderboard_result, + }, + } + + +def _build_recent_matches_snapshot( + server_key: str, + generated_at: datetime, + *, + limit: int, + db_path: Path | None = None, +) -> dict[str, object]: + if get_historical_data_source_kind() == SOURCE_KIND_RCON: + data_source = get_rcon_historical_read_model() + items = ( + data_source.list_recent_activity(server_key=server_key, limit=limit) + if data_source is not None + else [] + ) + else: + items = list_recent_historical_matches( + limit=limit, + server_slug=server_key, + db_path=db_path, + ) + closed_points = [ + _parse_optional_timestamp(item.get("closed_at")) + for item in items + if isinstance(item, dict) and item.get("closed_at") + ] + return { + "server_key": server_key, + "snapshot_type": SNAPSHOT_TYPE_RECENT_MATCHES, + "metric": None, + "window": DEFAULT_SNAPSHOT_WINDOW, + "generated_at": generated_at, + "source_range_start": min(closed_points) if closed_points else None, + "source_range_end": max(closed_points) if closed_points else None, + "is_stale": False, + "payload": { + "server_key": server_key, + "limit": limit, + "generated_at": _to_iso(generated_at), + "items": items, + }, + } + + +def _build_player_event_snapshot( + server_key: str, + snapshot_type: str, + generated_at: datetime, + *, + limit: int, + db_path: Path | None = None, +) -> dict[str, object]: + month_key = _get_latest_player_event_month_key(server_key=server_key, db_path=db_path) + source_range_start = None + source_range_end = None + items: list[dict[str, object]] = [] + found = False + + if month_key: + source_range_start, source_range_end = _get_player_event_source_range( + server_key=server_key, + month_key=month_key, + db_path=db_path, + ) + items = _list_player_event_snapshot_items( + snapshot_type=snapshot_type, + server_key=server_key, + month_key=month_key, + limit=limit, + db_path=db_path, + ) + found = bool(items or source_range_start or source_range_end) + + return { + "server_key": server_key, + "snapshot_type": snapshot_type, + "metric": None, + "window": DEFAULT_MONTHLY_SNAPSHOT_WINDOW, + "generated_at": generated_at, + "source_range_start": source_range_start, + "source_range_end": source_range_end, + "is_stale": False, + "payload": { + "server_key": server_key, + "period": "monthly", + "month_key": month_key, + "limit": limit, + "found": found, + "generated_at": _to_iso(generated_at), + "source_range_start": _to_iso(source_range_start) if source_range_start else None, + "source_range_end": _to_iso(source_range_end) if source_range_end else None, + "items": items, + }, + } + + +def _build_monthly_mvp_snapshot( + server_key: str, + generated_at: datetime, + *, + limit: int, + db_path: Path | None = None, +) -> dict[str, object]: + ranking_result = list_monthly_mvp_ranking( + limit=limit, + server_id=server_key, + db_path=db_path, + ) + month_key = str(ranking_result.get("window_start") or "")[:7] or None + return { + "server_key": server_key, + "snapshot_type": SNAPSHOT_TYPE_MONTHLY_MVP, + "metric": None, + "window": DEFAULT_MONTHLY_SNAPSHOT_WINDOW, + "generated_at": generated_at, + "source_range_start": _parse_optional_timestamp(ranking_result.get("window_start")), + "source_range_end": _parse_optional_timestamp(ranking_result.get("window_end")), + "is_stale": False, + "payload": { + "server_key": server_key, + "limit": limit, + "month_key": month_key, + "generated_at": _to_iso(generated_at), + **ranking_result, + }, + } + + +def _build_monthly_mvp_v2_snapshot( + server_key: str, + generated_at: datetime, + *, + limit: int, + db_path: Path | None = None, +) -> dict[str, object]: + ranking_result = list_monthly_mvp_v2_ranking( + limit=limit, + server_id=server_key, + db_path=db_path, + ) + month_key = str(ranking_result.get("window_start") or "")[:7] or None + event_coverage = ranking_result.get("event_coverage") + source_range_start = None + source_range_end = None + if isinstance(event_coverage, dict): + source_range_start = _parse_optional_timestamp(event_coverage.get("source_range_start")) + source_range_end = _parse_optional_timestamp(event_coverage.get("source_range_end")) + return { + "server_key": server_key, + "snapshot_type": SNAPSHOT_TYPE_MONTHLY_MVP_V2, + "metric": None, + "window": DEFAULT_MONTHLY_SNAPSHOT_WINDOW, + "generated_at": generated_at, + "source_range_start": source_range_start, + "source_range_end": source_range_end, + "is_stale": False, + "payload": { + "server_key": server_key, + "limit": limit, + "month_key": month_key, + "found": bool(event_coverage.get("ready")) if isinstance(event_coverage, dict) else False, + "generated_at": _to_iso(generated_at), + **ranking_result, + }, + } + + +def _resolve_snapshot_target_keys( + *, + server_key: str | None, + db_path: Path | None = None, +) -> list[str]: + """Expand targeted rebuilds so the logical global aggregate stays in sync.""" + if not server_key: + return list_snapshot_server_keys(db_path=db_path) + + normalized_server_key = server_key.strip() + if not normalized_server_key: + return list_snapshot_server_keys(db_path=db_path) + if normalized_server_key == ALL_SERVERS_SLUG: + return [ALL_SERVERS_SLUG] + + return [normalized_server_key, ALL_SERVERS_SLUG] + + +def _list_player_event_snapshot_items( + *, + snapshot_type: str, + server_key: str, + month_key: str, + limit: int, + db_path: Path | None, +) -> list[dict[str, object]]: + aggregator_by_snapshot_type = { + SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED: list_most_killed, + SNAPSHOT_TYPE_PLAYER_EVENT_DEATH_BY: list_death_by, + SNAPSHOT_TYPE_PLAYER_EVENT_DUELS: list_net_duel_summaries, + SNAPSHOT_TYPE_PLAYER_EVENT_WEAPON_KILLS: list_weapon_kills, + SNAPSHOT_TYPE_PLAYER_EVENT_TEAMKILLS: list_teamkill_summaries, + } + aggregator = aggregator_by_snapshot_type[snapshot_type] + return aggregator( + server_slug=server_key, + month=month_key, + limit=limit, + db_path=db_path, + ) + + +def _get_latest_player_event_month_key( + *, + server_key: str, + db_path: Path | None = None, +) -> str | None: + resolved_path = initialize_player_event_storage(db_path=db_path) + where_sql, params = _build_player_event_scope_where(server_key=server_key) + with _connect(resolved_path) as connection: + row = connection.execute( + f""" + SELECT MAX(substr(CAST(occurred_at AS TEXT), 1, 7)) AS latest_month + FROM player_event_raw_ledger + WHERE occurred_at IS NOT NULL + AND {where_sql} + """, + params, + ).fetchone() + if not row or not row["latest_month"]: + return None + return str(row["latest_month"]) + + +def _get_player_event_source_range( + *, + server_key: str, + month_key: str, + db_path: Path | None = None, +) -> tuple[datetime | None, datetime | None]: + resolved_path = initialize_player_event_storage(db_path=db_path) + where_sql, params = _build_player_event_scope_where(server_key=server_key) + with _connect(resolved_path) as connection: + row = connection.execute( + f""" + SELECT + MIN(occurred_at) AS source_range_start, + MAX(occurred_at) AS source_range_end + FROM player_event_raw_ledger + WHERE occurred_at IS NOT NULL + AND substr(CAST(occurred_at AS TEXT), 1, 7) = ? + AND {where_sql} + """, + [month_key, *params], + ).fetchone() + if not row: + return None, None + return ( + _parse_optional_timestamp(row["source_range_start"]), + _parse_optional_timestamp(row["source_range_end"]), + ) + + +def _build_player_event_scope_where(*, server_key: str) -> tuple[str, list[object]]: + if server_key == ALL_SERVERS_SLUG: + return "1 = 1", [] + return "server_slug = ?", [server_key] + + +def _connect(db_path: Path) -> sqlite3.Connection: + if get_database_url(): + from .postgres_display_storage import connect_postgres_compat + + return connect_postgres_compat() + connection = sqlite3.connect(db_path) + connection.row_factory = sqlite3.Row + return connection + + +def _parse_optional_timestamp(value: object) -> datetime | None: + if not isinstance(value, str) or not value.strip(): + return None + normalized = value.strip().replace("Z", "+00:00") + parsed = datetime.fromisoformat(normalized) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _to_iso(value: datetime) -> str: + return _as_utc(value).isoformat().replace("+00:00", "Z") + + +def _normalize_snapshot_limit(name: str, value: object) -> int: + try: + limit = int(value) + except (TypeError, ValueError) as error: + raise ValueError(f"{name} must be a positive integer.") from error + if limit <= 0: + raise ValueError(f"{name} must be a positive integer.") + return limit + + +def _log_snapshot_build_started( + server_key: str, + snapshot_type: str, + *, + metric: str | None = None, +) -> None: + print( + json.dumps( + { + "event": "historical-snapshot-build-started", + "server_key": server_key, + "snapshot_type": snapshot_type, + "metric": metric, + }, + ensure_ascii=True, + default=str, + ), + flush=True, + ) diff --git a/backend/app/historical_storage.py b/backend/app/historical_storage.py new file mode 100644 index 0000000..db7dd57 --- /dev/null +++ b/backend/app/historical_storage.py @@ -0,0 +1,3325 @@ +"""SQLite persistence for historical CRCON scoreboard data.""" + +from __future__ import annotations + +import sqlite3 +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Mapping + +from .config import ( + get_historical_refresh_overlap_hours, + get_historical_weekly_fallback_max_weekday, + get_historical_weekly_fallback_min_matches, + get_storage_path, + use_postgres_rcon_storage, +) +from .historical_models import HistoricalServerDefinition +from .monthly_mvp import build_monthly_mvp_rankings +from .monthly_mvp_v2 import build_monthly_mvp_v2_rankings +from .player_external_profiles import build_external_player_profile_fields +from .scoreboard_origins import ( + list_trusted_public_scoreboard_origins, + resolve_trusted_scoreboard_match_url, +) +from .sqlite_utils import connect_sqlite_writer + + +DEFAULT_HISTORICAL_SERVERS = tuple( + HistoricalServerDefinition( + slug=origin.slug, + display_name=origin.display_name, + scoreboard_base_url=origin.base_url, + server_number=origin.server_number, + source_kind=origin.source_kind, + ) + for origin in list_trusted_public_scoreboard_origins() +) +ALL_SERVERS_SLUG = "all-servers" +ALL_SERVERS_DISPLAY_NAME = "Todos" +DEFAULT_WEEKLY_WINDOW_DAYS = 7 +SUPPORTED_WEEKLY_LEADERBOARD_METRICS = frozenset( + { + "kills", + "deaths", + "support", + "matches_over_100_kills", + } +) +SUPPORTED_MONTHLY_LEADERBOARD_METRICS = SUPPORTED_WEEKLY_LEADERBOARD_METRICS + + +def initialize_historical_storage(*, db_path: Path | None = None) -> Path: + """Create or migrate the local SQLite schema for historical data.""" + resolved_path = db_path or get_storage_path() + resolved_path.parent.mkdir(parents=True, exist_ok=True) + + with _connect(resolved_path) as connection: + legacy_historical_schema = _has_legacy_historical_schema(connection) + if legacy_historical_schema: + _rename_legacy_historical_tables(connection) + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS historical_servers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + scoreboard_base_url TEXT NOT NULL UNIQUE, + server_number INTEGER, + source_kind 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_maps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + external_map_id TEXT UNIQUE, + map_name TEXT, + pretty_name TEXT, + game_mode TEXT, + image_name TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS historical_matches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + historical_server_id INTEGER NOT NULL, + external_match_id TEXT NOT NULL, + historical_map_id INTEGER, + created_at_source TEXT, + started_at TEXT, + ended_at TEXT, + map_name TEXT, + map_pretty_name TEXT, + game_mode TEXT, + image_name TEXT, + allied_score INTEGER, + axis_score INTEGER, + last_seen_at TEXT NOT NULL, + raw_payload_ref TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(historical_server_id, external_match_id), + FOREIGN KEY (historical_server_id) REFERENCES historical_servers(id), + FOREIGN KEY (historical_map_id) REFERENCES historical_maps(id) + ); + + CREATE TABLE IF NOT EXISTS historical_players ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stable_player_key TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + steam_id TEXT, + source_player_id TEXT, + first_seen_at TEXT NOT NULL, + last_seen_at 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, + historical_match_id INTEGER NOT NULL, + historical_player_id INTEGER NOT NULL, + match_player_ref TEXT, + team_side TEXT, + level INTEGER, + kills INTEGER, + deaths INTEGER, + teamkills INTEGER, + time_seconds INTEGER, + kills_per_minute REAL, + deaths_per_minute REAL, + kill_death_ratio REAL, + combat INTEGER, + offense INTEGER, + defense INTEGER, + support INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(historical_match_id, historical_player_id), + FOREIGN KEY (historical_match_id) REFERENCES historical_matches(id), + FOREIGN KEY (historical_player_id) REFERENCES historical_players(id) + ); + + CREATE TABLE IF NOT EXISTS historical_ingestion_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mode TEXT NOT NULL, + status TEXT NOT NULL, + started_at TEXT NOT NULL, + completed_at TEXT, + target_server_slug TEXT, + pages_processed INTEGER NOT NULL DEFAULT 0, + matches_seen INTEGER NOT NULL DEFAULT 0, + matches_inserted INTEGER NOT NULL DEFAULT 0, + matches_updated INTEGER NOT NULL DEFAULT 0, + player_rows_inserted INTEGER NOT NULL DEFAULT 0, + player_rows_updated INTEGER NOT NULL DEFAULT 0, + notes TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS historical_backfill_progress ( + historical_server_id INTEGER NOT NULL, + mode TEXT NOT NULL, + next_page INTEGER NOT NULL DEFAULT 1, + last_completed_page INTEGER, + discovered_total_matches INTEGER, + discovered_total_pages INTEGER, + archive_exhausted INTEGER NOT NULL DEFAULT 0, + last_run_id INTEGER, + last_run_status TEXT, + last_run_started_at TEXT, + last_run_completed_at TEXT, + last_error TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (historical_server_id, mode), + FOREIGN KEY (historical_server_id) REFERENCES historical_servers(id) + ); + + CREATE INDEX IF NOT EXISTS idx_historical_matches_server_end + ON historical_matches(historical_server_id, ended_at DESC, started_at DESC); + + CREATE INDEX IF NOT EXISTS idx_historical_player_stats_match + ON historical_player_match_stats(historical_match_id); + + CREATE INDEX IF NOT EXISTS idx_historical_players_steam + ON historical_players(steam_id); + + CREATE INDEX IF NOT EXISTS idx_historical_backfill_progress_run + ON historical_backfill_progress(last_run_id); + """ + ) + _seed_default_historical_servers(connection) + if legacy_historical_schema: + _migrate_legacy_historical_data(connection) + _normalize_historical_player_identities(connection) + _normalize_historical_match_identities(connection) + + return resolved_path + + +def list_historical_servers(*, db_path: Path | None = None) -> list[dict[str, object]]: + """Return configured CRCON historical sources.""" + resolved_path = initialize_historical_storage(db_path=db_path) + with _connect(resolved_path) as connection: + rows = connection.execute( + """ + SELECT slug, display_name, scoreboard_base_url, server_number, source_kind + FROM historical_servers + ORDER BY slug ASC + """ + ).fetchall() + return [dict(row) for row in rows] + + +def start_ingestion_run( + *, + mode: str, + target_server_slug: str | None = None, + db_path: Path | None = None, +) -> int: + """Create a row tracking one ingestion execution.""" + resolved_path = initialize_historical_storage(db_path=db_path) + with _connect(resolved_path) as connection: + cursor = connection.execute( + """ + INSERT INTO historical_ingestion_runs ( + mode, + status, + started_at, + target_server_slug + ) VALUES (?, 'running', ?, ?) + """, + (mode, _utc_now_iso(), target_server_slug), + ) + return int(cursor.lastrowid) + + +def finalize_ingestion_run( + run_id: int, + *, + status: str, + pages_processed: int, + matches_seen: int, + matches_inserted: int, + matches_updated: int, + player_rows_inserted: int, + player_rows_updated: int, + notes: str | None = None, + db_path: Path | None = None, +) -> None: + """Update an ingestion run row with outcome metrics.""" + resolved_path = initialize_historical_storage(db_path=db_path) + with _connect(resolved_path) as connection: + connection.execute( + """ + UPDATE historical_ingestion_runs + SET status = ?, + completed_at = ?, + pages_processed = ?, + matches_seen = ?, + matches_inserted = ?, + matches_updated = ?, + player_rows_inserted = ?, + player_rows_updated = ?, + notes = ? + WHERE id = ? + """, + ( + status, + _utc_now_iso(), + pages_processed, + matches_seen, + matches_inserted, + matches_updated, + player_rows_inserted, + player_rows_updated, + notes, + run_id, + ), + ) + + +def mark_backfill_progress_started( + *, + server_slug: str, + mode: str, + run_id: int, + db_path: Path | None = None, +) -> None: + """Persist the start of one resumable historical backfill attempt.""" + resolved_path = initialize_historical_storage(db_path=db_path) + with _connect(resolved_path) as connection: + server_row = _resolve_historical_server(connection, server_slug) + connection.execute( + """ + INSERT INTO historical_backfill_progress ( + historical_server_id, + mode, + next_page, + archive_exhausted, + last_run_id, + last_run_status, + last_run_started_at, + last_run_completed_at, + last_error + ) VALUES (?, ?, 1, 0, ?, 'running', ?, NULL, NULL) + ON CONFLICT(historical_server_id, mode) DO UPDATE SET + last_run_id = excluded.last_run_id, + last_run_status = excluded.last_run_status, + last_run_started_at = excluded.last_run_started_at, + last_run_completed_at = NULL, + last_error = NULL, + archive_exhausted = CASE + WHEN excluded.mode = 'bootstrap' THEN 0 + ELSE historical_backfill_progress.archive_exhausted + END, + updated_at = CURRENT_TIMESTAMP + """, + (server_row["id"], mode, run_id, _utc_now_iso()), + ) + + +def mark_backfill_progress_page_completed( + *, + server_slug: str, + mode: str, + page_number: int, + page_size: int, + run_id: int, + discovered_total_matches: int | None, + db_path: Path | None = None, +) -> None: + """Persist the latest completed page so bootstraps can resume safely.""" + resolved_path = initialize_historical_storage(db_path=db_path) + discovered_total_pages = None + if discovered_total_matches and page_size > 0: + discovered_total_pages = (discovered_total_matches + page_size - 1) // page_size + + with _connect(resolved_path) as connection: + server_row = _resolve_historical_server(connection, server_slug) + connection.execute( + """ + INSERT INTO historical_backfill_progress ( + historical_server_id, + mode, + next_page, + last_completed_page, + discovered_total_matches, + discovered_total_pages, + archive_exhausted, + last_run_id, + last_run_status, + last_run_started_at, + last_run_completed_at, + last_error + ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, NULL, NULL) + ON CONFLICT(historical_server_id, mode) DO UPDATE SET + next_page = excluded.next_page, + last_completed_page = excluded.last_completed_page, + discovered_total_matches = COALESCE( + excluded.discovered_total_matches, + historical_backfill_progress.discovered_total_matches + ), + discovered_total_pages = COALESCE( + excluded.discovered_total_pages, + historical_backfill_progress.discovered_total_pages + ), + archive_exhausted = 0, + last_run_id = excluded.last_run_id, + last_run_status = excluded.last_run_status, + last_run_started_at = excluded.last_run_started_at, + last_run_completed_at = NULL, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP + """, + ( + server_row["id"], + mode, + page_number + 1, + page_number, + discovered_total_matches, + discovered_total_pages, + run_id, + "running", + _utc_now_iso(), + ), + ) + + +def finalize_backfill_progress( + *, + server_slug: str, + mode: str, + run_id: int, + status: str, + archive_exhausted: bool = False, + error_message: str | None = None, + db_path: Path | None = None, +) -> None: + """Persist the final state of one resumable historical backfill attempt.""" + resolved_path = initialize_historical_storage(db_path=db_path) + with _connect(resolved_path) as connection: + server_row = _resolve_historical_server(connection, server_slug) + connection.execute( + """ + INSERT INTO historical_backfill_progress ( + historical_server_id, + mode, + next_page, + archive_exhausted, + last_run_id, + last_run_status, + last_run_started_at, + last_run_completed_at, + last_error + ) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?) + ON CONFLICT(historical_server_id, mode) DO UPDATE SET + archive_exhausted = CASE + WHEN excluded.last_run_status = 'success' AND excluded.archive_exhausted = 1 + THEN 1 + WHEN excluded.last_run_status = 'success' + THEN historical_backfill_progress.archive_exhausted + ELSE historical_backfill_progress.archive_exhausted + END, + last_run_id = excluded.last_run_id, + last_run_status = excluded.last_run_status, + last_run_started_at = COALESCE( + historical_backfill_progress.last_run_started_at, + excluded.last_run_started_at + ), + last_run_completed_at = excluded.last_run_completed_at, + last_error = excluded.last_error, + updated_at = CURRENT_TIMESTAMP + """, + ( + server_row["id"], + mode, + 1 if archive_exhausted else 0, + run_id, + status, + _utc_now_iso(), + _utc_now_iso(), + error_message, + ), + ) + + +def get_backfill_resume_page( + server_slug: str, + *, + mode: str = "bootstrap", + db_path: Path | None = None, +) -> int: + """Return the next page recorded for one resumable historical backfill.""" + resolved_path = initialize_historical_storage(db_path=db_path) + with _connect(resolved_path) as connection: + server_row = _resolve_historical_server(connection, server_slug) + row = connection.execute( + """ + SELECT next_page + FROM historical_backfill_progress + WHERE historical_server_id = ? AND mode = ? + """, + (server_row["id"], mode), + ).fetchone() + return max(1, int(row["next_page"])) if row and row["next_page"] else 1 + + +def list_historical_backfill_progress( + *, + server_slug: str | None = None, + mode: str = "bootstrap", + db_path: Path | None = None, +) -> list[dict[str, object]]: + """Return persisted resume checkpoints and last run state per server.""" + resolved_path = initialize_historical_storage(db_path=db_path) + where_clause = "" + params: list[object] = [mode] + if server_slug: + where_clause = "WHERE historical_servers.slug = ?" + params.append(server_slug) + + with _connect(resolved_path) as connection: + rows = connection.execute( + f""" + SELECT + historical_servers.slug AS server_slug, + historical_servers.display_name AS server_name, + progress.mode AS mode, + progress.next_page AS next_page, + progress.last_completed_page AS last_completed_page, + progress.discovered_total_matches AS discovered_total_matches, + progress.discovered_total_pages AS discovered_total_pages, + progress.archive_exhausted AS archive_exhausted, + progress.last_run_id AS last_run_id, + progress.last_run_status AS last_run_status, + progress.last_run_started_at AS last_run_started_at, + progress.last_run_completed_at AS last_run_completed_at, + progress.last_error AS last_error + FROM historical_servers + LEFT JOIN historical_backfill_progress AS progress + ON progress.historical_server_id = historical_servers.id + AND progress.mode = ? + {where_clause} + ORDER BY historical_servers.server_number ASC, historical_servers.slug ASC + """, + params, + ).fetchall() + + items: list[dict[str, object]] = [] + for row in rows: + items.append( + { + "server": { + "slug": row["server_slug"], + "name": row["server_name"], + }, + "mode": row["mode"] or mode, + "next_page": int(row["next_page"] or 1), + "last_completed_page": _coerce_int(row["last_completed_page"]), + "discovered_total_matches": _coerce_int(row["discovered_total_matches"]), + "discovered_total_pages": _coerce_int(row["discovered_total_pages"]), + "archive_exhausted": bool(row["archive_exhausted"]), + "last_run": { + "run_id": _coerce_int(row["last_run_id"]), + "status": _stringify(row["last_run_status"]), + "started_at": _stringify(row["last_run_started_at"]), + "completed_at": _stringify(row["last_run_completed_at"]), + "error": _stringify(row["last_error"]), + }, + } + ) + return items + + +def upsert_historical_match( + *, + server_slug: str, + match_payload: Mapping[str, object], + db_path: Path | None = None, +) -> dict[str, int]: + """Persist one historical match and its player stats idempotently.""" + resolved_path = initialize_historical_storage(db_path=db_path) + match_external_id = _stringify(match_payload.get("id")) + if not match_external_id: + raise ValueError("Historical match payload is missing a stable id.") + + with _connect(resolved_path) as connection: + server_row = _resolve_historical_server(connection, server_slug) + map_id = _upsert_historical_map(connection, match_payload) + match_row = connection.execute( + """ + SELECT id + FROM historical_matches + WHERE historical_server_id = ? AND external_match_id = ? + """, + (server_row["id"], match_external_id), + ).fetchone() + match_exists = match_row is not None + + connection.execute( + """ + INSERT INTO historical_matches ( + historical_server_id, + external_match_id, + historical_map_id, + created_at_source, + started_at, + ended_at, + map_name, + map_pretty_name, + game_mode, + image_name, + allied_score, + axis_score, + last_seen_at, + raw_payload_ref + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(historical_server_id, external_match_id) DO UPDATE SET + historical_map_id = excluded.historical_map_id, + created_at_source = excluded.created_at_source, + started_at = excluded.started_at, + ended_at = excluded.ended_at, + map_name = excluded.map_name, + map_pretty_name = excluded.map_pretty_name, + game_mode = excluded.game_mode, + image_name = excluded.image_name, + allied_score = excluded.allied_score, + axis_score = excluded.axis_score, + last_seen_at = excluded.last_seen_at, + raw_payload_ref = excluded.raw_payload_ref, + updated_at = CURRENT_TIMESTAMP + """, + ( + server_row["id"], + match_external_id, + map_id, + _normalize_timestamp(match_payload.get("creation_time")), + _normalize_timestamp(match_payload.get("start")), + _normalize_timestamp(match_payload.get("end")), + _extract_map_name(match_payload), + _extract_map_pretty_name(match_payload), + _extract_map_game_mode(match_payload), + _extract_map_image_name(match_payload), + _coerce_int(_get_nested(match_payload, "result", "allied")), + _coerce_int(_get_nested(match_payload, "result", "axis")), + _utc_now_iso(), + f"{server_row['scoreboard_base_url']}/games/{match_external_id}", + ), + ) + match_id_row = connection.execute( + """ + SELECT id + FROM historical_matches + WHERE historical_server_id = ? AND external_match_id = ? + """, + (server_row["id"], match_external_id), + ).fetchone() + if match_id_row is None: + raise RuntimeError("Failed to persist historical match.") + + player_rows_inserted = 0 + player_rows_updated = 0 + for player_payload in _coerce_list(match_payload.get("player_stats")): + player_id = _upsert_historical_player(connection, player_payload) + stat_exists = connection.execute( + """ + SELECT id + FROM historical_player_match_stats + WHERE historical_match_id = ? AND historical_player_id = ? + """, + (match_id_row["id"], player_id), + ).fetchone() + connection.execute( + """ + INSERT INTO historical_player_match_stats ( + historical_match_id, + historical_player_id, + match_player_ref, + team_side, + level, + kills, + deaths, + teamkills, + time_seconds, + kills_per_minute, + deaths_per_minute, + kill_death_ratio, + combat, + offense, + defense, + support + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(historical_match_id, historical_player_id) DO UPDATE SET + match_player_ref = excluded.match_player_ref, + team_side = excluded.team_side, + level = excluded.level, + kills = excluded.kills, + deaths = excluded.deaths, + teamkills = excluded.teamkills, + time_seconds = excluded.time_seconds, + kills_per_minute = excluded.kills_per_minute, + deaths_per_minute = excluded.deaths_per_minute, + kill_death_ratio = excluded.kill_death_ratio, + combat = excluded.combat, + offense = excluded.offense, + defense = excluded.defense, + support = excluded.support, + updated_at = CURRENT_TIMESTAMP + """, + ( + match_id_row["id"], + player_id, + _stringify(player_payload.get("id")), + _stringify(_get_nested(player_payload, "team", "side")), + _coerce_int(player_payload.get("level")), + _coerce_int(player_payload.get("kills")), + _coerce_int(player_payload.get("deaths")), + _coerce_int(player_payload.get("teamkills")), + _coerce_int(player_payload.get("time_seconds")), + _coerce_float(player_payload.get("kills_per_minute")), + _coerce_float(player_payload.get("deaths_per_minute")), + _coerce_float(player_payload.get("kill_death_ratio")), + _coerce_int(player_payload.get("combat")), + _coerce_int(player_payload.get("offense")), + _coerce_int(player_payload.get("defense")), + _coerce_int(player_payload.get("support")), + ), + ) + if stat_exists is None: + player_rows_inserted += 1 + else: + player_rows_updated += 1 + + return { + "matches_inserted": 0 if match_exists else 1, + "matches_updated": 1 if match_exists else 0, + "player_rows_inserted": player_rows_inserted, + "player_rows_updated": player_rows_updated, + } + + +def get_refresh_cutoff_for_server( + server_slug: str, + *, + overlap_hours: int | None = None, + db_path: Path | None = None, +) -> str | None: + """Return the timestamp used to stop incremental scans once older pages appear.""" + resolved_overlap_hours = ( + get_historical_refresh_overlap_hours() + if overlap_hours is None + else overlap_hours + ) + if resolved_overlap_hours < 0: + raise ValueError("overlap_hours must be zero or positive.") + resolved_path = initialize_historical_storage(db_path=db_path) + with _connect(resolved_path) as connection: + server_row = _resolve_historical_server(connection, server_slug) + row = connection.execute( + """ + SELECT COALESCE(MAX(ended_at), MAX(started_at), MAX(created_at_source)) AS latest_seen_at + FROM historical_matches + WHERE historical_server_id = ? + """, + (server_row["id"],), + ).fetchone() + latest_seen_at = _stringify(row["latest_seen_at"] if row else None) + if not latest_seen_at: + return None + + cutoff = _parse_timestamp(latest_seen_at) - timedelta(hours=resolved_overlap_hours) + return cutoff.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def list_recent_historical_matches( + *, + server_slug: str | None = None, + limit: int = 20, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """Return recent persisted matches grouped for the historical API layer.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import list_recent_scoreboard_matches + + return list_recent_scoreboard_matches(server_slug=server_slug, limit=limit) + resolved_path = initialize_historical_storage(db_path=db_path) + where_clause = "" + params: list[object] = [] + if server_slug and not _is_all_servers_selector(server_slug): + where_clause = "WHERE historical_servers.slug = ?" + params.append(server_slug) + params.append(limit) + + with _connect(resolved_path) as connection: + rows = connection.execute( + f""" + SELECT + historical_servers.slug AS server_slug, + historical_servers.display_name AS server_name, + historical_matches.external_match_id, + historical_matches.started_at, + historical_matches.ended_at, + historical_matches.map_pretty_name, + historical_matches.map_name, + historical_matches.allied_score, + historical_matches.axis_score, + historical_matches.raw_payload_ref, + historical_servers.slug, + historical_servers.scoreboard_base_url, + COUNT(historical_player_match_stats.id) AS player_count + FROM historical_matches + INNER JOIN historical_servers + ON historical_servers.id = historical_matches.historical_server_id + LEFT JOIN historical_player_match_stats + ON historical_player_match_stats.historical_match_id = historical_matches.id + {where_clause} + GROUP BY historical_matches.id + ORDER BY COALESCE(historical_matches.ended_at, historical_matches.started_at) DESC + LIMIT ? + """, + params, + ).fetchall() + items: list[dict[str, object]] = [] + for row in rows: + items.append( + { + "server": { + "slug": row["server_slug"], + "name": row["server_name"], + }, + "match_id": row["external_match_id"], + "started_at": row["started_at"], + "ended_at": row["ended_at"], + "closed_at": row["ended_at"] or row["started_at"], + "map": { + "name": row["map_name"], + "pretty_name": row["map_pretty_name"] or row["map_name"], + }, + "result": { + "allied_score": _coerce_int(row["allied_score"]), + "axis_score": _coerce_int(row["axis_score"]), + "winner": _resolve_match_winner( + row["allied_score"], + row["axis_score"], + ), + }, + "player_count": int(row["player_count"] or 0), + "match_url": _resolve_safe_match_url( + row["raw_payload_ref"], + row["server_slug"], + ), + } + ) + return items + + +def get_historical_match_detail( + *, + server_slug: str, + match_id: str, + db_path: Path | None = None, +) -> dict[str, object] | None: + """Return one persisted public-scoreboard match detail for the historical API layer.""" + normalized_server_slug = _stringify(server_slug) + normalized_match_id = _stringify(match_id) + if not normalized_server_slug or not normalized_match_id: + return None + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import get_scoreboard_match_detail + + return get_scoreboard_match_detail( + server_slug=normalized_server_slug, + match_id=normalized_match_id, + ) + resolved_path = initialize_historical_storage(db_path=db_path) + with _connect(resolved_path) as connection: + row = connection.execute( + """ + SELECT + historical_matches.id AS match_pk, + historical_servers.slug AS server_slug, + historical_servers.display_name AS server_name, + historical_matches.external_match_id, + historical_matches.started_at, + historical_matches.ended_at, + historical_matches.map_pretty_name, + historical_matches.map_name, + historical_matches.allied_score, + historical_matches.axis_score, + historical_matches.raw_payload_ref, + historical_servers.slug, + historical_servers.scoreboard_base_url, + COUNT(historical_player_match_stats.id) AS player_count, + SUM(COALESCE(historical_player_match_stats.time_seconds, 0)) AS total_time_seconds + FROM historical_matches + INNER JOIN historical_servers + ON historical_servers.id = historical_matches.historical_server_id + LEFT JOIN historical_player_match_stats + ON historical_player_match_stats.historical_match_id = historical_matches.id + WHERE historical_servers.slug = ? + AND historical_matches.external_match_id = ? + GROUP BY historical_matches.id + LIMIT 1 + """, + (normalized_server_slug, normalized_match_id), + ).fetchone() + player_rows = [] + if row is not None: + player_rows = connection.execute( + """ + SELECT + historical_players.display_name, + historical_players.stable_player_key, + historical_players.steam_id, + historical_player_match_stats.team_side, + historical_player_match_stats.level, + historical_player_match_stats.kills, + historical_player_match_stats.deaths, + historical_player_match_stats.teamkills, + historical_player_match_stats.combat, + historical_player_match_stats.offense, + historical_player_match_stats.defense, + historical_player_match_stats.support, + historical_player_match_stats.time_seconds + FROM historical_player_match_stats + INNER JOIN historical_players + ON historical_players.id = historical_player_match_stats.historical_player_id + WHERE historical_player_match_stats.historical_match_id = ? + ORDER BY + COALESCE(historical_player_match_stats.kills, 0) DESC, + historical_players.display_name ASC + """, + (row["match_pk"],), + ).fetchall() + if row is None: + return None + started_at = row["started_at"] + ended_at = row["ended_at"] + return { + "server": { + "slug": row["server_slug"], + "name": row["server_name"], + }, + "match_id": row["external_match_id"], + "started_at": started_at, + "ended_at": ended_at, + "closed_at": ended_at or started_at, + "duration_seconds": _calculate_match_duration_seconds(started_at, ended_at), + "map": { + "name": row["map_name"], + "pretty_name": row["map_pretty_name"] or row["map_name"], + }, + "result": { + "allied_score": _coerce_int(row["allied_score"]), + "axis_score": _coerce_int(row["axis_score"]), + "winner": _resolve_match_winner( + row["allied_score"], + row["axis_score"], + ), + }, + "player_count": int(row["player_count"] or 0), + "total_time_seconds": _coerce_int(row["total_time_seconds"]), + "players": [ + { + "name": player_row["display_name"], + "stable_player_key": player_row["stable_player_key"], + "team_side": player_row["team_side"], + **build_external_player_profile_fields(steam_id=player_row["steam_id"]), + "level": _coerce_int(player_row["level"]), + "kills": _coerce_int(player_row["kills"]), + "deaths": _coerce_int(player_row["deaths"]), + "teamkills": _coerce_int(player_row["teamkills"]), + "combat": _coerce_int(player_row["combat"]), + "offense": _coerce_int(player_row["offense"]), + "defense": _coerce_int(player_row["defense"]), + "support": _coerce_int(player_row["support"]), + "time_seconds": _coerce_int(player_row["time_seconds"]), + } + for player_row in player_rows + ], + "capture_basis": "public-scoreboard-match", + "match_url": _resolve_safe_match_url( + row["raw_payload_ref"], + row["server_slug"], + ), + } + + +def list_historical_server_summaries( + *, + server_slug: str | None = None, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """Return aggregate historical metrics per server.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import list_scoreboard_server_summaries + + return list_scoreboard_server_summaries(server_slug=server_slug) + resolved_path = initialize_historical_storage(db_path=db_path) + if _is_all_servers_selector(server_slug): + return [_build_all_servers_summary(db_path=resolved_path)] + + where_clause = "" + params: list[object] = [] + if server_slug: + where_clause = "WHERE historical_servers.slug = ?" + params.append(server_slug) + + with _connect(resolved_path) as connection: + summary_rows = connection.execute( + f""" + SELECT + historical_servers.slug AS server_slug, + historical_servers.display_name AS server_name, + COUNT(DISTINCT historical_matches.id) AS matches_count, + COUNT(DISTINCT historical_players.id) AS unique_players, + COALESCE(SUM(historical_player_match_stats.kills), 0) AS total_kills, + COUNT(DISTINCT COALESCE(historical_matches.map_pretty_name, historical_matches.map_name)) AS map_count, + MIN(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS first_match_at, + MAX(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS last_match_at + FROM historical_servers + LEFT JOIN historical_matches + ON historical_matches.historical_server_id = historical_servers.id + LEFT JOIN historical_player_match_stats + ON historical_player_match_stats.historical_match_id = historical_matches.id + LEFT JOIN historical_players + ON historical_players.id = historical_player_match_stats.historical_player_id + {where_clause} + GROUP BY historical_servers.id + ORDER BY historical_servers.server_number ASC, historical_servers.slug ASC + """, + params, + ).fetchall() + + map_rows = connection.execute( + f""" + SELECT + historical_servers.slug AS server_slug, + COALESCE(historical_matches.map_pretty_name, historical_matches.map_name, 'Mapa no disponible') AS map_name, + COUNT(*) AS matches_count + FROM historical_matches + INNER JOIN historical_servers + ON historical_servers.id = historical_matches.historical_server_id + {where_clause} + GROUP BY historical_servers.slug, COALESCE(historical_matches.map_pretty_name, historical_matches.map_name, 'Mapa no disponible') + ORDER BY historical_servers.slug ASC, matches_count DESC, map_name ASC + """, + params, + ).fetchall() + + progress_by_server = { + item["server"]["slug"]: item + for item in list_historical_backfill_progress( + server_slug=server_slug, + db_path=resolved_path, + ) + } + top_maps_by_server: dict[str, list[dict[str, object]]] = {} + for row in map_rows: + server_key = str(row["server_slug"]) + top_maps_by_server.setdefault(server_key, []) + if len(top_maps_by_server[server_key]) >= 3: + continue + top_maps_by_server[server_key].append( + { + "map_name": row["map_name"], + "matches_count": int(row["matches_count"] or 0), + } + ) + + items: list[dict[str, object]] = [] + for row in summary_rows: + matches_count = int(row["matches_count"] or 0) + first_match_at = _stringify(row["first_match_at"]) + last_match_at = _stringify(row["last_match_at"]) + coverage_days = _calculate_coverage_days(first_match_at, last_match_at) + progress = progress_by_server.get(str(row["server_slug"]), {}) + discovered_total_matches = _coerce_int(progress.get("discovered_total_matches")) + items.append( + { + "server": { + "slug": row["server_slug"], + "name": row["server_name"], + }, + "matches_count": matches_count, + "imported_matches_count": matches_count, + "unique_players": int(row["unique_players"] or 0), + "total_kills": int(row["total_kills"] or 0), + "map_count": int(row["map_count"] or 0), + "top_maps": top_maps_by_server.get(str(row["server_slug"]), []), + "coverage": { + "basis": "persisted-import", + "status": _classify_coverage_status(matches_count, coverage_days), + "imported_matches_count": matches_count, + "discovered_total_matches": discovered_total_matches, + "first_match_at": first_match_at, + "last_match_at": last_match_at, + "coverage_days": coverage_days, + }, + "backfill": { + "mode": progress.get("mode", "bootstrap"), + "next_page": _coerce_int(progress.get("next_page")) or 1, + "last_completed_page": _coerce_int(progress.get("last_completed_page")), + "discovered_total_matches": discovered_total_matches, + "discovered_total_pages": _coerce_int(progress.get("discovered_total_pages")), + "remaining_matches_estimate": ( + max(discovered_total_matches - matches_count, 0) + if discovered_total_matches is not None + else None + ), + "archive_exhausted": bool(progress.get("archive_exhausted")), + "last_run": progress.get("last_run"), + }, + "time_range": { + "start": first_match_at, + "end": last_match_at, + }, + } + ) + return items + + +def list_historical_coverage_report( + *, + server_slug: str | None = None, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """Return persisted coverage metrics used to validate historical bootstrap depth.""" + resolved_path = initialize_historical_storage(db_path=db_path) + where_clause = "" + params: list[object] = [] + if server_slug: + where_clause = "WHERE historical_servers.slug = ?" + params.append(server_slug) + + with _connect(resolved_path) as connection: + rows = connection.execute( + f""" + SELECT + historical_servers.slug AS server_slug, + historical_servers.display_name AS server_name, + historical_servers.scoreboard_base_url AS scoreboard_base_url, + historical_servers.server_number AS server_number, + COUNT(DISTINCT historical_matches.id) AS imported_matches_count, + COUNT(DISTINCT historical_players.id) AS unique_players, + COUNT(DISTINCT historical_player_match_stats.id) AS player_stat_rows, + MIN(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS first_match_at, + MAX(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS last_match_at + FROM historical_servers + LEFT JOIN historical_matches + ON historical_matches.historical_server_id = historical_servers.id + LEFT JOIN historical_player_match_stats + ON historical_player_match_stats.historical_match_id = historical_matches.id + LEFT JOIN historical_players + ON historical_players.id = historical_player_match_stats.historical_player_id + {where_clause} + GROUP BY historical_servers.id + ORDER BY historical_servers.server_number ASC, historical_servers.slug ASC + """, + params, + ).fetchall() + + items: list[dict[str, object]] = [] + progress_by_server = { + item["server"]["slug"]: item + for item in list_historical_backfill_progress( + server_slug=server_slug, + db_path=resolved_path, + ) + } + for row in rows: + first_match_at = _stringify(row["first_match_at"]) + last_match_at = _stringify(row["last_match_at"]) + progress = progress_by_server.get(str(row["server_slug"]), {}) + items.append( + { + "server": { + "slug": row["server_slug"], + "name": row["server_name"], + "server_number": row["server_number"], + "scoreboard_base_url": row["scoreboard_base_url"], + }, + "imported_matches_count": int(row["imported_matches_count"] or 0), + "unique_players": int(row["unique_players"] or 0), + "player_stat_rows": int(row["player_stat_rows"] or 0), + "first_match_at": first_match_at, + "last_match_at": last_match_at, + "coverage_days": _calculate_coverage_days(first_match_at, last_match_at), + "backfill": { + "next_page": _coerce_int(progress.get("next_page")) or 1, + "last_completed_page": _coerce_int(progress.get("last_completed_page")), + "discovered_total_matches": _coerce_int( + progress.get("discovered_total_matches") + ), + "discovered_total_pages": _coerce_int( + progress.get("discovered_total_pages") + ), + "archive_exhausted": bool(progress.get("archive_exhausted")), + "last_run": progress.get("last_run"), + }, + } + ) + return items + + +def get_historical_player_profile( + player_id: str, + *, + db_path: Path | None = None, +) -> dict[str, object] | None: + """Return aggregate historical metrics for one player identity.""" + resolved_player_id = player_id.strip() + if not resolved_player_id: + return None + + resolved_path = initialize_historical_storage(db_path=db_path) + with _connect(resolved_path) as connection: + player_row = connection.execute( + """ + SELECT + historical_players.id, + historical_players.stable_player_key, + historical_players.display_name, + historical_players.steam_id, + historical_players.source_player_id, + COUNT(DISTINCT historical_matches.id) AS matches_count, + COALESCE(SUM(historical_player_match_stats.kills), 0) AS total_kills, + MIN(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS first_match_at, + MAX(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS last_match_at + FROM historical_players + LEFT JOIN historical_player_match_stats + ON historical_player_match_stats.historical_player_id = historical_players.id + LEFT JOIN historical_matches + ON historical_matches.id = historical_player_match_stats.historical_match_id + WHERE historical_players.stable_player_key = ? + OR historical_players.steam_id = ? + OR historical_players.source_player_id = ? + GROUP BY historical_players.id + ORDER BY historical_players.display_name ASC + LIMIT 1 + """, + (resolved_player_id, resolved_player_id, resolved_player_id), + ).fetchone() + if player_row is None: + return None + + server_rows = connection.execute( + """ + SELECT + historical_servers.slug AS server_slug, + historical_servers.display_name AS server_name, + COUNT(DISTINCT historical_matches.id) AS matches_count, + COALESCE(SUM(historical_player_match_stats.kills), 0) AS total_kills, + MIN(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS first_match_at, + MAX(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS last_match_at + FROM historical_player_match_stats + INNER JOIN historical_matches + ON historical_matches.id = historical_player_match_stats.historical_match_id + INNER JOIN historical_servers + ON historical_servers.id = historical_matches.historical_server_id + WHERE historical_player_match_stats.historical_player_id = ? + GROUP BY historical_servers.id + ORDER BY total_kills DESC, historical_servers.server_number ASC, historical_servers.slug ASC + """, + (player_row["id"],), + ).fetchall() + + return { + "player": { + "stable_player_key": player_row["stable_player_key"], + "name": player_row["display_name"], + "steam_id": player_row["steam_id"], + "source_player_id": player_row["source_player_id"], + }, + "matches_count": int(player_row["matches_count"] or 0), + "total_kills": int(player_row["total_kills"] or 0), + "time_range": { + "start": player_row["first_match_at"], + "end": player_row["last_match_at"], + }, + "servers": [ + { + "server": { + "slug": row["server_slug"], + "name": row["server_name"], + }, + "matches_count": int(row["matches_count"] or 0), + "total_kills": int(row["total_kills"] or 0), + "time_range": { + "start": row["first_match_at"], + "end": row["last_match_at"], + }, + } + for row in server_rows + ], + } + + +def list_weekly_leaderboard( + *, + limit: int = 10, + server_id: str | None = None, + metric: str = "kills", + db_path: Path | None = None, +) -> dict[str, object]: + """Return ranked weekly leaderboard totals from persisted historical match stats.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import list_scoreboard_leaderboard + + return list_scoreboard_leaderboard( + timeframe="weekly", + metric=metric, + server_id=server_id, + limit=limit, + ) + resolved_path = initialize_historical_storage(db_path=db_path) + aggregate_all_servers = _is_all_servers_selector(server_id) + current_time = datetime.now(timezone.utc) + current_week_start = _start_of_week(current_time) + previous_week_start = current_week_start - timedelta(days=DEFAULT_WEEKLY_WINDOW_DAYS) + normalized_metric = metric.strip() if isinstance(metric, str) else "" + if normalized_metric not in SUPPORTED_WEEKLY_LEADERBOARD_METRICS: + raise ValueError(f"Unsupported weekly leaderboard metric: {metric}") + + weekly_window = _select_weekly_window( + server_id=server_id, + current_time=current_time, + current_week_start=current_week_start, + previous_week_start=previous_week_start, + db_path=resolved_path, + ) + window_start = weekly_window["window_start"] + window_end = weekly_window["window_end"] + where_clauses = [ + "historical_matches.ended_at IS NOT NULL", + "historical_matches.ended_at >= ?", + "historical_matches.ended_at < ?", + ] + params: list[object] = [ + window_start.isoformat().replace("+00:00", "Z"), + window_end.isoformat().replace("+00:00", "Z"), + ] + if server_id and not aggregate_all_servers: + normalized_server_id = server_id.strip() + where_clauses.append( + "(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)" + ) + params.extend([normalized_server_id, normalized_server_id]) + + server_slug_expression = ( + f"'{ALL_SERVERS_SLUG}'" + if aggregate_all_servers + else "historical_servers.slug" + ) + server_name_expression = ( + f"'{ALL_SERVERS_DISPLAY_NAME}'" + if aggregate_all_servers + else "historical_servers.display_name" + ) + partition_expression = ( + f"'{ALL_SERVERS_SLUG}'" + if aggregate_all_servers + else "historical_servers.slug" + ) + group_by_expression = ( + "historical_players.id" + if aggregate_all_servers + else "historical_servers.slug, historical_players.id" + ) + + metric_sum_expression = { + "kills": "COALESCE(SUM(historical_player_match_stats.kills), 0)", + "deaths": "COALESCE(SUM(historical_player_match_stats.deaths), 0)", + "support": "COALESCE(SUM(historical_player_match_stats.support), 0)", + "matches_over_100_kills": ( + "COALESCE(SUM(CASE WHEN COALESCE(historical_player_match_stats.kills, 0) >= 100 " + "THEN 1 ELSE 0 END), 0)" + ), + }[normalized_metric] + + with _connect(resolved_path) as connection: + rows = connection.execute( + f""" + WITH ranked_players AS ( + SELECT + {server_slug_expression} AS server_slug, + {server_name_expression} AS server_name, + historical_players.stable_player_key, + historical_players.display_name AS player_name, + historical_players.steam_id, + COUNT(DISTINCT historical_matches.id) AS matches_count, + {metric_sum_expression} AS metric_value, + ROW_NUMBER() OVER ( + PARTITION BY {partition_expression} + ORDER BY + {metric_sum_expression} DESC, + COUNT(DISTINCT historical_matches.id) ASC, + historical_players.display_name ASC + ) AS ranking_position + FROM historical_player_match_stats + INNER JOIN historical_matches + ON historical_matches.id = historical_player_match_stats.historical_match_id + INNER JOIN historical_servers + ON historical_servers.id = historical_matches.historical_server_id + INNER JOIN historical_players + ON historical_players.id = historical_player_match_stats.historical_player_id + WHERE {" AND ".join(where_clauses)} + GROUP BY {group_by_expression} + ) + SELECT * + FROM ranked_players + WHERE ranking_position <= ? + ORDER BY server_slug ASC, ranking_position ASC + """, + [*params, limit], + ).fetchall() + + items: list[dict[str, object]] = [] + for row in rows: + items.append( + { + "server": { + "slug": row["server_slug"], + "name": row["server_name"], + }, + "time_range": { + "start": window_start.isoformat().replace("+00:00", "Z"), + "end": window_end.isoformat().replace("+00:00", "Z"), + "window_days": DEFAULT_WEEKLY_WINDOW_DAYS, + }, + "player": { + "stable_player_key": row["stable_player_key"], + "name": row["player_name"], + "steam_id": row["steam_id"], + }, + "metric": normalized_metric, + "ranking_position": int(row["ranking_position"]), + "metric_value": int(row["metric_value"] or 0), + "matches_considered": int(row["matches_count"] or 0), + } + ) + + return { + "metric": normalized_metric, + "window_start": window_start.isoformat().replace("+00:00", "Z"), + "window_end": window_end.isoformat().replace("+00:00", "Z"), + "window_days": DEFAULT_WEEKLY_WINDOW_DAYS, + "window_kind": weekly_window["window_kind"], + "window_label": weekly_window["window_label"], + "uses_fallback": weekly_window["uses_fallback"], + "selection_reason": weekly_window["selection_reason"], + "current_week_start": current_week_start.isoformat().replace("+00:00", "Z"), + "current_week_closed_matches": weekly_window["current_week_closed_matches"], + "previous_week_closed_matches": weekly_window["previous_week_closed_matches"], + "sufficient_sample": { + "minimum_closed_matches": weekly_window["minimum_closed_matches"], + "current_week_closed_matches": weekly_window["current_week_closed_matches"], + "current_week_has_sufficient_sample": weekly_window["current_week_has_sufficient_sample"], + "is_early_week": weekly_window["is_early_week"], + "fallback_max_weekday": weekly_window["fallback_max_weekday"], + }, + "items": items, + } + + +def list_weekly_top_kills( + *, + limit: int = 10, + server_id: str | None = None, + db_path: Path | None = None, +) -> dict[str, object]: + """Return ranked weekly kill totals from persisted historical match stats.""" + result = list_weekly_leaderboard( + limit=limit, + server_id=server_id, + metric="kills", + db_path=db_path, + ) + items = [] + for item in result["items"]: + legacy_item = dict(item) + legacy_item["weekly_kills"] = legacy_item["metric_value"] + items.append(legacy_item) + + return { + "metric": "kills", + "window_start": result["window_start"], + "window_end": result["window_end"], + "items": items, + } + + +def list_monthly_leaderboard( + *, + limit: int = 10, + server_id: str | None = None, + metric: str = "kills", + db_path: Path | None = None, +) -> dict[str, object]: + """Return ranked monthly leaderboard totals from persisted historical match stats.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import list_scoreboard_leaderboard + + return list_scoreboard_leaderboard( + timeframe="monthly", + metric=metric, + server_id=server_id, + limit=limit, + ) + resolved_path = initialize_historical_storage(db_path=db_path) + aggregate_all_servers = _is_all_servers_selector(server_id) + current_time = datetime.now(timezone.utc) + current_month_start = _start_of_month(current_time) + previous_month_start = _start_of_previous_month(current_month_start) + normalized_metric = metric.strip() if isinstance(metric, str) else "" + if normalized_metric not in SUPPORTED_MONTHLY_LEADERBOARD_METRICS: + raise ValueError(f"Unsupported monthly leaderboard metric: {metric}") + + monthly_window = _select_monthly_window( + server_id=server_id, + current_time=current_time, + current_month_start=current_month_start, + previous_month_start=previous_month_start, + db_path=resolved_path, + ) + window_start = monthly_window["window_start"] + window_end = monthly_window["window_end"] + where_clauses = [ + "historical_matches.ended_at IS NOT NULL", + "historical_matches.ended_at >= ?", + "historical_matches.ended_at < ?", + ] + params: list[object] = [ + window_start.isoformat().replace("+00:00", "Z"), + window_end.isoformat().replace("+00:00", "Z"), + ] + if server_id and not aggregate_all_servers: + normalized_server_id = server_id.strip() + where_clauses.append( + "(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)" + ) + params.extend([normalized_server_id, normalized_server_id]) + + server_slug_expression = ( + f"'{ALL_SERVERS_SLUG}'" + if aggregate_all_servers + else "historical_servers.slug" + ) + server_name_expression = ( + f"'{ALL_SERVERS_DISPLAY_NAME}'" + if aggregate_all_servers + else "historical_servers.display_name" + ) + partition_expression = ( + f"'{ALL_SERVERS_SLUG}'" + if aggregate_all_servers + else "historical_servers.slug" + ) + group_by_expression = ( + "historical_players.id" + if aggregate_all_servers + else "historical_servers.slug, historical_players.id" + ) + + metric_sum_expression = { + "kills": "COALESCE(SUM(historical_player_match_stats.kills), 0)", + "deaths": "COALESCE(SUM(historical_player_match_stats.deaths), 0)", + "support": "COALESCE(SUM(historical_player_match_stats.support), 0)", + "matches_over_100_kills": ( + "COALESCE(SUM(CASE WHEN COALESCE(historical_player_match_stats.kills, 0) >= 100 " + "THEN 1 ELSE 0 END), 0)" + ), + }[normalized_metric] + + with _connect(resolved_path) as connection: + rows = connection.execute( + f""" + WITH ranked_players AS ( + SELECT + {server_slug_expression} AS server_slug, + {server_name_expression} AS server_name, + historical_players.stable_player_key, + historical_players.display_name AS player_name, + historical_players.steam_id, + COUNT(DISTINCT historical_matches.id) AS matches_count, + {metric_sum_expression} AS metric_value, + ROW_NUMBER() OVER ( + PARTITION BY {partition_expression} + ORDER BY + {metric_sum_expression} DESC, + COUNT(DISTINCT historical_matches.id) ASC, + historical_players.display_name ASC + ) AS ranking_position + FROM historical_player_match_stats + INNER JOIN historical_matches + ON historical_matches.id = historical_player_match_stats.historical_match_id + INNER JOIN historical_servers + ON historical_servers.id = historical_matches.historical_server_id + INNER JOIN historical_players + ON historical_players.id = historical_player_match_stats.historical_player_id + WHERE {" AND ".join(where_clauses)} + GROUP BY {group_by_expression} + ) + SELECT * + FROM ranked_players + WHERE ranking_position <= ? + ORDER BY server_slug ASC, ranking_position ASC + """, + [*params, limit], + ).fetchall() + + window_days = _calculate_window_days(window_start=window_start, window_end=window_end) + items: list[dict[str, object]] = [] + for row in rows: + items.append( + { + "server": { + "slug": row["server_slug"], + "name": row["server_name"], + }, + "time_range": { + "start": window_start.isoformat().replace("+00:00", "Z"), + "end": window_end.isoformat().replace("+00:00", "Z"), + "window_days": window_days, + }, + "player": { + "stable_player_key": row["stable_player_key"], + "name": row["player_name"], + "steam_id": row["steam_id"], + }, + "metric": normalized_metric, + "ranking_position": int(row["ranking_position"]), + "metric_value": int(row["metric_value"] or 0), + "matches_considered": int(row["matches_count"] or 0), + } + ) + + return { + "timeframe": "monthly", + "metric": normalized_metric, + "window_start": window_start.isoformat().replace("+00:00", "Z"), + "window_end": window_end.isoformat().replace("+00:00", "Z"), + "window_days": window_days, + "window_kind": monthly_window["window_kind"], + "window_label": monthly_window["window_label"], + "uses_fallback": monthly_window["uses_fallback"], + "selection_reason": monthly_window["selection_reason"], + "current_month_start": current_month_start.isoformat().replace("+00:00", "Z"), + "current_month_closed_matches": monthly_window["current_month_closed_matches"], + "previous_month_closed_matches": monthly_window["previous_month_closed_matches"], + "sufficient_sample": { + "minimum_closed_matches": monthly_window["minimum_closed_matches"], + "current_month_closed_matches": monthly_window["current_month_closed_matches"], + "current_month_has_sufficient_sample": monthly_window["current_month_has_sufficient_sample"], + "is_early_month": monthly_window["is_early_month"], + }, + "items": items, + } + + +def list_monthly_mvp_ranking( + *, + limit: int = 10, + server_id: str | None = None, + db_path: Path | None = None, +) -> dict[str, object]: + """Return the monthly MVP V1 ranking built from persisted historical totals.""" + resolved_path = initialize_historical_storage(db_path=db_path) + aggregate_all_servers = _is_all_servers_selector(server_id) + current_time = datetime.now(timezone.utc) + current_month_start = _start_of_month(current_time) + previous_month_start = _start_of_previous_month(current_month_start) + monthly_window = _select_monthly_window( + server_id=server_id, + current_time=current_time, + current_month_start=current_month_start, + previous_month_start=previous_month_start, + db_path=resolved_path, + ) + window_start = monthly_window["window_start"] + window_end = monthly_window["window_end"] + where_clauses = [ + "historical_matches.ended_at IS NOT NULL", + "historical_matches.ended_at >= ?", + "historical_matches.ended_at < ?", + ] + params: list[object] = [ + window_start.isoformat().replace("+00:00", "Z"), + window_end.isoformat().replace("+00:00", "Z"), + ] + if server_id and not aggregate_all_servers: + normalized_server_id = server_id.strip() + where_clauses.append( + "(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)" + ) + params.extend([normalized_server_id, normalized_server_id]) + + server_slug_expression = ( + f"'{ALL_SERVERS_SLUG}'" + if aggregate_all_servers + else "historical_servers.slug" + ) + server_name_expression = ( + f"'{ALL_SERVERS_DISPLAY_NAME}'" + if aggregate_all_servers + else "historical_servers.display_name" + ) + group_by_expression = ( + "historical_players.id" + if aggregate_all_servers + else "historical_servers.slug, historical_players.id" + ) + + with _connect(resolved_path) as connection: + rows = connection.execute( + f""" + SELECT + {server_slug_expression} AS server_slug, + {server_name_expression} AS server_name, + historical_players.stable_player_key, + historical_players.display_name AS player_name, + historical_players.steam_id, + COUNT(DISTINCT historical_matches.id) AS matches_count, + COALESCE(SUM(historical_player_match_stats.kills), 0) AS total_kills, + COALESCE(SUM(historical_player_match_stats.deaths), 0) AS total_deaths, + COALESCE(SUM(historical_player_match_stats.support), 0) AS total_support, + COALESCE(SUM(historical_player_match_stats.teamkills), 0) AS total_teamkills, + COALESCE(SUM(historical_player_match_stats.time_seconds), 0) AS total_time_seconds + FROM historical_player_match_stats + INNER JOIN historical_matches + ON historical_matches.id = historical_player_match_stats.historical_match_id + INNER JOIN historical_servers + ON historical_servers.id = historical_matches.historical_server_id + INNER JOIN historical_players + ON historical_players.id = historical_player_match_stats.historical_player_id + WHERE {" AND ".join(where_clauses)} + GROUP BY {group_by_expression} + """, + params, + ).fetchall() + + ranking_result = build_monthly_mvp_rankings( + [dict(row) for row in rows], + limit=limit, + ) + window_days = _calculate_window_days(window_start=window_start, window_end=window_end) + for item in ranking_result["items"]: + item["time_range"] = { + "start": window_start.isoformat().replace("+00:00", "Z"), + "end": window_end.isoformat().replace("+00:00", "Z"), + "window_days": window_days, + } + + return { + "timeframe": "monthly", + "metric": "mvp", + "ranking_version": ranking_result["ranking_version"], + "window_start": window_start.isoformat().replace("+00:00", "Z"), + "window_end": window_end.isoformat().replace("+00:00", "Z"), + "window_days": window_days, + "window_kind": monthly_window["window_kind"], + "window_label": monthly_window["window_label"], + "uses_fallback": monthly_window["uses_fallback"], + "selection_reason": monthly_window["selection_reason"], + "current_month_start": current_month_start.isoformat().replace("+00:00", "Z"), + "current_month_closed_matches": monthly_window["current_month_closed_matches"], + "previous_month_closed_matches": monthly_window["previous_month_closed_matches"], + "sufficient_sample": { + "minimum_closed_matches": monthly_window["minimum_closed_matches"], + "current_month_closed_matches": monthly_window["current_month_closed_matches"], + "current_month_has_sufficient_sample": monthly_window["current_month_has_sufficient_sample"], + "is_early_month": monthly_window["is_early_month"], + }, + "eligibility": ranking_result["eligibility"], + "eligible_players_count": ranking_result["eligible_players_count"], + "items": ranking_result["items"], + } + + +def list_monthly_mvp_v2_ranking( + *, + limit: int = 10, + server_id: str | None = None, + db_path: Path | None = None, +) -> dict[str, object]: + """Return the monthly MVP V2 ranking built from monthly totals plus V2 signals.""" + resolved_path = initialize_historical_storage(db_path=db_path) + aggregate_all_servers = _is_all_servers_selector(server_id) + current_time = datetime.now(timezone.utc) + current_month_start = _start_of_month(current_time) + previous_month_start = _start_of_previous_month(current_month_start) + monthly_window = _select_monthly_window( + server_id=server_id, + current_time=current_time, + current_month_start=current_month_start, + previous_month_start=previous_month_start, + db_path=resolved_path, + ) + window_start = monthly_window["window_start"] + window_end = monthly_window["window_end"] + month_key = window_start.strftime("%Y-%m") + event_coverage = _get_monthly_player_event_coverage( + server_id=server_id, + month_key=month_key, + db_path=resolved_path, + ) + window_days = _calculate_window_days(window_start=window_start, window_end=window_end) + + empty_result = { + "timeframe": "monthly", + "metric": "mvp-v2", + "ranking_version": "v2", + "window_start": window_start.isoformat().replace("+00:00", "Z"), + "window_end": window_end.isoformat().replace("+00:00", "Z"), + "window_days": window_days, + "window_kind": monthly_window["window_kind"], + "window_label": monthly_window["window_label"], + "uses_fallback": monthly_window["uses_fallback"], + "selection_reason": monthly_window["selection_reason"], + "current_month_start": current_month_start.isoformat().replace("+00:00", "Z"), + "current_month_closed_matches": monthly_window["current_month_closed_matches"], + "previous_month_closed_matches": monthly_window["previous_month_closed_matches"], + "sufficient_sample": { + "minimum_closed_matches": monthly_window["minimum_closed_matches"], + "current_month_closed_matches": monthly_window["current_month_closed_matches"], + "current_month_has_sufficient_sample": monthly_window["current_month_has_sufficient_sample"], + "is_early_month": monthly_window["is_early_month"], + }, + "event_coverage": event_coverage, + } + if not bool(event_coverage["ready"]): + return { + **empty_result, + "eligibility": None, + "eligible_players_count": 0, + "items": [], + } + + where_clauses = [ + "historical_matches.ended_at IS NOT NULL", + "historical_matches.ended_at >= ?", + "historical_matches.ended_at < ?", + ] + params: list[object] = [ + window_start.isoformat().replace("+00:00", "Z"), + window_end.isoformat().replace("+00:00", "Z"), + ] + if server_id and not aggregate_all_servers: + normalized_server_id = server_id.strip() + where_clauses.append( + "(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)" + ) + params.extend([normalized_server_id, normalized_server_id]) + + event_scope_sql, event_scope_params = _build_player_event_scope_sql(server_id) + server_slug_expression = ( + f"'{ALL_SERVERS_SLUG}'" + if aggregate_all_servers + else "historical_servers.slug" + ) + server_name_expression = ( + f"'{ALL_SERVERS_DISPLAY_NAME}'" + if aggregate_all_servers + else "historical_servers.display_name" + ) + group_by_expression = ( + "historical_players.id" + if aggregate_all_servers + else "historical_servers.slug, historical_players.id" + ) + + with _connect(resolved_path) as connection: + rows = connection.execute( + f""" + WITH most_killed_pairs AS ( + SELECT + killer_player_key AS stable_player_key, + victim_player_key, + COALESCE(SUM(event_value), 0) AS total_kills + FROM player_event_raw_ledger + WHERE event_type = 'player_kill_summary' + AND occurred_at IS NOT NULL + AND substr(CAST(occurred_at AS TEXT), 1, 7) = ? + AND {event_scope_sql} + AND killer_player_key IS NOT NULL + AND victim_player_key IS NOT NULL + GROUP BY killer_player_key, victim_player_key + ), + most_killed_by_player AS ( + SELECT + stable_player_key, + MAX(total_kills) AS most_killed_count + FROM most_killed_pairs + GROUP BY stable_player_key + ), + death_by_pairs AS ( + SELECT + victim_player_key AS stable_player_key, + killer_player_key, + COALESCE(SUM(event_value), 0) AS total_kills + FROM player_event_raw_ledger + WHERE event_type = 'player_death_summary' + AND occurred_at IS NOT NULL + AND substr(CAST(occurred_at AS TEXT), 1, 7) = ? + AND {event_scope_sql} + AND killer_player_key IS NOT NULL + AND victim_player_key IS NOT NULL + GROUP BY victim_player_key, killer_player_key + ), + death_by_player AS ( + SELECT + stable_player_key, + MAX(total_kills) AS death_by_count + FROM death_by_pairs + GROUP BY stable_player_key + ), + duel_pairs AS ( + SELECT + CASE + WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '') + THEN killer_player_key + ELSE victim_player_key + END AS player_a_key, + CASE + WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '') + THEN victim_player_key + ELSE killer_player_key + END AS player_b_key, + COALESCE( + SUM( + CASE + WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '') + THEN event_value + ELSE -event_value + END + ), + 0 + ) AS net_duel_value + FROM player_event_raw_ledger + WHERE event_type = 'player_kill_summary' + AND occurred_at IS NOT NULL + AND substr(CAST(occurred_at AS TEXT), 1, 7) = ? + AND {event_scope_sql} + AND killer_player_key IS NOT NULL + AND victim_player_key IS NOT NULL + GROUP BY + CASE + WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '') + THEN killer_player_key + ELSE victim_player_key + END, + CASE + WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '') + THEN victim_player_key + ELSE killer_player_key + END + ), + duel_player_values AS ( + SELECT + player_a_key AS stable_player_key, + CASE WHEN net_duel_value > 0 THEN net_duel_value ELSE 0 END AS positive_duel_value + FROM duel_pairs + UNION ALL + SELECT + player_b_key AS stable_player_key, + CASE WHEN net_duel_value < 0 THEN -net_duel_value ELSE 0 END AS positive_duel_value + FROM duel_pairs + ), + ranked_duel_values AS ( + SELECT + stable_player_key, + positive_duel_value, + ROW_NUMBER() OVER ( + PARTITION BY stable_player_key + ORDER BY positive_duel_value DESC + ) AS duel_rank + FROM duel_player_values + WHERE stable_player_key IS NOT NULL + AND positive_duel_value > 0 + ), + duel_control_by_player AS ( + SELECT + stable_player_key, + COALESCE(SUM(positive_duel_value), 0) AS duel_control_raw + FROM ranked_duel_values + WHERE duel_rank <= 3 + GROUP BY stable_player_key + ) + SELECT + {server_slug_expression} AS server_slug, + {server_name_expression} AS server_name, + historical_players.stable_player_key, + historical_players.display_name AS player_name, + historical_players.steam_id, + COUNT(DISTINCT historical_matches.id) AS matches_count, + COALESCE(SUM(historical_player_match_stats.kills), 0) AS total_kills, + COALESCE(SUM(historical_player_match_stats.deaths), 0) AS total_deaths, + COALESCE(SUM(historical_player_match_stats.support), 0) AS total_support, + COALESCE(SUM(historical_player_match_stats.teamkills), 0) AS total_teamkills, + COALESCE(SUM(historical_player_match_stats.time_seconds), 0) AS total_time_seconds, + COALESCE(MAX(most_killed_by_player.most_killed_count), 0) AS most_killed_count, + COALESCE(MAX(death_by_player.death_by_count), 0) AS death_by_count, + COALESCE(MAX(duel_control_by_player.duel_control_raw), 0) AS duel_control_raw + FROM historical_player_match_stats + INNER JOIN historical_matches + ON historical_matches.id = historical_player_match_stats.historical_match_id + INNER JOIN historical_servers + ON historical_servers.id = historical_matches.historical_server_id + INNER JOIN historical_players + ON historical_players.id = historical_player_match_stats.historical_player_id + LEFT JOIN most_killed_by_player + ON most_killed_by_player.stable_player_key = historical_players.stable_player_key + LEFT JOIN death_by_player + ON death_by_player.stable_player_key = historical_players.stable_player_key + LEFT JOIN duel_control_by_player + ON duel_control_by_player.stable_player_key = historical_players.stable_player_key + WHERE {" AND ".join(where_clauses)} + GROUP BY {group_by_expression} + """, + [ + month_key, + *event_scope_params, + month_key, + *event_scope_params, + month_key, + *event_scope_params, + *params, + ], + ).fetchall() + + ranking_result = build_monthly_mvp_v2_rankings( + [dict(row) for row in rows], + limit=limit, + ) + for item in ranking_result["items"]: + item["time_range"] = { + "start": window_start.isoformat().replace("+00:00", "Z"), + "end": window_end.isoformat().replace("+00:00", "Z"), + "window_days": window_days, + } + + return { + **empty_result, + "ranking_version": ranking_result["ranking_version"], + "eligibility": ranking_result["eligibility"], + "eligible_players_count": ranking_result["eligible_players_count"], + "items": ranking_result["items"], + } + + +def _get_monthly_player_event_coverage( + *, + server_id: str | None, + month_key: str, + db_path: Path, +) -> dict[str, object]: + scope_sql, scope_params = _build_player_event_scope_sql(server_id) + with _connect(db_path) as connection: + latest_row = connection.execute( + f""" + SELECT MAX(substr(CAST(occurred_at AS TEXT), 1, 7)) AS latest_month_key + FROM player_event_raw_ledger + WHERE occurred_at IS NOT NULL + AND {scope_sql} + """, + scope_params, + ).fetchone() + month_row = connection.execute( + f""" + SELECT + COUNT(*) AS event_count, + MIN(occurred_at) AS source_range_start, + MAX(occurred_at) AS source_range_end + FROM player_event_raw_ledger + WHERE occurred_at IS NOT NULL + AND substr(CAST(occurred_at AS TEXT), 1, 7) = ? + AND {scope_sql} + """, + [month_key, *scope_params], + ).fetchone() + latest_month_key = str(latest_row["latest_month_key"]) if latest_row and latest_row["latest_month_key"] else None + event_count = int(month_row["event_count"] or 0) if month_row else 0 + return { + "month_key": month_key, + "latest_month_key": latest_month_key, + "ready": bool(event_count > 0 and latest_month_key == month_key), + "event_count": event_count, + "source_range_start": month_row["source_range_start"] if month_row else None, + "source_range_end": month_row["source_range_end"] if month_row else None, + "selection_reason": ( + "month-key-aligned" + if event_count > 0 and latest_month_key == month_key + else "player-event-month-mismatch-or-missing" + ), + } + + +def _build_player_event_scope_sql(server_id: str | None) -> tuple[str, list[object]]: + if not server_id or _is_all_servers_selector(server_id): + return "1 = 1", [] + normalized_server_id = server_id.strip() + return "server_slug = ?", [normalized_server_id] + + +def _connect(db_path: Path) -> sqlite3.Connection: + return connect_sqlite_writer(db_path) + + +def _resolve_match_winner(allied_score: object, axis_score: object) -> str | None: + allied = _coerce_int(allied_score) + axis = _coerce_int(axis_score) + if allied is None or axis is None: + return None + if allied > axis: + return "allies" + if axis > allied: + return "axis" + return "draw" + + +def _has_legacy_historical_schema(connection: sqlite3.Connection) -> bool: + columns = { + str(row["name"]) + for row in connection.execute("PRAGMA table_info(historical_matches)").fetchall() + } + return bool(columns) and "historical_server_id" not in columns + + +def _rename_legacy_historical_tables(connection: sqlite3.Connection) -> None: + rename_plan = ( + ("historical_player_match_stats", "historical_player_match_stats_legacy"), + ("historical_players", "historical_players_legacy"), + ("historical_matches", "historical_matches_legacy"), + ) + for current_name, legacy_name in rename_plan: + table_exists = connection.execute( + """ + SELECT 1 + FROM sqlite_master + WHERE type = 'table' AND name = ? + """, + (current_name,), + ).fetchone() + if not table_exists: + continue + + legacy_exists = connection.execute( + """ + SELECT 1 + FROM sqlite_master + WHERE type = 'table' AND name = ? + """, + (legacy_name,), + ).fetchone() + if legacy_exists: + continue + + connection.execute(f"ALTER TABLE {current_name} RENAME TO {legacy_name}") + + +def _migrate_legacy_historical_data(connection: sqlite3.Connection) -> None: + matches_table = connection.execute( + """ + SELECT 1 + FROM sqlite_master + WHERE type = 'table' AND name = 'historical_matches_legacy' + """ + ).fetchone() + if not matches_table: + return + + player_map: dict[int, int] = {} + for row in connection.execute( + """ + SELECT id, source_player_ref, canonical_name, last_seen_name + FROM historical_players_legacy + ORDER BY id ASC + """ + ).fetchall(): + stable_player_key = _stringify(row["source_player_ref"]) or f"legacy-player:{row['id']}" + display_name = _stringify(row["last_seen_name"]) or _stringify(row["canonical_name"]) or "Unknown player" + now = _utc_now_iso() + connection.execute( + """ + INSERT INTO historical_players ( + stable_player_key, + display_name, + steam_id, + source_player_id, + first_seen_at, + last_seen_at + ) VALUES (?, ?, NULL, NULL, ?, ?) + ON CONFLICT(stable_player_key) DO UPDATE SET + display_name = excluded.display_name, + last_seen_at = excluded.last_seen_at, + updated_at = CURRENT_TIMESTAMP + """, + (stable_player_key, display_name, now, now), + ) + new_row = connection.execute( + "SELECT id FROM historical_players WHERE stable_player_key = ?", + (stable_player_key,), + ).fetchone() + if new_row is not None: + player_map[int(row["id"])] = int(new_row["id"]) + + match_map: dict[int, int] = {} + for row in connection.execute( + """ + SELECT * + FROM historical_matches_legacy + ORDER BY id ASC + """ + ).fetchall(): + server_slug = _stringify(row["external_server_id"]) or "comunidad-hispana-01" + server_row = _resolve_historical_server(connection, server_slug) + connection.execute( + """ + INSERT INTO historical_matches ( + historical_server_id, + external_match_id, + historical_map_id, + created_at_source, + started_at, + ended_at, + map_name, + map_pretty_name, + game_mode, + image_name, + allied_score, + axis_score, + last_seen_at, + raw_payload_ref + ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?) + ON CONFLICT(historical_server_id, external_match_id) DO UPDATE SET + started_at = excluded.started_at, + ended_at = excluded.ended_at, + map_name = excluded.map_name, + map_pretty_name = excluded.map_pretty_name, + game_mode = excluded.game_mode, + last_seen_at = excluded.last_seen_at, + raw_payload_ref = excluded.raw_payload_ref, + updated_at = CURRENT_TIMESTAMP + """, + ( + server_row["id"], + _stringify(row["source_match_ref"]) or f"legacy-match:{row['id']}", + _stringify(row["created_at"]), + _stringify(row["started_at"]), + _stringify(row["ended_at"]), + _stringify(row["map_name"]), + _stringify(row["map_name"]), + _stringify(row["mode_name"]), + _utc_now_iso(), + _stringify(row["source_url"]), + ), + ) + new_row = connection.execute( + """ + SELECT id + FROM historical_matches + WHERE historical_server_id = ? AND external_match_id = ? + """, + ( + server_row["id"], + _stringify(row["source_match_ref"]) or f"legacy-match:{row['id']}", + ), + ).fetchone() + if new_row is not None: + match_map[int(row["id"])] = int(new_row["id"]) + + for row in connection.execute( + """ + SELECT * + FROM historical_player_match_stats_legacy + ORDER BY id ASC + """ + ).fetchall(): + new_match_id = match_map.get(int(row["match_id"])) + new_player_id = player_map.get(int(row["player_id"])) + if new_match_id is None or new_player_id is None: + continue + + connection.execute( + """ + INSERT INTO historical_player_match_stats ( + historical_match_id, + historical_player_id, + match_player_ref, + team_side, + level, + kills, + deaths, + teamkills, + time_seconds, + kills_per_minute, + deaths_per_minute, + kill_death_ratio, + combat, + offense, + defense, + support + ) VALUES (?, ?, NULL, NULL, NULL, ?, ?, NULL, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL) + ON CONFLICT(historical_match_id, historical_player_id) DO UPDATE SET + kills = excluded.kills, + deaths = excluded.deaths, + time_seconds = excluded.time_seconds, + updated_at = CURRENT_TIMESTAMP + """, + ( + new_match_id, + new_player_id, + _coerce_int(row["kills"]), + _coerce_int(row["deaths"]), + _coerce_int(row["time_seconds"]), + ), + ) + + +def _normalize_historical_player_identities(connection: sqlite3.Connection) -> None: + rows = connection.execute( + """ + SELECT id, stable_player_key, display_name, steam_id, source_player_id + FROM historical_players + ORDER BY id ASC + """ + ).fetchall() + for row in rows: + player_id = int(row["id"]) + canonical_key, steam_id, source_player_id, display_name = _canonicalize_stored_player_row(row) + existing = connection.execute( + """ + SELECT id + FROM historical_players + WHERE stable_player_key = ? + """, + (canonical_key,), + ).fetchone() + if existing is not None and int(existing["id"]) != player_id: + _merge_historical_player_rows( + connection, + source_player_id=player_id, + target_player_id=int(existing["id"]), + display_name=display_name, + steam_id=steam_id, + source_ref=source_player_id, + ) + continue + + connection.execute( + """ + UPDATE historical_players + SET stable_player_key = ?, + display_name = ?, + steam_id = ?, + source_player_id = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (canonical_key, display_name, steam_id, source_player_id, player_id), + ) + + +def _normalize_historical_match_identities(connection: sqlite3.Connection) -> None: + rows = connection.execute( + """ + SELECT + historical_matches.id, + historical_matches.historical_server_id, + historical_matches.external_match_id, + historical_matches.started_at, + historical_matches.ended_at, + historical_matches.created_at_source, + historical_matches.map_name, + historical_matches.map_pretty_name, + COUNT(historical_player_match_stats.id) AS player_count + FROM historical_matches + LEFT JOIN historical_player_match_stats + ON historical_player_match_stats.historical_match_id = historical_matches.id + WHERE historical_matches.started_at IS NOT NULL + GROUP BY historical_matches.id + ORDER BY historical_matches.historical_server_id ASC, historical_matches.started_at ASC, historical_matches.id ASC + """ + ).fetchall() + + grouped_matches: dict[tuple[int, str, str], list[sqlite3.Row]] = {} + for row in rows: + group_key = ( + int(row["historical_server_id"]), + str(row["started_at"]), + _normalize_match_identity_label(row["map_pretty_name"] or row["map_name"]), + ) + grouped_matches.setdefault(group_key, []).append(row) + + for grouped_rows in grouped_matches.values(): + if len(grouped_rows) < 2: + continue + target_row = max(grouped_rows, key=_match_identity_preference) + for source_row in grouped_rows: + if int(source_row["id"]) == int(target_row["id"]): + continue + _merge_historical_match_rows( + connection, + source_match_id=int(source_row["id"]), + target_match_id=int(target_row["id"]), + ) + + +def _seed_default_historical_servers(connection: sqlite3.Connection) -> None: + for server in DEFAULT_HISTORICAL_SERVERS: + connection.execute( + """ + INSERT INTO historical_servers ( + slug, + display_name, + scoreboard_base_url, + server_number, + source_kind + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(slug) DO UPDATE SET + display_name = excluded.display_name, + scoreboard_base_url = excluded.scoreboard_base_url, + server_number = excluded.server_number, + source_kind = excluded.source_kind, + updated_at = CURRENT_TIMESTAMP + """, + ( + server.slug, + server.display_name, + server.scoreboard_base_url, + server.server_number, + server.source_kind, + ), + ) + + +def _resolve_historical_server( + connection: sqlite3.Connection, + server_slug: str, +) -> sqlite3.Row: + row = connection.execute( + """ + SELECT id, slug, scoreboard_base_url + FROM historical_servers + WHERE slug = ? + """, + (server_slug,), + ).fetchone() + if row is None: + raise ValueError(f"Unknown historical server slug: {server_slug}") + return row + + +def _upsert_historical_map( + connection: sqlite3.Connection, + match_payload: Mapping[str, object], +) -> int | None: + external_map_id = _stringify(_get_nested(match_payload, "map", "id")) + if not external_map_id: + return None + + connection.execute( + """ + INSERT INTO historical_maps ( + external_map_id, + map_name, + pretty_name, + game_mode, + image_name + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(external_map_id) DO UPDATE SET + map_name = excluded.map_name, + pretty_name = excluded.pretty_name, + game_mode = excluded.game_mode, + image_name = excluded.image_name, + updated_at = CURRENT_TIMESTAMP + """, + ( + external_map_id, + _extract_map_name(match_payload), + _extract_map_pretty_name(match_payload), + _extract_map_game_mode(match_payload), + _extract_map_image_name(match_payload), + ), + ) + row = connection.execute( + "SELECT id FROM historical_maps WHERE external_map_id = ?", + (external_map_id,), + ).fetchone() + return int(row["id"]) if row is not None else None + + +def _upsert_historical_player( + connection: sqlite3.Connection, + player_payload: Mapping[str, object], +) -> int: + stable_player_key, steam_id, source_player_id = _derive_player_identity(player_payload) + display_name = _normalize_player_display_name(player_payload.get("player")) or "Unknown player" + seen_at = _utc_now_iso() + + connection.execute( + """ + INSERT INTO historical_players ( + stable_player_key, + display_name, + steam_id, + source_player_id, + first_seen_at, + last_seen_at + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(stable_player_key) DO UPDATE SET + display_name = excluded.display_name, + steam_id = COALESCE(excluded.steam_id, historical_players.steam_id), + source_player_id = COALESCE(excluded.source_player_id, historical_players.source_player_id), + last_seen_at = excluded.last_seen_at, + updated_at = CURRENT_TIMESTAMP + """, + ( + stable_player_key, + display_name, + steam_id, + source_player_id, + seen_at, + seen_at, + ), + ) + row = connection.execute( + "SELECT id FROM historical_players WHERE stable_player_key = ?", + (stable_player_key,), + ).fetchone() + if row is None: + raise RuntimeError("Failed to persist historical player identity.") + return int(row["id"]) + + +def _build_stable_player_key(player_payload: Mapping[str, object]) -> str: + stable_player_key, _, _ = _derive_player_identity(player_payload) + return stable_player_key + + +def _derive_player_identity(player_payload: Mapping[str, object]) -> tuple[str, str | None, str | None]: + steam_id = _stringify(_get_nested(player_payload, "steaminfo", "profile", "steamid")) + source_player_id = _stringify(player_payload.get("player_id")) + steaminfo_id = _stringify(_get_nested(player_payload, "steaminfo", "id")) + + if steam_id: + return f"steam:{steam_id}", steam_id, source_player_id + if _is_probable_steam_id(source_player_id): + return f"steam:{source_player_id}", source_player_id, source_player_id + if source_player_id: + return f"crcon-player:{source_player_id}", None, source_player_id + if steaminfo_id: + return f"steaminfo:{steaminfo_id}", None, None + + player_name = _normalize_player_display_name(player_payload.get("player")) or "unknown-player" + return f"name:{_normalize_name_key(player_name)}", None, None + + +def _extract_map_name(match_payload: Mapping[str, object]) -> str | None: + return _stringify(match_payload.get("map_name")) or _stringify(_get_nested(match_payload, "map", "name")) + + +def _extract_map_pretty_name(match_payload: Mapping[str, object]) -> str | None: + return _stringify(_get_nested(match_payload, "map", "pretty_name")) or _extract_map_name(match_payload) + + +def _extract_map_game_mode(match_payload: Mapping[str, object]) -> str | None: + return _stringify(_get_nested(match_payload, "map", "game_mode")) + + +def _extract_map_image_name(match_payload: Mapping[str, object]) -> str | None: + return _stringify(_get_nested(match_payload, "map", "image_name")) + + +def _coerce_list(value: object) -> list[Mapping[str, object]]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, Mapping)] + + +def _coerce_int(value: object) -> int | None: + if value in (None, ""): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _coerce_float(value: object) -> float | None: + if value in (None, ""): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _stringify(value: object) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + +def _normalize_player_display_name(value: object) -> str | None: + text = _stringify(value) + if not text: + return None + return " ".join(text.split()) + + +def _normalize_name_key(player_name: str) -> str: + normalized_name = "".join( + character.lower() if character.isalnum() else "-" + for character in player_name + ) + compact_name = "-".join(part for part in normalized_name.split("-") if part) + return compact_name or "unknown-player" + + +def _is_probable_steam_id(value: object) -> bool: + text = _stringify(value) + return bool(text and text.isdigit() and len(text) >= 16) + + +def _canonicalize_stored_player_row( + row: sqlite3.Row, +) -> tuple[str, str | None, str | None, str]: + stable_player_key = _stringify(row["stable_player_key"]) + display_name = _normalize_player_display_name(row["display_name"]) or "Unknown player" + steam_id = _stringify(row["steam_id"]) + source_player_id = _stringify(row["source_player_id"]) + + if _is_probable_steam_id(steam_id): + return f"steam:{steam_id}", steam_id, source_player_id, display_name + if _is_probable_steam_id(source_player_id): + return f"steam:{source_player_id}", source_player_id, source_player_id, display_name + if source_player_id: + return f"crcon-player:{source_player_id}", None, source_player_id, display_name + if stable_player_key and stable_player_key.startswith("steaminfo:"): + return stable_player_key, None, None, display_name + if stable_player_key and stable_player_key.startswith("name:"): + return stable_player_key, None, None, display_name + if stable_player_key and stable_player_key.startswith("steam:"): + return stable_player_key, steam_id, source_player_id, display_name + if stable_player_key and stable_player_key.startswith("crcon-player:"): + source_ref = stable_player_key.removeprefix("crcon-player:") + return stable_player_key, None, source_player_id or source_ref, display_name + if stable_player_key: + if _is_probable_steam_id(stable_player_key): + return f"steam:{stable_player_key}", stable_player_key, source_player_id, display_name + return f"crcon-player:{stable_player_key}", None, source_player_id or stable_player_key, display_name + return f"name:{_normalize_name_key(display_name)}", None, None, display_name + + +def _merge_historical_player_rows( + connection: sqlite3.Connection, + *, + source_player_id: int, + target_player_id: int, + display_name: str, + steam_id: str | None, + source_ref: str | None, +) -> None: + target_row = connection.execute( + """ + SELECT display_name, steam_id, source_player_id, first_seen_at, last_seen_at + FROM historical_players + WHERE id = ? + """, + (target_player_id,), + ).fetchone() + if target_row is None: + return + + connection.execute( + """ + UPDATE historical_players + SET display_name = ?, + steam_id = ?, + source_player_id = ?, + first_seen_at = MIN(first_seen_at, ?), + last_seen_at = MAX(last_seen_at, ?), + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + ( + _pick_preferred_display_name(target_row["display_name"], display_name), + _pick_preferred_steam_id(target_row["steam_id"], steam_id), + _pick_preferred_source_player_id(target_row["source_player_id"], source_ref), + connection.execute( + "SELECT first_seen_at FROM historical_players WHERE id = ?", + (source_player_id,), + ).fetchone()["first_seen_at"], + connection.execute( + "SELECT last_seen_at FROM historical_players WHERE id = ?", + (source_player_id,), + ).fetchone()["last_seen_at"], + target_player_id, + ), + ) + + stats_rows = connection.execute( + """ + SELECT * + FROM historical_player_match_stats + WHERE historical_player_id = ? + ORDER BY id ASC + """, + (source_player_id,), + ).fetchall() + for stat_row in stats_rows: + existing = connection.execute( + """ + SELECT * + FROM historical_player_match_stats + WHERE historical_match_id = ? AND historical_player_id = ? + """, + (stat_row["historical_match_id"], target_player_id), + ).fetchone() + if existing is None: + connection.execute( + """ + UPDATE historical_player_match_stats + SET historical_player_id = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (target_player_id, stat_row["id"]), + ) + continue + + _merge_player_match_stats_row(connection, existing["id"], stat_row) + connection.execute( + "DELETE FROM historical_player_match_stats WHERE id = ?", + (stat_row["id"],), + ) + + connection.execute( + "DELETE FROM historical_players WHERE id = ?", + (source_player_id,), + ) + + +def _normalize_match_identity_label(value: object) -> str: + text = _stringify(value) or "unknown-map" + return " ".join(text.lower().split()) + + +def _match_identity_preference(row: sqlite3.Row) -> tuple[int, int, int, str, int]: + return ( + 1 if _stringify(row["ended_at"]) else 0, + 1 if (_stringify(row["external_match_id"]) or "").isdigit() else 0, + int(row["player_count"] or 0), + _stringify(row["created_at_source"]) or "", + int(row["id"]), + ) + + +def _merge_historical_match_rows( + connection: sqlite3.Connection, + *, + source_match_id: int, + target_match_id: int, +) -> None: + source_row = connection.execute( + "SELECT * FROM historical_matches WHERE id = ?", + (source_match_id,), + ).fetchone() + target_row = connection.execute( + "SELECT * FROM historical_matches WHERE id = ?", + (target_match_id,), + ).fetchone() + if source_row is None or target_row is None: + return + + connection.execute( + """ + UPDATE historical_matches + SET historical_map_id = COALESCE(historical_map_id, ?), + created_at_source = COALESCE(created_at_source, ?), + started_at = COALESCE(started_at, ?), + ended_at = COALESCE(ended_at, ?), + map_name = COALESCE(map_name, ?), + map_pretty_name = COALESCE(map_pretty_name, ?), + game_mode = COALESCE(game_mode, ?), + image_name = COALESCE(image_name, ?), + allied_score = COALESCE(allied_score, ?), + axis_score = COALESCE(axis_score, ?), + raw_payload_ref = COALESCE(raw_payload_ref, ?), + last_seen_at = MAX(last_seen_at, ?), + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + ( + source_row["historical_map_id"], + source_row["created_at_source"], + source_row["started_at"], + source_row["ended_at"], + source_row["map_name"], + source_row["map_pretty_name"], + source_row["game_mode"], + source_row["image_name"], + source_row["allied_score"], + source_row["axis_score"], + source_row["raw_payload_ref"], + source_row["last_seen_at"], + target_match_id, + ), + ) + + stats_rows = connection.execute( + """ + SELECT * + FROM historical_player_match_stats + WHERE historical_match_id = ? + ORDER BY id ASC + """, + (source_match_id,), + ).fetchall() + for stat_row in stats_rows: + existing = connection.execute( + """ + SELECT * + FROM historical_player_match_stats + WHERE historical_match_id = ? AND historical_player_id = ? + """, + (target_match_id, stat_row["historical_player_id"]), + ).fetchone() + if existing is None: + connection.execute( + """ + UPDATE historical_player_match_stats + SET historical_match_id = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (target_match_id, stat_row["id"]), + ) + continue + + _merge_player_match_stats_row(connection, existing["id"], stat_row) + connection.execute( + "DELETE FROM historical_player_match_stats WHERE id = ?", + (stat_row["id"],), + ) + + connection.execute( + "DELETE FROM historical_matches WHERE id = ?", + (source_match_id,), + ) + + +def _merge_player_match_stats_row( + connection: sqlite3.Connection, + target_stat_id: int, + source_row: sqlite3.Row, +) -> None: + target_row = connection.execute( + "SELECT * FROM historical_player_match_stats WHERE id = ?", + (target_stat_id,), + ).fetchone() + if target_row is None: + return + + connection.execute( + """ + UPDATE historical_player_match_stats + SET match_player_ref = COALESCE(match_player_ref, ?), + team_side = COALESCE(team_side, ?), + level = ?, + kills = ?, + deaths = ?, + teamkills = ?, + time_seconds = ?, + kills_per_minute = ?, + deaths_per_minute = ?, + kill_death_ratio = ?, + combat = ?, + offense = ?, + defense = ?, + support = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + ( + source_row["match_player_ref"], + source_row["team_side"], + _max_int_value(target_row["level"], source_row["level"]), + _max_int_value(target_row["kills"], source_row["kills"]), + _max_int_value(target_row["deaths"], source_row["deaths"]), + _max_int_value(target_row["teamkills"], source_row["teamkills"]), + _max_int_value(target_row["time_seconds"], source_row["time_seconds"]), + _max_float_value(target_row["kills_per_minute"], source_row["kills_per_minute"]), + _max_float_value(target_row["deaths_per_minute"], source_row["deaths_per_minute"]), + _max_float_value(target_row["kill_death_ratio"], source_row["kill_death_ratio"]), + _max_int_value(target_row["combat"], source_row["combat"]), + _max_int_value(target_row["offense"], source_row["offense"]), + _max_int_value(target_row["defense"], source_row["defense"]), + _max_int_value(target_row["support"], source_row["support"]), + target_stat_id, + ), + ) + + +def _pick_preferred_display_name(current_value: object, incoming_value: object) -> str: + current_name = _normalize_player_display_name(current_value) + incoming_name = _normalize_player_display_name(incoming_value) + if not current_name: + return incoming_name or "Unknown player" + if not incoming_name: + return current_name + if len(incoming_name) > len(current_name): + return incoming_name + return current_name + + +def _pick_preferred_steam_id(current_value: object, incoming_value: object) -> str | None: + current_id = _stringify(current_value) + incoming_id = _stringify(incoming_value) + if _is_probable_steam_id(current_id): + return current_id + if _is_probable_steam_id(incoming_id): + return incoming_id + return None + + +def _pick_preferred_source_player_id(current_value: object, incoming_value: object) -> str | None: + current_id = _stringify(current_value) + incoming_id = _stringify(incoming_value) + if current_id: + return current_id + return incoming_id + + +def _max_int_value(current_value: object, incoming_value: object) -> int | None: + current_number = _coerce_int(current_value) + incoming_number = _coerce_int(incoming_value) + if current_number is None: + return incoming_number + if incoming_number is None: + return current_number + return max(current_number, incoming_number) + + +def _max_float_value(current_value: object, incoming_value: object) -> float | None: + current_number = _coerce_float(current_value) + incoming_number = _coerce_float(incoming_value) + if current_number is None: + return incoming_number + if incoming_number is None: + return current_number + return max(current_number, incoming_number) + + +def _normalize_timestamp(value: object) -> str | None: + text = _stringify(value) + if not text: + return None + try: + return _parse_timestamp(text).astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + except ValueError: + return text + + +def _parse_timestamp(value: str) -> datetime: + normalized = value.strip().replace("Z", "+00:00") + parsed = datetime.fromisoformat(normalized) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + +def _calculate_coverage_days( + first_match_at: str | None, + last_match_at: str | None, +) -> float | None: + if not first_match_at or not last_match_at: + return None + try: + delta = _parse_timestamp(last_match_at) - _parse_timestamp(first_match_at) + except ValueError: + return None + return round(delta.total_seconds() / 86400, 2) + + +def _select_weekly_window( + *, + server_id: str | None, + current_time: datetime, + current_week_start: datetime, + previous_week_start: datetime, + db_path: Path, +) -> dict[str, object]: + fallback_max_weekday = get_historical_weekly_fallback_max_weekday() + current_week_closed_matches = _count_valid_matches_with_stats_in_window( + server_id=server_id, + window_start=current_week_start, + window_end=current_time, + db_path=db_path, + ) + previous_week_closed_matches = _count_valid_matches_with_stats_in_window( + server_id=server_id, + window_start=previous_week_start, + window_end=current_week_start, + db_path=db_path, + ) + is_early_week = current_time.weekday() <= fallback_max_weekday + min_matches = 1 + current_week_has_sufficient_sample = current_week_closed_matches >= min_matches + uses_fallback = ( + not current_week_has_sufficient_sample + and previous_week_closed_matches > 0 + ) + + if uses_fallback: + return { + "window_start": previous_week_start, + "window_end": current_week_start, + "window_kind": "previous-closed-week-fallback", + "window_label": "Semana cerrada anterior", + "uses_fallback": True, + "selection_reason": "insufficient-current-week-sample", + "minimum_closed_matches": min_matches, + "current_week_closed_matches": current_week_closed_matches, + "previous_week_closed_matches": previous_week_closed_matches, + "current_week_has_sufficient_sample": False, + "is_early_week": is_early_week, + "fallback_max_weekday": fallback_max_weekday, + } + + return { + "window_start": current_week_start, + "window_end": current_time, + "window_kind": "current-week", + "window_label": "Semana actual", + "uses_fallback": False, + "selection_reason": "current-week", + "minimum_closed_matches": min_matches, + "current_week_closed_matches": current_week_closed_matches, + "previous_week_closed_matches": previous_week_closed_matches, + "current_week_has_sufficient_sample": current_week_has_sufficient_sample, + "is_early_week": is_early_week, + "fallback_max_weekday": fallback_max_weekday, + } + + +def _select_monthly_window( + *, + server_id: str | None, + current_time: datetime, + current_month_start: datetime, + previous_month_start: datetime, + db_path: Path, +) -> dict[str, object]: + current_month_closed_matches = _count_closed_matches_in_window( + server_id=server_id, + window_start=current_month_start, + window_end=current_time, + db_path=db_path, + ) + previous_month_closed_matches = _count_closed_matches_in_window( + server_id=server_id, + window_start=previous_month_start, + window_end=current_month_start, + db_path=db_path, + ) + is_early_month = current_time.day <= 3 + uses_fallback = current_month_closed_matches <= 0 and previous_month_closed_matches > 0 + + if uses_fallback: + return { + "window_start": previous_month_start, + "window_end": current_month_start, + "window_kind": "previous-closed-month-fallback", + "window_label": "Mes cerrado anterior", + "uses_fallback": True, + "selection_reason": "no-current-month-matches", + "minimum_closed_matches": 1, + "current_month_closed_matches": current_month_closed_matches, + "previous_month_closed_matches": previous_month_closed_matches, + "current_month_has_sufficient_sample": False, + "is_early_month": is_early_month, + } + + return { + "window_start": current_month_start, + "window_end": current_time, + "window_kind": "current-month", + "window_label": "Mes actual", + "uses_fallback": False, + "selection_reason": "current-month", + "minimum_closed_matches": 1, + "current_month_closed_matches": current_month_closed_matches, + "previous_month_closed_matches": previous_month_closed_matches, + "current_month_has_sufficient_sample": current_month_closed_matches > 0, + "is_early_month": is_early_month, + } + + +def _count_closed_matches_in_window( + *, + server_id: str | None, + window_start: datetime, + window_end: datetime, + db_path: Path, +) -> int: + where_clauses = [ + "historical_matches.ended_at IS NOT NULL", + "historical_matches.ended_at >= ?", + "historical_matches.ended_at < ?", + ] + params: list[object] = [ + window_start.isoformat().replace("+00:00", "Z"), + window_end.isoformat().replace("+00:00", "Z"), + ] + if server_id and not _is_all_servers_selector(server_id): + normalized_server_id = server_id.strip() + where_clauses.append( + "(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)" + ) + params.extend([normalized_server_id, normalized_server_id]) + + with _connect(db_path) as connection: + row = connection.execute( + f""" + SELECT COUNT(DISTINCT historical_matches.id) AS matches_count + FROM historical_matches + INNER JOIN historical_servers + ON historical_servers.id = historical_matches.historical_server_id + WHERE {" AND ".join(where_clauses)} + """, + params, + ).fetchone() + return int(row["matches_count"] or 0) if row is not None else 0 + + +def _count_valid_matches_with_stats_in_window( + *, + server_id: str | None, + window_start: datetime, + window_end: datetime, + db_path: Path, +) -> int: + where_clauses = [ + "historical_matches.ended_at IS NOT NULL", + "historical_matches.ended_at >= ?", + "historical_matches.ended_at < ?", + "(" + "COALESCE(historical_player_match_stats.kills, 0) > 0 " + "OR COALESCE(historical_player_match_stats.deaths, 0) > 0 " + "OR COALESCE(historical_player_match_stats.support, 0) > 0 " + "OR COALESCE(historical_player_match_stats.combat, 0) > 0 " + "OR COALESCE(historical_player_match_stats.offense, 0) > 0 " + "OR COALESCE(historical_player_match_stats.defense, 0) > 0 " + "OR COALESCE(historical_player_match_stats.time_seconds, 0) > 0" + ")", + ] + params: list[object] = [ + window_start.isoformat().replace("+00:00", "Z"), + window_end.isoformat().replace("+00:00", "Z"), + ] + if server_id and not _is_all_servers_selector(server_id): + normalized_server_id = server_id.strip() + where_clauses.append( + "(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)" + ) + params.extend([normalized_server_id, normalized_server_id]) + + with _connect(db_path) as connection: + row = connection.execute( + f""" + SELECT COUNT(DISTINCT historical_matches.id) AS matches_count + FROM historical_matches + INNER JOIN historical_servers + ON historical_servers.id = historical_matches.historical_server_id + INNER JOIN historical_player_match_stats + ON historical_player_match_stats.historical_match_id = historical_matches.id + WHERE {" AND ".join(where_clauses)} + """, + params, + ).fetchone() + return int(row["matches_count"] or 0) if row is not None else 0 + + +def _classify_coverage_status( + matches_count: int, + coverage_days: float | None, +) -> str: + if matches_count <= 0: + return "empty" + if coverage_days is None: + return "range-unknown" + if coverage_days < DEFAULT_WEEKLY_WINDOW_DAYS: + return "under-week" + return "week-plus" + + +def _build_all_servers_summary(*, db_path: Path) -> dict[str, object]: + per_server_items = list_historical_server_summaries(db_path=db_path) + imported_matches_count = sum(int(item.get("matches_count") or 0) for item in per_server_items) + unique_players = _count_all_servers_unique_players(db_path=db_path) + total_kills = sum(int(item.get("total_kills") or 0) for item in per_server_items) + discovered_total_matches = sum( + _coerce_int(item.get("backfill", {}).get("discovered_total_matches")) or 0 + for item in per_server_items + ) + first_points = [ + item.get("coverage", {}).get("first_match_at") + for item in per_server_items + if item.get("coverage", {}).get("first_match_at") + ] + last_points = [ + item.get("coverage", {}).get("last_match_at") + for item in per_server_items + if item.get("coverage", {}).get("last_match_at") + ] + first_match_at = min(first_points) if first_points else None + last_match_at = max(last_points) if last_points else None + coverage_days = _calculate_coverage_days(first_match_at, last_match_at) + + return { + "server": { + "slug": ALL_SERVERS_SLUG, + "name": ALL_SERVERS_DISPLAY_NAME, + }, + "matches_count": imported_matches_count, + "imported_matches_count": imported_matches_count, + "unique_players": unique_players, + "total_kills": total_kills, + "map_count": _count_all_servers_maps(db_path=db_path), + "top_maps": _list_all_servers_top_maps(db_path=db_path, limit=3), + "coverage": { + "basis": "persisted-import-aggregate", + "status": _classify_coverage_status(imported_matches_count, coverage_days), + "imported_matches_count": imported_matches_count, + "discovered_total_matches": discovered_total_matches or None, + "first_match_at": first_match_at, + "last_match_at": last_match_at, + "coverage_days": coverage_days, + }, + "backfill": { + "mode": "aggregate", + "server_count": len(per_server_items), + "discovered_total_matches": discovered_total_matches or None, + "remaining_matches_estimate": ( + max(discovered_total_matches - imported_matches_count, 0) + if discovered_total_matches + else None + ), + "archive_exhausted": all( + bool(item.get("backfill", {}).get("archive_exhausted")) + for item in per_server_items + ), + "last_run": None, + }, + "time_range": { + "start": first_match_at, + "end": last_match_at, + }, + } + + +def _count_all_servers_unique_players(*, db_path: Path) -> int: + with _connect(db_path) as connection: + row = connection.execute( + """ + SELECT COUNT(DISTINCT historical_players.id) AS unique_players + FROM historical_player_match_stats + INNER JOIN historical_players + ON historical_players.id = historical_player_match_stats.historical_player_id + """ + ).fetchone() + return int(row["unique_players"] or 0) if row is not None else 0 + + +def _count_all_servers_maps(*, db_path: Path) -> int: + with _connect(db_path) as connection: + row = connection.execute( + """ + SELECT COUNT(DISTINCT COALESCE(map_pretty_name, map_name)) AS map_count + FROM historical_matches + """ + ).fetchone() + return int(row["map_count"] or 0) if row is not None else 0 + + +def _list_all_servers_top_maps(*, db_path: Path, limit: int) -> list[dict[str, object]]: + with _connect(db_path) as connection: + rows = connection.execute( + """ + SELECT + COALESCE(map_pretty_name, map_name, 'Mapa no disponible') AS map_name, + COUNT(*) AS matches_count + FROM historical_matches + GROUP BY COALESCE(map_pretty_name, map_name, 'Mapa no disponible') + ORDER BY matches_count DESC, map_name ASC + LIMIT ? + """, + (limit,), + ).fetchall() + return [ + { + "map_name": row["map_name"], + "matches_count": int(row["matches_count"] or 0), + } + for row in rows + ] + + +def _is_all_servers_selector(value: str | None) -> bool: + return isinstance(value, str) and value.strip() == ALL_SERVERS_SLUG + + +def _resolve_safe_match_url(raw_payload_ref: object, server_slug: object) -> str | None: + return resolve_trusted_scoreboard_match_url(raw_payload_ref, server_slug) + + +def _calculate_match_duration_seconds(started_at: object, ended_at: object) -> int | None: + start_text = _stringify(started_at) + end_text = _stringify(ended_at) + if not start_text or not end_text: + return None + try: + duration = _parse_timestamp(end_text) - _parse_timestamp(start_text) + except ValueError: + return None + return max(0, int(duration.total_seconds())) + + +def _start_of_week(value: datetime) -> datetime: + normalized = value.astimezone(timezone.utc) + midnight = normalized.replace(hour=0, minute=0, second=0, microsecond=0) + return midnight - timedelta(days=midnight.weekday()) + + +def _start_of_month(value: datetime) -> datetime: + normalized = value.astimezone(timezone.utc) + return normalized.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + +def _start_of_previous_month(value: datetime) -> datetime: + previous_day = value - timedelta(days=1) + return _start_of_month(previous_day) + + +def _calculate_window_days(*, window_start: datetime, window_end: datetime) -> int: + delta = window_end - window_start + return max(1, int((delta.total_seconds() + 86399) // 86400)) + + +def _get_nested(payload: Mapping[str, object], *path: str) -> object: + current: object = payload + for key in path: + if not isinstance(current, Mapping): + return None + current = current.get(key) + return current + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..262e592 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,90 @@ +"""Minimal HTTP entrypoint for the HLL Vietnam backend bootstrap.""" + +from __future__ import annotations + +import json +from datetime import date, datetime +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from .config import get_allowed_origins, get_bind_address +from .payloads import build_error_payload +from .routes import resolve_get_payload + + +class HealthHandler(BaseHTTPRequestHandler): + """Serve the minimal routes required for the backend bootstrap.""" + + server_version = "HLLVietnamBackend/0.1" + + def do_OPTIONS(self) -> None: # noqa: N802 - BaseHTTPRequestHandler interface + self.send_response(HTTPStatus.NO_CONTENT) + self._send_default_headers() + self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler interface + try: + status, payload = resolve_get_payload(self.path) + except Exception: # noqa: BLE001 - preserve HTTP/CORS response on route failures + self._write_json( + HTTPStatus.INTERNAL_SERVER_ERROR, + build_error_payload("Unexpected backend error"), + ) + return + + if status is None: + self._write_json( + HTTPStatus.NOT_FOUND, + {"status": "error", "message": "Route not found"}, + ) + return + + self._write_json(status, payload) + + def log_message(self, format: str, *args: object) -> None: + # Keep local startup output clean unless future tasks need request logging. + return + + def _write_json(self, status: HTTPStatus, payload: dict[str, object]) -> None: + body = json.dumps(payload, default=_json_default).encode("utf-8") + self.send_response(status) + self._send_default_headers(content_length=len(body)) + self.end_headers() + self.wfile.write(body) + + def _send_default_headers(self, content_length: int | None = None) -> None: + origin = self.headers.get("Origin") + if origin in get_allowed_origins(): + self.send_header("Access-Control-Allow-Origin", origin) + self.send_header("Vary", "Origin") + + self.send_header("Content-Type", "application/json; charset=utf-8") + if content_length is not None: + self.send_header("Content-Length", str(content_length)) + + +def create_server() -> ThreadingHTTPServer: + """Build the HTTP server using the package-supported handler and bind settings.""" + host, port = get_bind_address() + return ThreadingHTTPServer((host, port), HealthHandler) + + +def _json_default(value: object) -> str: + """Serialize PostgreSQL date/time values before they can abort an HTTP response.""" + if isinstance(value, (date, datetime)): + return value.isoformat() + raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable") + + +def run() -> None: + """Start the local bootstrap server.""" + host, port = get_bind_address() + server = create_server() + print(f"HLL Vietnam backend bootstrap listening on http://{host}:{port}") + server.serve_forever() + + +if __name__ == "__main__": + run() diff --git a/backend/app/monthly_mvp.py b/backend/app/monthly_mvp.py new file mode 100644 index 0000000..d58681b --- /dev/null +++ b/backend/app/monthly_mvp.py @@ -0,0 +1,163 @@ +"""Monthly MVP V1 scoring helpers.""" + +from __future__ import annotations + +import math +from typing import Mapping + + +MONTHLY_MVP_VERSION = "v1" +MONTHLY_MVP_MIN_MATCHES = 6 +MONTHLY_MVP_MIN_TIME_SECONDS = 21600 +MONTHLY_MVP_FULL_PARTICIPATION_SECONDS = 28800 +MONTHLY_MVP_TEAMKILL_PENALTY_CAP = 6.0 +MONTHLY_MVP_TEAMKILL_PENALTY_PER_KILL = 0.5 + + +def build_monthly_mvp_rankings( + aggregated_rows: list[Mapping[str, object]], + *, + limit: int, +) -> dict[str, object]: + """Transform aggregated monthly totals into ranked MVP V1 items.""" + eligible_rows = [ + _build_eligible_player_summary(row) + for row in aggregated_rows + if _is_eligible_player_row(row) + ] + + if not eligible_rows: + return { + "ranking_version": MONTHLY_MVP_VERSION, + "eligibility": _build_eligibility_metadata(), + "items": [], + "eligible_players_count": 0, + } + + max_total_kills = max(item["totals"]["kills"] for item in eligible_rows) + max_total_support = max(item["totals"]["support"] for item in eligible_rows) + max_kpm = max(item["derived"]["kpm"] for item in eligible_rows) + max_kda = max(item["derived"]["kda"] for item in eligible_rows) + + for item in eligible_rows: + component_scores = { + "kills_score": _log_normalized_score(item["totals"]["kills"], max_total_kills), + "support_score": _log_normalized_score(item["totals"]["support"], max_total_support), + "kpm_score": _log_normalized_score(item["derived"]["kpm"], max_kpm), + "kda_score": _log_normalized_score(item["derived"]["kda"], max_kda), + "participation_score": round( + 100 + * min( + 1.0, + item["totals"]["time_seconds"] / MONTHLY_MVP_FULL_PARTICIPATION_SECONDS, + ), + 3, + ), + } + teamkill_penalty = round( + min( + MONTHLY_MVP_TEAMKILL_PENALTY_CAP, + item["totals"]["teamkills"] * MONTHLY_MVP_TEAMKILL_PENALTY_PER_KILL, + ), + 3, + ) + item["component_scores"] = component_scores + item["teamkill_penalty"] = teamkill_penalty + item["mvp_score"] = round( + (0.35 * component_scores["kills_score"]) + + (0.20 * component_scores["support_score"]) + + (0.20 * component_scores["kpm_score"]) + + (0.15 * component_scores["kda_score"]) + + (0.10 * component_scores["participation_score"]) + - teamkill_penalty, + 3, + ) + + ranked_items = sorted( + eligible_rows, + key=lambda item: ( + -item["mvp_score"], + -item["component_scores"]["participation_score"], + -item["component_scores"]["kills_score"], + -item["component_scores"]["support_score"], + item["totals"]["teamkills"], + str(item["player"]["name"]).casefold(), + str(item["player"]["stable_player_key"]), + ), + ) + for position, item in enumerate(ranked_items[:limit], start=1): + item["ranking_position"] = position + + return { + "ranking_version": MONTHLY_MVP_VERSION, + "eligibility": _build_eligibility_metadata(), + "eligible_players_count": len(eligible_rows), + "items": ranked_items[:limit], + } + + +def _is_eligible_player_row(row: Mapping[str, object]) -> bool: + matches_count = int(row.get("matches_count") or 0) + time_seconds = int(row.get("total_time_seconds") or 0) + has_required_fields = all( + row.get(field_name) is not None + for field_name in ("total_kills", "total_deaths", "total_support", "total_time_seconds") + ) + return ( + has_required_fields + and matches_count >= MONTHLY_MVP_MIN_MATCHES + and time_seconds >= MONTHLY_MVP_MIN_TIME_SECONDS + ) + + +def _build_eligible_player_summary(row: Mapping[str, object]) -> dict[str, object]: + total_kills = int(row.get("total_kills") or 0) + total_deaths = int(row.get("total_deaths") or 0) + total_support = int(row.get("total_support") or 0) + total_teamkills = int(row.get("total_teamkills") or 0) + total_time_seconds = int(row.get("total_time_seconds") or 0) + total_time_minutes = max(total_time_seconds / 60.0, 1.0) + kpm = round(total_kills / total_time_minutes, 6) + kda = round(total_kills / max(total_deaths, 1), 6) + return { + "server": { + "slug": row.get("server_slug"), + "name": row.get("server_name"), + }, + "player": { + "stable_player_key": row.get("stable_player_key"), + "name": row.get("player_name"), + "steam_id": row.get("steam_id"), + }, + "matches_considered": int(row.get("matches_count") or 0), + "totals": { + "kills": total_kills, + "deaths": total_deaths, + "support": total_support, + "teamkills": total_teamkills, + "time_seconds": total_time_seconds, + "time_minutes": round(total_time_seconds / 60.0, 2), + }, + "derived": { + "kpm": kpm, + "kda": kda, + }, + } + + +def _log_normalized_score(value: float | int, max_value: float | int) -> float: + if value <= 0 or max_value <= 0: + return 0.0 + return round((100 * math.log1p(value)) / math.log1p(max_value), 3) + + +def _build_eligibility_metadata() -> dict[str, object]: + return { + "minimum_matches": MONTHLY_MVP_MIN_MATCHES, + "minimum_time_seconds": MONTHLY_MVP_MIN_TIME_SECONDS, + "minimum_time_hours": round(MONTHLY_MVP_MIN_TIME_SECONDS / 3600, 1), + "full_participation_seconds": MONTHLY_MVP_FULL_PARTICIPATION_SECONDS, + "full_participation_hours": round(MONTHLY_MVP_FULL_PARTICIPATION_SECONDS / 3600, 1), + "teamkill_penalty_per_kill": MONTHLY_MVP_TEAMKILL_PENALTY_PER_KILL, + "teamkill_penalty_cap": MONTHLY_MVP_TEAMKILL_PENALTY_CAP, + } diff --git a/backend/app/monthly_mvp_v2.py b/backend/app/monthly_mvp_v2.py new file mode 100644 index 0000000..5c83272 --- /dev/null +++ b/backend/app/monthly_mvp_v2.py @@ -0,0 +1,201 @@ +"""Monthly MVP V2 scoring helpers.""" + +from __future__ import annotations + +import math +from typing import Mapping + + +MONTHLY_MVP_V2_VERSION = "v2" +MONTHLY_MVP_V2_MIN_MATCHES = 6 +MONTHLY_MVP_V2_MIN_TIME_SECONDS = 21600 +MONTHLY_MVP_V2_FULL_PARTICIPATION_SECONDS = 28800 +MONTHLY_MVP_V2_ADVANCED_CONFIDENCE_KILLS = 35 +MONTHLY_MVP_V2_TEAMKILL_PENALTY_CAP = 8.0 +MONTHLY_MVP_V2_TEAMKILL_PENALTY_PER_KILL = 0.75 + + +def build_monthly_mvp_v2_rankings( + aggregated_rows: list[Mapping[str, object]], + *, + limit: int, +) -> dict[str, object]: + """Transform aggregated monthly totals plus V2 event signals into rankings.""" + eligible_rows = [ + _build_eligible_player_summary(row) + for row in aggregated_rows + if _is_eligible_player_row(row) + ] + + if not eligible_rows: + return { + "ranking_version": MONTHLY_MVP_V2_VERSION, + "eligibility": _build_eligibility_metadata(), + "eligible_players_count": 0, + "items": [], + } + + max_total_kills = max(item["totals"]["kills"] for item in eligible_rows) + max_total_support = max(item["totals"]["support"] for item in eligible_rows) + max_kpm = max(item["derived"]["kpm"] for item in eligible_rows) + max_kda = max(item["derived"]["kda"] for item in eligible_rows) + max_rivalry_edge = max(item["advanced"]["rivalry_edge_raw"] for item in eligible_rows) + max_duel_control = max(item["advanced"]["duel_control_raw"] for item in eligible_rows) + + for item in eligible_rows: + component_scores = { + "kills_score": _log_normalized_score(item["totals"]["kills"], max_total_kills), + "support_score": _log_normalized_score(item["totals"]["support"], max_total_support), + "kpm_score": _log_normalized_score(item["derived"]["kpm"], max_kpm), + "kda_score": _log_normalized_score(item["derived"]["kda"], max_kda), + "participation_score": round( + 100 + * min( + 1.0, + item["totals"]["time_seconds"] / MONTHLY_MVP_V2_FULL_PARTICIPATION_SECONDS, + ), + 3, + ), + "rivalry_edge_score": _log_normalized_score( + item["advanced"]["rivalry_edge_raw"], + max_rivalry_edge, + ), + "duel_control_score": _log_normalized_score( + item["advanced"]["duel_control_raw"], + max_duel_control, + ), + } + advanced_confidence = round( + min( + 1.0, + item["totals"]["kills"] / MONTHLY_MVP_V2_ADVANCED_CONFIDENCE_KILLS, + ), + 3, + ) + teamkill_penalty_v2 = round( + min( + MONTHLY_MVP_V2_TEAMKILL_PENALTY_CAP, + item["totals"]["teamkills"] * MONTHLY_MVP_V2_TEAMKILL_PENALTY_PER_KILL, + ), + 3, + ) + item["component_scores"] = component_scores + item["advanced_confidence"] = advanced_confidence + item["teamkill_penalty_v2"] = teamkill_penalty_v2 + item["mvp_v2_score"] = round( + (0.30 * component_scores["kills_score"]) + + (0.18 * component_scores["support_score"]) + + (0.18 * component_scores["kpm_score"]) + + (0.12 * component_scores["kda_score"]) + + (0.10 * component_scores["participation_score"]) + + advanced_confidence + * ( + (0.07 * component_scores["rivalry_edge_score"]) + + (0.05 * component_scores["duel_control_score"]) + ) + - teamkill_penalty_v2, + 3, + ) + + ranked_items = sorted( + eligible_rows, + key=lambda item: ( + -item["mvp_v2_score"], + -item["advanced_confidence"], + -item["component_scores"]["participation_score"], + -item["component_scores"]["kills_score"], + -item["component_scores"]["rivalry_edge_score"], + item["totals"]["teamkills"], + str(item["player"]["name"]).casefold(), + str(item["player"]["stable_player_key"]), + ), + ) + for position, item in enumerate(ranked_items[:limit], start=1): + item["ranking_position"] = position + + return { + "ranking_version": MONTHLY_MVP_V2_VERSION, + "eligibility": _build_eligibility_metadata(), + "eligible_players_count": len(eligible_rows), + "items": ranked_items[:limit], + } + + +def _is_eligible_player_row(row: Mapping[str, object]) -> bool: + matches_count = int(row.get("matches_count") or 0) + time_seconds = int(row.get("total_time_seconds") or 0) + has_required_fields = all( + row.get(field_name) is not None + for field_name in ("total_kills", "total_deaths", "total_support", "total_time_seconds") + ) + return ( + has_required_fields + and matches_count >= MONTHLY_MVP_V2_MIN_MATCHES + and time_seconds >= MONTHLY_MVP_V2_MIN_TIME_SECONDS + ) + + +def _build_eligible_player_summary(row: Mapping[str, object]) -> dict[str, object]: + total_kills = int(row.get("total_kills") or 0) + total_deaths = int(row.get("total_deaths") or 0) + total_support = int(row.get("total_support") or 0) + total_teamkills = int(row.get("total_teamkills") or 0) + total_time_seconds = int(row.get("total_time_seconds") or 0) + total_time_minutes = max(total_time_seconds / 60.0, 1.0) + most_killed_count = int(row.get("most_killed_count") or 0) + death_by_count = int(row.get("death_by_count") or 0) + duel_control_raw = int(row.get("duel_control_raw") or 0) + kpm = round(total_kills / total_time_minutes, 6) + kda = round(total_kills / max(total_deaths, 1), 6) + return { + "server": { + "slug": row.get("server_slug"), + "name": row.get("server_name"), + }, + "player": { + "stable_player_key": row.get("stable_player_key"), + "name": row.get("player_name"), + "steam_id": row.get("steam_id"), + }, + "matches_considered": int(row.get("matches_count") or 0), + "totals": { + "kills": total_kills, + "deaths": total_deaths, + "support": total_support, + "teamkills": total_teamkills, + "time_seconds": total_time_seconds, + "time_minutes": round(total_time_seconds / 60.0, 2), + }, + "derived": { + "kpm": kpm, + "kda": kda, + }, + "advanced": { + "most_killed_count": most_killed_count, + "death_by_count": death_by_count, + "rivalry_edge_raw": max(0, most_killed_count - death_by_count), + "duel_control_raw": duel_control_raw, + }, + } + + +def _log_normalized_score(value: float | int, max_value: float | int) -> float: + if value <= 0 or max_value <= 0: + return 0.0 + return round((100 * math.log1p(value)) / math.log1p(max_value), 3) + + +def _build_eligibility_metadata() -> dict[str, object]: + return { + "minimum_matches": MONTHLY_MVP_V2_MIN_MATCHES, + "minimum_time_seconds": MONTHLY_MVP_V2_MIN_TIME_SECONDS, + "minimum_time_hours": round(MONTHLY_MVP_V2_MIN_TIME_SECONDS / 3600, 1), + "full_participation_seconds": MONTHLY_MVP_V2_FULL_PARTICIPATION_SECONDS, + "full_participation_hours": round( + MONTHLY_MVP_V2_FULL_PARTICIPATION_SECONDS / 3600, + 1, + ), + "advanced_confidence_kills": MONTHLY_MVP_V2_ADVANCED_CONFIDENCE_KILLS, + "teamkill_penalty_per_kill": MONTHLY_MVP_V2_TEAMKILL_PENALTY_PER_KILL, + "teamkill_penalty_cap": MONTHLY_MVP_V2_TEAMKILL_PENALTY_CAP, + } diff --git a/backend/app/normalizers.py b/backend/app/normalizers.py new file mode 100644 index 0000000..d35d14a --- /dev/null +++ b/backend/app/normalizers.py @@ -0,0 +1,164 @@ +"""Normalization helpers for provisional server collection flows.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from typing import Mapping + +if TYPE_CHECKING: + from .a2s_client import A2SServerInfo + + +MAP_NAME_ALIASES = { + "stmarie": "St. Marie Du Mont", + "stmariedumont": "St. Marie Du Mont", + "saintemariedumont": "St. Marie Du Mont", + "saintemariedumontwarfare": "St. Marie Du Mont", + "saintemariedumontoffensiveus": "St. Marie Du Mont", + "saintemariedumontoffensiveger": "St. Marie Du Mont", + "saintemariedumontnight": "St. Marie Du Mont", + "saintemariedumontovercast": "St. Marie Du Mont", + "sainte-mariedumont": "St. Marie Du Mont", + "sainte-marie-du-mont": "St. Marie Du Mont", + "stmereeglise": "St. Mere Eglise", + "stmereeglisewarfare": "St. Mere Eglise", + "stmereegliseoffensiveus": "St. Mere Eglise", + "stmereegliseoffensiveger": "St. Mere Eglise", + "saintemereeglise": "St. Mere Eglise", + "sainte-mere-eglise": "St. Mere Eglise", + "purpleheartlane": "Purple Heart Lane", + "utahbeach": "Utah Beach", + "omahabeach": "Omaha Beach", + "hurtgenforest": "Hurtgen Forest", + "hill400": "Hill 400", + "foy": "Foy", + "kursk": "Kursk", + "kharkov": "Kharkov", + "kharkiv": "Kharkiv", + "stalingrad": "Stalingrad", + "remagen": "Remagen", + "driel": "Driel", + "elalamein": "El Alamein", + "mortain": "Mortain", + "carentan": "Carentan", + "devn": "Elsenborn Ridge", + "elsenbornridge": "Elsenborn Ridge", + "elsenborn": "Elsenborn Ridge", + "smolensk": "Smolensk", + "smolenskwarfare": "Smolensk", + "smolenskoffensiverus": "Smolensk", + "smolenskoffensiveger": "Smolensk", + "developertestmap": "Smolensk", + "devq": "Smolensk", +} + + +def normalize_server_record( + raw_record: Mapping[str, object], + *, + source_name: str, +) -> dict[str, object]: + """Normalize a raw server record into the collector's internal shape.""" + external_server_id = _string_or_none(raw_record.get("external_server_id")) + return { + "external_server_id": external_server_id, + "server_name": _string_or_default(raw_record.get("server_name"), "Unknown server"), + "status": _normalize_status(raw_record.get("status")), + "players": _coerce_int(raw_record.get("players")), + "max_players": _coerce_int(raw_record.get("max_players")), + "current_map": normalize_map_name(raw_record.get("current_map")), + "region": _string_or_none(raw_record.get("region")), + "source_name": source_name, + "snapshot_origin": "controlled-fallback", + "source_ref": external_server_id or source_name, + } + + +def normalize_a2s_server_info( + server_info: "A2SServerInfo", + *, + source_name: str, + external_server_id: str | None = None, + region: str | None = None, +) -> dict[str, object]: + """Normalize a probed A2S payload into the collector's internal shape.""" + resolved_external_id = external_server_id or ( + f"a2s:{server_info.host}:{server_info.query_port}" + ) + return { + "external_server_id": resolved_external_id, + "server_name": server_info.server_name or "Unknown server", + "status": "online", + "players": server_info.players, + "max_players": server_info.max_players, + "current_map": normalize_map_name(server_info.map_name), + "region": region, + "source_name": source_name, + "snapshot_origin": "real-a2s", + "source_ref": f"a2s://{server_info.host}:{server_info.query_port}", + } + + +def normalize_map_name(value: object) -> str | None: + """Normalize internal or abbreviated HLL map labels into a stable display name.""" + normalized = _string_or_none(value) + if normalized is None: + return None + + alias_key = "".join(character.lower() for character in normalized if character.isalnum()) + alias_match = MAP_NAME_ALIASES.get(alias_key) + if alias_match: + return alias_match + + for candidate_key, candidate_label in MAP_NAME_ALIASES.items(): + if alias_key.startswith(candidate_key): + return candidate_label + + prettified = _prettify_map_name(normalized) + return prettified or normalized + + +def _normalize_status(value: object) -> str: + if not isinstance(value, str): + return "unknown" + + normalized = value.strip().lower() + if normalized in {"online", "offline", "unknown"}: + return normalized + + return "unknown" + + +def _coerce_int(value: object) -> int | None: + if value is None: + return None + + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _string_or_none(value: object) -> str | None: + if not isinstance(value, str): + return None + + stripped = value.strip() + return stripped or None + + +def _string_or_default(value: object, default: str) -> str: + normalized = _string_or_none(value) + return normalized or default + + +def _prettify_map_name(value: str) -> str: + text = value.replace("_", " ").replace("-", " ").strip() + compact_text = " ".join(text.split()) + if not compact_text: + return value + + return " ".join( + word.upper() if word.isdigit() else word.capitalize() + for word in compact_text.split(" ") + ) diff --git a/backend/app/payloads.py b/backend/app/payloads.py new file mode 100644 index 0000000..69efea5 --- /dev/null +++ b/backend/app/payloads.py @@ -0,0 +1,2187 @@ +"""Payload builders for the HLL Vietnam backend.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import re + +from .config import ( + get_historical_data_source_kind, + get_live_data_source_kind, + get_refresh_interval_seconds, +) +from .data_sources import ( + LIVE_SOURCE_A2S, + SOURCE_KIND_PUBLIC_SCOREBOARD, + SOURCE_KIND_RCON, + build_source_attempt, + build_source_policy, + build_historical_runtime_source_policy, + describe_historical_runtime_policy, + get_live_data_source, + get_rcon_historical_read_model, +) +from .historical_snapshot_storage import get_historical_snapshot +from .historical_snapshots import ( + DEFAULT_MONTHLY_SNAPSHOT_WINDOW, + DEFAULT_SNAPSHOT_WINDOW, + DEFAULT_WEEKLY_SNAPSHOT_WINDOW, + SNAPSHOT_TYPE_MONTHLY_LEADERBOARD, + SNAPSHOT_TYPE_MONTHLY_MVP, + SNAPSHOT_TYPE_MONTHLY_MVP_V2, + SNAPSHOT_TYPE_PLAYER_EVENT_DEATH_BY, + SNAPSHOT_TYPE_PLAYER_EVENT_DUELS, + SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED, + SNAPSHOT_TYPE_PLAYER_EVENT_TEAMKILLS, + SNAPSHOT_TYPE_PLAYER_EVENT_WEAPON_KILLS, + SNAPSHOT_TYPE_RECENT_MATCHES, + SNAPSHOT_TYPE_SERVER_SUMMARY, + SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, +) +from .historical_storage import ( + ALL_SERVERS_SLUG, + get_historical_match_detail, + get_historical_player_profile, + list_historical_server_summaries, + list_monthly_leaderboard, + list_recent_historical_matches, + list_weekly_leaderboard, + list_weekly_top_kills, +) +from .rcon_historical_read_model import get_rcon_historical_match_detail +from .normalizers import normalize_map_name +from .rcon_client import load_rcon_targets, query_live_server_sample +from .rcon_admin_log_storage import list_current_match_kill_feed, list_current_match_player_stats +from .scoreboard_origins import get_trusted_public_scoreboard_origin +from .storage import list_latest_snapshots, list_server_history, list_snapshot_history + + +def build_health_payload() -> dict[str, str]: + """Return a small status payload without committing to business contracts.""" + return { + "status": "ok", + "service": "hll-vietnam-backend", + "phase": "bootstrap", + "live_data_source": get_live_data_source_kind(), + "historical_data_source": get_historical_data_source_kind(), + "historical_runtime_policy": describe_historical_runtime_policy()["mode"], + "live_runtime_policy": ( + "rcon-first-with-a2s-fallback" + if get_live_data_source_kind() == SOURCE_KIND_RCON + else "a2s-primary" + ), + } + + +def build_community_payload() -> dict[str, object]: + """Return placeholder community content aligned with the documented contract.""" + return { + "status": "ok", + "data": { + "title": "Comunidad Hispana HLL Vietnam", + "summary": "Punto de encuentro para jugadores, escuadras y comunidad.", + "discord_invite_url": "https://discord.com/invite/PedEqZ2Xsa", + }, + } + + +def build_trailer_payload() -> dict[str, object]: + """Return placeholder trailer metadata for future frontend consumption.""" + return { + "status": "ok", + "data": { + "video_url": "https://www.youtube.com/embed/JzYzYNVWZ_A", + "title": "Trailer HLL Vietnam", + "provider": "youtube", + }, + } + + +def build_discord_payload() -> dict[str, object]: + """Return public Discord placeholder data without real integration.""" + return { + "status": "ok", + "data": { + "invite_url": "https://discord.com/invite/PedEqZ2Xsa", + "label": "Unirse al Discord", + "availability": "manual", + }, + } + + +def build_servers_payload() -> dict[str, object]: + """Return current server status, refreshing stale snapshots before responding.""" + max_snapshot_age_seconds = get_refresh_interval_seconds() + persisted_items = _select_primary_snapshot_items( + _enrich_server_items(list_latest_snapshots()) + ) + persisted_snapshot_at = _resolve_last_snapshot_at(persisted_items) + persisted_snapshot_age_seconds = _calculate_snapshot_age_seconds(persisted_snapshot_at) + + refresh_attempted = _should_refresh_snapshot( + persisted_items, + persisted_snapshot_age_seconds, + max_snapshot_age_seconds, + ) + refresh_errors: list[dict[str, object]] = [] + refresh_source_policy = build_source_policy( + primary_source=get_live_data_source_kind(), + selected_source="none", + fallback_reason=None, + source_attempts=[], + ) + + if refresh_attempted: + refreshed_items, refresh_errors, refresh_source_policy = _try_collect_real_time_snapshot() + if refreshed_items: + refreshed_snapshot_at = _resolve_last_snapshot_at(refreshed_items) + refreshed_snapshot_age_seconds = _calculate_snapshot_age_seconds(refreshed_snapshot_at) + return _build_servers_response( + items=refreshed_items, + response_source=_build_live_response_source(refresh_source_policy), + last_snapshot_at=refreshed_snapshot_at, + snapshot_age_seconds=refreshed_snapshot_age_seconds, + max_snapshot_age_seconds=max_snapshot_age_seconds, + refresh_attempted=True, + refresh_status="success", + refresh_errors=refresh_errors, + source_policy=refresh_source_policy, + ) + + if persisted_items: + refresh_status = "failed" if refresh_attempted else "not-needed" + response_source = ( + "persisted-stale-snapshot" if refresh_attempted else "persisted-fresh-snapshot" + ) + return _build_servers_response( + items=persisted_items, + response_source=response_source, + last_snapshot_at=persisted_snapshot_at, + snapshot_age_seconds=persisted_snapshot_age_seconds, + max_snapshot_age_seconds=max_snapshot_age_seconds, + refresh_attempted=refresh_attempted, + refresh_status=refresh_status, + refresh_errors=refresh_errors, + source_policy=_infer_live_source_policy_from_items( + persisted_items, + refresh_attempted=refresh_attempted, + refresh_errors=refresh_errors, + ), + ) + + return { + "status": "ok", + "data": { + "title": "Estado actual de servidores", + "context": "current-hll-status", + "source": "no-snapshot-available", + "last_snapshot_at": None, + "snapshot_age_seconds": None, + "snapshot_age_minutes": None, + "max_snapshot_age_seconds": max_snapshot_age_seconds, + "is_stale": True, + "freshness": "stale", + "refresh_attempted": refresh_attempted, + "refresh_status": "failed" if refresh_attempted else "not-needed", + "refresh_errors": refresh_errors, + **refresh_source_policy, + "items": [], + }, + } + + +def build_server_latest_payload() -> dict[str, object]: + """Return the latest persisted snapshot for each known server.""" + items = _enrich_server_items(list_latest_snapshots()) + return { + "status": "ok", + "data": { + "title": "Ultimo estado conocido de servidores", + "context": "current-hll-history", + "source": "local-snapshot-storage", + "summary_window_size": 6, + "items": items, + }, + } + + +def build_server_history_payload(*, limit: int = 20) -> dict[str, object]: + """Return recent persisted snapshots across all known servers.""" + items = _enrich_server_items(list_snapshot_history(limit=limit)) + return { + "status": "ok", + "data": { + "title": "Historial reciente de servidores", + "context": "current-hll-history", + "source": "local-snapshot-storage", + "limit": limit, + "items": items, + }, + } + + +def build_server_detail_history_payload( + server_id: str, + *, + limit: int = 20, +) -> dict[str, object]: + """Return recent persisted snapshots for one server.""" + items = _enrich_server_items(list_server_history(server_id, limit=limit)) + return { + "status": "ok", + "data": { + "title": "Historial por servidor", + "context": "current-hll-history", + "source": "local-snapshot-storage", + "server_id": server_id, + "limit": limit, + "items": items, + }, + } + + +def build_current_match_payload(*, server_slug: str) -> dict[str, object]: + """Return the live page projection for one trusted active server.""" + origin = get_trusted_public_scoreboard_origin(server_slug) + if origin is None: + raise ValueError("Unsupported current match server.") + + sample = _query_current_match_rcon_sample(origin.slug) + if sample is not None: + normalized = sample["normalized"] + raw_session = sample["raw_session"] + captured_at = _utc_timestamp_now() + map_id = raw_session.get("mapId") or normalized.get("current_map") + map_name = raw_session.get("mapName") or map_id + map_pretty_name = normalize_map_name(map_name) + return { + "status": "ok", + "data": { + "found": True, + "server_slug": origin.slug, + "server_name": normalized.get("server_name") or origin.display_name, + "status": normalized.get("status") or "unavailable", + "map": map_pretty_name, + "map_id": map_id, + "map_pretty_name": map_pretty_name, + "game_mode": normalized.get("game_mode"), + "started_at": None, + "allied_score": normalized.get("allied_score"), + "axis_score": normalized.get("axis_score"), + "allied_players": normalized.get("allied_players"), + "axis_players": normalized.get("axis_players"), + "players": normalized.get("players"), + "max_players": normalized.get("max_players"), + # RCA: getSession currently reports 0 while the public scoreboard + # can show players, so session population is exposed but unverified. + "player_count_quality": ( + "rcon-session-unverified" + if normalized.get("players") is not None + else None + ), + "player_count_source": _source_when_present( + normalized.get("players"), + source="rcon-session", + ), + "score_source": _source_when_present( + normalized.get("allied_score"), + normalized.get("axis_score"), + source="rcon-session", + ), + "map_source": _source_when_present(map_id, map_name, source="rcon-session"), + "match_time_seconds": normalized.get("match_time_seconds"), + "remaining_match_time_seconds": normalized.get( + "remaining_match_time_seconds" + ), + "captured_at": captured_at, + "updated_at": captured_at, + "public_scoreboard_url": origin.base_url, + }, + } + + # The generic live server snapshot is a fallback only. It intentionally + # drops richer RCON session fields such as game mode and current scores. + server_payload = build_servers_payload() + server_data = server_payload["data"] + item = _find_current_match_snapshot_item(server_data.get("items", []), origin) + return { + "status": "ok", + "data": { + "found": item is not None, + "server_slug": origin.slug, + "server_name": item.get("server_name") if item else origin.display_name, + "status": item.get("status") if item else "unavailable", + "map": item.get("current_map") if item else None, + "map_id": None, + "map_pretty_name": item.get("current_map") if item else None, + "game_mode": item.get("game_mode") if item else None, + "started_at": item.get("started_at") if item else None, + "allied_score": item.get("allied_score") if item else None, + "axis_score": item.get("axis_score") if item else None, + "allied_players": item.get("allied_players") if item else None, + "axis_players": item.get("axis_players") if item else None, + "players": item.get("players") if item else None, + "max_players": item.get("max_players") if item else None, + "player_count_quality": _snapshot_player_count_quality(item), + "player_count_source": _snapshot_player_count_source(item), + "score_source": _source_when_present( + item.get("allied_score") if item else None, + item.get("axis_score") if item else None, + source="live-server-snapshot", + ), + "map_source": _source_when_present( + item.get("current_map") if item else None, + source="live-server-snapshot", + ), + "match_time_seconds": item.get("match_time_seconds") if item else None, + "remaining_match_time_seconds": ( + item.get("remaining_match_time_seconds") if item else None + ), + "captured_at": item.get("captured_at") if item else None, + "updated_at": server_data.get("last_snapshot_at"), + "public_scoreboard_url": origin.base_url, + }, + } + + +def _find_current_match_snapshot_item( + items: list[dict[str, object]], + origin: object, +) -> dict[str, object] | None: + """Resolve one trusted live snapshot for the current-match fallback.""" + origin_slug = str(getattr(origin, "slug", "") or "").strip() + source_markers = { + "comunidad-hispana-01": ("152.114.195.174", ":7779"), + "comunidad-hispana-02": ("152.114.195.150", ":7879"), + }.get(origin_slug) + server_number = getattr(origin, "server_number", None) + + for item in items: + if any( + str(item.get(field) or "").strip() == origin_slug + for field in ( + "external_server_id", + "server_slug", + "target_key", + "slug", + "community_slug", + ) + ): + return item + + server_label = str(item.get("server_name") or item.get("name") or "") + if _current_match_server_name_matches(server_label, server_number): + return item + + source_identity = " ".join( + str(item.get(field) or "") for field in ("external_server_id", "source_ref") + ) + if source_markers and any(marker in source_identity for marker in source_markers): + return item + + return None + + +def _current_match_server_name_matches(server_label: str, server_number: object) -> bool: + if not isinstance(server_number, int): + return False + + normalized_label = server_label.strip().casefold() + if not normalized_label: + return False + + numbered_marker = re.compile(rf"(? dict[str, object]: + """Return normalized AdminLog kill rows for one trusted current-match page.""" + origin = get_trusted_public_scoreboard_origin(server_slug) + if origin is None: + raise ValueError("Unsupported current match server.") + feed = list_current_match_kill_feed( + server_key=origin.slug, + limit=limit, + since_event_id=since_event_id, + ) + return { + "status": "ok", + "data": { + "server_slug": origin.slug, + "server_name": origin.display_name, + **feed, + }, + } + + +def build_current_match_player_stats_payload(*, server_slug: str) -> dict[str, object]: + """Return current player stats only when safe AdminLog evidence exists.""" + origin = get_trusted_public_scoreboard_origin(server_slug) + if origin is None: + raise ValueError("Unsupported current match server.") + stats = list_current_match_player_stats(server_key=origin.slug) + return { + "status": "ok", + "data": { + "server_slug": origin.slug, + "server_name": origin.display_name, + **stats, + }, + } + + +def _query_current_match_rcon_sample(server_slug: str) -> dict[str, object] | None: + """Read one configured trusted RCON target for the current-match view.""" + try: + targets = load_rcon_targets() + except (RuntimeError, ValueError): + return None + target = next( + (candidate for candidate in targets if candidate.external_server_id == server_slug), + None, + ) + if target is None: + return None + try: + return query_live_server_sample(target) + except Exception: # noqa: BLE001 - fall back to the existing live snapshot read + return None + + +def _utc_timestamp_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _source_when_present(*values: object, source: str) -> str | None: + return source if any(value is not None for value in values) else None + + +def _snapshot_player_count_quality(item: dict[str, object] | None) -> str | None: + if item is None or item.get("players") is None: + return None + if item.get("snapshot_origin") == "real-rcon": + return "rcon-session-unverified" + if item.get("snapshot_origin") == "real-a2s": + return "a2s-query" + return "snapshot-unverified" + + +def _snapshot_player_count_source(item: dict[str, object] | None) -> str | None: + if item is None or item.get("players") is None: + return None + if item.get("snapshot_origin") == "real-rcon": + return "rcon-session" + if item.get("snapshot_origin") == "real-a2s": + return "a2s" + return "live-server-snapshot" + + +def build_error_payload(message: str) -> dict[str, str]: + """Return the shared error payload shape used by the backend bootstrap.""" + return { + "status": "error", + "message": message, + } + + +def build_weekly_top_kills_payload( + *, + limit: int = 10, + server_id: str | None = None, +) -> dict[str, object]: + """Return weekly top kills grouped by real community server.""" + result = list_weekly_top_kills(limit=limit, server_id=server_id) + return { + "status": "ok", + "data": { + "title": "Top kills semanales por servidor", + "context": "historical-top-kills", + "metric": "kills", + "summary_basis": "closed-matches-last-7-days", + "window_days": 7, + "window_start": result["window_start"], + "window_end": result["window_end"], + "limit": limit, + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-weekly-top-kills", + ), + "items": result["items"], + }, + } + + +def build_historical_leaderboard_payload( + *, + limit: int = 10, + server_id: str | None = None, + metric: str = "kills", + timeframe: str = "weekly", +) -> dict[str, object]: + """Return one historical leaderboard for the requested timeframe and metric.""" + normalized_timeframe = timeframe.strip().lower() if isinstance(timeframe, str) else "weekly" + if normalized_timeframe == "monthly": + result = list_monthly_leaderboard(limit=limit, server_id=server_id, metric=metric) + summary_basis = "closed-matches-calendar-month" + context = "historical-monthly-leaderboard" + else: + normalized_timeframe = "weekly" + result = list_weekly_leaderboard(limit=limit, server_id=server_id, metric=metric) + summary_basis = "closed-matches-calendar-week" + context = "historical-weekly-leaderboard" + + is_all_servers = server_id == ALL_SERVERS_SLUG + return { + "status": "ok", + "data": { + "title": _build_leaderboard_title( + metric=metric, + timeframe=normalized_timeframe, + is_all_servers=is_all_servers, + ), + "context": context, + "timeframe": normalized_timeframe, + "metric": metric, + "summary_basis": summary_basis, + "window_days": result.get("window_days", 7), + "window_start": result["window_start"], + "window_end": result["window_end"], + "window_kind": result.get("window_kind"), + "window_label": result.get("window_label"), + "uses_fallback": bool(result.get("uses_fallback")), + "selection_reason": result.get("selection_reason"), + "current_week_start": result.get("current_week_start"), + "current_week_closed_matches": result.get("current_week_closed_matches"), + "previous_week_closed_matches": result.get("previous_week_closed_matches"), + "current_month_start": result.get("current_month_start"), + "current_month_closed_matches": result.get("current_month_closed_matches"), + "previous_month_closed_matches": result.get("previous_month_closed_matches"), + "sufficient_sample": result.get("sufficient_sample"), + "limit": limit, + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-competitive-leaderboards", + ), + "items": result["items"], + }, + } + + +def build_weekly_leaderboard_payload( + *, + limit: int = 10, + server_id: str | None = None, + metric: str = "kills", +) -> dict[str, object]: + """Return one weekly historical leaderboard for the requested metric.""" + return build_historical_leaderboard_payload( + limit=limit, + server_id=server_id, + metric=metric, + timeframe="weekly", + ) + + +def build_monthly_leaderboard_payload( + *, + limit: int = 10, + server_id: str | None = None, + metric: str = "kills", +) -> dict[str, object]: + """Return one monthly historical leaderboard for the requested metric.""" + return build_historical_leaderboard_payload( + limit=limit, + server_id=server_id, + metric=metric, + timeframe="monthly", + ) + + +def build_recent_historical_matches_payload( + *, + limit: int = 20, + server_slug: str | None = None, +) -> dict[str, object]: + """Return recent historical matches from persisted CRCON data.""" + if get_historical_data_source_kind() == "rcon": + data_source = get_rcon_historical_read_model() + if data_source is not None: + capabilities = data_source.describe_capabilities() + try: + items = data_source.list_recent_activity(server_key=server_slug, limit=limit) + except Exception as error: # noqa: BLE001 - explicit runtime fallback boundary + items = [] + rcon_source_policy = build_historical_runtime_source_policy( + operation="historical-recent-matches", + rcon_status="error", + fallback_reason="rcon-historical-read-model-request-failed", + rcon_message=str(error), + ) + else: + rcon_source_policy = build_historical_runtime_source_policy( + operation="historical-recent-matches", + rcon_status=( + "success" + if data_source.has_recent_activity_coverage(items) + else "empty" + ), + fallback_reason="rcon-historical-read-model-has-no-recent-activity", + ) + + if not bool(rcon_source_policy.get("fallback_used")): + if 0 < len(items) < limit and not _recent_items_include_rcon_results(items): + fallback_items = [ + _with_recent_result_source(item, "public-scoreboard-fallback") + for item in list_recent_historical_matches( + limit=limit, + server_slug=server_slug, + ) + ] + merged_items = _merge_recent_match_items( + primary_items=items, + fallback_items=fallback_items, + limit=limit, + ) + if len(merged_items) > len(items): + return { + "status": "ok", + "data": { + "title": "Actividad competitiva reciente capturada por RCON", + "context": "historical-recent-matches", + "source": "hybrid-rcon-plus-public-scoreboard", + "historical_data_source": "rcon", + "supported": True, + "coverage_basis": "rcon-competitive-windows-plus-public-scoreboard-fallback", + "limit": limit, + "server_slug": server_slug, + **build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source="hybrid-rcon-plus-public-scoreboard", + fallback_used=True, + fallback_reason=( + "rcon-historical-recent-matches-did-not-reach-requested-limit" + ), + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="success", + reason="historical-recent-matches-served-by-rcon", + ), + build_source_attempt( + source=SOURCE_KIND_PUBLIC_SCOREBOARD, + role="fallback", + status="success", + reason="historical-recent-matches-completed-from-public-scoreboard", + message=( + f"RCON returned {len(items)} items, completed to " + f"{len(merged_items)} of requested {limit}." + ), + ), + ], + ), + "items": merged_items, + "capabilities": capabilities, + }, + } + return { + "status": "ok", + "data": { + "title": "Actividad competitiva reciente capturada por RCON", + "context": "historical-recent-matches", + "source": "rcon-historical-competitive-read-model", + "historical_data_source": "rcon", + "supported": True, + "coverage_basis": "rcon-competitive-windows", + "limit": limit, + "server_slug": server_slug, + **rcon_source_policy, + "items": items, + "capabilities": capabilities, + }, + } + items = [ + _with_recent_result_source(item, "public-scoreboard-fallback") + for item in list_recent_historical_matches(limit=limit, server_slug=server_slug) + ] + return { + "status": "ok", + "data": { + "title": "Partidas recientes por servidor", + "context": "historical-recent-matches", + "source": "historical-crcon-storage", + "limit": limit, + "server_slug": server_slug, + **( + rcon_source_policy + if get_historical_data_source_kind() == "rcon" + and "rcon_source_policy" in locals() + else _resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-has-no-recent-activity", + ) + ), + "items": items, + }, + } + + +def build_historical_match_detail_payload( + *, + server_slug: str, + match_id: str, +) -> dict[str, object]: + """Return available detail for one historical match without inventing external URLs.""" + if get_historical_data_source_kind() == SOURCE_KIND_RCON: + item = get_rcon_historical_match_detail( + server_key=server_slug, + match_id=match_id, + ) + if item is not None: + return { + "status": "ok", + "data": { + "title": "Detalle de partida historica", + "context": "historical-match-detail", + "source": "rcon-historical-competitive-read-model", + "found": True, + **build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source=SOURCE_KIND_RCON, + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="success", + reason="historical-match-detail-served-by-rcon", + ) + ], + ), + "item": item, + }, + } + + item = get_historical_match_detail(server_slug=server_slug, match_id=match_id) + return { + "status": "ok", + "data": { + "title": "Detalle de partida historica", + "context": "historical-match-detail", + "source": "historical-crcon-storage", + "found": item is not None, + **( + _resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-has-no-match-detail" + ) + if get_historical_data_source_kind() == SOURCE_KIND_RCON + else build_source_policy( + primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD, + selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD, + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_PUBLIC_SCOREBOARD, + role="primary", + status="success" if item is not None else "empty", + reason="historical-match-detail-served-by-public-scoreboard", + ) + ], + ) + ), + "item": item, + }, + } + + +def build_monthly_mvp_payload( + *, + limit: int = 10, + server_id: str | None = None, +) -> dict[str, object]: + """Return the precomputed monthly MVP payload through the stable API surface.""" + snapshot_payload = build_monthly_mvp_snapshot_payload( + limit=limit, + server_id=server_id, + ) + data = snapshot_payload["data"] + return { + "status": "ok", + "data": { + **data, + "title": _build_monthly_mvp_title( + is_all_servers=server_id == ALL_SERVERS_SLUG, + snapshot=False, + ), + "context": "historical-monthly-mvp", + "source": "historical-precomputed-snapshots", + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-monthly-mvp-yet", + ), + }, + } + + +def build_player_event_payload( + *, + limit: int = 10, + server_id: str | None = None, + view: str = "most-killed", +) -> dict[str, object]: + """Return one V2 player-event payload through the stable API surface.""" + snapshot_payload = build_player_event_snapshot_payload( + limit=limit, + server_id=server_id, + view=view, + ) + data = snapshot_payload["data"] + return { + "status": "ok", + "data": { + **data, + "title": _build_player_event_title( + view=view, + is_all_servers=server_id == ALL_SERVERS_SLUG, + snapshot=False, + ), + "context": "historical-player-events", + "source": "historical-precomputed-player-event-snapshots", + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-player-events-yet", + ), + }, + } + + +def build_monthly_mvp_v2_payload( + *, + limit: int = 10, + server_id: str | None = None, +) -> dict[str, object]: + """Return the precomputed monthly MVP V2 payload through the stable API surface.""" + snapshot_payload = build_monthly_mvp_v2_snapshot_payload( + limit=limit, + server_id=server_id, + ) + data = snapshot_payload["data"] + return { + "status": "ok", + "data": { + **data, + "title": _build_monthly_mvp_v2_title( + is_all_servers=server_id == ALL_SERVERS_SLUG, + snapshot=False, + ), + "context": "historical-monthly-mvp-v2", + "source": "historical-precomputed-snapshots", + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-monthly-mvp-v2-yet", + ), + }, + } + + +def build_historical_server_summary_snapshot_payload( + *, + server_slug: str | None = None, +) -> dict[str, object]: + """Return one precomputed summary snapshot without recalculating aggregates.""" + snapshot = _get_historical_snapshot_record( + server_key=server_slug, + snapshot_type=SNAPSHOT_TYPE_SERVER_SUMMARY, + window=DEFAULT_SNAPSHOT_WINDOW, + ) + payload = snapshot.get("payload") if snapshot else {} + item = payload.get("item") if isinstance(payload, dict) else None + return { + "status": "ok", + "data": { + "title": "Snapshot historico de resumen por servidor", + "context": "historical-server-summary-snapshot", + "source": "historical-precomputed-snapshots", + "server_slug": server_slug, + "found": snapshot is not None and isinstance(item, dict), + **( + build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source=SOURCE_KIND_RCON, + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="success", + reason="server-summary-snapshot-served-by-rcon-competitive-model", + ) + ], + ) + if get_historical_data_source_kind() == SOURCE_KIND_RCON and isinstance(item, dict) + else _resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet", + ) + ), + **_build_historical_snapshot_metadata(snapshot), + "item": item if isinstance(item, dict) else None, + }, + } + + +def build_leaderboard_snapshot_payload( + *, + limit: int = 10, + server_id: str | None = None, + metric: str = "kills", + timeframe: str = "weekly", +) -> dict[str, object]: + """Return one precomputed leaderboard snapshot for the requested timeframe.""" + normalized_timeframe = timeframe.strip().lower() if isinstance(timeframe, str) else "weekly" + if normalized_timeframe == "monthly": + snapshot_type = SNAPSHOT_TYPE_MONTHLY_LEADERBOARD + window = DEFAULT_MONTHLY_SNAPSHOT_WINDOW + context = "historical-monthly-leaderboard-snapshot" + else: + normalized_timeframe = "weekly" + snapshot_type = SNAPSHOT_TYPE_WEEKLY_LEADERBOARD + window = DEFAULT_WEEKLY_SNAPSHOT_WINDOW + context = "historical-weekly-leaderboard-snapshot" + + snapshot = _get_historical_snapshot_record( + server_key=server_id, + snapshot_type=snapshot_type, + metric=metric, + window=window, + ) + payload = snapshot.get("payload") if snapshot else {} + items = payload.get("items") if isinstance(payload, dict) else None + sliced_items = list(items[:limit]) if isinstance(items, list) else [] + runtime_enrichment_applied = False + if _leaderboard_snapshot_items_need_playtime_enrichment(sliced_items): + runtime_items = _load_runtime_leaderboard_items( + limit=limit, + server_id=server_id, + metric=metric, + timeframe=normalized_timeframe, + ) + if runtime_items: + sliced_items = runtime_items[:limit] + runtime_enrichment_applied = True + is_all_servers = server_id == ALL_SERVERS_SLUG + return { + "status": "ok", + "data": { + "title": _build_leaderboard_title( + metric=metric, + timeframe=normalized_timeframe, + is_all_servers=is_all_servers, + snapshot=True, + ), + "context": context, + "source": "historical-precomputed-snapshots", + "server_slug": server_id, + "timeframe": normalized_timeframe, + "metric": metric, + "found": snapshot is not None, + **_build_historical_snapshot_metadata(snapshot), + "window_days": payload.get("window_days") if isinstance(payload, dict) else 7, + "window_start": payload.get("window_start") if isinstance(payload, dict) else None, + "window_end": payload.get("window_end") if isinstance(payload, dict) else None, + "window_kind": payload.get("window_kind") if isinstance(payload, dict) else None, + "window_label": payload.get("window_label") if isinstance(payload, dict) else None, + "uses_fallback": bool(payload.get("uses_fallback")) if isinstance(payload, dict) else False, + "selection_reason": payload.get("selection_reason") if isinstance(payload, dict) else None, + "current_week_start": payload.get("current_week_start") if isinstance(payload, dict) else None, + "current_week_closed_matches": ( + payload.get("current_week_closed_matches") if isinstance(payload, dict) else None + ), + "previous_week_closed_matches": ( + payload.get("previous_week_closed_matches") if isinstance(payload, dict) else None + ), + "current_month_start": payload.get("current_month_start") if isinstance(payload, dict) else None, + "current_month_closed_matches": ( + payload.get("current_month_closed_matches") if isinstance(payload, dict) else None + ), + "previous_month_closed_matches": ( + payload.get("previous_month_closed_matches") if isinstance(payload, dict) else None + ), + "sufficient_sample": payload.get("sufficient_sample") if isinstance(payload, dict) else None, + "snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None, + "limit": limit, + "runtime_enrichment": { + "applied": runtime_enrichment_applied, + "reason": ( + "snapshot-items-missing-total-time-seconds" + if runtime_enrichment_applied + else None + ), + }, + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet", + ), + "items": sliced_items, + }, + } + + +def build_weekly_leaderboard_snapshot_payload( + *, + limit: int = 10, + server_id: str | None = None, + metric: str = "kills", +) -> dict[str, object]: + """Return one precomputed weekly leaderboard snapshot.""" + return build_leaderboard_snapshot_payload( + limit=limit, + server_id=server_id, + metric=metric, + timeframe="weekly", + ) + + +def build_monthly_leaderboard_snapshot_payload( + *, + limit: int = 10, + server_id: str | None = None, + metric: str = "kills", +) -> dict[str, object]: + """Return one precomputed monthly leaderboard snapshot.""" + return build_leaderboard_snapshot_payload( + limit=limit, + server_id=server_id, + metric=metric, + timeframe="monthly", + ) + + +def build_recent_historical_matches_snapshot_payload( + *, + limit: int = 20, + server_slug: str | None = None, +) -> dict[str, object]: + """Return one precomputed recent-matches snapshot.""" + snapshot = _get_historical_snapshot_record( + server_key=server_slug, + snapshot_type=SNAPSHOT_TYPE_RECENT_MATCHES, + window=DEFAULT_SNAPSHOT_WINDOW, + ) + payload = snapshot.get("payload") if snapshot else {} + items = payload.get("items") if isinstance(payload, dict) else None + sliced_items = list(items[:limit]) if isinstance(items, list) else [] + if ( + get_historical_data_source_kind() == SOURCE_KIND_RCON + and 0 < len(sliced_items) < limit + ): + fallback_items = list_recent_historical_matches(limit=limit, server_slug=server_slug) + merged_items = _merge_recent_match_items( + primary_items=sliced_items, + fallback_items=fallback_items, + limit=limit, + ) + if len(merged_items) > len(sliced_items): + return { + "status": "ok", + "data": { + "title": "Snapshot historico de partidas recientes por servidor", + "context": "historical-recent-matches-snapshot", + "source": "historical-precomputed-snapshots", + "server_slug": server_slug, + "found": snapshot is not None, + **_build_historical_snapshot_metadata(snapshot), + "snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None, + "limit": limit, + **build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source="hybrid-rcon-plus-public-scoreboard", + fallback_used=True, + fallback_reason="rcon-historical-recent-matches-did-not-reach-requested-limit", + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="success", + reason="recent-matches-snapshot-served-by-rcon-competitive-model", + ), + build_source_attempt( + source=SOURCE_KIND_PUBLIC_SCOREBOARD, + role="fallback", + status="success", + reason="recent-matches-snapshot-completed-from-public-scoreboard", + message=( + f"RCON snapshot returned {len(sliced_items)} items, completed to " + f"{len(merged_items)} of requested {limit}." + ), + ), + ], + ), + "items": merged_items, + }, + } + return { + "status": "ok", + "data": { + "title": "Snapshot historico de partidas recientes por servidor", + "context": "historical-recent-matches-snapshot", + "source": "historical-precomputed-snapshots", + "server_slug": server_slug, + "found": snapshot is not None, + **_build_historical_snapshot_metadata(snapshot), + "snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None, + "limit": limit, + **( + build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source=SOURCE_KIND_RCON, + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="success", + reason="recent-matches-snapshot-served-by-rcon-competitive-model", + ) + ], + ) + if get_historical_data_source_kind() == SOURCE_KIND_RCON and sliced_items + else _resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet", + ) + ), + "items": sliced_items, + }, + } + + +def build_monthly_mvp_snapshot_payload( + *, + limit: int = 10, + server_id: str | None = None, +) -> dict[str, object]: + """Return one precomputed monthly MVP snapshot.""" + snapshot = _get_historical_snapshot_record( + server_key=server_id, + snapshot_type=SNAPSHOT_TYPE_MONTHLY_MVP, + window=DEFAULT_MONTHLY_SNAPSHOT_WINDOW, + ) + payload = snapshot.get("payload") if snapshot else {} + items = payload.get("items") if isinstance(payload, dict) else None + sliced_items = list(items[:limit]) if isinstance(items, list) else [] + return { + "status": "ok", + "data": { + "title": _build_monthly_mvp_title( + is_all_servers=server_id == ALL_SERVERS_SLUG, + snapshot=True, + ), + "context": "historical-monthly-mvp-snapshot", + "source": "historical-precomputed-snapshots", + "server_slug": server_id, + "timeframe": "monthly", + "metric": "mvp", + "found": snapshot is not None, + **_build_historical_snapshot_metadata(snapshot), + "month_key": payload.get("month_key") if isinstance(payload, dict) else None, + "window_days": payload.get("window_days") if isinstance(payload, dict) else None, + "window_start": payload.get("window_start") if isinstance(payload, dict) else None, + "window_end": payload.get("window_end") if isinstance(payload, dict) else None, + "window_kind": payload.get("window_kind") if isinstance(payload, dict) else None, + "window_label": payload.get("window_label") if isinstance(payload, dict) else None, + "uses_fallback": bool(payload.get("uses_fallback")) if isinstance(payload, dict) else False, + "selection_reason": payload.get("selection_reason") if isinstance(payload, dict) else None, + "current_month_start": payload.get("current_month_start") if isinstance(payload, dict) else None, + "current_month_closed_matches": ( + payload.get("current_month_closed_matches") if isinstance(payload, dict) else None + ), + "previous_month_closed_matches": ( + payload.get("previous_month_closed_matches") if isinstance(payload, dict) else None + ), + "sufficient_sample": payload.get("sufficient_sample") if isinstance(payload, dict) else None, + "eligibility": payload.get("eligibility") if isinstance(payload, dict) else None, + "ranking_version": payload.get("ranking_version") if isinstance(payload, dict) else None, + "eligible_players_count": ( + payload.get("eligible_players_count") if isinstance(payload, dict) else 0 + ), + "snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None, + "limit": limit, + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet", + ), + "items": sliced_items, + }, + } + + +def build_monthly_mvp_v2_snapshot_payload( + *, + limit: int = 10, + server_id: str | None = None, +) -> dict[str, object]: + """Return one precomputed monthly MVP V2 snapshot.""" + snapshot = _get_historical_snapshot_record( + server_key=server_id, + snapshot_type=SNAPSHOT_TYPE_MONTHLY_MVP_V2, + window=DEFAULT_MONTHLY_SNAPSHOT_WINDOW, + ) + payload = snapshot.get("payload") if snapshot else {} + items = payload.get("items") if isinstance(payload, dict) else None + sliced_items = list(items[:limit]) if isinstance(items, list) else [] + found = bool(payload.get("found")) if isinstance(payload, dict) else False + return { + "status": "ok", + "data": { + "title": _build_monthly_mvp_v2_title( + is_all_servers=server_id == ALL_SERVERS_SLUG, + snapshot=True, + ), + "context": "historical-monthly-mvp-v2-snapshot", + "source": "historical-precomputed-snapshots", + "server_slug": server_id, + "timeframe": "monthly", + "metric": "mvp-v2", + "found": snapshot is not None and found, + **_build_historical_snapshot_metadata(snapshot), + "month_key": payload.get("month_key") if isinstance(payload, dict) else None, + "window_days": payload.get("window_days") if isinstance(payload, dict) else None, + "window_start": payload.get("window_start") if isinstance(payload, dict) else None, + "window_end": payload.get("window_end") if isinstance(payload, dict) else None, + "window_kind": payload.get("window_kind") if isinstance(payload, dict) else None, + "window_label": payload.get("window_label") if isinstance(payload, dict) else None, + "uses_fallback": bool(payload.get("uses_fallback")) if isinstance(payload, dict) else False, + "selection_reason": payload.get("selection_reason") if isinstance(payload, dict) else None, + "current_month_start": payload.get("current_month_start") if isinstance(payload, dict) else None, + "current_month_closed_matches": ( + payload.get("current_month_closed_matches") if isinstance(payload, dict) else None + ), + "previous_month_closed_matches": ( + payload.get("previous_month_closed_matches") if isinstance(payload, dict) else None + ), + "sufficient_sample": payload.get("sufficient_sample") if isinstance(payload, dict) else None, + "eligibility": payload.get("eligibility") if isinstance(payload, dict) else None, + "ranking_version": payload.get("ranking_version") if isinstance(payload, dict) else None, + "event_coverage": payload.get("event_coverage") if isinstance(payload, dict) else None, + "eligible_players_count": ( + payload.get("eligible_players_count") if isinstance(payload, dict) else 0 + ), + "snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None, + "limit": limit, + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet", + ), + "items": sliced_items, + }, + } + + +def build_player_event_snapshot_payload( + *, + limit: int = 10, + server_id: str | None = None, + view: str = "most-killed", +) -> dict[str, object]: + """Return one precomputed V2 player-event snapshot.""" + snapshot_type = _resolve_player_event_snapshot_type(view) + snapshot = _get_historical_snapshot_record( + server_key=server_id, + snapshot_type=snapshot_type, + window=DEFAULT_MONTHLY_SNAPSHOT_WINDOW, + ) + payload = snapshot.get("payload") if snapshot else {} + items = payload.get("items") if isinstance(payload, dict) else None + sliced_items = list(items[:limit]) if isinstance(items, list) else [] + found = bool(payload.get("found")) if isinstance(payload, dict) else False + return { + "status": "ok", + "data": { + "title": _build_player_event_title( + view=view, + is_all_servers=server_id == ALL_SERVERS_SLUG, + snapshot=True, + ), + "context": "historical-player-events-snapshot", + "source": "historical-precomputed-player-event-snapshots", + "server_slug": server_id, + "timeframe": "monthly", + "metric": view, + "found": snapshot is not None and found, + **_build_historical_snapshot_metadata(snapshot), + "period": payload.get("period") if isinstance(payload, dict) else "monthly", + "month_key": payload.get("month_key") if isinstance(payload, dict) else None, + "snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None, + "limit": limit, + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet", + ), + "items": sliced_items, + }, + } + + +def build_historical_server_summary_payload( + *, + server_slug: str | None = None, +) -> dict[str, object]: + """Return aggregated historical metrics per server.""" + if get_historical_data_source_kind() == "rcon": + data_source = get_rcon_historical_read_model() + if data_source is not None: + capabilities = data_source.describe_capabilities() + try: + items = data_source.list_server_summaries(server_key=server_slug) + except Exception as error: # noqa: BLE001 - explicit runtime fallback boundary + items = [] + rcon_source_policy = build_historical_runtime_source_policy( + operation="historical-server-summary", + rcon_status="error", + fallback_reason="rcon-historical-read-model-request-failed", + rcon_message=str(error), + ) + else: + rcon_source_policy = build_historical_runtime_source_policy( + operation="historical-server-summary", + rcon_status=( + "success" + if data_source.has_server_summary_coverage(items) + else "empty" + ), + fallback_reason="rcon-historical-read-model-has-no-summary-coverage", + ) + + if not bool(rcon_source_policy.get("fallback_used")): + return { + "status": "ok", + "data": { + "title": ( + "Cobertura historica minima por RCON" + if server_slug != ALL_SERVERS_SLUG + else "Cobertura historica minima RCON agregada" + ), + "context": "historical-server-summary", + "source": "rcon-historical-competitive-read-model", + "historical_data_source": "rcon", + "summary_basis": "rcon-competitive-windows", + "server_slug": server_slug, + "supported": True, + **rcon_source_policy, + "items": items, + "capabilities": capabilities, + }, + } + items = list_historical_server_summaries(server_slug=server_slug) + return { + "status": "ok", + "data": { + "title": ( + "Cobertura historica agregada de todos los servidores" + if server_slug == ALL_SERVERS_SLUG + else "Cobertura historica importada por servidor" + ), + "context": "historical-server-summary", + "source": "historical-crcon-storage", + "summary_basis": "persisted-import", + "weekly_ranking_window_days": 7, + "server_slug": server_slug, + **( + rcon_source_policy + if get_historical_data_source_kind() == "rcon" + and "rcon_source_policy" in locals() + else _resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-has-no-summary-coverage", + ) + ), + "items": items, + }, + } + + +def build_historical_player_profile_payload(player_id: str) -> dict[str, object]: + """Return aggregate historical metrics for one player identity.""" + profile = get_historical_player_profile(player_id) + return { + "status": "ok", + "data": { + "title": "Perfil historico de jugador", + "context": "historical-player-profile", + "source": "historical-crcon-storage", + "player_id": player_id, + "found": profile is not None, + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-player-profile-yet", + ), + "profile": profile, + }, + } + + +def build_elo_mmr_leaderboard_payload( + *, + limit: int = 10, + server_id: str | None = None, +) -> dict[str, object]: + """Return the current Elo/MMR monthly leaderboard.""" + engine = _load_elo_mmr_engine() + if engine is None: + return _build_elo_mmr_unavailable_payload( + context="historical-elo-mmr-leaderboard", + title=( + "Leaderboard mensual Elo/MMR global" + if server_id == ALL_SERVERS_SLUG + else "Leaderboard mensual Elo/MMR por servidor" + ), + server_id=server_id, + limit=limit, + extra={"items": []}, + operation="elo-mmr-leaderboard", + ) + + list_elo_mmr_leaderboard_payload = engine[1] + payload = list_elo_mmr_leaderboard_payload(server_id=server_id, limit=limit) + is_all_servers = server_id == ALL_SERVERS_SLUG + accuracy_contract = _build_elo_accuracy_contract(payload.get("capabilities_summary")) + return { + "status": "ok", + "data": { + "title": ( + "Leaderboard mensual Elo/MMR global" + if is_all_servers + else "Leaderboard mensual Elo/MMR por servidor" + ), + "context": "historical-elo-mmr-leaderboard", + "source": "elo-mmr-persisted-read-model", + "server_slug": server_id, + "month_key": payload.get("month_key"), + "found": bool(payload.get("found")), + "generated_at": payload.get("generated_at"), + "limit": limit, + **(payload.get("source_policy") or _resolve_historical_fallback_policy( + operation="elo-mmr-leaderboard", + fallback_reason="elo-mmr-source-policy-missing", + )), + "capabilities_summary": payload.get("capabilities_summary"), + "accuracy_contract": accuracy_contract, + "model_contract": _build_elo_model_contract(accuracy_contract), + "items": [ + _enrich_elo_leaderboard_item(item, accuracy_contract=accuracy_contract) + for item in (payload.get("items") or []) + if isinstance(item, dict) + ], + }, + } + + +def build_elo_mmr_player_payload( + *, + player_id: str, + server_id: str | None = None, +) -> dict[str, object]: + """Return one Elo/MMR player profile.""" + engine = _load_elo_mmr_engine() + if engine is None: + return _build_elo_mmr_unavailable_payload( + context="historical-elo-mmr-player", + title="Perfil Elo/MMR de jugador", + server_id=server_id, + extra={ + "player_id": player_id, + "found": False, + "profile": None, + }, + operation="elo-mmr-player", + ) + + get_elo_mmr_player_payload, list_elo_mmr_leaderboard_payload = engine + profile = get_elo_mmr_player_payload(player_id=player_id, server_id=server_id) + source_policy = list_elo_mmr_leaderboard_payload(server_id=server_id, limit=1).get("source_policy") + accuracy_contract = _build_elo_player_accuracy_contract(profile) + return { + "status": "ok", + "data": { + "title": "Perfil Elo/MMR de jugador", + "context": "historical-elo-mmr-player", + "source": "elo-mmr-persisted-read-model", + "player_id": player_id, + "server_slug": server_id, + "found": profile is not None, + **(source_policy or _resolve_historical_fallback_policy( + operation="elo-mmr-player", + fallback_reason="elo-mmr-player-source-policy-missing", + )), + "accuracy_contract": accuracy_contract, + "model_contract": _build_elo_model_contract(accuracy_contract), + "profile": _enrich_elo_profile(profile, accuracy_contract=accuracy_contract), + }, + } + + +def _load_elo_mmr_engine(): + try: + from .elo_mmr_engine import ( # noqa: PLC0415 - lazy boundary for paused Elo/MMR + get_elo_mmr_player_payload, + list_elo_mmr_leaderboard_payload, + ) + except ImportError: + return None + return get_elo_mmr_player_payload, list_elo_mmr_leaderboard_payload + + +def _build_elo_mmr_unavailable_payload( + *, + context: str, + title: str, + server_id: str | None, + operation: str, + limit: int | None = None, + extra: dict[str, object] | None = None, +) -> dict[str, object]: + accuracy_contract = _build_elo_accuracy_contract(None) + data = { + "title": title, + "context": context, + "source": "elo-mmr-paused", + "server_slug": server_id, + "available": False, + "unavailable_reason": "elo-mmr-engine-import-unavailable", + **_resolve_historical_fallback_policy( + operation=operation, + fallback_reason="elo-mmr-operationally-paused", + ), + "capabilities_summary": None, + "accuracy_contract": accuracy_contract, + "model_contract": _build_elo_model_contract(accuracy_contract), + } + if limit is not None: + data["limit"] = limit + if extra: + data.update(extra) + return { + "status": "ok", + "data": data, + } + + +def _build_elo_player_accuracy_contract(profile: dict[str, object] | None) -> dict[str, object]: + if not isinstance(profile, dict): + return _build_elo_accuracy_contract(None) + monthly_ranking = profile.get("monthly_ranking") + if isinstance(monthly_ranking, dict) and isinstance(monthly_ranking.get("capabilities"), dict): + return _build_elo_accuracy_contract(monthly_ranking.get("capabilities")) + persistent_rating = profile.get("persistent_rating") + if isinstance(persistent_rating, dict) and isinstance(persistent_rating.get("capabilities"), dict): + return _build_elo_accuracy_contract(persistent_rating.get("capabilities")) + return _build_elo_accuracy_contract(None) + + +def _build_elo_accuracy_contract(summary: dict[str, object] | None) -> dict[str, object]: + capabilities = summary if isinstance(summary, dict) else {} + signals = capabilities.get("signals") + normalized_signals = [signal for signal in signals if isinstance(signal, dict)] if isinstance(signals, list) else [] + component_status = { + str(signal.get("name") or "").strip(): signal.get("status") + for signal in normalized_signals + if str(signal.get("name") or "").strip() + } + return { + "accuracy_mode": capabilities.get("accuracy_mode") or "unknown", + "exact_ratio": capabilities.get("exact_ratio"), + "approximate_ratio": capabilities.get("approximate_ratio"), + "not_available_ratio": capabilities.get("unavailable_ratio"), + "component_status": component_status, + "blocked_components": [ + name for name, status in component_status.items() if status == "not_available" + ], + "explanation": { + "exact": "computed from persisted repository signals without proxy substitution", + "approximate": "computed with explicit proxies because the ideal telemetry is not stored yet", + "not_available": "not computable yet with the current repository telemetry", + }, + } + + +def _build_elo_model_contract(accuracy_contract: dict[str, object]) -> dict[str, object]: + blocked_components = accuracy_contract.get("blocked_components") + return { + "persistent_rating": { + "meaning": "long-lived competitive rating rebuilt from persisted matches for the selected scope", + "primary_field": "persistent_rating.mmr", + }, + "monthly_rank_score": { + "meaning": "monthly leaderboard ordering score that combines rating movement, match quality, activity and confidence", + "primary_field": "monthly_rank_score", + }, + "elo_core": { + "meaning": "competitive rating movement driven by expected-vs-actual outcome against opponent rating pressure", + "fields": ["components.elo_core_gain"], + }, + "performance_modifiers": { + "meaning": "bounded HLL-specific adjustments layered on top of the competitive Elo core", + "fields": [ + "components.performance_modifier_gain", + "components.proxy_modifier_gain", + ], + }, + "proxy_boundary": { + "meaning": "subset of modifier logic that still depends on approximate signals such as role, objective, schedule or discipline proxies", + "blocked_by_telemetry": blocked_components if isinstance(blocked_components, list) else [], + }, + } + + +def _enrich_elo_leaderboard_item( + item: dict[str, object], + *, + accuracy_contract: dict[str, object], +) -> dict[str, object]: + enriched = dict(item) + components = item.get("components") if isinstance(item.get("components"), dict) else {} + persistent_rating = item.get("persistent_rating") if isinstance(item.get("persistent_rating"), dict) else {} + delta_breakdown = _resolve_elo_delta_sources( + components, + persistent_rating=persistent_rating, + ) + enriched["rating_breakdown"] = { + "persistent_rating": { + "mmr": persistent_rating.get("mmr"), + "baseline_mmr": persistent_rating.get("baseline_mmr"), + "net_mmr_gain": persistent_rating.get("mmr_gain"), + }, + "monthly_ranking": { + "score": item.get("monthly_rank_score"), + "valid_matches": item.get("valid_matches"), + "confidence": components.get("confidence"), + }, + "delta_sources": delta_breakdown["values"], + "materialization": delta_breakdown["materialization"], + "telemetry_boundary": { + "approximate_ratio": accuracy_contract.get("approximate_ratio"), + "blocked_components": accuracy_contract.get("blocked_components") or [], + }, + } + return enriched + + +def _enrich_elo_profile( + profile: dict[str, object] | None, + *, + accuracy_contract: dict[str, object], +) -> dict[str, object] | None: + if not isinstance(profile, dict): + return profile + enriched = dict(profile) + monthly_ranking = dict(profile.get("monthly_ranking")) if isinstance(profile.get("monthly_ranking"), dict) else None + if monthly_ranking is not None: + components = monthly_ranking.get("components") if isinstance(monthly_ranking.get("components"), dict) else {} + delta_breakdown = _resolve_elo_delta_sources( + components, + persistent_rating={ + "mmr_gain": monthly_ranking.get("mmr_gain"), + "baseline_mmr": monthly_ranking.get("baseline_mmr"), + "mmr": monthly_ranking.get("current_mmr"), + }, + ) + monthly_ranking["rating_breakdown"] = { + "monthly_rank_score": monthly_ranking.get("monthly_rank_score"), + "current_mmr": monthly_ranking.get("current_mmr"), + "baseline_mmr": monthly_ranking.get("baseline_mmr"), + "net_mmr_gain": monthly_ranking.get("mmr_gain"), + "elo_core_gain": delta_breakdown["values"]["elo_core_gain"], + "performance_modifier_gain": delta_breakdown["values"]["performance_modifier_gain"], + "proxy_modifier_gain": delta_breakdown["values"]["proxy_modifier_gain"], + "confidence": components.get("confidence"), + "avg_participation_ratio": components.get("avg_participation_ratio"), + "materialization": delta_breakdown["materialization"], + } + enriched["monthly_ranking"] = monthly_ranking + persistent_rating = dict(profile.get("persistent_rating")) if isinstance(profile.get("persistent_rating"), dict) else None + if persistent_rating is not None: + persistent_rating["meaning"] = "persistent competitive rating for the selected scope" + enriched["persistent_rating"] = persistent_rating + enriched["telemetry_boundary"] = { + "accuracy_mode": accuracy_contract.get("accuracy_mode"), + "blocked_components": accuracy_contract.get("blocked_components") or [], + } + return enriched + + +def _resolve_elo_delta_sources( + components: dict[str, object], + *, + persistent_rating: dict[str, object] | None, +) -> dict[str, object]: + elo_core_gain = _coerce_optional_float(components.get("elo_core_gain")) + performance_modifier_gain = _coerce_optional_float(components.get("performance_modifier_gain")) + proxy_modifier_gain = _coerce_optional_float(components.get("proxy_modifier_gain")) + if ( + elo_core_gain is not None + or performance_modifier_gain is not None + or proxy_modifier_gain is not None + ): + return { + "values": { + "elo_core_gain": elo_core_gain, + "performance_modifier_gain": performance_modifier_gain, + "proxy_modifier_gain": proxy_modifier_gain, + }, + "materialization": { + "status": "v3-materialized", + "reason": "persisted-monthly-ranking-includes-v3-delta-sources", + "delta_sources_accuracy": "exact-or-proxy-as-persisted", + }, + } + + legacy_net_gain = _coerce_optional_float(components.get("mmr_gain_raw")) + if legacy_net_gain is None and isinstance(persistent_rating, dict): + legacy_net_gain = _coerce_optional_float(persistent_rating.get("mmr_gain")) + if legacy_net_gain is None: + return { + "values": { + "elo_core_gain": None, + "performance_modifier_gain": None, + "proxy_modifier_gain": None, + }, + "materialization": { + "status": "v3-delta-sources-unavailable", + "reason": ( + "persisted-monthly-ranking-predates-v3-delta-split-and-has-no-compatible-net-gain" + ), + "delta_sources_accuracy": "not_available", + }, + } + + return { + "values": { + "elo_core_gain": legacy_net_gain, + "performance_modifier_gain": 0.0, + "proxy_modifier_gain": 0.0, + }, + "materialization": { + "status": "legacy-compatibility-approximation", + "reason": ( + "persisted-monthly-ranking-predates-v3-delta-split-api-approximates-delta-sources-" + "from-legacy-net-mmr-gain" + ), + "delta_sources_accuracy": "approximate", + }, + } + + +def _coerce_optional_float(value: object) -> float | None: + if value is None: + return None + try: + return round(float(value), 3) + except (TypeError, ValueError): + return None + + +def _leaderboard_snapshot_items_need_playtime_enrichment(items: list[object]) -> bool: + normalized_items = [item for item in items if isinstance(item, dict)] + if not normalized_items: + return False + return any("total_time_seconds" not in item for item in normalized_items) + + +def _load_runtime_leaderboard_items( + *, + limit: int, + server_id: str | None, + metric: str, + timeframe: str, +) -> list[dict[str, object]]: + if timeframe == "monthly": + result = list_monthly_leaderboard(limit=limit, server_id=server_id, metric=metric) + else: + result = list_weekly_leaderboard(limit=limit, server_id=server_id, metric=metric) + items = result.get("items") if isinstance(result, dict) else None + return [item for item in items if isinstance(item, dict)] if isinstance(items, list) else [] + + +def _get_historical_snapshot_record( + *, + server_key: str | None, + snapshot_type: str, + metric: str | None = None, + window: str | None = None, +) -> dict[str, object] | None: + if not server_key: + return None + return get_historical_snapshot( + server_key=server_key, + snapshot_type=snapshot_type, + metric=metric, + window=window, + ) + + +def _build_historical_snapshot_metadata(snapshot: dict[str, object] | None) -> dict[str, object]: + if snapshot is None: + return { + "snapshot_status": "missing", + "missing_reason": "snapshot-not-generated", + "request_path_policy": "read-only-fast-path", + "generation_policy": "out-of-band-refresh-only", + "generated_at": None, + "source_range_start": None, + "source_range_end": None, + "is_stale": True, + "freshness": "stale", + } + is_stale = bool(snapshot.get("is_stale", False)) + return { + "snapshot_status": "ready", + "missing_reason": None, + "request_path_policy": "read-only-fast-path", + "generation_policy": "out-of-band-refresh-only", + "generated_at": snapshot.get("generated_at"), + "source_range_start": snapshot.get("source_range_start"), + "source_range_end": snapshot.get("source_range_end"), + "is_stale": is_stale, + "freshness": "stale" if is_stale else "fresh", + } + + +def _build_leaderboard_title( + *, + metric: str, + timeframe: str, + is_all_servers: bool, + snapshot: bool = False, +) -> str: + timeframe_label = "mensual" if timeframe == "monthly" else "semanal" + scope_label = "totales" if is_all_servers else "por servidor" + prefix = "Snapshot " if snapshot else "" + title_by_metric = { + "kills": f"{prefix}Top kills {timeframe_label} {scope_label}", + "deaths": f"{prefix}Top muertes {timeframe_label} {scope_label}", + "support": f"{prefix}Top puntos de soporte {timeframe_label} {scope_label}", + "matches_over_100_kills": f"{prefix}Top partidas de 100+ kills {timeframe_label} {scope_label}", + } + fallback_label = f"{prefix}Ranking {timeframe_label} por servidor".strip() + return title_by_metric.get(metric, fallback_label) + + +def _build_monthly_mvp_title(*, is_all_servers: bool, snapshot: bool = False) -> str: + prefix = "Snapshot " if snapshot else "" + scope_label = "global" if is_all_servers else "por servidor" + return f"{prefix}Top MVP mensual {scope_label}" + + +def _build_monthly_mvp_v2_title(*, is_all_servers: bool, snapshot: bool = False) -> str: + prefix = "Snapshot " if snapshot else "" + scope_label = "global" if is_all_servers else "por servidor" + return f"{prefix}Top MVP mensual V2 {scope_label}" + + +def _build_player_event_title( + *, + view: str, + is_all_servers: bool, + snapshot: bool = False, +) -> str: + prefix = "Snapshot " if snapshot else "" + scope_label = "global" if is_all_servers else "por servidor" + title_by_view = { + "most-killed": f"{prefix}Most killed mensual {scope_label}", + "death-by": f"{prefix}Death by mensual {scope_label}", + "duels": f"{prefix}Duelos netos mensuales {scope_label}", + "weapon-kills": f"{prefix}Kills por arma mensuales {scope_label}", + "teamkills": f"{prefix}Teamkills mensuales {scope_label}", + } + return title_by_view.get(view, f"{prefix}Metricas V2 mensuales {scope_label}") + + +def _resolve_player_event_snapshot_type(view: str) -> str: + normalized_view = view.strip().lower() if isinstance(view, str) else "most-killed" + snapshot_type_by_view = { + "most-killed": SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED, + "death-by": SNAPSHOT_TYPE_PLAYER_EVENT_DEATH_BY, + "duels": SNAPSHOT_TYPE_PLAYER_EVENT_DUELS, + "weapon-kills": SNAPSHOT_TYPE_PLAYER_EVENT_WEAPON_KILLS, + "teamkills": SNAPSHOT_TYPE_PLAYER_EVENT_TEAMKILLS, + } + return snapshot_type_by_view.get(normalized_view, SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED) + + +def _enrich_server_items(items: list[dict[str, object]]) -> list[dict[str, object]]: + target_index = get_live_data_source().build_target_index() + enriched_items: list[dict[str, object]] = [] + for item in items: + enriched_items.append(_enrich_server_item(item, target_index)) + return enriched_items + + +def _select_primary_snapshot_items(items: list[dict[str, object]]) -> list[dict[str, object]]: + preferred_origin = ( + "real-rcon" + if get_live_data_source_kind() == "rcon" + else "real-a2s" + ) + preferred_items = [ + item + for item in items + if item.get("snapshot_origin") == preferred_origin + ] + return preferred_items or items + + +def _enrich_server_item( + item: dict[str, object], + target_index: dict[str, object], +) -> dict[str, object]: + enriched = dict(item) + enriched["current_map"] = normalize_map_name(enriched.get("current_map")) + history_url = _resolve_community_history_url(enriched.get("external_server_id")) + enriched["community_history_url"] = history_url + enriched["community_history_available"] = bool(history_url) + external_server_id = enriched.get("external_server_id") + snapshot_origin = enriched.get("snapshot_origin") + target = target_index.get(external_server_id) + + if not target or snapshot_origin not in {"real-a2s", "real-rcon"}: + enriched["host"] = None + enriched["query_port"] = None + enriched["game_port"] = None + return enriched + + enriched["host"] = target.host + enriched["query_port"] = target.query_port + enriched["game_port"] = target.game_port + return enriched + + +def _resolve_last_snapshot_at(items: list[dict[str, object]]) -> str | None: + timestamps = [ + str(item["captured_at"]) + for item in items + if item.get("captured_at") + ] + if not timestamps: + return None + + return max(timestamps) + + +def _should_refresh_snapshot( + items: list[dict[str, object]], + snapshot_age_seconds: int | None, + max_snapshot_age_seconds: int, +) -> bool: + if not items: + return True + + if snapshot_age_seconds is None: + return True + + return snapshot_age_seconds > max_snapshot_age_seconds + + +def _try_collect_real_time_snapshot() -> tuple[ + list[dict[str, object]], + list[dict[str, object]], + dict[str, object], +]: + payload = get_live_data_source().collect_snapshots(persist=False) + snapshots = payload.get("snapshots") + items = _select_primary_snapshot_items(_enrich_server_items(list(snapshots or []))) + errors = payload.get("errors") + return ( + items, + list(errors or []), + { + "primary_source": payload.get("primary_source"), + "selected_source": payload.get("selected_source"), + "fallback_used": bool(payload.get("fallback_used")), + "fallback_reason": payload.get("fallback_reason"), + "source_attempts": list(payload.get("source_attempts") or []), + }, + ) + + +def _build_servers_response( + *, + items: list[dict[str, object]], + response_source: str, + last_snapshot_at: str | None, + snapshot_age_seconds: int | None, + max_snapshot_age_seconds: int, + refresh_attempted: bool, + refresh_status: str, + refresh_errors: list[dict[str, object]], + source_policy: dict[str, object], +) -> dict[str, object]: + freshness = ( + "fresh" + if snapshot_age_seconds is not None and snapshot_age_seconds <= max_snapshot_age_seconds + else "stale" + ) + return { + "status": "ok", + "data": { + "title": "Estado actual de servidores", + "context": "current-hll-status", + "source": response_source, + "last_snapshot_at": last_snapshot_at, + "snapshot_age_seconds": snapshot_age_seconds, + "snapshot_age_minutes": _to_snapshot_age_minutes(snapshot_age_seconds), + "max_snapshot_age_seconds": max_snapshot_age_seconds, + "is_stale": freshness == "stale", + "freshness": freshness, + "refresh_attempted": refresh_attempted, + "refresh_status": refresh_status, + "refresh_errors": refresh_errors, + **source_policy, + "items": items, + }, + } + + +def _calculate_snapshot_age_seconds(timestamp: str | None) -> int | None: + if not timestamp: + return None + + normalized = timestamp.replace("Z", "+00:00") + captured_at = datetime.fromisoformat(normalized) + if captured_at.tzinfo is None: + captured_at = captured_at.replace(tzinfo=timezone.utc) + + age = datetime.now(timezone.utc) - captured_at.astimezone(timezone.utc) + return max(0, int(age.total_seconds())) + + +def _to_snapshot_age_minutes(snapshot_age_seconds: int | None) -> int | None: + if snapshot_age_seconds is None: + return None + + return snapshot_age_seconds // 60 + + +def _resolve_historical_fallback_policy( + *, + fallback_reason: str, + operation: str = "historical-read", +) -> dict[str, object]: + return build_historical_runtime_source_policy( + operation=operation, + rcon_status="unsupported", + fallback_reason=fallback_reason, + ) + + +def _merge_recent_match_items( + *, + primary_items: list[dict[str, object]], + fallback_items: list[dict[str, object]], + limit: int, +) -> list[dict[str, object]]: + merged: list[dict[str, object]] = [] + seen_keys: set[str] = set() + for item in list(primary_items) + list(fallback_items): + if not isinstance(item, dict): + continue + dedupe_key = _build_recent_match_dedupe_key(item) + if dedupe_key in seen_keys: + continue + seen_keys.add(dedupe_key) + merged.append(item) + merged.sort(key=_recent_match_sort_key, reverse=True) + return merged[:limit] + + +def _with_recent_result_source( + item: dict[str, object], + result_source: str, +) -> dict[str, object]: + enriched = dict(item) + enriched.setdefault("result_source", result_source) + return enriched + + +def _recent_items_include_rcon_results(items: list[dict[str, object]]) -> bool: + return any( + item.get("result_source") in {"admin-log-match-ended", "rcon-session"} + for item in items + if isinstance(item, dict) + ) + + +def _build_recent_match_dedupe_key(item: dict[str, object]) -> str: + server = item.get("server") if isinstance(item.get("server"), dict) else {} + map_payload = item.get("map") if isinstance(item.get("map"), dict) else {} + match_id = str(item.get("match_id") or "").strip() + server_slug = str(server.get("slug") or server.get("external_server_id") or "").strip() + map_name = str(map_payload.get("name") or map_payload.get("pretty_name") or "").strip().lower() + closed_at = _truncate_recent_match_timestamp( + item.get("closed_at") or item.get("ended_at") + ) + started_at = _truncate_recent_match_timestamp(item.get("started_at")) + if match_id and match_id.isdigit(): + return f"scoreboard:{server_slug}:{match_id}" + return f"recent:{server_slug}:{map_name}:{started_at}:{closed_at}" + + +def _truncate_recent_match_timestamp(value: object) -> str: + normalized = str(value or "").strip() + return normalized[:16] if normalized else "" + + +def _recent_match_sort_key(item: dict[str, object]) -> tuple[str, str]: + closed_at = str(item.get("closed_at") or item.get("ended_at") or "").strip() + started_at = str(item.get("started_at") or "").strip() + return (closed_at, started_at) + + +def _infer_live_source_policy_from_items( + items: list[dict[str, object]], + *, + refresh_attempted: bool, + refresh_errors: list[dict[str, object]], +) -> dict[str, object]: + selected_source = "persisted-snapshot" + fallback_used = False + fallback_reason = None + snapshot_origins = { + str(item.get("snapshot_origin") or "").strip() + for item in items + if item.get("snapshot_origin") + } + if "real-rcon" in snapshot_origins: + selected_source = SOURCE_KIND_RCON + elif "real-a2s" in snapshot_origins: + selected_source = LIVE_SOURCE_A2S + if get_live_data_source_kind() == SOURCE_KIND_RCON: + fallback_used = True + fallback_reason = "persisted-live-snapshot-came-from-a2s" + + attempt_status = "success" if items else ("error" if refresh_attempted else "cached") + attempt_reason = None if items else "no-live-snapshot-items" + if refresh_errors and attempt_reason is None: + attempt_reason = "live-refresh-errors-present" + + return build_source_policy( + primary_source=get_live_data_source_kind(), + selected_source=selected_source, + fallback_used=fallback_used, + fallback_reason=fallback_reason, + source_attempts=[ + build_source_attempt( + source=selected_source, + role="served-response", + status=attempt_status, + reason=attempt_reason, + ) + ], + ) + + +def _build_live_response_source(source_policy: dict[str, object]) -> str: + selected_source = str(source_policy.get("selected_source") or "") + if selected_source == SOURCE_KIND_RCON: + return "real-time-rcon-refresh" + if selected_source == LIVE_SOURCE_A2S: + return "real-time-a2s-fallback" + return "real-time-refresh" + + +def _resolve_community_history_url(external_server_id: object) -> str | None: + normalized_server_id = str(external_server_id or "").strip() + if not normalized_server_id: + return None + origin = get_trusted_public_scoreboard_origin(normalized_server_id) + return f"{origin.base_url}/games" if origin else None diff --git a/backend/app/player_event_aggregates.py b/backend/app/player_event_aggregates.py new file mode 100644 index 0000000..e6c1592 --- /dev/null +++ b/backend/app/player_event_aggregates.py @@ -0,0 +1,261 @@ +"""Derived duel and weapon aggregates computed from the raw player event ledger.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from .config import get_database_url, get_storage_path +from .player_event_storage import initialize_player_event_storage + + +def list_most_killed( + *, + server_slug: str | None = None, + month: str | None = None, + external_match_id: str | None = None, + limit: int = 10, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """Return strongest killer -> victim summaries from the raw ledger.""" + return _query_pair_summary( + event_type="player_kill_summary", + server_slug=server_slug, + month=month, + external_match_id=external_match_id, + limit=limit, + db_path=db_path, + ) + + +def list_death_by( + *, + server_slug: str | None = None, + month: str | None = None, + external_match_id: str | None = None, + limit: int = 10, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """Return strongest killer -> victim summaries from the victim perspective.""" + return _query_pair_summary( + event_type="player_death_summary", + server_slug=server_slug, + month=month, + external_match_id=external_match_id, + limit=limit, + db_path=db_path, + ) + + +def list_net_duel_summaries( + *, + server_slug: str | None = None, + month: str | None = None, + external_match_id: str | None = None, + limit: int = 10, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """Return partial net duel summaries using the strongest encounter signals available.""" + resolved_path = initialize_player_event_storage(db_path=db_path) + where_sql, params = _build_common_where( + event_type="player_kill_summary", + server_slug=server_slug, + month=month, + external_match_id=external_match_id, + ) + with _connect(resolved_path) as connection: + rows = connection.execute( + f""" + WITH duel_pairs AS ( + SELECT + CASE + WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '') + THEN killer_player_key + ELSE victim_player_key + END AS player_a_key, + CASE + WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '') + THEN killer_display_name + ELSE victim_display_name + END AS player_a_name, + CASE + WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '') + THEN victim_player_key + ELSE killer_player_key + END AS player_b_key, + CASE + WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '') + THEN victim_display_name + ELSE killer_display_name + END AS player_b_name, + CASE + WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '') + THEN event_value + ELSE -event_value + END AS net_value, + event_value + FROM player_event_raw_ledger + WHERE {where_sql} + AND killer_player_key IS NOT NULL + AND victim_player_key IS NOT NULL + ) + SELECT + player_a_key, + player_a_name, + player_b_key, + player_b_name, + COALESCE(SUM(event_value), 0) AS total_encounters, + COALESCE(SUM(net_value), 0) AS net_duel_value + FROM duel_pairs + GROUP BY player_a_key, player_a_name, player_b_key, player_b_name + ORDER BY ABS(COALESCE(SUM(net_value), 0)) DESC, + COALESCE(SUM(event_value), 0) DESC, + player_a_name ASC, + player_b_name ASC + LIMIT ? + """, + [*params, limit], + ).fetchall() + return [dict(row) for row in rows] + + +def list_weapon_kills( + *, + server_slug: str | None = None, + month: str | None = None, + external_match_id: str | None = None, + limit: int = 10, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """Return partial weapon summaries derived from top kill events.""" + resolved_path = initialize_player_event_storage(db_path=db_path) + where_sql, params = _build_common_where( + event_type="player_weapon_kill_summary", + server_slug=server_slug, + month=month, + external_match_id=external_match_id, + ) + with _connect(resolved_path) as connection: + rows = connection.execute( + f""" + SELECT + killer_player_key, + killer_display_name, + COALESCE(weapon_name, 'unknown') AS weapon_name, + COALESCE(SUM(event_value), 0) AS total_kills + FROM player_event_raw_ledger + WHERE {where_sql} + AND killer_player_key IS NOT NULL + GROUP BY killer_player_key, killer_display_name, COALESCE(weapon_name, 'unknown') + ORDER BY total_kills DESC, killer_display_name ASC, weapon_name ASC + LIMIT ? + """, + [*params, limit], + ).fetchall() + return [dict(row) for row in rows] + + +def list_teamkill_summaries( + *, + server_slug: str | None = None, + month: str | None = None, + external_match_id: str | None = None, + limit: int = 10, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """Return derived teamkill totals per player from the raw ledger.""" + resolved_path = initialize_player_event_storage(db_path=db_path) + where_sql, params = _build_common_where( + event_type="player_teamkill_summary", + server_slug=server_slug, + month=month, + external_match_id=external_match_id, + ) + with _connect(resolved_path) as connection: + rows = connection.execute( + f""" + SELECT + killer_player_key, + killer_display_name, + COALESCE(SUM(event_value), 0) AS total_teamkills + FROM player_event_raw_ledger + WHERE {where_sql} + AND killer_player_key IS NOT NULL + GROUP BY killer_player_key, killer_display_name + ORDER BY total_teamkills DESC, killer_display_name ASC + LIMIT ? + """, + [*params, limit], + ).fetchall() + return [dict(row) for row in rows] + + +def _query_pair_summary( + *, + event_type: str, + server_slug: str | None, + month: str | None, + external_match_id: str | None, + limit: int, + db_path: Path | None, +) -> list[dict[str, object]]: + resolved_path = initialize_player_event_storage(db_path=db_path) + where_sql, params = _build_common_where( + event_type=event_type, + server_slug=server_slug, + month=month, + external_match_id=external_match_id, + ) + with _connect(resolved_path) as connection: + rows = connection.execute( + f""" + SELECT + killer_player_key, + killer_display_name, + victim_player_key, + victim_display_name, + COALESCE(SUM(event_value), 0) AS total_kills + FROM player_event_raw_ledger + WHERE {where_sql} + AND killer_player_key IS NOT NULL + AND victim_player_key IS NOT NULL + GROUP BY killer_player_key, killer_display_name, victim_player_key, victim_display_name + ORDER BY total_kills DESC, killer_display_name ASC, victim_display_name ASC + LIMIT ? + """, + [*params, limit], + ).fetchall() + return [dict(row) for row in rows] + + +def _build_common_where( + *, + event_type: str, + server_slug: str | None, + month: str | None, + external_match_id: str | None, +) -> tuple[str, list[object]]: + clauses = ["event_type = ?"] + params: list[object] = [event_type] + + if server_slug and server_slug != "all-servers": + clauses.append("server_slug = ?") + params.append(server_slug.strip()) + if month: + clauses.append("substr(COALESCE(CAST(occurred_at AS TEXT), ''), 1, 7) = ?") + params.append(month.strip()) + if external_match_id: + clauses.append("external_match_id = ?") + params.append(external_match_id.strip()) + + return " AND ".join(clauses), params + + +def _connect(db_path: Path) -> sqlite3.Connection: + if get_database_url(): + from .postgres_display_storage import connect_postgres_compat + + return connect_postgres_compat() + connection = sqlite3.connect(db_path or get_storage_path()) + connection.row_factory = sqlite3.Row + return connection diff --git a/backend/app/player_event_models.py b/backend/app/player_event_models.py new file mode 100644 index 0000000..182ed65 --- /dev/null +++ b/backend/app/player_event_models.py @@ -0,0 +1,32 @@ +"""Normalized player event models for the V2 event pipeline foundation.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass + + +@dataclass(frozen=True, slots=True) +class PlayerEventRecord: + """Minimal normalized player event contract reused across source and storage.""" + + event_id: str + event_type: str + occurred_at: str | None + server_slug: str + external_match_id: str + source_kind: str + source_ref: str | None + raw_event_ref: str | None + killer_player_key: str | None + killer_display_name: str | None + victim_player_key: str | None + victim_display_name: str | None + weapon_name: str | None + weapon_category: str | None + kill_category: str | None + is_teamkill: bool + event_value: int = 1 + + def to_dict(self) -> dict[str, object]: + """Return the event as a plain dictionary.""" + return asdict(self) diff --git a/backend/app/player_event_source.py b/backend/app/player_event_source.py new file mode 100644 index 0000000..6268516 --- /dev/null +++ b/backend/app/player_event_source.py @@ -0,0 +1,111 @@ +"""Player event source selection and contracts for the V2 pipeline.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from .config import get_historical_data_source_kind +from .data_sources import ( + SOURCE_KIND_PUBLIC_SCOREBOARD, + SOURCE_KIND_RCON, + build_source_attempt, + build_source_policy, +) +from .player_event_models import PlayerEventRecord +from .providers.player_event_source_provider import PublicScoreboardPlayerEventSource + + +class PlayerEventSource(Protocol): + """Contract for adapters that normalize player event signals.""" + + source_kind: str + + def extract_match_events( + self, + *, + server_slug: str, + match_payload: dict[str, object], + source_ref: str | None = None, + ) -> list[PlayerEventRecord]: + """Normalize one match payload into reusable player event records.""" + + def describe_scope(self) -> dict[str, object]: + """Describe what the adapter can and cannot capture today.""" + + +class RconPlayerEventSource: + """Placeholder adapter for a future raw RCON/log feed.""" + + source_kind = "rcon-events" + + def extract_match_events( + self, + *, + server_slug: str, + match_payload: dict[str, object], + source_ref: str | None = None, + ) -> list[PlayerEventRecord]: + raise RuntimeError("Raw RCON player event extraction is not implemented yet.") + + def describe_scope(self) -> dict[str, object]: + return { + "source_kind": self.source_kind, + "supports_raw_kill_events": False, + "captures": [], + "limitations": [ + "No raw RCON event or log feed is integrated in this repository yet.", + ], + } + + +@dataclass(frozen=True, slots=True) +class PlayerEventSourceSelection: + """Resolved player-event adapter plus source-policy metadata.""" + + source: PlayerEventSource + source_policy: dict[str, object] + + +def resolve_player_event_source() -> PlayerEventSourceSelection: + """Select the event adapter with safe fallback when raw RCON events are unavailable.""" + source_kind = get_historical_data_source_kind() + if source_kind == SOURCE_KIND_PUBLIC_SCOREBOARD: + return PlayerEventSourceSelection( + source=PublicScoreboardPlayerEventSource(), + source_policy=build_source_policy( + primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD, + selected_source="public-scoreboard-match-summary", + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_PUBLIC_SCOREBOARD, + role="primary", + status="success", + ) + ], + ), + ) + if source_kind == SOURCE_KIND_RCON: + return PlayerEventSourceSelection( + source=PublicScoreboardPlayerEventSource(), + source_policy=build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source="public-scoreboard-match-summary", + fallback_used=True, + fallback_reason="rcon-player-events-not-implemented-yet", + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="unsupported", + reason="rcon-player-events-not-implemented-yet", + ), + build_source_attempt( + source="public-scoreboard-match-summary", + role="fallback", + status="success", + ), + ], + ), + ) + raise ValueError(f"Unsupported player event source: {source_kind}") diff --git a/backend/app/player_event_storage.py b/backend/app/player_event_storage.py new file mode 100644 index 0000000..1997583 --- /dev/null +++ b/backend/app/player_event_storage.py @@ -0,0 +1,440 @@ +"""Raw storage and run tracking for the V2 player event pipeline.""" + +from __future__ import annotations + +import sqlite3 +from collections.abc import Iterable +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from .config import ( + get_player_event_refresh_overlap_hours, + get_storage_path, + use_postgres_rcon_storage, +) +from .player_event_models import PlayerEventRecord +from .sqlite_utils import connect_sqlite_writer + + +def initialize_player_event_storage(*, db_path: Path | None = None) -> Path: + """Create the append-only player event ledger and its worker metadata 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 player_event_raw_ledger ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + event_type TEXT NOT NULL, + occurred_at TEXT, + server_slug TEXT NOT NULL, + external_match_id TEXT NOT NULL, + source_kind TEXT NOT NULL, + source_ref TEXT, + raw_event_ref TEXT, + killer_player_key TEXT, + killer_display_name TEXT, + victim_player_key TEXT, + victim_display_name TEXT, + weapon_name TEXT, + weapon_category TEXT, + kill_category TEXT, + is_teamkill INTEGER NOT NULL DEFAULT 0, + event_value INTEGER NOT NULL DEFAULT 1, + inserted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS player_event_ingestion_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mode TEXT NOT NULL, + status TEXT NOT NULL, + target_server_slug TEXT, + started_at TEXT NOT NULL, + completed_at TEXT, + pages_processed INTEGER NOT NULL DEFAULT 0, + matches_seen INTEGER NOT NULL DEFAULT 0, + matches_fetched INTEGER NOT NULL DEFAULT 0, + events_inserted INTEGER NOT NULL DEFAULT 0, + duplicate_events INTEGER NOT NULL DEFAULT 0, + notes TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS player_event_backfill_progress ( + server_slug TEXT NOT NULL, + mode TEXT NOT NULL, + next_page INTEGER NOT NULL DEFAULT 1, + last_completed_page INTEGER, + cutoff_occurred_at TEXT, + discovered_total_matches INTEGER, + archive_exhausted INTEGER NOT NULL DEFAULT 0, + last_run_id INTEGER, + last_run_status TEXT, + last_run_started_at TEXT, + last_run_completed_at TEXT, + last_error TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (server_slug, mode) + ); + + CREATE INDEX IF NOT EXISTS idx_player_event_raw_server_match + ON player_event_raw_ledger(server_slug, external_match_id); + + CREATE INDEX IF NOT EXISTS idx_player_event_raw_occurred_at + ON player_event_raw_ledger(occurred_at DESC); + + CREATE INDEX IF NOT EXISTS idx_player_event_raw_killer_victim + ON player_event_raw_ledger(killer_player_key, victim_player_key); + """ + ) + + return resolved_path + + +def upsert_player_events( + events: Iterable[PlayerEventRecord], + *, + db_path: Path | None = None, +) -> dict[str, int]: + """Insert normalized events idempotently into the raw ledger.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import upsert_player_event_rows + + return upsert_player_event_rows(events) + resolved_path = initialize_player_event_storage(db_path=db_path) + inserted = 0 + duplicates = 0 + with _connect(resolved_path) as connection: + for event in events: + cursor = connection.execute( + """ + INSERT OR IGNORE INTO player_event_raw_ledger ( + event_id, + event_type, + occurred_at, + server_slug, + external_match_id, + source_kind, + source_ref, + raw_event_ref, + killer_player_key, + killer_display_name, + victim_player_key, + victim_display_name, + weapon_name, + weapon_category, + kill_category, + is_teamkill, + event_value + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + event.event_id, + event.event_type, + event.occurred_at, + event.server_slug, + event.external_match_id, + event.source_kind, + event.source_ref, + event.raw_event_ref, + event.killer_player_key, + event.killer_display_name, + event.victim_player_key, + event.victim_display_name, + event.weapon_name, + event.weapon_category, + event.kill_category, + 1 if event.is_teamkill else 0, + max(1, int(event.event_value)), + ), + ) + if int(cursor.rowcount or 0) > 0: + inserted += 1 + else: + duplicates += 1 + return { + "events_inserted": inserted, + "duplicate_events": duplicates, + } + + +def start_player_event_ingestion_run( + *, + mode: str, + target_server_slug: str | None = None, + db_path: Path | None = None, +) -> int: + """Persist one player event ingestion attempt.""" + resolved_path = initialize_player_event_storage(db_path=db_path) + with _connect(resolved_path) as connection: + cursor = connection.execute( + """ + INSERT INTO player_event_ingestion_runs ( + mode, + status, + target_server_slug, + started_at + ) VALUES (?, 'running', ?, ?) + """, + (mode, target_server_slug, _utc_now_iso()), + ) + return int(cursor.lastrowid) + + +def finalize_player_event_ingestion_run( + run_id: int, + *, + status: str, + pages_processed: int, + matches_seen: int, + matches_fetched: int, + events_inserted: int, + duplicate_events: int, + notes: str | None = None, + db_path: Path | None = None, +) -> None: + """Update one player event ingestion attempt with final counters.""" + resolved_path = initialize_player_event_storage(db_path=db_path) + with _connect(resolved_path) as connection: + connection.execute( + """ + UPDATE player_event_ingestion_runs + SET status = ?, + completed_at = ?, + pages_processed = ?, + matches_seen = ?, + matches_fetched = ?, + events_inserted = ?, + duplicate_events = ?, + notes = ? + WHERE id = ? + """, + ( + status, + _utc_now_iso(), + pages_processed, + matches_seen, + matches_fetched, + events_inserted, + duplicate_events, + notes, + run_id, + ), + ) + + +def mark_player_event_progress_started( + *, + server_slug: str, + mode: str, + run_id: int, + cutoff_occurred_at: str | None, + db_path: Path | None = None, +) -> None: + """Persist the start state for one server ingestion attempt.""" + resolved_path = initialize_player_event_storage(db_path=db_path) + with _connect(resolved_path) as connection: + connection.execute( + """ + INSERT INTO player_event_backfill_progress ( + server_slug, + mode, + next_page, + cutoff_occurred_at, + archive_exhausted, + last_run_id, + last_run_status, + last_run_started_at, + last_run_completed_at, + last_error + ) VALUES (?, ?, 1, ?, 0, ?, 'running', ?, NULL, NULL) + ON CONFLICT(server_slug, mode) DO UPDATE SET + cutoff_occurred_at = excluded.cutoff_occurred_at, + last_run_id = excluded.last_run_id, + last_run_status = excluded.last_run_status, + last_run_started_at = excluded.last_run_started_at, + last_run_completed_at = NULL, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP + """, + (server_slug, mode, cutoff_occurred_at, run_id, _utc_now_iso()), + ) + + +def mark_player_event_progress_page_completed( + *, + server_slug: str, + mode: str, + page_number: int, + discovered_total_matches: int | None, + run_id: int, + db_path: Path | None = None, +) -> None: + """Advance the resume checkpoint after one page completes successfully.""" + resolved_path = initialize_player_event_storage(db_path=db_path) + with _connect(resolved_path) as connection: + connection.execute( + """ + INSERT INTO player_event_backfill_progress ( + server_slug, + mode, + next_page, + last_completed_page, + discovered_total_matches, + archive_exhausted, + last_run_id, + last_run_status, + last_run_started_at, + last_run_completed_at, + last_error + ) VALUES (?, ?, ?, ?, ?, 0, ?, 'running', ?, NULL, NULL) + ON CONFLICT(server_slug, mode) DO UPDATE SET + next_page = excluded.next_page, + last_completed_page = excluded.last_completed_page, + discovered_total_matches = COALESCE( + excluded.discovered_total_matches, + player_event_backfill_progress.discovered_total_matches + ), + archive_exhausted = 0, + last_run_id = excluded.last_run_id, + last_run_status = excluded.last_run_status, + last_run_started_at = excluded.last_run_started_at, + last_run_completed_at = NULL, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP + """, + ( + server_slug, + mode, + page_number + 1, + page_number, + discovered_total_matches, + run_id, + _utc_now_iso(), + ), + ) + + +def finalize_player_event_progress( + *, + server_slug: str, + mode: str, + run_id: int, + status: str, + archive_exhausted: bool = False, + error_message: str | None = None, + db_path: Path | None = None, +) -> None: + """Persist the final state of one server event ingestion attempt.""" + resolved_path = initialize_player_event_storage(db_path=db_path) + with _connect(resolved_path) as connection: + connection.execute( + """ + INSERT INTO player_event_backfill_progress ( + server_slug, + mode, + next_page, + archive_exhausted, + last_run_id, + last_run_status, + last_run_started_at, + last_run_completed_at, + last_error + ) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?) + ON CONFLICT(server_slug, mode) DO UPDATE SET + archive_exhausted = CASE + WHEN excluded.last_run_status = 'success' AND excluded.archive_exhausted = 1 + THEN 1 + ELSE player_event_backfill_progress.archive_exhausted + END, + last_run_id = excluded.last_run_id, + last_run_status = excluded.last_run_status, + last_run_started_at = COALESCE( + player_event_backfill_progress.last_run_started_at, + excluded.last_run_started_at + ), + last_run_completed_at = excluded.last_run_completed_at, + last_error = excluded.last_error, + updated_at = CURRENT_TIMESTAMP + """, + ( + server_slug, + mode, + 1 if archive_exhausted else 0, + run_id, + status, + _utc_now_iso(), + _utc_now_iso(), + error_message, + ), + ) + + +def get_player_event_resume_page( + server_slug: str, + *, + mode: str = "bootstrap", + db_path: Path | None = None, +) -> int: + """Return the saved resume page for a bootstrap-like event backfill.""" + resolved_path = initialize_player_event_storage(db_path=db_path) + with _connect(resolved_path) as connection: + row = connection.execute( + """ + SELECT next_page + FROM player_event_backfill_progress + WHERE server_slug = ? AND mode = ? + """, + (server_slug, mode), + ).fetchone() + return max(1, int(row["next_page"])) if row and row["next_page"] else 1 + + +def get_player_event_refresh_cutoff_for_server( + server_slug: str, + *, + overlap_hours: int | None = None, + db_path: Path | None = None, +) -> str | None: + """Return the latest occurred_at already persisted for one server.""" + resolved_overlap_hours = ( + get_player_event_refresh_overlap_hours() + if overlap_hours is None + else overlap_hours + ) + if resolved_overlap_hours < 0: + raise ValueError("overlap_hours must be zero or positive.") + resolved_path = initialize_player_event_storage(db_path=db_path) + with _connect(resolved_path) as connection: + row = connection.execute( + """ + SELECT MAX(occurred_at) AS latest_occurred_at + FROM player_event_raw_ledger + WHERE server_slug = ? + """, + (server_slug,), + ).fetchone() + latest_occurred_at = str(row["latest_occurred_at"]) if row and row["latest_occurred_at"] else None + if not latest_occurred_at: + return None + + cutoff = _parse_timestamp(latest_occurred_at) - timedelta(hours=resolved_overlap_hours) + return cutoff.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _connect(db_path: Path) -> sqlite3.Connection: + return connect_sqlite_writer(db_path) + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _parse_timestamp(value: str) -> datetime: + normalized = value.strip().replace("Z", "+00:00") + parsed = datetime.fromisoformat(normalized) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed diff --git a/backend/app/player_event_worker.py b/backend/app/player_event_worker.py new file mode 100644 index 0000000..4724fa3 --- /dev/null +++ b/backend/app/player_event_worker.py @@ -0,0 +1,490 @@ +"""Incremental worker for the V2 player event ingestion pipeline.""" + +from __future__ import annotations + +import argparse +import json +import time +from dataclasses import dataclass +from typing import Iterable + +from .config import ( + get_historical_crcon_detail_workers, + get_historical_crcon_page_size, + get_player_event_refresh_interval_seconds, + get_player_event_refresh_max_retries, + get_player_event_refresh_overlap_hours, + get_player_event_refresh_retry_delay_seconds, +) +from .data_sources import resolve_historical_ingestion_data_source +from .historical_storage import list_historical_servers +from .player_event_source import resolve_player_event_source +from .player_event_storage import ( + finalize_player_event_ingestion_run, + finalize_player_event_progress, + get_player_event_refresh_cutoff_for_server, + get_player_event_resume_page, + initialize_player_event_storage, + mark_player_event_progress_page_completed, + mark_player_event_progress_started, + start_player_event_ingestion_run, + upsert_player_events, +) +from .writer_lock import backend_writer_lock, build_writer_lock_holder + + +@dataclass(slots=True) +class PlayerEventIngestionStats: + pages_processed: int = 0 + matches_seen: int = 0 + matches_fetched: int = 0 + events_inserted: int = 0 + duplicate_events: int = 0 + + def apply(self, delta: dict[str, int]) -> None: + self.events_inserted += int(delta.get("events_inserted", 0)) + self.duplicate_events += int(delta.get("duplicate_events", 0)) + + +def run_player_event_refresh( + *, + server_slug: str | None = None, + max_pages: int | None = None, + page_size: int | None = None, + start_page: int | None = None, + detail_workers: int | None = None, + overlap_hours: int | None = None, +) -> dict[str, object]: + """Refresh recent player event summaries from the configured historical source.""" + with backend_writer_lock( + holder=build_writer_lock_holder( + f"app.player_event_worker refresh:{server_slug or 'all-servers'}" + ) + ): + initialize_player_event_storage() + data_source, data_source_policy = resolve_historical_ingestion_data_source() + event_source_selection = resolve_player_event_source() + event_source = event_source_selection.source + resolved_page_size = page_size or get_historical_crcon_page_size() + resolved_detail_workers = detail_workers or get_historical_crcon_detail_workers() + resolved_overlap_hours = ( + get_player_event_refresh_overlap_hours() + if overlap_hours is None + else overlap_hours + ) + if resolved_overlap_hours < 0: + raise ValueError("--overlap-hours must be zero or positive.") + selected_servers = _select_servers(server_slug) + processed_servers: list[dict[str, object]] = [] + active_runs: dict[str, int] = {} + + try: + for server in selected_servers: + current_server_slug = str(server["slug"]) + run_id = start_player_event_ingestion_run( + mode="refresh", + target_server_slug=current_server_slug, + ) + active_runs[current_server_slug] = run_id + cutoff = get_player_event_refresh_cutoff_for_server( + current_server_slug, + overlap_hours=resolved_overlap_hours, + ) + mark_player_event_progress_started( + server_slug=current_server_slug, + mode="refresh", + run_id=run_id, + cutoff_occurred_at=cutoff, + ) + server_stats = _ingest_server( + server=server, + run_id=run_id, + data_source=data_source, + event_source=event_source, + page_size=resolved_page_size, + max_pages=max_pages, + start_page=_resolve_start_page( + server_slug=current_server_slug, + start_page=start_page, + ), + detail_workers=resolved_detail_workers, + cutoff=cutoff, + ) + finalize_player_event_ingestion_run( + run_id, + status="success", + pages_processed=server_stats["pages_processed"], + matches_seen=server_stats["matches_seen"], + matches_fetched=server_stats["matches_fetched"], + events_inserted=server_stats["events_inserted"], + duplicate_events=server_stats["duplicate_events"], + notes=f"source={data_source.source_kind};adapter={event_source.source_kind}", + ) + finalize_player_event_progress( + server_slug=current_server_slug, + mode="refresh", + run_id=run_id, + status="success", + archive_exhausted=bool(server_stats["archive_exhausted"]), + ) + processed_servers.append(server_stats) + active_runs.pop(current_server_slug, None) + except Exception as exc: + for active_server_slug, run_id in active_runs.items(): + finalize_player_event_ingestion_run( + run_id, + status="failed", + pages_processed=0, + matches_seen=0, + matches_fetched=0, + events_inserted=0, + duplicate_events=0, + notes=str(exc), + ) + finalize_player_event_progress( + server_slug=active_server_slug, + mode="refresh", + run_id=run_id, + status="failed", + error_message=str(exc), + ) + raise + + return { + "status": "ok", + "mode": "refresh", + "source_provider": data_source.source_kind, + "source_policy": data_source_policy, + "event_adapter": event_source.source_kind, + "event_source_policy": event_source_selection.source_policy, + "page_size": resolved_page_size, + "detail_workers": resolved_detail_workers, + "overlap_hours": resolved_overlap_hours, + "scope": event_source.describe_scope(), + "servers": processed_servers, + } + + +def run_periodic_player_event_refresh( + *, + interval_seconds: int, + max_retries: int, + retry_delay_seconds: int, + server_slug: str | None = None, + max_pages: int | None = None, + page_size: int | None = None, + detail_workers: int | None = None, + max_runs: int | None = None, +) -> None: + """Run the refresh worker repeatedly with bounded retries.""" + completed_runs = 0 + print( + json.dumps( + { + "event": "player-event-refresh-loop-started", + "interval_seconds": interval_seconds, + "max_retries": max_retries, + "retry_delay_seconds": retry_delay_seconds, + "server_scope": [server_slug] if server_slug else [server["slug"] for server in list_historical_servers()], + }, + indent=2, + ) + ) + print("Press Ctrl+C to stop.") + + try: + while max_runs is None or completed_runs < max_runs: + completed_runs += 1 + payload = _run_refresh_with_retries( + max_retries=max_retries, + retry_delay_seconds=retry_delay_seconds, + server_slug=server_slug, + max_pages=max_pages, + page_size=page_size, + detail_workers=detail_workers, + ) + print(json.dumps({"run": completed_runs, **payload}, indent=2)) + if max_runs is not None and completed_runs >= max_runs: + break + time.sleep(interval_seconds) + except KeyboardInterrupt: + print("\nPlayer event refresh loop stopped by user.") + + +def _run_refresh_with_retries( + *, + max_retries: int, + retry_delay_seconds: int, + server_slug: str | None, + max_pages: int | None, + page_size: int | None, + detail_workers: int | None, +) -> dict[str, object]: + attempt = 0 + while True: + attempt += 1 + try: + return { + "status": "ok", + "attempts_used": attempt, + "refresh_result": run_player_event_refresh( + server_slug=server_slug, + max_pages=max_pages, + page_size=page_size, + detail_workers=detail_workers, + ), + } + except Exception as exc: + if attempt > max_retries: + return { + "status": "error", + "attempts_used": attempt, + "error": str(exc), + } + if retry_delay_seconds > 0: + time.sleep(retry_delay_seconds) + + +def _ingest_server( + *, + server: dict[str, object], + run_id: int, + data_source: object, + event_source: object, + page_size: int, + max_pages: int | None, + start_page: int, + detail_workers: int, + cutoff: str | None, +) -> dict[str, object]: + page_limit = max_pages or 1000000 + local_stats = PlayerEventIngestionStats() + discovered_total_matches: int | None = None + archive_exhausted = False + + for page_number in range(start_page, start_page + page_limit): + payload = data_source.fetch_match_page( + base_url=str(server["scoreboard_base_url"]), + page=page_number, + limit=page_size, + ) + if discovered_total_matches is None: + discovered_total_matches = _coerce_int(payload.get("total")) + page_matches = _coerce_match_list(payload.get("maps")) + if not page_matches: + archive_exhausted = True + break + + local_stats.pages_processed += 1 + stop_after_page = False + match_ids_to_fetch: list[str] = [] + + for match_summary in page_matches: + local_stats.matches_seen += 1 + reference_timestamp = _pick_match_timestamp(match_summary) + if cutoff and reference_timestamp and reference_timestamp < cutoff: + stop_after_page = True + continue + + match_id = _stringify(match_summary.get("id")) + if match_id: + match_ids_to_fetch.append(match_id) + + detail_payloads = data_source.fetch_match_details( + base_url=str(server["scoreboard_base_url"]), + match_ids=match_ids_to_fetch, + max_workers=detail_workers, + ) + local_stats.matches_fetched += len(detail_payloads) + for detail_payload in detail_payloads: + match_id = _stringify(detail_payload.get("id")) or "unknown" + source_ref = ( + f"{server['scoreboard_base_url']}/api/get_map_scoreboard?map_id={match_id}" + ) + normalized_events = event_source.extract_match_events( + server_slug=str(server["slug"]), + match_payload=detail_payload, + source_ref=source_ref, + ) + local_stats.apply(upsert_player_events(normalized_events)) + + mark_player_event_progress_page_completed( + server_slug=str(server["slug"]), + mode="refresh", + page_number=page_number, + discovered_total_matches=discovered_total_matches, + run_id=run_id, + ) + + if stop_after_page: + break + + return { + "server_slug": server["slug"], + "source_provider": data_source.source_kind, + "event_adapter": event_source.source_kind, + "pages_processed": local_stats.pages_processed, + "matches_seen": local_stats.matches_seen, + "matches_fetched": local_stats.matches_fetched, + "events_inserted": local_stats.events_inserted, + "duplicate_events": local_stats.duplicate_events, + "cutoff": cutoff, + "archive_exhausted": archive_exhausted, + "discovered_total_matches": discovered_total_matches, + } + + +def _resolve_start_page(*, server_slug: str, start_page: int | None) -> int: + if start_page is not None: + return max(1, start_page) + return get_player_event_resume_page(server_slug, mode="refresh") + + +def _select_servers(server_slug: str | None) -> list[dict[str, object]]: + servers = list_historical_servers() + if server_slug is None: + return servers + normalized = server_slug.strip() + selected = [server for server in servers if server["slug"] == normalized] + if not selected: + raise ValueError(f"Unknown historical server slug: {server_slug}") + return selected + + +def _coerce_match_list(payload: object) -> list[dict[str, object]]: + if not isinstance(payload, list): + return [] + return [item for item in payload if isinstance(item, dict)] + + +def _pick_match_timestamp(match_payload: dict[str, object]) -> str | None: + for key in ("end", "start", "creation_time"): + value = match_payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _stringify(value: object) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + +def _coerce_int(value: object) -> int | None: + if value in (None, ""): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def build_arg_parser() -> argparse.ArgumentParser: + """Create the CLI parser for manual or periodic player event ingestion.""" + parser = argparse.ArgumentParser( + description="Player event refresh worker for HLL Vietnam.", + ) + parser.add_argument( + "mode", + choices=("refresh", "loop"), + help="refresh runs once; loop keeps the worker running periodically", + ) + parser.add_argument( + "--server", + dest="server_slug", + help="optional historical server slug", + ) + parser.add_argument( + "--max-pages", + type=int, + help="optional page cap for local validation", + ) + parser.add_argument( + "--page-size", + type=int, + help="override CRCON page size", + ) + parser.add_argument( + "--start-page", + type=int, + help="override the saved resume page", + ) + parser.add_argument( + "--detail-workers", + type=int, + help="parallel worker count for per-match detail requests", + ) + parser.add_argument( + "--overlap-hours", + type=int, + help="override the incremental overlap window in hours", + ) + parser.add_argument( + "--interval", + type=int, + default=get_player_event_refresh_interval_seconds(), + help="seconds to wait between loop runs", + ) + parser.add_argument( + "--retries", + type=int, + default=get_player_event_refresh_max_retries(), + help="retry attempts after a failed refresh", + ) + parser.add_argument( + "--retry-delay", + type=int, + default=get_player_event_refresh_retry_delay_seconds(), + help="seconds to wait between failed attempts", + ) + parser.add_argument( + "--max-runs", + type=int, + help="optional safety cap for loop mode", + ) + return parser + + +def main(argv: Iterable[str] | None = None) -> int: + """Run the player event worker CLI.""" + parser = build_arg_parser() + args = parser.parse_args(list(argv) if argv is not None else None) + + if args.mode == "refresh": + result = run_player_event_refresh( + server_slug=args.server_slug, + max_pages=args.max_pages, + page_size=args.page_size, + start_page=args.start_page, + detail_workers=args.detail_workers, + overlap_hours=args.overlap_hours, + ) + print(json.dumps(result, indent=2)) + return 0 + + if args.interval <= 0: + raise ValueError("--interval must be a positive integer.") + if args.retries < 0: + raise ValueError("--retries must be zero or positive.") + if args.retry_delay < 0: + raise ValueError("--retry-delay must be zero or positive.") + if args.max_runs is not None and args.max_runs <= 0: + raise ValueError("--max-runs must be positive when provided.") + + run_periodic_player_event_refresh( + interval_seconds=args.interval, + max_retries=args.retries, + retry_delay_seconds=args.retry_delay, + server_slug=args.server_slug, + max_pages=args.max_pages, + page_size=args.page_size, + detail_workers=args.detail_workers, + max_runs=args.max_runs, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/app/player_external_profiles.py b/backend/app/player_external_profiles.py new file mode 100644 index 0000000..032f57e --- /dev/null +++ b/backend/app/player_external_profiles.py @@ -0,0 +1,65 @@ +"""Safe external profile fields derived from captured player identifiers.""" + +from __future__ import annotations + +import re + + +_STEAM_ID64_RE = re.compile(r"^\d{17}$") +_EPIC_ID_RE = re.compile(r"^[0-9a-f]{32}$", re.IGNORECASE) + + +def build_external_player_profile_fields( + *, + player_id: object = None, + steam_id: object = None, +) -> dict[str, object]: + """Expose external profile links only when a captured identifier is safe.""" + + steam_id_64 = normalize_steam_id_64(steam_id) or normalize_steam_id_64(player_id) + if steam_id_64: + return { + "steam_id_64": steam_id_64, + "platform": "steam", + "external_profile_links": { + "steam": f"https://steamcommunity.com/profiles/{steam_id_64}", + "hellor": f"https://hellor.pro/player/{steam_id_64}", + "hll_records": f"https://hllrecords.com/profiles/{steam_id_64}", + "helo": f"https://helo-system.de/statistics/players/{steam_id_64}?series=2024", + }, + } + + epic_id = normalize_epic_id(player_id) + if epic_id: + return { + "epic_id": epic_id, + "platform": "epic", + "external_profile_links": { + "hellor": f"https://hellor.pro/player/{epic_id}", + "hll_records": f"https://hllrecords.com/profiles/{epic_id}", + }, + } + + return { + "platform": infer_player_platform(player_id=player_id, steam_id=steam_id), + "external_profile_links": {}, + } + + +def normalize_steam_id_64(value: object) -> str | None: + normalized = str(value or "").strip() + return normalized if _STEAM_ID64_RE.fullmatch(normalized) else None + + +def normalize_epic_id(value: object) -> str | None: + normalized = str(value or "").strip() + return normalized.lower() if _EPIC_ID_RE.fullmatch(normalized) else None + + +def infer_player_platform(*, player_id: object = None, steam_id: object = None) -> str: + normalized_player_id = str(player_id or "").strip() + if normalize_steam_id_64(steam_id) or normalize_steam_id_64(normalized_player_id): + return "steam" + if normalize_epic_id(normalized_player_id): + return "epic" + return "unknown" diff --git a/backend/app/postgres_display_storage.py b/backend/app/postgres_display_storage.py new file mode 100644 index 0000000..36898b1 --- /dev/null +++ b/backend/app/postgres_display_storage.py @@ -0,0 +1,929 @@ +"""PostgreSQL read/write storage for data displayed outside the RCON write path.""" + +from __future__ import annotations + +import json +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Iterable, Mapping + +from .config import get_database_url, get_historical_weekly_fallback_max_weekday +from .historical_models import HistoricalSnapshotRecord +from .player_external_profiles import build_external_player_profile_fields +from .scoreboard_origins import resolve_trusted_scoreboard_match_url + + +ALL_SERVERS_SLUG = "all-servers" +ALL_SERVERS_DISPLAY_NAME = "Todos" +SUMMARY_SNAPSHOT_LIMIT = 6 + + +DISPLAY_SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS game_sources ( + id BIGSERIAL PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + provider_kind TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS servers ( + id BIGSERIAL PRIMARY KEY, + game_source_id BIGINT NOT NULL REFERENCES game_sources(id), + external_server_id TEXT, + server_name TEXT NOT NULL, + region TEXT, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(game_source_id, external_server_id) +); +CREATE TABLE IF NOT EXISTS server_snapshots ( + id BIGSERIAL PRIMARY KEY, + server_id BIGINT NOT NULL REFERENCES servers(id), + captured_at TEXT NOT NULL, + status TEXT NOT NULL, + players INTEGER, + max_players INTEGER, + current_map TEXT, + source_name TEXT NOT NULL, + snapshot_origin TEXT, + source_ref TEXT, + raw_payload_ref TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(server_id, captured_at, source_name, source_ref) +); +CREATE INDEX IF NOT EXISTS idx_pg_server_snapshots_server_time +ON server_snapshots(server_id, captured_at DESC); + +CREATE TABLE IF NOT EXISTS historical_servers ( + id BIGSERIAL PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + scoreboard_base_url TEXT NOT NULL UNIQUE, + server_number INTEGER, + source_kind 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_maps ( + id BIGSERIAL PRIMARY KEY, + external_map_id TEXT UNIQUE, + map_name TEXT, + pretty_name TEXT, + game_mode TEXT, + image_name TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS historical_matches ( + id BIGSERIAL PRIMARY KEY, + historical_server_id BIGINT NOT NULL REFERENCES historical_servers(id), + external_match_id TEXT NOT NULL, + historical_map_id BIGINT REFERENCES historical_maps(id), + created_at_source TEXT, + started_at TEXT, + ended_at TEXT, + map_name TEXT, + map_pretty_name TEXT, + game_mode TEXT, + image_name TEXT, + allied_score INTEGER, + axis_score INTEGER, + last_seen_at TEXT NOT NULL, + raw_payload_ref TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(historical_server_id, external_match_id) +); +CREATE TABLE IF NOT EXISTS historical_players ( + id BIGSERIAL PRIMARY KEY, + stable_player_key TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + steam_id TEXT, + source_player_id TEXT, + first_seen_at TEXT NOT NULL, + last_seen_at 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 BIGSERIAL PRIMARY KEY, + historical_match_id BIGINT NOT NULL REFERENCES historical_matches(id), + historical_player_id BIGINT NOT NULL REFERENCES historical_players(id), + match_player_ref TEXT, + team_side TEXT, + level INTEGER, + kills INTEGER, + deaths INTEGER, + teamkills INTEGER, + time_seconds INTEGER, + kills_per_minute DOUBLE PRECISION, + deaths_per_minute DOUBLE PRECISION, + kill_death_ratio DOUBLE PRECISION, + combat INTEGER, + offense INTEGER, + defense INTEGER, + support INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(historical_match_id, historical_player_id) +); +CREATE INDEX IF NOT EXISTS idx_pg_historical_matches_server_end +ON historical_matches(historical_server_id, ended_at DESC, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_pg_historical_player_stats_match +ON historical_player_match_stats(historical_match_id); + +CREATE TABLE IF NOT EXISTS displayed_historical_snapshots ( + server_key TEXT NOT NULL, + snapshot_type TEXT NOT NULL, + metric TEXT NOT NULL DEFAULT '', + snapshot_window TEXT NOT NULL DEFAULT '', + payload_json TEXT NOT NULL, + generated_at TEXT NOT NULL, + source_range_start TEXT, + source_range_end TEXT, + is_stale BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(server_key, snapshot_type, metric, snapshot_window) +); + +CREATE TABLE IF NOT EXISTS player_event_raw_ledger ( + id BIGSERIAL PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE, + event_type TEXT NOT NULL, + occurred_at TEXT, + server_slug TEXT NOT NULL, + external_match_id TEXT NOT NULL, + source_kind TEXT NOT NULL, + source_ref TEXT, + raw_event_ref TEXT, + killer_player_key TEXT, + killer_display_name TEXT, + victim_player_key TEXT, + victim_display_name TEXT, + weapon_name TEXT, + weapon_category TEXT, + kill_category TEXT, + is_teamkill BOOLEAN NOT NULL DEFAULT FALSE, + event_value INTEGER NOT NULL DEFAULT 1, + inserted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_pg_player_event_raw_occurred_at +ON player_event_raw_ledger(occurred_at DESC); +""" + + +def initialize_postgres_display_storage() -> None: + with connect_postgres() as connection: + connection.execute(DISPLAY_SCHEMA_SQL) + + +def connect_postgres(): + try: + import psycopg + from psycopg.rows import dict_row + except ImportError as error: # pragma: no cover - environment-specific + raise RuntimeError("psycopg is required when HLL_BACKEND_DATABASE_URL is set.") from error + database_url = get_database_url() + if not database_url: + raise RuntimeError("HLL_BACKEND_DATABASE_URL is required for displayed PostgreSQL storage.") + return psycopg.connect(database_url, row_factory=dict_row) + + +class PostgresCompatConnection: + """Small placeholder shim for SQLite-shaped displayed read queries.""" + + def __init__(self, connection: Any): + self.connection = connection + + def execute(self, sql: str, params: Iterable[object] | None = None): + return self.connection.execute(sql.replace("?", "%s"), tuple(params or ())) + + +@contextmanager +def connect_postgres_compat(): + initialize_postgres_display_storage() + with connect_postgres() as connection: + yield PostgresCompatConnection(connection) + + +def persist_snapshot_record(snapshot: Mapping[str, object]) -> HistoricalSnapshotRecord: + initialize_postgres_display_storage() + generated_at = _iso(snapshot.get("generated_at")) or _utc_now_iso() + metric = str(snapshot.get("metric") or "") + window = str(snapshot.get("window") or "") + payload = snapshot.get("payload") + payload_json = json.dumps( + payload, + ensure_ascii=True, + separators=(",", ":"), + default=_json_payload_default, + ) + with connect_postgres() as connection: + connection.execute( + """ + INSERT INTO displayed_historical_snapshots ( + server_key, snapshot_type, metric, snapshot_window, payload_json, generated_at, + source_range_start, source_range_end, is_stale + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT(server_key, snapshot_type, metric, snapshot_window) DO UPDATE SET + payload_json = EXCLUDED.payload_json, + generated_at = EXCLUDED.generated_at, + source_range_start = EXCLUDED.source_range_start, + source_range_end = EXCLUDED.source_range_end, + is_stale = EXCLUDED.is_stale, + updated_at = CURRENT_TIMESTAMP + """, + ( + str(snapshot["server_key"]), + str(snapshot["snapshot_type"]), + metric, + window, + payload_json, + generated_at, + _iso(snapshot.get("source_range_start")), + _iso(snapshot.get("source_range_end")), + bool(snapshot.get("is_stale", False)), + ), + ) + return HistoricalSnapshotRecord( + server_key=str(snapshot["server_key"]), + snapshot_type=str(snapshot["snapshot_type"]), + metric=metric or None, + window=window or None, + payload_json=payload_json, + generated_at=_parse_datetime(generated_at) or datetime.now(timezone.utc), + source_range_start=_parse_datetime(_iso(snapshot.get("source_range_start"))), + source_range_end=_parse_datetime(_iso(snapshot.get("source_range_end"))), + is_stale=bool(snapshot.get("is_stale", False)), + ) + + +def get_snapshot( + *, + server_key: str, + snapshot_type: str, + metric: str | None, + window: str | None, +) -> dict[str, object] | None: + initialize_postgres_display_storage() + with connect_postgres() as connection: + row = connection.execute( + """ + SELECT * + FROM displayed_historical_snapshots + WHERE server_key = %s AND snapshot_type = %s AND metric = %s AND snapshot_window = %s + """, + (server_key, snapshot_type, metric or "", window or ""), + ).fetchone() + if not row: + return None + return { + "server_key": row["server_key"], + "snapshot_type": row["snapshot_type"], + "metric": row["metric"] or None, + "window": row["snapshot_window"] or None, + "generated_at": row["generated_at"], + "source_range_start": row["source_range_start"], + "source_range_end": row["source_range_end"], + "is_stale": bool(row["is_stale"]), + "payload": json.loads(row["payload_json"]), + } + + +def list_latest_server_snapshots() -> list[dict[str, object]]: + initialize_postgres_display_storage() + with connect_postgres() as connection: + rows = connection.execute( + """ + SELECT s.id AS server_id, s.external_server_id, s.server_name, s.region, + g.slug AS context, snap.source_name, snap.snapshot_origin, + snap.source_ref, snap.captured_at, snap.status, snap.players, + snap.max_players, snap.current_map + FROM servers AS s + JOIN game_sources AS g ON g.id = s.game_source_id + JOIN server_snapshots AS snap ON snap.server_id = s.id + JOIN ( + SELECT server_id, MAX(captured_at) AS captured_at + FROM server_snapshots GROUP BY server_id + ) AS latest ON latest.server_id = snap.server_id + AND latest.captured_at = snap.captured_at + ORDER BY s.server_name ASC + """ + ).fetchall() + return [_attach_server_history(connection, dict(row)) for row in rows] + + +def persist_server_snapshots( + snapshots: Iterable[Mapping[str, object]], + *, + source_name: str, + captured_at: str, + game_source: Mapping[str, str], +) -> dict[str, object]: + initialize_postgres_display_storage() + persisted = 0 + with connect_postgres() as connection: + source = connection.execute( + """ + INSERT INTO game_sources (slug, display_name, provider_kind, is_active) + VALUES (%s, %s, %s, TRUE) + ON CONFLICT(slug) DO UPDATE SET + display_name = EXCLUDED.display_name, + provider_kind = EXCLUDED.provider_kind, + is_active = TRUE, + updated_at = CURRENT_TIMESTAMP + RETURNING id + """, + (game_source["slug"], game_source["display_name"], game_source["provider_kind"]), + ).fetchone() + for snapshot in snapshots: + external_server_id = str(snapshot.get("external_server_id") or "").strip() + if not external_server_id: + external_server_id = _fallback_external_id(snapshot.get("server_name")) + server = connection.execute( + """ + INSERT INTO servers ( + game_source_id, external_server_id, server_name, region, + first_seen_at, last_seen_at + ) VALUES (%s, %s, %s, %s, %s, %s) + ON CONFLICT(game_source_id, external_server_id) DO UPDATE SET + server_name = EXCLUDED.server_name, + region = EXCLUDED.region, + last_seen_at = EXCLUDED.last_seen_at, + updated_at = CURRENT_TIMESTAMP + RETURNING id + """, + ( + source["id"], + external_server_id, + str(snapshot.get("server_name") or "Unknown server"), + snapshot.get("region"), + captured_at, + captured_at, + ), + ).fetchone() + connection.execute( + """ + INSERT INTO server_snapshots ( + server_id, captured_at, status, players, max_players, current_map, + source_name, snapshot_origin, source_ref, raw_payload_ref + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NULL) + ON CONFLICT(server_id, captured_at, source_name, source_ref) DO UPDATE SET + status = EXCLUDED.status, + players = EXCLUDED.players, + max_players = EXCLUDED.max_players, + current_map = EXCLUDED.current_map, + snapshot_origin = EXCLUDED.snapshot_origin + """, + ( + server["id"], + captured_at, + snapshot.get("status") or "unknown", + snapshot.get("players"), + snapshot.get("max_players"), + snapshot.get("current_map"), + snapshot.get("source_name") or source_name, + snapshot.get("snapshot_origin"), + snapshot.get("source_ref") or snapshot.get("source_name") or source_name, + ), + ) + persisted += 1 + return { + "db_path": "postgresql", + "captured_at": captured_at, + "persisted_snapshots": persisted, + "game_source_slug": game_source["slug"], + } + + +def upsert_player_event_rows(events: Iterable[object]) -> dict[str, int]: + initialize_postgres_display_storage() + inserted = 0 + duplicates = 0 + with connect_postgres() as connection: + for event in events: + row = connection.execute( + """ + INSERT INTO player_event_raw_ledger ( + event_id, event_type, occurred_at, server_slug, external_match_id, + source_kind, source_ref, raw_event_ref, killer_player_key, + killer_display_name, victim_player_key, victim_display_name, + weapon_name, weapon_category, kill_category, is_teamkill, event_value + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT(event_id) DO NOTHING + RETURNING id + """, + ( + event.event_id, + event.event_type, + event.occurred_at, + event.server_slug, + event.external_match_id, + event.source_kind, + event.source_ref, + event.raw_event_ref, + event.killer_player_key, + event.killer_display_name, + event.victim_player_key, + event.victim_display_name, + event.weapon_name, + event.weapon_category, + event.kill_category, + bool(event.is_teamkill), + max(1, int(event.event_value)), + ), + ).fetchone() + inserted += int(bool(row)) + duplicates += int(not row) + return {"events_inserted": inserted, "duplicate_events": duplicates} + + +def list_server_snapshot_history(*, server_id: str | None = None, limit: int) -> list[dict[str, object]]: + initialize_postgres_display_storage() + where = "" + params: list[object] = [] + if server_id: + if server_id.strip().isdigit(): + where = "WHERE s.id = %s" + params.append(int(server_id)) + else: + where = "WHERE s.external_server_id = %s" + params.append(server_id.strip()) + with connect_postgres() as connection: + rows = connection.execute( + f""" + SELECT s.id AS server_id, s.external_server_id, s.server_name, s.region, + g.slug AS context, snap.source_name, snap.snapshot_origin, + snap.source_ref, snap.captured_at, snap.status, snap.players, + snap.max_players, snap.current_map + FROM server_snapshots AS snap + JOIN servers AS s ON s.id = snap.server_id + JOIN game_sources AS g ON g.id = s.game_source_id + {where} + ORDER BY snap.captured_at DESC, s.server_name ASC + LIMIT %s + """, + (*params, limit), + ).fetchall() + return [dict(row) for row in rows] + + +def list_recent_scoreboard_matches(*, server_slug: str | None, limit: int) -> list[dict[str, object]]: + initialize_postgres_display_storage() + where = "" + params: list[object] = [] + if server_slug and server_slug != ALL_SERVERS_SLUG: + where = "WHERE hs.slug = %s" + params.append(server_slug) + with connect_postgres() as connection: + rows = connection.execute( + f""" + SELECT hs.slug AS server_slug, hs.display_name AS server_name, + hm.external_match_id, hm.started_at, hm.ended_at, + hm.map_pretty_name, hm.map_name, hm.allied_score, hm.axis_score, + hm.raw_payload_ref, COUNT(stats.id) AS player_count + FROM historical_matches AS hm + JOIN historical_servers AS hs ON hs.id = hm.historical_server_id + LEFT JOIN historical_player_match_stats AS stats ON stats.historical_match_id = hm.id + {where} + GROUP BY hm.id, hs.slug, hs.display_name + ORDER BY COALESCE(hm.ended_at, hm.started_at) DESC + LIMIT %s + """, + (*params, limit), + ).fetchall() + return [_recent_match_row(row) for row in rows] + + +def get_scoreboard_match_detail(*, server_slug: str, match_id: str) -> dict[str, object] | None: + initialize_postgres_display_storage() + with connect_postgres() as connection: + row = connection.execute( + """ + SELECT hm.id AS match_pk, hs.slug AS server_slug, hs.display_name AS server_name, + hm.external_match_id, hm.started_at, hm.ended_at, hm.map_pretty_name, + hm.map_name, hm.allied_score, hm.axis_score, hm.raw_payload_ref, + COUNT(stats.id) AS player_count, + SUM(COALESCE(stats.time_seconds, 0)) AS total_time_seconds + FROM historical_matches AS hm + JOIN historical_servers AS hs ON hs.id = hm.historical_server_id + LEFT JOIN historical_player_match_stats AS stats ON stats.historical_match_id = hm.id + WHERE hs.slug = %s AND hm.external_match_id = %s + GROUP BY hm.id, hs.slug, hs.display_name + LIMIT 1 + """, + (server_slug, match_id), + ).fetchone() + if not row: + return None + players = connection.execute( + """ + SELECT hp.display_name, hp.stable_player_key, hp.steam_id, stats.team_side, stats.level, + stats.kills, stats.deaths, stats.teamkills, stats.combat, stats.offense, + stats.defense, stats.support, stats.time_seconds + FROM historical_player_match_stats AS stats + JOIN historical_players AS hp ON hp.id = stats.historical_player_id + WHERE stats.historical_match_id = %s + ORDER BY COALESCE(stats.kills, 0) DESC, hp.display_name ASC + """, + (row["match_pk"],), + ).fetchall() + started_at = row["started_at"] + ended_at = row["ended_at"] + return { + "server": {"slug": row["server_slug"], "name": row["server_name"]}, + "match_id": row["external_match_id"], + "started_at": started_at, + "ended_at": ended_at, + "closed_at": ended_at or started_at, + "duration_seconds": _duration_seconds(started_at, ended_at), + "map": {"name": row["map_name"], "pretty_name": row["map_pretty_name"] or row["map_name"]}, + "result": _match_result(row["allied_score"], row["axis_score"]), + "player_count": int(row["player_count"] or 0), + "total_time_seconds": _int(row["total_time_seconds"]), + "players": [ + { + "name": player["display_name"], + "stable_player_key": player["stable_player_key"], + "team_side": player["team_side"], + **build_external_player_profile_fields(steam_id=player["steam_id"]), + **{ + key: _int(player[key]) + for key in ( + "level", "kills", "deaths", "teamkills", "combat", + "offense", "defense", "support", "time_seconds", + ) + }, + } + for player in players + ], + "capture_basis": "public-scoreboard-match", + "match_url": resolve_trusted_scoreboard_match_url(row["raw_payload_ref"], row["server_slug"]), + } + + +def list_scoreboard_server_summaries(*, server_slug: str | None) -> list[dict[str, object]]: + initialize_postgres_display_storage() + if server_slug == ALL_SERVERS_SLUG: + rows = list_scoreboard_server_summaries(server_slug=None) + return [_all_server_summary(rows)] + where = "WHERE hs.slug = %s" if server_slug else "" + params = (server_slug,) if server_slug else () + with connect_postgres() as connection: + rows = connection.execute( + f""" + SELECT hs.slug AS server_slug, hs.display_name AS server_name, + COUNT(DISTINCT hm.id) AS matches_count, + COUNT(DISTINCT hp.id) AS unique_players, + COALESCE(SUM(stats.kills), 0) AS total_kills, + COUNT(DISTINCT COALESCE(hm.map_pretty_name, hm.map_name)) AS map_count, + MIN(COALESCE(hm.ended_at, hm.started_at, hm.created_at_source)) AS first_match_at, + MAX(COALESCE(hm.ended_at, hm.started_at, hm.created_at_source)) AS last_match_at + FROM historical_servers AS hs + LEFT JOIN historical_matches AS hm ON hm.historical_server_id = hs.id + LEFT JOIN historical_player_match_stats AS stats ON stats.historical_match_id = hm.id + LEFT JOIN historical_players AS hp ON hp.id = stats.historical_player_id + {where} + GROUP BY hs.id + ORDER BY hs.server_number ASC, hs.slug ASC + """, + params, + ).fetchall() + map_rows = connection.execute( + f""" + SELECT hs.slug AS server_slug, + COALESCE(hm.map_pretty_name, hm.map_name, 'Mapa no disponible') AS map_name, + COUNT(*) AS matches_count + FROM historical_matches AS hm + JOIN historical_servers AS hs ON hs.id = hm.historical_server_id + {where} + GROUP BY hs.slug, COALESCE(hm.map_pretty_name, hm.map_name, 'Mapa no disponible') + ORDER BY hs.slug ASC, matches_count DESC, map_name ASC + """, + params, + ).fetchall() + maps: dict[str, list[dict[str, object]]] = {} + for row in map_rows: + maps.setdefault(str(row["server_slug"]), []) + if len(maps[str(row["server_slug"])]) < 3: + maps[str(row["server_slug"])].append( + {"map_name": row["map_name"], "matches_count": int(row["matches_count"] or 0)} + ) + return [_summary_row(row, maps.get(str(row["server_slug"]), [])) for row in rows] + + +def list_scoreboard_leaderboard( + *, timeframe: str, metric: str, server_id: str | None, limit: int +) -> dict[str, object]: + current = datetime.now(timezone.utc) + if timeframe == "monthly": + current_start = current.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + previous_start = (current_start - timedelta(days=1)).replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + label = ("current-month", "Mes actual", "previous-closed-month-fallback", "Mes cerrado anterior") + else: + current_midnight = current.replace(hour=0, minute=0, second=0, microsecond=0) + current_start = current_midnight - timedelta(days=current_midnight.weekday()) + previous_start = current_start - timedelta(days=7) + label = ("current-week", "Semana actual", "previous-closed-week-fallback", "Semana cerrada anterior") + current_count = _count_scoreboard_matches(server_id, current_start, current) + previous_count = _count_scoreboard_matches(server_id, previous_start, current_start) + fallback = current_count <= 0 and previous_count > 0 + start, end = (previous_start, current_start) if fallback else (current_start, current) + rows = _leaderboard_rows(server_id=server_id, metric=metric, start=start, end=end, limit=limit) + window_days = max(1, int(((end - start).total_seconds() + 86399) // 86400)) + result = { + "metric": metric, + "window_start": _iso(start), + "window_end": _iso(end), + "window_days": window_days, + "window_kind": label[2] if fallback else label[0], + "window_label": label[3] if fallback else label[1], + "uses_fallback": fallback, + "selection_reason": ( + "no-current-month-matches" if fallback and timeframe == "monthly" + else "insufficient-current-week-sample" if fallback + else label[0] + ), + "items": rows, + } + if timeframe == "monthly": + result.update( + { + "timeframe": "monthly", + "current_month_start": _iso(current_start), + "current_month_closed_matches": current_count, + "previous_month_closed_matches": previous_count, + "sufficient_sample": { + "minimum_closed_matches": 1, + "current_month_closed_matches": current_count, + "current_month_has_sufficient_sample": current_count > 0, + "is_early_month": current.day <= 3, + }, + } + ) + else: + result.update( + { + "current_week_start": _iso(current_start), + "current_week_closed_matches": current_count, + "previous_week_closed_matches": previous_count, + "sufficient_sample": { + "minimum_closed_matches": 1, + "current_week_closed_matches": current_count, + "current_week_has_sufficient_sample": current_count > 0, + "is_early_week": current.weekday() <= get_historical_weekly_fallback_max_weekday(), + "fallback_max_weekday": get_historical_weekly_fallback_max_weekday(), + }, + } + ) + return result + + +def table_counts() -> dict[str, int]: + initialize_postgres_display_storage() + tables = ( + "historical_matches", + "historical_player_match_stats", + "displayed_historical_snapshots", + "player_event_raw_ledger", + "server_snapshots", + ) + with connect_postgres() as connection: + return { + table: int(connection.execute(f"SELECT COUNT(*) AS count FROM {table}").fetchone()["count"] or 0) + for table in tables + } + + +def _leaderboard_rows( + *, server_id: str | None, metric: str, start: datetime, end: datetime, limit: int +) -> list[dict[str, object]]: + metric_sql = { + "kills": "COALESCE(SUM(stats.kills), 0)", + "deaths": "COALESCE(SUM(stats.deaths), 0)", + "support": "COALESCE(SUM(stats.support), 0)", + "matches_over_100_kills": ( + "COALESCE(SUM(CASE WHEN COALESCE(stats.kills, 0) >= 100 THEN 1 ELSE 0 END), 0)" + ), + }[metric] + aggregate = server_id == ALL_SERVERS_SLUG + where, server_params = _server_where(server_id) + server_slug = f"'{ALL_SERVERS_SLUG}'" if aggregate else "hs.slug" + server_name = f"'{ALL_SERVERS_DISPLAY_NAME}'" if aggregate else "hs.display_name" + partition = f"'{ALL_SERVERS_SLUG}'" if aggregate else "hs.slug" + group_by = "hp.id" if aggregate else "hs.slug, hs.display_name, hp.id" + with connect_postgres() as connection: + rows = connection.execute( + f""" + WITH ranked AS ( + SELECT {server_slug} AS server_slug, {server_name} AS server_name, + hp.stable_player_key, hp.display_name AS player_name, hp.steam_id, + COUNT(DISTINCT hm.id) AS matches_count, {metric_sql} AS metric_value, + ROW_NUMBER() OVER ( + PARTITION BY {partition} + ORDER BY {metric_sql} DESC, COUNT(DISTINCT hm.id) ASC, hp.display_name ASC + ) AS ranking_position + FROM historical_player_match_stats AS stats + JOIN historical_matches AS hm ON hm.id = stats.historical_match_id + JOIN historical_servers AS hs ON hs.id = hm.historical_server_id + JOIN historical_players AS hp ON hp.id = stats.historical_player_id + WHERE hm.ended_at IS NOT NULL AND hm.ended_at >= %s AND hm.ended_at < %s {where} + GROUP BY {group_by} + ) + SELECT * FROM ranked WHERE ranking_position <= %s + ORDER BY server_slug ASC, ranking_position ASC + """, + (_iso(start), _iso(end), *server_params, limit), + ).fetchall() + return [ + { + "server": {"slug": row["server_slug"], "name": row["server_name"]}, + "time_range": {"start": _iso(start), "end": _iso(end), "window_days": max(1, (end - start).days or 1)}, + "player": { + "stable_player_key": row["stable_player_key"], + "name": row["player_name"], + "steam_id": row["steam_id"], + }, + "metric": metric, + "ranking_position": int(row["ranking_position"]), + "metric_value": int(row["metric_value"] or 0), + "matches_considered": int(row["matches_count"] or 0), + } + for row in rows + ] + + +def _count_scoreboard_matches(server_id: str | None, start: datetime, end: datetime) -> int: + where, server_params = _server_where(server_id) + with connect_postgres() as connection: + row = connection.execute( + f""" + SELECT COUNT(DISTINCT hm.id) AS count + FROM historical_matches AS hm + JOIN historical_servers AS hs ON hs.id = hm.historical_server_id + JOIN historical_player_match_stats AS stats ON stats.historical_match_id = hm.id + WHERE hm.ended_at IS NOT NULL AND hm.ended_at >= %s AND hm.ended_at < %s {where} + """, + (_iso(start), _iso(end), *server_params), + ).fetchone() + return int(row["count"] or 0) + + +def _server_where(server_id: str | None) -> tuple[str, tuple[object, ...]]: + if not server_id or server_id == ALL_SERVERS_SLUG: + return "", () + return "AND (hs.slug = %s OR CAST(hs.server_number AS TEXT) = %s)", (server_id, server_id) + + +def _recent_match_row(row: Mapping[str, object]) -> dict[str, object]: + return { + "server": {"slug": row["server_slug"], "name": row["server_name"]}, + "match_id": row["external_match_id"], + "started_at": row["started_at"], + "ended_at": row["ended_at"], + "closed_at": row["ended_at"] or row["started_at"], + "map": {"name": row["map_name"], "pretty_name": row["map_pretty_name"] or row["map_name"]}, + "result": _match_result(row["allied_score"], row["axis_score"]), + "player_count": int(row["player_count"] or 0), + "match_url": resolve_trusted_scoreboard_match_url(row["raw_payload_ref"], row["server_slug"]), + } + + +def _summary_row(row: Mapping[str, object], top_maps: list[dict[str, object]]) -> dict[str, object]: + first = row["first_match_at"] + last = row["last_match_at"] + matches = int(row["matches_count"] or 0) + return { + "server": {"slug": row["server_slug"], "name": row["server_name"]}, + "matches_count": matches, + "imported_matches_count": matches, + "unique_players": int(row["unique_players"] or 0), + "total_kills": int(row["total_kills"] or 0), + "map_count": int(row["map_count"] or 0), + "top_maps": top_maps, + "coverage": { + "basis": "postgres-migrated-public-scoreboard", + "status": "available" if matches else "empty", + "imported_matches_count": matches, + "discovered_total_matches": None, + "first_match_at": first, + "last_match_at": last, + "coverage_days": _coverage_days(first, last), + }, + "backfill": {}, + "time_range": {"start": first, "end": last}, + } + + +def _all_server_summary(items: list[dict[str, object]]) -> dict[str, object]: + starts = [item["time_range"]["start"] for item in items if item["time_range"]["start"]] + ends = [item["time_range"]["end"] for item in items if item["time_range"]["end"]] + return { + "server": {"slug": ALL_SERVERS_SLUG, "name": ALL_SERVERS_DISPLAY_NAME}, + "matches_count": sum(int(item["matches_count"]) for item in items), + "imported_matches_count": sum(int(item["imported_matches_count"]) for item in items), + "unique_players": None, + "total_kills": sum(int(item["total_kills"]) for item in items), + "map_count": None, + "top_maps": [], + "coverage": {"basis": "postgres-migrated-public-scoreboard", "status": "available" if items else "empty"}, + "backfill": {}, + "time_range": {"start": min(starts) if starts else None, "end": max(ends) if ends else None}, + } + + +def _attach_server_history(connection: Any, item: dict[str, object]) -> dict[str, object]: + rows = connection.execute( + """ + SELECT captured_at, status, players FROM server_snapshots + WHERE server_id = %s ORDER BY captured_at DESC LIMIT %s + """, + (item["server_id"], SUMMARY_SNAPSHOT_LIMIT), + ).fetchall() + players = [int(row["players"]) for row in rows if row["players"] is not None] + online = [row for row in rows if row["status"] == "online"] + item["history_summary"] = { + "window_size": SUMMARY_SNAPSHOT_LIMIT, + "recent_capture_count": len(rows), + "recent_online_count": len(online), + "recent_average_players": round(sum(players) / len(players), 1) if players else None, + "recent_peak_players": max(players, default=None), + "last_seen_online_at": online[0]["captured_at"] if online else None, + "minutes_since_last_capture": _minutes_since(rows[0]["captured_at"]) if rows else None, + } + return item + + +def _match_result(allied: object, axis: object) -> dict[str, object]: + allied_int, axis_int = _int(allied), _int(axis) + winner = None + if allied_int is not None and axis_int is not None: + winner = "allied" if allied_int > axis_int else "axis" if axis_int > allied_int else "draw" + return {"allied_score": allied_int, "axis_score": axis_int, "winner": winner} + + +def _duration_seconds(start: object, end: object) -> int | None: + start_point, end_point = _parse_datetime(_iso(start)), _parse_datetime(_iso(end)) + return max(0, int((end_point - start_point).total_seconds())) if start_point and end_point else None + + +def _coverage_days(start: object, end: object) -> int | None: + seconds = _duration_seconds(start, end) + return max(1, int((seconds + 86399) // 86400)) if seconds is not None else None + + +def _minutes_since(value: object) -> int | None: + point = _parse_datetime(_iso(value)) + return max(0, int((datetime.now(timezone.utc) - point).total_seconds() // 60)) if point else None + + +def _int(value: object) -> int | None: + try: + return None if value is None else int(value) + except (TypeError, ValueError): + return None + + +def _fallback_external_id(value: object) -> str: + normalized = "".join( + character.lower() if character.isalnum() else "-" + for character in str(value or "unknown-server") + ) + compact = "-".join(part for part in normalized.split("-") if part) + return compact or "unknown-server" + + +def _iso(value: object) -> str | None: + if isinstance(value, datetime): + point = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return point.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + text = str(value or "").strip() + return text or None + + +def _json_payload_default(value: object) -> str: + if isinstance(value, datetime): + return _iso(value) or "" + raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable") + + +def _parse_datetime(value: str | None) -> datetime | None: + if not value: + return None + try: + point = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + return point.astimezone(timezone.utc) if point.tzinfo else point.replace(tzinfo=timezone.utc) + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/backend/app/postgres_rcon_storage.py b/backend/app/postgres_rcon_storage.py new file mode 100644 index 0000000..4fad140 --- /dev/null +++ b/backend/app/postgres_rcon_storage.py @@ -0,0 +1,1038 @@ +"""PostgreSQL persistence for the phase-1 RCON historical pipeline.""" + +from __future__ import annotations + +import json +from collections.abc import Iterable, Mapping +from contextlib import contextmanager +from datetime import datetime, timezone +from typing import Any + +from .config import get_database_url +from .normalizers import normalize_map_name +from .rcon_client import load_rcon_targets + + +COMPETITIVE_WINDOW_GAP_SECONDS = 1800 +COMPETITIVE_MODE_PARTIAL = "partial" +COMPETITIVE_MODE_APPROXIMATE = "approximate" +COMPETITIVE_MODE_EXACT = "exact" + + +RCON_SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS rcon_historical_targets ( + id BIGSERIAL PRIMARY KEY, + target_key TEXT NOT NULL UNIQUE, + external_server_id TEXT, + display_name TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER NOT NULL, + region TEXT, + game_port INTEGER, + query_port INTEGER, + source_name TEXT NOT NULL, + last_configured_at TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS rcon_historical_capture_runs ( + id BIGSERIAL PRIMARY KEY, + mode TEXT NOT NULL, + status TEXT NOT NULL, + target_scope TEXT, + started_at TEXT NOT NULL, + completed_at TEXT, + targets_seen INTEGER NOT NULL DEFAULT 0, + samples_inserted INTEGER NOT NULL DEFAULT 0, + duplicate_samples INTEGER NOT NULL DEFAULT 0, + failed_targets INTEGER NOT NULL DEFAULT 0, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS rcon_historical_samples ( + id BIGSERIAL PRIMARY KEY, + target_id BIGINT NOT NULL REFERENCES rcon_historical_targets(id), + capture_run_id BIGINT REFERENCES rcon_historical_capture_runs(id), + captured_at TEXT NOT NULL, + source_kind TEXT NOT NULL, + status TEXT NOT NULL, + players INTEGER, + max_players INTEGER, + current_map TEXT, + normalized_payload_json TEXT NOT NULL, + raw_payload_json TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(target_id, captured_at) +); + +CREATE TABLE IF NOT EXISTS rcon_historical_checkpoints ( + target_id BIGINT PRIMARY KEY REFERENCES rcon_historical_targets(id), + last_successful_capture_at TEXT, + last_sample_at TEXT, + last_run_id BIGINT REFERENCES rcon_historical_capture_runs(id), + last_run_status TEXT, + last_error TEXT, + last_error_at TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS rcon_historical_competitive_windows ( + id BIGSERIAL PRIMARY KEY, + target_id BIGINT NOT NULL REFERENCES rcon_historical_targets(id), + session_key TEXT NOT NULL UNIQUE, + source_kind TEXT NOT NULL, + map_name TEXT, + map_pretty_name TEXT, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + sample_count INTEGER NOT NULL DEFAULT 0, + total_players INTEGER NOT NULL DEFAULT 0, + peak_players INTEGER NOT NULL DEFAULT 0, + last_players INTEGER, + max_players INTEGER, + status TEXT NOT NULL, + confidence_mode TEXT NOT NULL, + capabilities_json TEXT NOT NULL, + latest_payload_json TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS rcon_admin_log_events ( + id BIGSERIAL PRIMARY KEY, + target_key TEXT NOT NULL, + external_server_id TEXT, + event_timestamp TEXT, + server_time BIGINT, + relative_time TEXT, + event_type TEXT NOT NULL, + raw_message TEXT NOT NULL, + canonical_message TEXT NOT NULL, + parsed_payload_json TEXT NOT NULL, + raw_entry_json TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE NULLS NOT DISTINCT(target_key, server_time, canonical_message) +); + +CREATE TABLE IF NOT EXISTS rcon_player_profile_snapshots ( + id BIGSERIAL PRIMARY KEY, + target_key TEXT NOT NULL, + external_server_id TEXT, + player_id TEXT NOT NULL, + player_name TEXT NOT NULL, + source_server_time BIGINT NOT NULL, + event_timestamp TEXT, + first_seen TEXT, + sessions INTEGER, + matches_played INTEGER, + play_time TEXT, + total_kills INTEGER, + total_deaths INTEGER, + teamkills_done INTEGER, + teamkills_received INTEGER, + kd_ratio DOUBLE PRECISION, + favorite_weapons_json TEXT NOT NULL DEFAULT '{}', + victims_json TEXT NOT NULL DEFAULT '{}', + nemesis_json TEXT NOT NULL DEFAULT '{}', + averages_json TEXT NOT NULL DEFAULT '{}', + sanctions_json TEXT NOT NULL DEFAULT '{}', + raw_content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(target_key, player_id, source_server_time) +); + +CREATE TABLE IF NOT EXISTS rcon_materialized_matches ( + id BIGSERIAL PRIMARY KEY, + target_key TEXT NOT NULL, + external_server_id TEXT, + match_key TEXT NOT NULL, + map_name TEXT, + map_pretty_name TEXT, + game_mode TEXT, + started_server_time BIGINT, + ended_server_time BIGINT, + started_at TEXT, + ended_at TEXT, + allied_score INTEGER, + axis_score INTEGER, + winner TEXT, + confidence_mode TEXT NOT NULL, + source_basis TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(target_key, match_key) +); + +CREATE TABLE IF NOT EXISTS rcon_match_player_stats ( + id BIGSERIAL PRIMARY KEY, + target_key TEXT NOT NULL, + match_key TEXT NOT NULL, + player_id TEXT NOT NULL, + player_name TEXT NOT NULL, + team TEXT, + kills INTEGER NOT NULL DEFAULT 0, + deaths INTEGER NOT NULL DEFAULT 0, + teamkills INTEGER NOT NULL DEFAULT 0, + deaths_by_teamkill INTEGER NOT NULL DEFAULT 0, + weapons_json TEXT NOT NULL DEFAULT '{}', + death_by_weapons_json TEXT NOT NULL DEFAULT '{}', + most_killed_json TEXT NOT NULL DEFAULT '{}', + death_by_json TEXT NOT NULL DEFAULT '{}', + first_seen_server_time BIGINT, + last_seen_server_time BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(target_key, match_key, player_id) +); + +CREATE TABLE IF NOT EXISTS rcon_scoreboard_match_candidates ( + id BIGSERIAL PRIMARY KEY, + server_slug TEXT NOT NULL, + external_match_id TEXT NOT NULL, + started_at TEXT, + ended_at TEXT, + map_name TEXT, + map_pretty_name TEXT, + allied_score INTEGER, + axis_score INTEGER, + player_count INTEGER, + match_url TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(server_slug, external_match_id) +); + +CREATE INDEX IF NOT EXISTS idx_rcon_historical_samples_target_time +ON rcon_historical_samples(target_id, captured_at DESC); +CREATE INDEX IF NOT EXISTS idx_rcon_historical_windows_target_time +ON rcon_historical_competitive_windows(target_id, last_seen_at DESC); +CREATE INDEX IF NOT EXISTS idx_rcon_admin_log_events_target_time +ON rcon_admin_log_events(target_key, server_time DESC); +CREATE INDEX IF NOT EXISTS idx_rcon_admin_log_events_type +ON rcon_admin_log_events(event_type); +CREATE INDEX IF NOT EXISTS idx_rcon_player_profile_snapshots_player +ON rcon_player_profile_snapshots(target_key, player_id, source_server_time DESC); +CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_recent +ON rcon_materialized_matches(target_key, ended_at DESC, ended_server_time DESC); +CREATE INDEX IF NOT EXISTS idx_rcon_match_player_stats_match +ON rcon_match_player_stats(target_key, match_key); +CREATE INDEX IF NOT EXISTS idx_rcon_scoreboard_candidates_server_end +ON rcon_scoreboard_match_candidates(server_slug, ended_at DESC, started_at DESC); +""" + + +def initialize_postgres_rcon_storage() -> None: + """Create deterministic PostgreSQL schema for migrated RCON domains.""" + with connect_postgres() as connection: + with connection.cursor() as cursor: + cursor.execute(RCON_SCHEMA_SQL) + + +@contextmanager +def connect_postgres(): + """Yield one PostgreSQL connection with dict-shaped rows.""" + try: + import psycopg + from psycopg.rows import dict_row + except ImportError as error: # pragma: no cover - dependency is environment-specific + raise RuntimeError("psycopg is required when HLL_BACKEND_DATABASE_URL is set.") from error + + database_url = get_database_url() + if not database_url: + raise RuntimeError("HLL_BACKEND_DATABASE_URL is required for PostgreSQL RCON storage.") + with psycopg.connect(database_url, row_factory=dict_row) as connection: + yield connection + + +class PostgresCompatConnection: + """Small DB-API shim for RCON SQL shared with SQLite functions.""" + + def __init__(self, connection: Any): + self.connection = connection + + def execute(self, sql: str, params: Iterable[object] | None = None): + normalized = sql.replace("server_time IS ?", "server_time IS NOT DISTINCT FROM ?") + normalized = normalized.replace("?", "%s") + return self.connection.execute(normalized, tuple(params or ())) + + +@contextmanager +def connect_postgres_compat(): + """Yield a query shim that accepts the phase-1 SQLite-style placeholders.""" + initialize_postgres_rcon_storage() + with connect_postgres() as connection: + yield PostgresCompatConnection(connection) + + +def start_capture_run(*, mode: str, target_scope: str) -> int: + initialize_postgres_rcon_storage() + with connect_postgres() as connection: + row = connection.execute( + """ + INSERT INTO rcon_historical_capture_runs (mode, status, target_scope, started_at) + VALUES (%s, 'running', %s, %s) + RETURNING id + """, + (mode, target_scope, _utc_now_iso()), + ).fetchone() + return int(row["id"]) + + +def finalize_capture_run( + run_id: int, + *, + status: str, + targets_seen: int, + samples_inserted: int, + duplicate_samples: int, + failed_targets: int, + notes: str | None, +) -> None: + initialize_postgres_rcon_storage() + with connect_postgres() as connection: + connection.execute( + """ + UPDATE rcon_historical_capture_runs + SET status = %s, + completed_at = %s, + targets_seen = %s, + samples_inserted = %s, + duplicate_samples = %s, + failed_targets = %s, + notes = %s + WHERE id = %s + """, + ( + status, + _utc_now_iso(), + targets_seen, + samples_inserted, + duplicate_samples, + failed_targets, + notes, + run_id, + ), + ) + + +def persist_sample( + *, + run_id: int, + captured_at: str, + target: Mapping[str, object], + normalized_payload: Mapping[str, object], + raw_payload: Mapping[str, object] | None, +) -> dict[str, int]: + initialize_postgres_rcon_storage() + with connect_postgres() as connection: + target_id = _upsert_target(connection, target=target) + row = connection.execute( + """ + INSERT INTO rcon_historical_samples ( + target_id, capture_run_id, captured_at, source_kind, status, players, + max_players, current_map, normalized_payload_json, raw_payload_json + ) VALUES (%s, %s, %s, 'rcon-live-sample', %s, %s, %s, %s, %s, %s) + ON CONFLICT(target_id, captured_at) DO NOTHING + RETURNING id + """, + ( + target_id, + run_id, + captured_at, + normalized_payload.get("status") or "unknown", + normalized_payload.get("players"), + normalized_payload.get("max_players"), + normalized_payload.get("current_map"), + json.dumps(dict(normalized_payload), separators=(",", ":")), + json.dumps(dict(raw_payload), separators=(",", ":")) if raw_payload else None, + ), + ).fetchone() + inserted = int(row is not None) + _upsert_checkpoint_success( + connection, + target_id=target_id, + run_id=run_id, + captured_at=captured_at, + ) + if inserted: + _upsert_competitive_window( + connection, + target_id=target_id, + captured_at=captured_at, + normalized_payload=normalized_payload, + ) + return {"samples_inserted": inserted, "duplicate_samples": 0 if inserted else 1} + + +def mark_capture_failure( + *, + run_id: int, + target: Mapping[str, object], + error_message: str, +) -> None: + initialize_postgres_rcon_storage() + with connect_postgres() as connection: + target_id = _upsert_target(connection, target=target) + connection.execute( + """ + INSERT INTO rcon_historical_checkpoints ( + target_id, last_run_id, last_run_status, last_error, last_error_at + ) VALUES (%s, %s, 'failed', %s, %s) + ON CONFLICT(target_id) DO UPDATE SET + last_run_id = EXCLUDED.last_run_id, + last_run_status = EXCLUDED.last_run_status, + last_error = EXCLUDED.last_error, + last_error_at = EXCLUDED.last_error_at, + updated_at = CURRENT_TIMESTAMP + """, + (target_id, run_id, error_message, _utc_now_iso()), + ) + + +def list_target_statuses() -> list[dict[str, object]]: + rows = _fetchall( + """ + SELECT + targets.target_key, + targets.external_server_id, + targets.display_name, + targets.host, + targets.port, + targets.region, + targets.source_name, + checkpoints.last_successful_capture_at, + checkpoints.last_sample_at, + checkpoints.last_run_id, + checkpoints.last_run_status, + checkpoints.last_error, + checkpoints.last_error_at, + (SELECT MIN(samples.captured_at) FROM rcon_historical_samples AS samples + WHERE samples.target_id = targets.id) AS first_sample_at, + (SELECT MAX(samples.captured_at) FROM rcon_historical_samples AS samples + WHERE samples.target_id = targets.id) AS latest_sample_at, + (SELECT COUNT(*) FROM rcon_historical_samples AS samples + WHERE samples.target_id = targets.id) AS sample_count + FROM rcon_historical_targets AS targets + LEFT JOIN rcon_historical_checkpoints AS checkpoints + ON checkpoints.target_id = targets.id + ORDER BY targets.display_name ASC, targets.target_key ASC + """ + ) + return [ + { + **dict(row), + "sample_count": int(row["sample_count"] or 0), + "last_sample_at": row["latest_sample_at"] or row["last_sample_at"], + } + for row in rows + ] + + +def list_recent_samples(*, target_key: str | None, limit: int) -> list[dict[str, object]]: + where_clause, params = _target_where_clause(target_key) + rows = _fetchall( + f""" + SELECT targets.target_key, targets.external_server_id, targets.display_name, + targets.region, samples.captured_at, samples.status, samples.players, + samples.max_players, samples.current_map + FROM rcon_historical_samples AS samples + INNER JOIN rcon_historical_targets AS targets ON targets.id = samples.target_id + {where_clause} + ORDER BY samples.captured_at DESC, targets.display_name ASC + LIMIT %s + """, + [*params, limit], + ) + return [dict(row) for row in rows] + + +def list_competitive_windows(*, target_key: str | None, limit: int) -> list[dict[str, object]]: + where_clause, params = _target_where_clause(target_key) + rows = _fetchall( + f""" + SELECT targets.target_key, targets.external_server_id, targets.display_name, + targets.region, windows.session_key, windows.map_name, + windows.map_pretty_name, windows.first_seen_at, windows.last_seen_at, + windows.sample_count, windows.total_players, windows.peak_players, + windows.last_players, windows.max_players, windows.status, + windows.confidence_mode, windows.capabilities_json, + windows.latest_payload_json + FROM rcon_historical_competitive_windows AS windows + INNER JOIN rcon_historical_targets AS targets ON targets.id = windows.target_id + {where_clause} + ORDER BY windows.last_seen_at DESC, targets.display_name ASC + LIMIT %s + """, + [*params, limit], + ) + return [_serialize_window(row) for row in rows] + + +def count_samples_since(since: str | None) -> int: + if not since: + return 0 + row = _fetchone( + "SELECT COUNT(*) AS sample_count FROM rcon_historical_samples WHERE captured_at > %s", + (since,), + ) + return int(row["sample_count"] or 0) if row else 0 + + +def list_competitive_summary_rows(*, target_key: str | None) -> list[dict[str, object]]: + where_clause, params = _target_where_clause(target_key) + rows = _fetchall( + f""" + SELECT targets.target_key, targets.external_server_id, targets.display_name, + targets.region, checkpoints.last_successful_capture_at, + checkpoints.last_run_status, checkpoints.last_error, + checkpoints.last_error_at, COUNT(windows.id) AS window_count, + COALESCE(SUM(windows.sample_count), 0) AS sample_count, + MIN(windows.first_seen_at) AS first_seen_at, + MAX(windows.last_seen_at) AS last_seen_at, + COALESCE(MAX(windows.peak_players), 0) AS peak_players + FROM rcon_historical_targets AS targets + LEFT JOIN rcon_historical_checkpoints AS checkpoints ON checkpoints.target_id = targets.id + LEFT JOIN rcon_historical_competitive_windows AS windows ON windows.target_id = targets.id + {where_clause} + GROUP BY targets.id, checkpoints.target_id + ORDER BY targets.display_name ASC, targets.target_key ASC + """, + params, + ) + return [ + { + **dict(row), + "window_count": int(row["window_count"] or 0), + "sample_count": int(row["sample_count"] or 0), + "peak_players": int(row["peak_players"] or 0), + } + for row in rows + ] + + +def find_competitive_window( + *, + server_key: str, + ended_at: str | None, + map_name: str | None, +) -> dict[str, object] | None: + if not ended_at: + return None + aliases = _expand_target_key_aliases(server_key) + candidates = _fetchall( + """ + SELECT windows.session_key, windows.first_seen_at, windows.last_seen_at, + windows.map_name, windows.map_pretty_name, windows.sample_count, + windows.total_players, windows.peak_players, windows.confidence_mode, + windows.capabilities_json, windows.latest_payload_json + FROM rcon_historical_competitive_windows AS windows + INNER JOIN rcon_historical_targets AS targets ON targets.id = windows.target_id + WHERE targets.target_key = ANY(%s) OR targets.external_server_id = ANY(%s) + ORDER BY windows.last_seen_at DESC + LIMIT 12 + """, + (aliases, aliases), + ) + ended_point = _parse_timestamp(ended_at) + if ended_point is None: + return None + normalized_map_name = normalize_map_name(map_name) + best_row: dict[str, object] | None = None + best_distance: float | None = None + for row in candidates: + row_map = normalize_map_name(row["map_pretty_name"] or row["map_name"]) + if normalized_map_name and row_map and normalized_map_name != row_map: + continue + row_last = _parse_timestamp(row["last_seen_at"]) + if row_last is None: + continue + distance = abs((row_last - ended_point).total_seconds()) + if best_distance is None or distance < best_distance: + best_row = dict(row) + best_distance = distance + if best_row is None or best_distance is None or best_distance > 21600: + return None + sample_count = int(best_row["sample_count"] or 0) + return { + "session_key": best_row["session_key"], + "first_seen_at": best_row["first_seen_at"], + "last_seen_at": best_row["last_seen_at"], + "duration_seconds": _calculate_duration_seconds( + best_row["first_seen_at"], + best_row["last_seen_at"], + ), + "map_name": best_row["map_name"], + "map_pretty_name": best_row["map_pretty_name"] or best_row["map_name"], + "sample_count": sample_count, + "average_players": ( + round((int(best_row["total_players"] or 0) / sample_count), 2) + if sample_count > 0 + else 0.0 + ), + "peak_players": int(best_row["peak_players"] or 0), + "confidence_mode": best_row["confidence_mode"], + "capabilities": _deserialize_json_object(best_row["capabilities_json"]), + } + + +def get_competitive_window_by_session( + *, + server_key: str, + session_key: str, +) -> dict[str, object] | None: + normalized_session_key = str(session_key or "").strip() + if not normalized_session_key: + return None + aliases = _expand_target_key_aliases(server_key) + row = _fetchone( + """ + SELECT targets.target_key, targets.external_server_id, targets.display_name, + targets.region, windows.session_key, windows.map_name, + windows.map_pretty_name, windows.first_seen_at, windows.last_seen_at, + windows.sample_count, windows.total_players, windows.peak_players, + windows.confidence_mode, windows.capabilities_json, + windows.latest_payload_json + FROM rcon_historical_competitive_windows AS windows + INNER JOIN rcon_historical_targets AS targets ON targets.id = windows.target_id + WHERE windows.session_key = %s + AND (targets.target_key = ANY(%s) OR targets.external_server_id = ANY(%s)) + LIMIT 1 + """, + (normalized_session_key, aliases, aliases), + ) + return _serialize_window(row) if row else None + + +def list_scoreboard_candidates(*, server_slug: str, limit: int) -> list[dict[str, object]]: + rows = _fetchall( + """ + SELECT external_match_id, started_at, ended_at, map_name, map_pretty_name, + allied_score, axis_score, player_count, match_url + FROM rcon_scoreboard_match_candidates + WHERE server_slug = %s + ORDER BY COALESCE(ended_at, started_at) DESC + LIMIT %s + """, + (server_slug, limit), + ) + return [dict(row) for row in rows] + + +def upsert_scoreboard_candidates( + *, + server_slug: str, + candidates: Iterable[Mapping[str, object]], +) -> int: + """Cache trusted scoreboard correlation candidates in PostgreSQL.""" + rows = [candidate for candidate in candidates if candidate.get("match_url")] + if not rows: + return 0 + initialize_postgres_rcon_storage() + inserted_or_updated = 0 + with connect_postgres() as connection: + for candidate in rows: + connection.execute( + """ + INSERT INTO rcon_scoreboard_match_candidates ( + server_slug, external_match_id, started_at, ended_at, map_name, + map_pretty_name, allied_score, axis_score, player_count, match_url + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT(server_slug, external_match_id) DO UPDATE SET + started_at = EXCLUDED.started_at, + ended_at = EXCLUDED.ended_at, + map_name = EXCLUDED.map_name, + map_pretty_name = EXCLUDED.map_pretty_name, + allied_score = EXCLUDED.allied_score, + axis_score = EXCLUDED.axis_score, + player_count = EXCLUDED.player_count, + match_url = EXCLUDED.match_url, + updated_at = CURRENT_TIMESTAMP + """, + ( + server_slug, + str(candidate.get("external_match_id") or ""), + candidate.get("started_at"), + candidate.get("ended_at"), + candidate.get("map_name"), + candidate.get("map_pretty_name"), + candidate.get("allied_score"), + candidate.get("axis_score"), + candidate.get("player_count"), + candidate["match_url"], + ), + ) + inserted_or_updated += 1 + return inserted_or_updated + + +def upsert_scoreboard_candidate( + *, + server_slug: str, + candidate: Mapping[str, object], +) -> str: + """Persist one trusted scoreboard correlation candidate and report the upsert path.""" + external_match_id = str(candidate.get("external_match_id") or "").strip() + match_url = str(candidate.get("match_url") or "").strip() + if not external_match_id or not match_url: + return "skipped" + + initialize_postgres_rcon_storage() + with connect_postgres() as connection: + existing = connection.execute( + """ + SELECT id + FROM rcon_scoreboard_match_candidates + WHERE server_slug = %s AND external_match_id = %s + LIMIT 1 + """, + (server_slug, external_match_id), + ).fetchone() + connection.execute( + """ + INSERT INTO rcon_scoreboard_match_candidates ( + server_slug, external_match_id, started_at, ended_at, map_name, + map_pretty_name, allied_score, axis_score, player_count, match_url + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT(server_slug, external_match_id) DO UPDATE SET + started_at = EXCLUDED.started_at, + ended_at = EXCLUDED.ended_at, + map_name = EXCLUDED.map_name, + map_pretty_name = EXCLUDED.map_pretty_name, + allied_score = EXCLUDED.allied_score, + axis_score = EXCLUDED.axis_score, + player_count = EXCLUDED.player_count, + match_url = EXCLUDED.match_url, + updated_at = CURRENT_TIMESTAMP + """, + ( + server_slug, + external_match_id, + candidate.get("started_at"), + candidate.get("ended_at"), + candidate.get("map_name"), + candidate.get("map_pretty_name"), + candidate.get("allied_score"), + candidate.get("axis_score"), + candidate.get("player_count"), + match_url, + ), + ) + return "updated" if existing else "inserted" + + +def count_migrated_tables() -> dict[str, int]: + table_names = ( + "rcon_admin_log_events", + "rcon_player_profile_snapshots", + "rcon_materialized_matches", + "rcon_match_player_stats", + "rcon_historical_targets", + "rcon_historical_samples", + "rcon_historical_competitive_windows", + "rcon_scoreboard_match_candidates", + ) + with connect_postgres() as connection: + return { + table_name: int( + connection.execute(f"SELECT COUNT(*) AS count FROM {table_name}").fetchone()[ + "count" + ] + or 0 + ) + for table_name in table_names + } + + +def _fetchall(sql: str, params: Iterable[object] = ()) -> list[dict[str, object]]: + with connect_postgres() as connection: + return [dict(row) for row in connection.execute(sql, tuple(params)).fetchall()] + + +def _fetchone(sql: str, params: Iterable[object] = ()) -> dict[str, object] | None: + with connect_postgres() as connection: + row = connection.execute(sql, tuple(params)).fetchone() + return dict(row) if row else None + + +def _upsert_target(connection: Any, *, target: Mapping[str, object]) -> int: + target_key = str(target.get("target_key") or "").strip() + display_name = str(target.get("name") or target.get("display_name") or target_key).strip() + host = str(target.get("host") or "").strip() + port = int(target.get("port") or 0) + if not target_key or not host or port <= 0: + raise ValueError("Prospective RCON targets require target_key, host and port.") + row = connection.execute( + """ + INSERT INTO rcon_historical_targets ( + target_key, external_server_id, display_name, host, port, region, + game_port, query_port, source_name, last_configured_at + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT(target_key) DO UPDATE SET + external_server_id = EXCLUDED.external_server_id, + display_name = EXCLUDED.display_name, + host = EXCLUDED.host, + port = EXCLUDED.port, + region = EXCLUDED.region, + game_port = EXCLUDED.game_port, + query_port = EXCLUDED.query_port, + source_name = EXCLUDED.source_name, + last_configured_at = EXCLUDED.last_configured_at, + updated_at = CURRENT_TIMESTAMP + RETURNING id + """, + ( + target_key, + target.get("external_server_id"), + display_name, + host, + port, + target.get("region"), + target.get("game_port"), + target.get("query_port"), + str(target.get("source_name") or "community-hispana-rcon"), + _utc_now_iso(), + ), + ).fetchone() + return int(row["id"]) + + +def _upsert_checkpoint_success( + connection: Any, + *, + target_id: int, + run_id: int, + captured_at: str, +) -> None: + connection.execute( + """ + INSERT INTO rcon_historical_checkpoints ( + target_id, last_successful_capture_at, last_sample_at, last_run_id, + last_run_status, last_error, last_error_at + ) VALUES (%s, %s, %s, %s, 'success', NULL, NULL) + ON CONFLICT(target_id) DO UPDATE SET + last_successful_capture_at = EXCLUDED.last_successful_capture_at, + last_sample_at = EXCLUDED.last_sample_at, + last_run_id = EXCLUDED.last_run_id, + last_run_status = EXCLUDED.last_run_status, + last_error = NULL, + last_error_at = NULL, + updated_at = CURRENT_TIMESTAMP + """, + (target_id, captured_at, captured_at, run_id), + ) + + +def _upsert_competitive_window( + connection: Any, + *, + target_id: int, + captured_at: str, + normalized_payload: Mapping[str, object], +) -> None: + current_map_raw = str(normalized_payload.get("current_map") or "").strip() + if not current_map_raw: + return + map_pretty_name = normalize_map_name(current_map_raw) or current_map_raw + players = int(normalized_payload.get("players") or 0) + max_players = normalized_payload.get("max_players") + status = str(normalized_payload.get("status") or "unknown") + latest_window = connection.execute( + """ + SELECT * + FROM rcon_historical_competitive_windows + WHERE target_id = %s + ORDER BY last_seen_at DESC, id DESC + LIMIT 1 + """, + (target_id,), + ).fetchone() + if latest_window and _should_extend_competitive_window( + latest_window=dict(latest_window), + captured_at=captured_at, + current_map=current_map_raw, + ): + connection.execute( + """ + UPDATE rcon_historical_competitive_windows + SET map_name = %s, + map_pretty_name = %s, + last_seen_at = %s, + sample_count = sample_count + 1, + total_players = total_players + %s, + peak_players = GREATEST(peak_players, %s), + last_players = %s, + max_players = %s, + status = %s, + confidence_mode = %s, + capabilities_json = %s, + latest_payload_json = %s, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + ( + current_map_raw, + map_pretty_name, + captured_at, + players, + players, + players, + max_players, + status, + COMPETITIVE_MODE_APPROXIMATE, + json.dumps(_build_competitive_capabilities(), separators=(",", ":")), + json.dumps(dict(normalized_payload), separators=(",", ":")), + latest_window["id"], + ), + ) + return + connection.execute( + """ + INSERT INTO rcon_historical_competitive_windows ( + target_id, session_key, source_kind, map_name, map_pretty_name, + first_seen_at, last_seen_at, sample_count, total_players, + peak_players, last_players, max_players, status, confidence_mode, + capabilities_json, latest_payload_json + ) VALUES (%s, %s, 'rcon-historical-samples', %s, %s, %s, %s, 1, + %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + target_id, + f"{target_id}:{captured_at}", + current_map_raw, + map_pretty_name, + captured_at, + captured_at, + players, + players, + players, + max_players, + status, + COMPETITIVE_MODE_APPROXIMATE, + json.dumps(_build_competitive_capabilities(), separators=(",", ":")), + json.dumps(dict(normalized_payload), separators=(",", ":")), + ), + ) + + +def _target_where_clause(target_key: str | None) -> tuple[str, list[object]]: + if not target_key: + return "", [] + aliases = _expand_target_key_aliases(target_key) + return "WHERE targets.target_key = ANY(%s) OR targets.external_server_id = ANY(%s)", [ + aliases, + aliases, + ] + + +def _expand_target_key_aliases(target_key: str) -> list[str]: + normalized_target_key = str(target_key or "").strip() + aliases = {normalized_target_key} + try: + configured_targets = load_rcon_targets() + except Exception: + configured_targets = () + for target in configured_targets: + external_server_id = str(target.external_server_id or "").strip() + legacy_target_key = f"rcon:{target.host}:{target.port}" + if external_server_id and external_server_id == normalized_target_key: + aliases.update((legacy_target_key, external_server_id)) + elif legacy_target_key == normalized_target_key: + aliases.add(legacy_target_key) + if external_server_id: + aliases.add(external_server_id) + return sorted(alias for alias in aliases if alias) + + +def _serialize_window(row: Mapping[str, object]) -> dict[str, object]: + sample_count = int(row["sample_count"] or 0) + return { + "target_key": row["target_key"], + "external_server_id": row["external_server_id"], + "display_name": row["display_name"], + "region": row["region"], + "session_key": row["session_key"], + "map_name": row["map_name"], + "map_pretty_name": row["map_pretty_name"] or row["map_name"], + "first_seen_at": row["first_seen_at"], + "last_seen_at": row["last_seen_at"], + "duration_seconds": _calculate_duration_seconds( + row["first_seen_at"], + row["last_seen_at"], + ), + "sample_count": sample_count, + "average_players": ( + round((int(row["total_players"] or 0) / sample_count), 2) + if sample_count > 0 + else 0.0 + ), + "peak_players": int(row["peak_players"] or 0), + "last_players": row.get("last_players"), + "max_players": row.get("max_players"), + "status": row.get("status"), + "confidence_mode": row["confidence_mode"], + "capabilities": _deserialize_json_object(row["capabilities_json"]), + "latest_payload": _deserialize_json_object(row["latest_payload_json"]), + } + + +def _should_extend_competitive_window( + *, + latest_window: Mapping[str, object], + captured_at: str, + current_map: str, +) -> bool: + if normalize_map_name(latest_window.get("map_name")) != normalize_map_name(current_map): + return False + latest_seen = _parse_timestamp(latest_window.get("last_seen_at")) + captured_point = _parse_timestamp(captured_at) + if latest_seen is None or captured_point is None: + return False + return (captured_point - latest_seen).total_seconds() <= COMPETITIVE_WINDOW_GAP_SECONDS + + +def _build_competitive_capabilities() -> dict[str, object]: + return { + "recent_matches": COMPETITIVE_MODE_APPROXIMATE, + "server_summary": COMPETITIVE_MODE_EXACT, + "competitive_quality": COMPETITIVE_MODE_PARTIAL, + "result": "session-score", + "gamestate": "session", + "player_stats": "unavailable", + } + + +def _deserialize_json_object(raw_value: object) -> dict[str, object]: + if isinstance(raw_value, str) and raw_value.strip(): + try: + parsed = json.loads(raw_value) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + return {} + + +def _calculate_duration_seconds(first_seen_at: object, last_seen_at: object) -> int | None: + first_point = _parse_timestamp(first_seen_at) + last_point = _parse_timestamp(last_seen_at) + if first_point is None or last_point is None: + return None + return max(0, int((last_point - first_point).total_seconds())) + + +def _parse_timestamp(raw_value: object) -> datetime | None: + if not isinstance(raw_value, str) or not raw_value.strip(): + return None + try: + timestamp = datetime.fromisoformat(raw_value.replace("Z", "+00:00")) + except ValueError: + return None + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=timezone.utc) + return timestamp.astimezone(timezone.utc) + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/backend/app/providers/player_event_source_provider.py b/backend/app/providers/player_event_source_provider.py new file mode 100644 index 0000000..c1ed8af --- /dev/null +++ b/backend/app/providers/player_event_source_provider.py @@ -0,0 +1,415 @@ +"""Player event adapter backed by public CRCON scoreboard match details.""" + +from __future__ import annotations + +import hashlib +from collections.abc import Mapping +from dataclasses import dataclass + +from ..player_event_models import PlayerEventRecord + + +@dataclass(frozen=True, slots=True) +class _PlayerIdentity: + stable_player_key: str + display_name: str | None + + +@dataclass(frozen=True, slots=True) +class PublicScoreboardPlayerEventSource: + """Normalize partial duel and weapon signals from CRCON match detail payloads.""" + + source_kind: str = "public-scoreboard-match-summary" + + def extract_match_events( + self, + *, + server_slug: str, + match_payload: dict[str, object], + source_ref: str | None = None, + ) -> list[PlayerEventRecord]: + match_id = _stringify(match_payload.get("id")) + if not match_id: + return [] + + occurred_at = _pick_match_timestamp(match_payload) + player_rows = _coerce_player_rows(match_payload.get("player_stats")) + if not player_rows: + return [] + + identity_index = _build_identity_index(player_rows) + events: list[PlayerEventRecord] = [] + + for player_row in player_rows: + actor = _build_player_identity(player_row) + if actor is None: + continue + + top_kill_type_name = _extract_primary_name(player_row.get("kills_by_type")) + + for victim_name, victim_count in _extract_named_counts(player_row.get("most_killed")): + victim = _find_identity_by_name(identity_index, victim_name) + if victim is None or victim_count <= 0: + continue + events.append( + _build_event( + event_type="player_kill_summary", + occurred_at=occurred_at, + server_slug=server_slug, + match_id=match_id, + source_kind=self.source_kind, + source_ref=source_ref, + raw_event_ref=( + f"match:{match_id}:player:{actor.stable_player_key}:most-killed:{victim.stable_player_key}" + ), + killer=actor, + victim=victim, + weapon_name=None, + kill_category=top_kill_type_name, + is_teamkill=False, + event_value=victim_count, + ) + ) + + for killer_name, killer_count in _extract_named_counts(player_row.get("death_by")): + killer = _find_identity_by_name(identity_index, killer_name) + if killer is None or killer_count <= 0: + continue + events.append( + _build_event( + event_type="player_death_summary", + occurred_at=occurred_at, + server_slug=server_slug, + match_id=match_id, + source_kind=self.source_kind, + source_ref=source_ref, + raw_event_ref=( + f"match:{match_id}:player:{actor.stable_player_key}:death-by:{killer.stable_player_key}" + ), + killer=killer, + victim=actor, + weapon_name=None, + kill_category=None, + is_teamkill=False, + event_value=killer_count, + ) + ) + + for weapon_name, weapon_count in _extract_named_counts(player_row.get("weapons")): + events.append( + _build_event( + event_type="player_weapon_kill_summary", + occurred_at=occurred_at, + server_slug=server_slug, + match_id=match_id, + source_kind=self.source_kind, + source_ref=source_ref, + raw_event_ref=( + f"match:{match_id}:player:{actor.stable_player_key}:weapons:{weapon_name}" + ), + killer=actor, + victim=None, + weapon_name=weapon_name, + kill_category=top_kill_type_name, + is_teamkill=False, + event_value=weapon_count, + ) + ) + + for weapon_name, weapon_count in _extract_named_counts(player_row.get("death_by_weapons")): + events.append( + _build_event( + event_type="player_weapon_death_summary", + occurred_at=occurred_at, + server_slug=server_slug, + match_id=match_id, + source_kind=self.source_kind, + source_ref=source_ref, + raw_event_ref=( + f"match:{match_id}:player:{actor.stable_player_key}:death-by-weapons:{weapon_name}" + ), + killer=None, + victim=actor, + weapon_name=weapon_name, + kill_category=None, + is_teamkill=False, + event_value=weapon_count, + ) + ) + + teamkills = _coerce_int(player_row.get("teamkills")) or 0 + if teamkills > 0: + events.append( + _build_event( + event_type="player_teamkill_summary", + occurred_at=occurred_at, + server_slug=server_slug, + match_id=match_id, + source_kind=self.source_kind, + source_ref=source_ref, + raw_event_ref=f"match:{match_id}:player:{actor.stable_player_key}:teamkills", + killer=actor, + victim=None, + weapon_name=None, + kill_category=top_kill_type_name, + is_teamkill=True, + event_value=teamkills, + ) + ) + + return events + + def describe_scope(self) -> dict[str, object]: + return { + "source_kind": self.source_kind, + "supports_raw_kill_events": False, + "captures": [ + "Encounter summaries per player from most_killed", + "Death summaries per player from death_by", + "Weapon kill summaries per player from weapons", + "Weapon death summaries per player from death_by_weapons", + "Aggregated teamkills per player and match", + ], + "limitations": [ + "The current source is match-summary data, not a true per-kill event feed.", + "occurred_at uses the match end/start timestamp, not the exact kill timestamp.", + "Only summary counters exposed by the CRCON detail payload are normalized.", + "Full killer->victim ledgers, complete weapon breakdowns, and exact per-event teamkills still require a dedicated raw event/log source.", + ], + } + + +def _build_identity_index(player_rows: list[dict[str, object]]) -> dict[str, _PlayerIdentity]: + identity_index: dict[str, _PlayerIdentity] = {} + for player_row in player_rows: + identity = _build_player_identity(player_row) + if identity is None or not identity.display_name: + continue + identity_index[_normalize_name(identity.display_name)] = identity + return identity_index + + +def _build_player_identity(player_row: dict[str, object]) -> _PlayerIdentity | None: + display_name = _stringify(player_row.get("player")) or _stringify(player_row.get("name")) + source_player_id = _stringify(player_row.get("player_id")) or _stringify(player_row.get("id")) + steam_id = _extract_steam_id(player_row.get("steaminfo")) + stable_player_key = _build_stable_player_key(steam_id=steam_id, source_player_id=source_player_id) + if stable_player_key is None: + return None + return _PlayerIdentity( + stable_player_key=stable_player_key, + display_name=display_name or stable_player_key, + ) + + +def _find_identity_by_name( + identity_index: dict[str, _PlayerIdentity], + player_name: str | None, +) -> _PlayerIdentity | None: + if not player_name: + return None + return identity_index.get(_normalize_name(player_name)) + + +def _build_event( + *, + event_type: str, + occurred_at: str | None, + server_slug: str, + match_id: str, + source_kind: str, + source_ref: str | None, + raw_event_ref: str, + killer: _PlayerIdentity | None, + victim: _PlayerIdentity | None, + weapon_name: str | None, + kill_category: str | None, + is_teamkill: bool, + event_value: int, +) -> PlayerEventRecord: + event_id = _build_event_id( + event_type=event_type, + occurred_at=occurred_at, + server_slug=server_slug, + match_id=match_id, + killer_player_key=killer.stable_player_key if killer else None, + victim_player_key=victim.stable_player_key if victim else None, + weapon_name=weapon_name, + is_teamkill=is_teamkill, + event_value=event_value, + ) + return PlayerEventRecord( + event_id=event_id, + event_type=event_type, + occurred_at=occurred_at, + server_slug=server_slug, + external_match_id=match_id, + source_kind=source_kind, + source_ref=source_ref, + raw_event_ref=raw_event_ref, + killer_player_key=killer.stable_player_key if killer else None, + killer_display_name=killer.display_name if killer else None, + victim_player_key=victim.stable_player_key if victim else None, + victim_display_name=victim.display_name if victim else None, + weapon_name=weapon_name, + weapon_category=None, + kill_category=kill_category, + is_teamkill=is_teamkill, + event_value=max(1, event_value), + ) + + +def _build_event_id( + *, + event_type: str, + occurred_at: str | None, + server_slug: str, + match_id: str, + killer_player_key: str | None, + victim_player_key: str | None, + weapon_name: str | None, + is_teamkill: bool, + event_value: int, +) -> str: + raw_key = "|".join( + [ + event_type, + occurred_at or "", + server_slug, + match_id, + killer_player_key or "", + victim_player_key or "", + weapon_name or "", + "1" if is_teamkill else "0", + str(event_value), + ] + ) + return hashlib.sha1(raw_key.encode("utf-8")).hexdigest() + + +def _pick_match_timestamp(match_payload: Mapping[str, object]) -> str | None: + for key in ("end", "start", "creation_time"): + value = _stringify(match_payload.get(key)) + if value: + return value + return None + + +def _extract_primary_name(value: object) -> str | None: + named_counts = _extract_named_counts(value) + if not named_counts: + return None + return named_counts[0][0] + + +def _extract_named_counts(value: object) -> list[tuple[str, int]]: + aggregated: dict[str, tuple[str, int]] = {} + for name, count in _iter_named_counts(value): + normalized_name = _normalize_name(name) + existing = aggregated.get(normalized_name) + if existing is None: + aggregated[normalized_name] = (name, count) + continue + aggregated[normalized_name] = (existing[0], existing[1] + count) + return sorted( + aggregated.values(), + key=lambda item: (-item[1], item[0].casefold()), + ) + + +def _iter_named_counts(value: object) -> list[tuple[str, int]]: + if isinstance(value, str): + name = _stringify(value) + return [(name, 1)] if name else [] + if isinstance(value, Mapping): + named_count = _extract_named_count_mapping(value) + if named_count is not None: + return [named_count] + + items: list[tuple[str, int]] = [] + for raw_name, raw_count in value.items(): + name = _stringify(raw_name) + count = _coerce_int(raw_count) + if name and count and count > 0: + items.append((name, count)) + return items + if isinstance(value, list): + items: list[tuple[str, int]] = [] + for item in value: + items.extend(_iter_named_counts(item)) + return items + return [] + + +def _extract_named_count_mapping(value: Mapping[str, object]) -> tuple[str, int] | None: + nested_name = None + nested_player = value.get("player") + if isinstance(nested_player, Mapping): + nested_name = _stringify(nested_player.get("name")) or _stringify(nested_player.get("player")) + name = ( + _stringify(value.get("name")) + or _stringify(value.get("player")) + or _stringify(value.get("victim")) + or _stringify(value.get("killer")) + or nested_name + ) + if not name: + return None + count = ( + _coerce_int(value.get("count")) + or _coerce_int(value.get("kills")) + or _coerce_int(value.get("deaths")) + or _coerce_int(value.get("value")) + or _coerce_int(value.get("total")) + or 1 + ) + return name, max(1, count) + + +def _extract_steam_id(value: object) -> str | None: + if isinstance(value, Mapping): + profile = value.get("profile") + if isinstance(profile, Mapping): + steam_id = _stringify(profile.get("steamid")) + if steam_id: + return steam_id + return _stringify(value.get("id")) + return None + + +def _build_stable_player_key( + *, + steam_id: str | None, + source_player_id: str | None, +) -> str | None: + if steam_id: + return f"steam:{steam_id}" + if source_player_id: + return f"crcon-player:{source_player_id}" + return None + + +def _coerce_player_rows(value: object) -> list[dict[str, object]]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict)] + + +def _normalize_name(value: str) -> str: + return value.strip().casefold() + + +def _stringify(value: object) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + +def _coerce_int(value: object) -> int | None: + if value in (None, ""): + return None + try: + return int(value) + except (TypeError, ValueError): + return None diff --git a/backend/app/providers/public_scoreboard_provider.py b/backend/app/providers/public_scoreboard_provider.py new file mode 100644 index 0000000..58232e9 --- /dev/null +++ b/backend/app/providers/public_scoreboard_provider.py @@ -0,0 +1,139 @@ +"""Public scoreboard provider adapter for historical HLL data.""" + +from __future__ import annotations + +import json +import time +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from ..config import ( + get_historical_crcon_request_retries, + get_historical_crcon_request_timeout_seconds, + get_historical_crcon_retry_delay_seconds, +) + + +PUBLIC_INFO_ENDPOINT = "/api/get_public_info" +MATCH_LIST_ENDPOINT = "/api/get_scoreboard_maps" +MATCH_DETAIL_ENDPOINT = "/api/get_map_scoreboard" + + +@dataclass(frozen=True, slots=True) +class PublicScoreboardHistoricalDataSource: + """Historical provider backed by the public CRCON scoreboard JSON API.""" + + source_kind: str = "public-scoreboard" + + def fetch_public_info(self, *, base_url: str) -> dict[str, object]: + return self._fetch_dict_payload(base_url, PUBLIC_INFO_ENDPOINT) + + def fetch_match_page(self, *, base_url: str, page: int, limit: int) -> dict[str, object]: + return self._fetch_dict_payload( + base_url, + MATCH_LIST_ENDPOINT, + {"page": page, "limit": limit}, + context=f"page={page}", + ) + + def fetch_match_details( + self, + *, + base_url: str, + match_ids: list[str], + max_workers: int, + ) -> list[dict[str, object]]: + if not match_ids: + return [] + if max_workers <= 1: + return [ + self._fetch_match_detail(base_url=base_url, match_id=match_id) + for match_id in match_ids + ] + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [ + executor.submit(self._fetch_match_detail, base_url=base_url, match_id=match_id) + for match_id in match_ids + ] + return [future.result() for future in futures] + + def _fetch_match_detail(self, *, base_url: str, match_id: str) -> dict[str, object]: + return self._fetch_dict_payload( + base_url, + MATCH_DETAIL_ENDPOINT, + {"map_id": match_id}, + context=f"match={match_id}", + ) + + def _fetch_json( + self, + *, + base_url: str, + endpoint: str, + query: dict[str, object] | None = None, + ) -> object: + url = f"{base_url}{endpoint}" + if query: + url = f"{url}?{urlencode(query)}" + + request = Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": "HLL-Vietnam-Historical-Ingestion/0.1", + }, + ) + try: + with urlopen( + request, + timeout=get_historical_crcon_request_timeout_seconds(), + ) as response: + return json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + raise RuntimeError(f"Historical provider request failed: {url} ({exc.code})") from exc + except URLError as exc: + raise RuntimeError(f"Historical provider request failed: {url} ({exc.reason})") from exc + + def _fetch_dict_payload( + self, + base_url: str, + endpoint: str, + query: dict[str, object] | None = None, + *, + context: str = "", + retries: int | None = None, + ) -> dict[str, object]: + resolved_retries = retries or get_historical_crcon_request_retries() + base_retry_delay_seconds = get_historical_crcon_retry_delay_seconds() + last_error: Exception | None = None + for attempt in range(1, resolved_retries + 1): + try: + payload = _unwrap_result( + self._fetch_json(base_url=base_url, endpoint=endpoint, query=query) + ) + except Exception as exc: # pragma: no cover - network path + last_error = exc + else: + if isinstance(payload, dict): + return payload + last_error = ValueError( + f"Unexpected payload type for {base_url}{endpoint} {context}".strip() + ) + + if attempt < resolved_retries: + time.sleep(base_retry_delay_seconds * attempt) + + assert last_error is not None + raise last_error + + +def _unwrap_result(payload: object) -> object: + if not isinstance(payload, dict): + return payload + if "result" not in payload: + return payload + return payload.get("result") diff --git a/backend/app/providers/rcon_provider.py b/backend/app/providers/rcon_provider.py new file mode 100644 index 0000000..61e7eef --- /dev/null +++ b/backend/app/providers/rcon_provider.py @@ -0,0 +1,67 @@ +"""RCON provider adapter for live HLL server state.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ..rcon_client import ( + RconServerTarget, + load_rcon_targets, + query_live_server_sample, +) +from ..snapshots import build_snapshot_batch, utc_now +from ..storage import persist_snapshot_batch + + +@dataclass(frozen=True, slots=True) +class RconLiveDataSource: + """Live provider backed by direct HLL RCON access.""" + + source_kind: str = "rcon" + + def collect_snapshots(self, *, persist: bool) -> dict[str, object]: + configured_targets = load_rcon_targets() + if not configured_targets: + raise RuntimeError("No RCON targets configured in HLL_BACKEND_RCON_TARGETS.") + + captured_at = utc_now() + normalized_records: list[dict[str, object]] = [] + errors: list[dict[str, object]] = [] + + for target in configured_targets: + try: + normalized_records.append(query_live_server_sample(target)["normalized"]) + except Exception as error: # noqa: BLE001 - keep provider failures controlled + errors.append( + { + "target": target.name, + "host": target.host, + "port": target.port, + "message": str(error), + } + ) + + payload = { + "source_name": "hll-rcon", + "collection_mode": "rcon", + "fallback_used": False, + "target_count": len(configured_targets), + "success_count": len(normalized_records), + "errors": errors, + "captured_at": captured_at.isoformat().replace("+00:00", "Z"), + "snapshots": build_snapshot_batch(normalized_records, captured_at=captured_at), + } + if persist: + payload["storage"] = persist_snapshot_batch( + payload["snapshots"], + source_name=payload["source_name"], + captured_at=payload["captured_at"], + ) + return payload + + def build_target_index(self) -> dict[str | None, RconServerTarget]: + return { + target.external_server_id: target + for target in load_rcon_targets() + if target.external_server_id + } diff --git a/backend/app/rcon_admin_log_ingestion.py b/backend/app/rcon_admin_log_ingestion.py new file mode 100644 index 0000000..e5badba --- /dev/null +++ b/backend/app/rcon_admin_log_ingestion.py @@ -0,0 +1,147 @@ +"""Manual ingestion of Hell Let Loose RCON AdminLog events.""" + +from __future__ import annotations + +import argparse +import json +from dataclasses import dataclass + +from .config import get_rcon_request_timeout_seconds +from .rcon_admin_log_storage import ( + list_rcon_admin_log_event_counts, + persist_rcon_admin_log_entries, +) +from .rcon_client import HllRconConnection, build_rcon_target_key, load_rcon_targets + + +@dataclass(slots=True) +class AdminLogIngestionStats: + targets_seen: int = 0 + events_seen: int = 0 + events_inserted: int = 0 + duplicate_events: int = 0 + failed_targets: int = 0 + + +def ingest_rcon_admin_logs( + *, + minutes: int, + target_key: str | None = None, +) -> dict[str, object]: + """Fetch and persist recent AdminLog entries from configured RCON targets.""" + selected_targets = _select_targets(target_key) + stats = AdminLogIngestionStats() + targets: list[dict[str, object]] = [] + errors: list[dict[str, object]] = [] + timeout_seconds = get_rcon_request_timeout_seconds() + + for target in selected_targets: + stats.targets_seen += 1 + target_metadata = _serialize_target(target) + + try: + with HllRconConnection(timeout_seconds=timeout_seconds) as connection: + connection.connect(host=target.host, port=target.port, password=target.password) + payload = connection.execute_json( + "GetAdminLog", + { + "LogBackTrackTime": minutes * 60, + "Filters": [], + }, + ) + + entries = payload.get("entries") + if not isinstance(entries, list): + entries = [] + + normalized_entries = [entry for entry in entries if isinstance(entry, dict)] + delta = persist_rcon_admin_log_entries( + target=target_metadata, + entries=normalized_entries, + ) + + stats.events_seen += int(delta["events_seen"]) + stats.events_inserted += int(delta["events_inserted"]) + stats.duplicate_events += int(delta["duplicate_events"]) + targets.append( + { + **target_metadata, + "status": "ok", + "minutes": minutes, + **delta, + } + ) + except Exception as exc: # noqa: BLE001 - manual diagnostic command reports per-target failures + stats.failed_targets += 1 + errors.append( + { + **target_metadata, + "status": "error", + "error_type": type(exc).__name__, + "message": str(exc), + } + ) + + return { + "status": "ok" if not errors else ("partial" if targets else "error"), + "target_scope": target_key or "all-configured-rcon-targets", + "minutes": minutes, + "targets": targets, + "errors": errors, + "totals": { + "targets_seen": stats.targets_seen, + "events_seen": stats.events_seen, + "events_inserted": stats.events_inserted, + "duplicate_events": stats.duplicate_events, + "failed_targets": stats.failed_targets, + }, + "event_counts": list_rcon_admin_log_event_counts(), + } + + +def _select_targets(target_key: str | None) -> list[object]: + configured_targets = list(load_rcon_targets()) + if not configured_targets: + raise RuntimeError("No RCON targets configured in HLL_BACKEND_RCON_TARGETS.") + if target_key is None: + return configured_targets + + normalized = target_key.strip() + selected = [ + target + for target in configured_targets + if build_rcon_target_key(target) == normalized + ] + if not selected: + raise ValueError(f"Unknown RCON target key: {target_key}") + return selected + + +def _serialize_target(target: object) -> dict[str, object]: + return { + "target_key": build_rcon_target_key(target), + "external_server_id": target.external_server_id, + "name": target.name, + "host": target.host, + "port": target.port, + "source_name": target.source_name, + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--minutes", type=int, default=60) + parser.add_argument("--target", default=None) + args = parser.parse_args() + + print( + json.dumps( + ingest_rcon_admin_logs(minutes=args.minutes, target_key=args.target), + ensure_ascii=False, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/backend/app/rcon_admin_log_materialization.py b/backend/app/rcon_admin_log_materialization.py new file mode 100644 index 0000000..879983c --- /dev/null +++ b/backend/app/rcon_admin_log_materialization.py @@ -0,0 +1,863 @@ +"""Materialize RCON AdminLog events into match and player-stat read models.""" + +from __future__ import annotations + +import argparse +import json +import sqlite3 +from collections import Counter +from collections.abc import Iterable +from contextlib import closing +from pathlib import Path + +from .config import get_storage_path, use_postgres_rcon_storage +from .normalizers import normalize_map_name +from .rcon_admin_log_storage import initialize_rcon_admin_log_storage +from .rcon_historical_storage import list_rcon_historical_competitive_windows +from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer + + +MATCH_RESULT_SOURCE = "admin-log-match-ended" +SESSION_RESULT_SOURCE = "rcon-session" + + +def initialize_rcon_materialized_storage(*, db_path: Path | None = None) -> Path: + """Create SQLite structures used by the materialized RCON match pipeline.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import initialize_postgres_rcon_storage + + initialize_postgres_rcon_storage() + return get_storage_path() + + resolved_path = initialize_rcon_admin_log_storage(db_path=db_path) + with closing(connect_sqlite_writer(resolved_path)) as connection: + with connection: + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS rcon_materialized_matches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_key TEXT NOT NULL, + external_server_id TEXT, + match_key TEXT NOT NULL, + map_name TEXT, + map_pretty_name TEXT, + game_mode TEXT, + started_server_time INTEGER, + ended_server_time INTEGER, + started_at TEXT, + ended_at TEXT, + allied_score INTEGER, + axis_score INTEGER, + winner TEXT, + confidence_mode TEXT NOT NULL, + source_basis TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(target_key, match_key) + ); + + CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_recent + ON rcon_materialized_matches(target_key, ended_at DESC, ended_server_time DESC); + + CREATE TABLE IF NOT EXISTS rcon_match_player_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_key TEXT NOT NULL, + match_key TEXT NOT NULL, + player_id TEXT NOT NULL, + player_name TEXT NOT NULL, + team TEXT, + kills INTEGER NOT NULL DEFAULT 0, + deaths INTEGER NOT NULL DEFAULT 0, + teamkills INTEGER NOT NULL DEFAULT 0, + deaths_by_teamkill INTEGER NOT NULL DEFAULT 0, + weapons_json TEXT NOT NULL DEFAULT '{}', + death_by_weapons_json TEXT NOT NULL DEFAULT '{}', + most_killed_json TEXT NOT NULL DEFAULT '{}', + death_by_json TEXT NOT NULL DEFAULT '{}', + first_seen_server_time INTEGER, + last_seen_server_time INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(target_key, match_key, player_id) + ); + + CREATE INDEX IF NOT EXISTS idx_rcon_match_player_stats_match + ON rcon_match_player_stats(target_key, match_key); + """ + ) + return resolved_path + + +def materialize_rcon_admin_log(*, db_path: Path | None = None) -> dict[str, object]: + """Materialize matches and player stats from stored AdminLog events.""" + resolved_path = initialize_rcon_materialized_storage(db_path=db_path) + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import connect_postgres_compat + + with connect_postgres_compat() as connection: + payload = _materialize_rcon_admin_log_with_connection( + connection, + session_window_db_path=None, + caught_errors=(Exception,), + ) + freshness = summarize_rcon_materialization_status() + return { + **payload, + "latest_materialized_matches": freshness["latest_materialized_matches"], + "latest_admin_log_match_end_events": freshness["latest_admin_log_match_end_events"], + "match_end_status": freshness["match_end_status"], + } + + with closing(connect_sqlite_writer(resolved_path)) as connection: + with connection: + payload = _materialize_rcon_admin_log_with_connection( + connection, + session_window_db_path=resolved_path, + caught_errors=(sqlite3.Error,), + ) + + freshness = summarize_rcon_materialization_status(db_path=resolved_path) + return { + **payload, + "latest_materialized_matches": freshness["latest_materialized_matches"], + "latest_admin_log_match_end_events": freshness["latest_admin_log_match_end_events"], + "match_end_status": freshness["match_end_status"], + } + + +def _materialize_rcon_admin_log_with_connection( + connection: object, + *, + session_window_db_path: Path | None, + caught_errors: tuple[type[BaseException], ...], +) -> dict[str, object]: + errors: list[str] = [] + matches_seen = 0 + matches_materialized = 0 + matches_updated = 0 + player_stats_seen = 0 + player_stats_materialized = 0 + player_stats_updated = 0 + + try: + match_rows = _derive_admin_log_matches(connection) + matches_seen = len(match_rows) + for row in match_rows: + outcome = _upsert_match(connection, row) + matches_materialized += int(outcome == "inserted") + matches_updated += int(outcome == "updated") + session_rows = _derive_session_fallback_matches( + connection, + db_path=session_window_db_path, + ) + matches_seen += len(session_rows) + for row in session_rows: + outcome = _upsert_match(connection, row) + matches_materialized += int(outcome == "inserted") + matches_updated += int(outcome == "updated") + + persisted_matches = _list_materialized_matches(connection) + for match in persisted_matches: + stats = _derive_player_stats_for_match(connection, match) + player_stats_seen += len(stats) + connection.execute( + """ + DELETE FROM rcon_match_player_stats + WHERE target_key = ? AND match_key = ? + """, + (match["target_key"], match["match_key"]), + ) + for stat in stats: + _insert_player_stat(connection, stat) + player_stats_materialized += 1 + except caught_errors as error: + errors.append(str(error)) + return { + "matches_seen": matches_seen, + "matches_materialized": matches_materialized, + "matches_updated": matches_updated, + "player_stats_seen": player_stats_seen, + "player_stats_materialized": player_stats_materialized, + "player_stats_updated": player_stats_updated, + "errors": errors, + } + + +def list_materialized_rcon_matches( + *, + target_key: str | None = None, + only_ended: bool = False, + limit: int = 20, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """Return recent materialized RCON matches.""" + resolved_path = initialize_rcon_materialized_storage(db_path=db_path) + clauses: list[str] = [] + params: list[object] = [] + if target_key: + clauses.append("(m.target_key = ? OR m.external_server_id = ?)") + params.extend([target_key, target_key]) + if only_ended: + clauses.append("m.source_basis = ?") + params.append(MATCH_RESULT_SOURCE) + where = "WHERE " + " AND ".join(clauses) if clauses else "" + params.append(limit) + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import connect_postgres_compat + + connection_scope = connect_postgres_compat() + else: + connection_scope = closing(connect_sqlite_readonly(resolved_path)) + with connection_scope as connection: + rows = connection.execute( + f""" + SELECT + m.*, + ( + SELECT COUNT(*) + FROM rcon_match_player_stats AS stats + WHERE stats.target_key = m.target_key + AND stats.match_key = m.match_key + ) AS materialized_player_count, + ( + SELECT COUNT(DISTINCT TRIM(stats.player_name)) + FROM rcon_match_player_stats AS stats + WHERE stats.target_key = m.target_key + AND stats.match_key = m.match_key + AND TRIM(COALESCE(stats.player_name, '')) != '' + ) AS materialized_distinct_player_count + FROM rcon_materialized_matches AS m + {where} + ORDER BY COALESCE(m.ended_at, m.started_at) DESC, + COALESCE(m.ended_server_time, m.started_server_time) DESC + LIMIT ? + """, + params, + ).fetchall() + return [dict(row) for row in rows] + + +def get_materialized_rcon_match_detail( + *, + server_key: str, + match_key: str, + db_path: Path | None = None, +) -> dict[str, object] | None: + """Return one materialized match with player stats.""" + resolved_path = initialize_rcon_materialized_storage(db_path=db_path) + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import connect_postgres_compat + + connection_scope = connect_postgres_compat() + else: + connection_scope = closing(connect_sqlite_readonly(resolved_path)) + with connection_scope as connection: + match = connection.execute( + """ + SELECT * + FROM rcon_materialized_matches + WHERE match_key = ? + AND (target_key = ? OR external_server_id = ?) + LIMIT 1 + """, + (match_key, server_key, server_key), + ).fetchone() + if match is None and match_key.startswith(f"{server_key}:"): + match = connection.execute( + """ + SELECT * + FROM rcon_materialized_matches + WHERE match_key = ? + LIMIT 1 + """, + (match_key,), + ).fetchone() + if match is None: + return None + stat_rows = connection.execute( + """ + SELECT * + FROM rcon_match_player_stats + WHERE target_key = ? AND match_key = ? + ORDER BY kills DESC, deaths ASC, player_name ASC + """, + (match["target_key"], match["match_key"]), + ).fetchall() + timeline_rows = connection.execute( + """ + SELECT event_type, COUNT(*) AS event_count + FROM rcon_admin_log_events + WHERE target_key = ? + AND server_time IS NOT NULL + AND (? IS NULL OR server_time >= ?) + AND (? IS NULL OR server_time <= ?) + GROUP BY event_type + ORDER BY event_count DESC, event_type ASC + """, + ( + match["target_key"], + match["started_server_time"], + match["started_server_time"], + match["ended_server_time"], + match["ended_server_time"], + ), + ).fetchall() + + return { + "match": dict(match), + "players": [dict(row) for row in stat_rows], + "timeline": [dict(row) for row in timeline_rows], + } + + +def summarize_rcon_materialization_status(*, db_path: Path | None = None) -> dict[str, object]: + """Return a small diagnostic summary for stored RCON materialization state.""" + resolved_path = initialize_rcon_materialized_storage(db_path=db_path) + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import connect_postgres_compat + + connection_scope = connect_postgres_compat() + else: + connection_scope = closing(connect_sqlite_readonly(resolved_path)) + with connection_scope as connection: + match_count = connection.execute( + "SELECT COUNT(*) AS count FROM rcon_materialized_matches" + ).fetchone()["count"] + stats_match_count = connection.execute( + """ + SELECT COUNT(*) AS count + FROM ( + SELECT 1 + FROM rcon_match_player_stats + GROUP BY target_key, match_key + ) AS stats_matches + """ + ).fetchone()["count"] + ranges = connection.execute( + """ + SELECT target_key, MIN(server_time) AS first_server_time, MAX(server_time) AS last_server_time + FROM rcon_admin_log_events + GROUP BY target_key + ORDER BY target_key ASC + """ + ).fetchall() + event_counts = connection.execute( + """ + SELECT target_key, event_type, COUNT(*) AS event_count + FROM rcon_admin_log_events + GROUP BY target_key, event_type + ORDER BY target_key ASC, event_count DESC + """ + ).fetchall() + latest_matches = connection.execute( + """ + SELECT + target_key, + external_server_id, + match_key, + map_pretty_name, + COALESCE(ended_at, started_at) AS closed_at, + ended_at, + ended_server_time, + source_basis, + updated_at + FROM ( + SELECT + *, + ROW_NUMBER() OVER ( + PARTITION BY target_key + ORDER BY COALESCE(ended_at, started_at) DESC, + COALESCE(ended_server_time, started_server_time) DESC, + updated_at DESC + ) AS row_number + FROM rcon_materialized_matches + WHERE source_basis = ? + ) AS ranked_matches + WHERE row_number = 1 + ORDER BY target_key ASC + """, + (MATCH_RESULT_SOURCE,), + ).fetchall() + latest_match_end_events = connection.execute( + """ + SELECT + target_key, + external_server_id, + MAX(event_timestamp) AS latest_event_timestamp, + MAX(server_time) AS latest_server_time, + COUNT(*) AS match_end_events + FROM rcon_admin_log_events + WHERE event_type = 'match_end' + GROUP BY target_key, external_server_id + ORDER BY target_key ASC + """ + ).fetchall() + return { + "materialized_matches": int(match_count or 0), + "matches_with_player_stats": int(stats_match_count or 0), + "server_time_ranges": [dict(row) for row in ranges], + "event_counts": [dict(row) for row in event_counts], + "latest_materialized_matches": [dict(row) for row in latest_matches], + "latest_admin_log_match_end_events": [dict(row) for row in latest_match_end_events], + "match_end_status": ( + "admin-log-match-end-events-available" + if latest_match_end_events + else "no-admin-log-match-end-events-stored" + ), + } + + +def _derive_admin_log_matches(connection: sqlite3.Connection) -> list[dict[str, object]]: + rows = connection.execute( + """ + SELECT * + FROM rcon_admin_log_events + WHERE event_type IN ('match_start', 'match_end') + ORDER BY target_key ASC, server_time ASC, id ASC + """ + ).fetchall() + matches: list[dict[str, object]] = [] + open_by_target: dict[str, sqlite3.Row] = {} + for row in rows: + target_key = row["target_key"] + payload = _json_object(row["parsed_payload_json"]) + if row["event_type"] == "match_start": + if target_key in open_by_target: + matches.append(_build_match_row(open_by_target.pop(target_key), None)) + open_by_target[target_key] = row + continue + start_row = open_by_target.pop(target_key, None) + matches.append(_build_match_row(start_row, row, end_payload=payload)) + for start_row in open_by_target.values(): + matches.append(_build_match_row(start_row, None)) + return matches + + +def _derive_session_fallback_matches( + connection: sqlite3.Connection, + *, + db_path: Path | None, +) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + existing = { + (row["target_key"], normalize_map_name(row["map_pretty_name"] or row["map_name"])) + for row in connection.execute( + """ + SELECT target_key, map_name, map_pretty_name + FROM rcon_materialized_matches + WHERE source_basis = ? + """, + (MATCH_RESULT_SOURCE,), + ).fetchall() + } + for window in list_rcon_historical_competitive_windows(limit=100, db_path=db_path): + target_key = str(window.get("target_key") or "") + map_name = window.get("map_pretty_name") or window.get("map_name") + if (target_key, normalize_map_name(map_name)) in existing: + continue + session_key = str(window.get("session_key") or "").strip() + if not target_key or not session_key: + continue + rows.append( + { + "target_key": target_key, + "external_server_id": window.get("external_server_id"), + "match_key": f"session:{session_key}", + "map_name": window.get("map_name"), + "map_pretty_name": normalize_map_name(map_name), + "game_mode": None, + "started_server_time": None, + "ended_server_time": None, + "started_at": window.get("first_seen_at"), + "ended_at": window.get("last_seen_at"), + "allied_score": _nested_int(window.get("latest_payload"), "allied_score"), + "axis_score": _nested_int(window.get("latest_payload"), "axis_score"), + "winner": _resolve_winner( + _nested_int(window.get("latest_payload"), "allied_score"), + _nested_int(window.get("latest_payload"), "axis_score"), + ), + "confidence_mode": "partial", + "source_basis": SESSION_RESULT_SOURCE, + } + ) + return rows + + +def _build_match_row( + start_row: sqlite3.Row | None, + end_row: sqlite3.Row | None, + *, + end_payload: dict[str, object] | None = None, +) -> dict[str, object]: + start_payload = _json_object(start_row["parsed_payload_json"]) if start_row else {} + end_payload = end_payload or (_json_object(end_row["parsed_payload_json"]) if end_row else {}) + target_key = str((end_row or start_row)["target_key"]) + external_server_id = (end_row or start_row)["external_server_id"] + started_server_time = start_row["server_time"] if start_row else None + ended_server_time = end_row["server_time"] if end_row else None + map_name = end_payload.get("map_name") or start_payload.get("map_name") + match_key = _build_match_key( + target_key=target_key, + started_server_time=started_server_time, + ended_server_time=ended_server_time, + map_name=map_name, + ) + return { + "target_key": target_key, + "external_server_id": external_server_id, + "match_key": match_key, + "map_name": map_name, + "map_pretty_name": normalize_map_name(map_name), + "game_mode": start_payload.get("game_mode"), + "started_server_time": started_server_time, + "ended_server_time": ended_server_time, + "started_at": start_row["event_timestamp"] if start_row else None, + "ended_at": end_row["event_timestamp"] if end_row else None, + "allied_score": _coerce_int(end_payload.get("allied_score")), + "axis_score": _coerce_int(end_payload.get("axis_score")), + "winner": end_payload.get("winner") + or _resolve_winner( + _coerce_int(end_payload.get("allied_score")), + _coerce_int(end_payload.get("axis_score")), + ), + "confidence_mode": "exact" if end_row else "partial", + "source_basis": MATCH_RESULT_SOURCE if end_row else "admin-log-match-start", + } + + +def _upsert_match(connection: sqlite3.Connection, row: dict[str, object]) -> str: + existing = connection.execute( + """ + SELECT id + FROM rcon_materialized_matches + WHERE target_key = ? AND match_key = ? + """, + (row["target_key"], row["match_key"]), + ).fetchone() + connection.execute( + """ + INSERT INTO rcon_materialized_matches ( + target_key, external_server_id, match_key, map_name, map_pretty_name, game_mode, + started_server_time, ended_server_time, started_at, ended_at, + allied_score, axis_score, winner, confidence_mode, source_basis + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(target_key, match_key) DO UPDATE SET + external_server_id = excluded.external_server_id, + map_name = excluded.map_name, + map_pretty_name = excluded.map_pretty_name, + game_mode = excluded.game_mode, + started_server_time = excluded.started_server_time, + ended_server_time = excluded.ended_server_time, + started_at = excluded.started_at, + ended_at = excluded.ended_at, + allied_score = excluded.allied_score, + axis_score = excluded.axis_score, + winner = excluded.winner, + confidence_mode = excluded.confidence_mode, + source_basis = excluded.source_basis, + updated_at = CURRENT_TIMESTAMP + """, + ( + row["target_key"], + row.get("external_server_id"), + row["match_key"], + row.get("map_name"), + row.get("map_pretty_name"), + row.get("game_mode"), + row.get("started_server_time"), + row.get("ended_server_time"), + row.get("started_at"), + row.get("ended_at"), + row.get("allied_score"), + row.get("axis_score"), + row.get("winner"), + row["confidence_mode"], + row["source_basis"], + ), + ) + return "updated" if existing else "inserted" + + +def _list_materialized_matches(connection: sqlite3.Connection) -> list[dict[str, object]]: + rows = connection.execute( + """ + SELECT * + FROM rcon_materialized_matches + WHERE started_server_time IS NOT NULL OR ended_server_time IS NOT NULL + ORDER BY target_key ASC, COALESCE(started_server_time, ended_server_time) ASC + """ + ).fetchall() + return [dict(row) for row in rows] + + +def _derive_player_stats_for_match( + connection: sqlite3.Connection, + match: dict[str, object], +) -> list[dict[str, object]]: + lower = match.get("started_server_time") + upper = match.get("ended_server_time") + if lower is None and upper is None: + return [] + clauses = ["target_key = ?", "server_time IS NOT NULL"] + params: list[object] = [match["target_key"]] + if lower is not None: + clauses.append("server_time >= ?") + params.append(lower) + if upper is not None: + clauses.append("server_time <= ?") + params.append(upper) + rows = connection.execute( + f""" + SELECT * + FROM rcon_admin_log_events + WHERE {" AND ".join(clauses)} + AND event_type IN ('kill', 'team_switch', 'connected', 'disconnected', 'chat') + ORDER BY server_time ASC, id ASC + """, + params, + ).fetchall() + + players: dict[str, dict[str, object]] = {} + team_by_player: dict[str, str] = {} + for row in rows: + payload = _json_object(row["parsed_payload_json"]) + server_time = _coerce_int(row["server_time"]) + event_type = row["event_type"] + if event_type == "kill": + killer_key = _player_key(payload.get("killer_id"), payload.get("killer_name")) + victim_key = _player_key(payload.get("victim_id"), payload.get("victim_name")) + killer = _ensure_player( + players, + player_id=killer_key, + player_name=payload.get("killer_name"), + team=payload.get("killer_team") or team_by_player.get(killer_key), + server_time=server_time, + ) + victim = _ensure_player( + players, + player_id=victim_key, + player_name=payload.get("victim_name"), + team=payload.get("victim_team") or team_by_player.get(victim_key), + server_time=server_time, + ) + team_by_player[killer_key] = str(payload.get("killer_team") or killer.get("team") or "") + team_by_player[victim_key] = str(payload.get("victim_team") or victim.get("team") or "") + weapon = str(payload.get("weapon") or "Unknown") + same_team = payload.get("killer_team") and payload.get("killer_team") == payload.get("victim_team") + if same_team: + killer["teamkills"] = int(killer["teamkills"]) + 1 + victim["deaths_by_teamkill"] = int(victim["deaths_by_teamkill"]) + 1 + else: + killer["kills"] = int(killer["kills"]) + 1 + victim["deaths"] = int(victim["deaths"]) + 1 + _counter(killer, "weapons")[weapon] += 1 + _counter(victim, "death_by_weapons")[weapon] += 1 + _counter(killer, "most_killed")[str(victim["player_name"])] += 1 + _counter(victim, "death_by")[str(killer["player_name"])] += 1 + _touch_player(killer, server_time) + _touch_player(victim, server_time) + continue + + if event_type == "team_switch" and not payload.get("player_id"): + continue + player_id = _player_key(payload.get("player_id"), payload.get("player_name")) + team = payload.get("to_team") or payload.get("chat_team") or team_by_player.get(player_id) + player = _ensure_player( + players, + player_id=player_id, + player_name=payload.get("player_name"), + team=team, + server_time=server_time, + ) + if team: + player["team"] = team + team_by_player[player_id] = str(team) + _touch_player(player, server_time) + + stats = [] + for player in players.values(): + stats.append( + { + "target_key": match["target_key"], + "match_key": match["match_key"], + "player_id": player["player_id"], + "player_name": player["player_name"], + "team": player.get("team"), + "kills": player["kills"], + "deaths": player["deaths"], + "teamkills": player["teamkills"], + "deaths_by_teamkill": player["deaths_by_teamkill"], + "weapons_json": _dump_counter(player["weapons"]), + "death_by_weapons_json": _dump_counter(player["death_by_weapons"]), + "most_killed_json": _dump_counter(player["most_killed"]), + "death_by_json": _dump_counter(player["death_by"]), + "first_seen_server_time": player.get("first_seen_server_time"), + "last_seen_server_time": player.get("last_seen_server_time"), + } + ) + return stats + + +def _insert_player_stat(connection: sqlite3.Connection, stat: dict[str, object]) -> None: + connection.execute( + """ + INSERT INTO rcon_match_player_stats ( + target_key, match_key, player_id, player_name, team, + kills, deaths, teamkills, deaths_by_teamkill, + weapons_json, death_by_weapons_json, most_killed_json, death_by_json, + first_seen_server_time, last_seen_server_time + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + stat["target_key"], + stat["match_key"], + stat["player_id"], + stat["player_name"], + stat.get("team"), + stat["kills"], + stat["deaths"], + stat["teamkills"], + stat["deaths_by_teamkill"], + stat["weapons_json"], + stat["death_by_weapons_json"], + stat["most_killed_json"], + stat["death_by_json"], + stat.get("first_seen_server_time"), + stat.get("last_seen_server_time"), + ), + ) + + +def _ensure_player( + players: dict[str, dict[str, object]], + *, + player_id: str, + player_name: object, + team: object, + server_time: int | None, +) -> dict[str, object]: + if player_id not in players: + players[player_id] = { + "player_id": player_id, + "player_name": str(player_name or player_id), + "team": team, + "kills": 0, + "deaths": 0, + "teamkills": 0, + "deaths_by_teamkill": 0, + "weapons": Counter(), + "death_by_weapons": Counter(), + "most_killed": Counter(), + "death_by": Counter(), + "first_seen_server_time": server_time, + "last_seen_server_time": server_time, + } + player = players[player_id] + if player_name: + player["player_name"] = str(player_name) + if team: + player["team"] = team + _touch_player(player, server_time) + return player + + +def _touch_player(player: dict[str, object], server_time: int | None) -> None: + if server_time is None: + return + first_seen = _coerce_int(player.get("first_seen_server_time")) + last_seen = _coerce_int(player.get("last_seen_server_time")) + player["first_seen_server_time"] = server_time if first_seen is None else min(first_seen, server_time) + player["last_seen_server_time"] = server_time if last_seen is None else max(last_seen, server_time) + + +def _counter(player: dict[str, object], key: str) -> Counter[str]: + value = player[key] + if isinstance(value, Counter): + return value + counter: Counter[str] = Counter() + player[key] = counter + return counter + + +def _player_key(player_id: object, player_name: object) -> str: + raw_id = str(player_id or "").strip() + if raw_id: + return raw_id + return f"name:{str(player_name or 'unknown').strip().lower()}" + + +def _build_match_key( + *, + target_key: str, + started_server_time: object, + ended_server_time: object, + map_name: object, +) -> str: + map_part = "".join(character.lower() for character in str(map_name or "unknown") if character.isalnum()) + start_part = "missing" if started_server_time is None else str(started_server_time) + end_part = "open" if ended_server_time is None else str(ended_server_time) + return f"{target_key}:{start_part}:{end_part}:{map_part}" + + +def _json_object(raw_value: object) -> dict[str, object]: + if not isinstance(raw_value, str) or not raw_value.strip(): + return {} + try: + parsed = json.loads(raw_value) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _dump_counter(counter: Counter[str]) -> str: + ordered = dict(sorted(counter.items(), key=lambda item: (-item[1], item[0]))) + return json.dumps(ordered, ensure_ascii=False, separators=(",", ":")) + + +def _coerce_int(value: object) -> int | None: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _nested_int(payload: object, key: str) -> int | None: + if not isinstance(payload, dict): + return None + return _coerce_int(payload.get(key)) + + +def _resolve_winner(allied_score: int | None, axis_score: int | None) -> str | None: + if allied_score is None or axis_score is None: + return None + if allied_score > axis_score: + return "allied" + if axis_score > allied_score: + return "axis" + return "draw" + + +def _main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Materialize stored RCON AdminLog events.") + parser.add_argument( + "command", + nargs="?", + choices=("materialize", "status"), + default="materialize", + ) + parser.add_argument("--db-path", type=Path, default=None) + args = parser.parse_args(list(argv) if argv is not None else None) + db_path = args.db_path or get_storage_path() + payload = ( + summarize_rcon_materialization_status(db_path=db_path) + if args.command == "status" + else materialize_rcon_admin_log(db_path=db_path) + ) + print(json.dumps({"status": "ok", "data": payload}, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(_main()) diff --git a/backend/app/rcon_admin_log_parser.py b/backend/app/rcon_admin_log_parser.py new file mode 100644 index 0000000..e8e2c0c --- /dev/null +++ b/backend/app/rcon_admin_log_parser.py @@ -0,0 +1,464 @@ +"""Parser for Hell Let Loose RCON admin log messages.""" + +from __future__ import annotations + +import re +from dataclasses import asdict, dataclass +from typing import Literal + + +RconAdminLogEventType = Literal[ + "match_start", + "match_end", + "kill", + "team_switch", + "connected", + "disconnected", + "chat", + "kick", + "ban", + "message", + "unknown", +] + + +_PREFIX_RE = re.compile( + r"^\[(?P.+?)\s+\((?P\d+)\)\]\s+(?P.*)$", + re.DOTALL, +) + +MATCH_START_RE = re.compile( + r"^MATCH START\s+(?P.+?)\s+(?P[A-Za-z]+)\s*$", + re.DOTALL, +) + +MATCH_END_RE = re.compile( + r"^MATCH ENDED\s+`(?P.+?)`\s+ALLIED\s+\((?P\d+)\s*-\s*(?P\d+)\)\s+AXIS\s*$", + re.DOTALL, +) + +KILL_RE = re.compile( + r"^KILL:\s+" + r"(?P.+?)" + r"\((?PAllies|Axis|None)/(?P[^)]*)\)" + r"\s+->\s+" + r"(?P.+?)" + r"\((?PAllies|Axis|None)/(?P[^)]*)\)" + r"\s+with\s+(?P.+?)\s*$", + re.DOTALL, +) + +TEAM_SWITCH_RE = re.compile( + r"^TEAMSWITCH\s+(?P.+?)\s+\((?P[^>]*)\s+>\s+(?P[^)]*)\)\s*$", + re.DOTALL, +) + +CONNECTED_RE = re.compile( + r"^CONNECTED\s+(?P.+?)\s+\((?P[^)]*)\)\s*$", + re.DOTALL, +) + +DISCONNECTED_RE = re.compile( + r"^DISCONNECTED\s+(?P.+?)\s+\((?P[^)]*)\)\s*$", + re.DOTALL, +) + +CHAT_RE = re.compile( + r"^CHAT\[(?P[^\]]+)\]\[(?P.+?)\((?PAllies|Axis|None)/(?P[^)]*)\)\]:\s*(?P.*)$", + re.DOTALL, +) + +KICK_RE = re.compile( + r"^KICK:\s+\[(?P.+?)\]\s+has been kicked\.\s+\[(?P.*)\]\s*$", + re.DOTALL, +) + +MESSAGE_RE = re.compile( + r"^MESSAGE:\s+player\s+\[(?P.+?)\((?P[^)]*)\)\],\s+content\s+\[(?P.*)\]\s*$", + re.DOTALL, +) + + +@dataclass(frozen=True, slots=True) +class ParsedRconAdminLogEvent: + event_type: RconAdminLogEventType + raw_message: str + relative_time: str | None = None + server_time: int | None = None + map_name: str | None = None + game_mode: str | None = None + allied_score: int | None = None + axis_score: int | None = None + winner: str | None = None + killer_name: str | None = None + killer_team: str | None = None + killer_id: str | None = None + victim_name: str | None = None + victim_team: str | None = None + victim_id: str | None = None + weapon: str | None = None + player_name: str | None = None + player_id: str | None = None + from_team: str | None = None + to_team: str | None = None + chat_scope: str | None = None + chat_team: str | None = None + content: str | None = None + reason: str | None = None + + +@dataclass(frozen=True, slots=True) +class ParsedRconPlayerProfileSnapshot: + player_name: str + player_id: str + source_server_time: int | None + event_timestamp: object + first_seen: str | None + sessions: int | None + matches_played: int | None + play_time: str | None + total_kills: int | None + total_deaths: int | None + teamkills_done: int | None + teamkills_received: int | None + kd_ratio: float | None + favorite_weapons: dict[str, int] + victims: dict[str, int] + nemesis: dict[str, int] + averages: dict[str, object] + sanctions: dict[str, object] + raw_content: str + + +def parse_rcon_admin_log_message(message: str) -> ParsedRconAdminLogEvent: + raw_message = str(message or "") + prefix_match = _PREFIX_RE.match(raw_message) + relative_time = None + server_time = None + body = raw_message + + if prefix_match: + relative_time = prefix_match.group("relative") + server_time = _coerce_int(prefix_match.group("server_time")) + body = prefix_match.group("body") + + parser_payload = { + "raw_message": raw_message, + "relative_time": relative_time, + "server_time": server_time, + } + + if match := MATCH_START_RE.match(body): + return ParsedRconAdminLogEvent( + event_type="match_start", + map_name=_clean(match.group("map_name")), + game_mode=_clean(match.group("game_mode")), + **parser_payload, + ) + + if match := MATCH_END_RE.match(body): + allied_score = _coerce_int(match.group("allied_score")) + axis_score = _coerce_int(match.group("axis_score")) + return ParsedRconAdminLogEvent( + event_type="match_end", + map_name=_clean(match.group("map_name")), + allied_score=allied_score, + axis_score=axis_score, + winner=_resolve_winner(allied_score, axis_score), + **parser_payload, + ) + + if match := KILL_RE.match(body): + return ParsedRconAdminLogEvent( + event_type="kill", + killer_name=_clean(match.group("killer_name")), + killer_team=_clean(match.group("killer_team")), + killer_id=_clean(match.group("killer_id")), + victim_name=_clean(match.group("victim_name")), + victim_team=_clean(match.group("victim_team")), + victim_id=_clean(match.group("victim_id")), + weapon=_clean(match.group("weapon")), + **parser_payload, + ) + + if match := TEAM_SWITCH_RE.match(body): + return ParsedRconAdminLogEvent( + event_type="team_switch", + player_name=_clean(match.group("player_name")), + from_team=_clean(match.group("from_team")), + to_team=_clean(match.group("to_team")), + **parser_payload, + ) + + if match := CONNECTED_RE.match(body): + return ParsedRconAdminLogEvent( + event_type="connected", + player_name=_clean(match.group("player_name")), + player_id=_clean(match.group("player_id")), + **parser_payload, + ) + + if match := DISCONNECTED_RE.match(body): + return ParsedRconAdminLogEvent( + event_type="disconnected", + player_name=_clean(match.group("player_name")), + player_id=_clean(match.group("player_id")), + **parser_payload, + ) + + if match := CHAT_RE.match(body): + return ParsedRconAdminLogEvent( + event_type="chat", + player_name=_clean(match.group("player_name")), + player_id=_clean(match.group("player_id")), + chat_scope=_clean(match.group("scope")), + chat_team=_clean(match.group("team")), + content=_clean(match.group("content")), + **parser_payload, + ) + + if match := KICK_RE.match(body): + return ParsedRconAdminLogEvent( + event_type="kick", + player_name=_clean(match.group("player_name")), + reason=_clean(match.group("reason")), + **parser_payload, + ) + + if body.upper().startswith("BAN"): + return ParsedRconAdminLogEvent(event_type="ban", content=_clean(body), **parser_payload) + + if match := MESSAGE_RE.match(body): + return ParsedRconAdminLogEvent( + event_type="message", + player_name=_clean(match.group("player_name")), + player_id=_clean(match.group("player_id")), + content=_clean(match.group("content")), + **parser_payload, + ) + + return ParsedRconAdminLogEvent(event_type="unknown", content=_clean(body), **parser_payload) + + +def parse_rcon_admin_log_entry(entry: dict[str, object]) -> dict[str, object]: + parsed = parse_rcon_admin_log_message(str(entry.get("message") or "")) + payload = asdict(parsed) + payload["timestamp"] = entry.get("timestamp") + return payload + + +def parse_rcon_player_profile_snapshot( + parsed_event: ParsedRconAdminLogEvent | dict[str, object], + *, + event_timestamp: object = None, +) -> ParsedRconPlayerProfileSnapshot | None: + """Extract long-term player profile data from bot-generated MESSAGE content.""" + if isinstance(parsed_event, ParsedRconAdminLogEvent): + event_type = parsed_event.event_type + player_name = parsed_event.player_name + player_id = parsed_event.player_id + server_time = parsed_event.server_time + content = parsed_event.content + else: + event_type = parsed_event.get("event_type") + player_name = parsed_event.get("player_name") + player_id = parsed_event.get("player_id") + server_time = parsed_event.get("server_time") + content = parsed_event.get("content") + event_timestamp = event_timestamp if event_timestamp is not None else parsed_event.get("timestamp") + + source_server_time = _coerce_int(server_time) + if event_type != "message" or not player_name or not player_id or not content: + return None + if source_server_time is None: + return None + + raw_content = str(content) + lines = [_clean_profile_line(line) for line in raw_content.splitlines()] + lines = [line for line in lines if line] + if not _looks_like_profile_message(lines): + return None + + sections = _profile_sections(lines) + flat_values = _profile_key_values(lines) + total_kills, teamkills_done = _parse_total_with_teamkills(flat_values, "bajas") + total_deaths, teamkills_received = _parse_total_with_teamkills(flat_values, "muertes") + + return ParsedRconPlayerProfileSnapshot( + player_name=str(player_name), + player_id=str(player_id), + source_server_time=source_server_time, + event_timestamp=event_timestamp, + first_seen=_first_value(flat_values, "first seen", "visto por primera vez", "primer visto"), + sessions=_first_int(flat_values, "sessions", "sesiones"), + matches_played=_first_int(flat_values, "matches played", "partidas jugadas", "partidas"), + play_time=_first_value(flat_values, "play time", "tiempo jugado", "tiempo de juego"), + total_kills=total_kills, + total_deaths=total_deaths, + teamkills_done=teamkills_done, + teamkills_received=teamkills_received, + kd_ratio=_first_float(flat_values, "k/d", "kd"), + favorite_weapons=_int_mapping(sections, "armas favoritas", "favorite weapons"), + victims=_int_mapping(sections, "victimas", "víctimas", "vã­ctimas", "victims"), + nemesis=_int_mapping(sections, "nemesis", "némesis", "nã©mesis"), + averages=_object_mapping(sections, "promedios", "averages"), + sanctions=_object_mapping(sections, "sanciones", "sanctions"), + raw_content=raw_content, + ) + + +def _clean(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + return normalized or None + + +def _coerce_int(value: object) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _coerce_float(value: object) -> float | None: + if value is None: + return None + normalized = str(value).strip().replace(",", ".") + match = re.search(r"-?\d+(?:\.\d+)?", normalized) + if not match: + return None + try: + return float(match.group(0)) + except ValueError: + return None + + +def _resolve_winner(allied_score: int | None, axis_score: int | None) -> str | None: + if allied_score is None or axis_score is None: + return None + if allied_score > axis_score: + return "allied" + if axis_score > allied_score: + return "axis" + return "draw" + + +def _clean_profile_line(value: str) -> str: + cleaned = value.strip().strip("─-").strip() + return cleaned.strip("▒").strip() + + +def _looks_like_profile_message(lines: list[str]) -> bool: + labels = {_normalize_profile_label(line.split(":", 1)[0]) for line in lines if ":" in line} + section_labels = {_normalize_profile_label(line) for line in lines if ":" not in line} + required = {"bajas", "muertes"} + known_sections = { + "totales", + "victimas", + "vã­ctimas", + "nemesis", + "nã©mesis", + "armas favoritas", + "promedios", + "sanciones", + } + return required.issubset(labels) and bool(section_labels & known_sections) + + +def _profile_sections(lines: list[str]) -> dict[str, list[str]]: + sections: dict[str, list[str]] = {} + current = "root" + for line in lines: + if ":" not in line: + current = _normalize_profile_label(line) + sections.setdefault(current, []) + continue + sections.setdefault(current, []).append(line) + return sections + + +def _profile_key_values(lines: list[str]) -> dict[str, str]: + values: dict[str, str] = {} + for line in lines: + if ":" not in line: + continue + key, value = line.split(":", 1) + values[_normalize_profile_label(key)] = value.strip() + return values + + +def _normalize_profile_label(value: object) -> str: + return ( + str(value or "") + .strip() + .lower() + .replace("\u00ad", "") + .replace("í", "i") + .replace("é", "e") + .replace("ã­", "i") + .replace("ã©", "e") + ) + + +def _first_value(values: dict[str, str], *keys: str) -> str | None: + for key in keys: + value = values.get(_normalize_profile_label(key)) + if value: + return value + return None + + +def _first_int(values: dict[str, str], *keys: str) -> int | None: + return _coerce_int_from_text(_first_value(values, *keys)) + + +def _first_float(values: dict[str, str], *keys: str) -> float | None: + return _coerce_float(_first_value(values, *keys)) + + +def _parse_total_with_teamkills(values: dict[str, str], key: str) -> tuple[int | None, int | None]: + raw_value = _first_value(values, key) + if not raw_value: + return None, None + return _coerce_int_from_text(raw_value), _coerce_int_from_text(_inside_parentheses(raw_value)) + + +def _inside_parentheses(value: str) -> str | None: + match = re.search(r"\((.*?)\)", value) + return match.group(1) if match else None + + +def _int_mapping(sections: dict[str, list[str]], *section_names: str) -> dict[str, int]: + mapped: dict[str, int] = {} + for line in _section_lines(sections, *section_names): + key, value = line.split(":", 1) + parsed = _coerce_int_from_text(value) + if parsed is not None: + mapped[key.strip()] = parsed + return mapped + + +def _object_mapping(sections: dict[str, list[str]], *section_names: str) -> dict[str, object]: + mapped: dict[str, object] = {} + for line in _section_lines(sections, *section_names): + key, value = line.split(":", 1) + cleaned = value.strip() + mapped[key.strip()] = _coerce_float(cleaned) if re.search(r"\d", cleaned) else cleaned + return mapped + + +def _section_lines(sections: dict[str, list[str]], *section_names: str) -> list[str]: + lines: list[str] = [] + wanted = {_normalize_profile_label(name) for name in section_names} + for section_name, section_lines in sections.items(): + if _normalize_profile_label(section_name) in wanted: + lines.extend(section_lines) + return lines + + +def _coerce_int_from_text(value: object) -> int | None: + if value is None: + return None + match = re.search(r"-?\d+", str(value)) + return _coerce_int(match.group(0)) if match else None diff --git a/backend/app/rcon_admin_log_storage.py b/backend/app/rcon_admin_log_storage.py new file mode 100644 index 0000000..b32dca2 --- /dev/null +++ b/backend/app/rcon_admin_log_storage.py @@ -0,0 +1,792 @@ +"""Storage helpers for parsed RCON AdminLog events.""" + +from __future__ import annotations + +import json +import re +import sqlite3 +from collections import Counter +from collections.abc import Mapping +from contextlib import closing +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from .config import get_storage_path, use_postgres_rcon_storage +from .rcon_admin_log_parser import parse_rcon_admin_log_entry +from .rcon_admin_log_parser import parse_rcon_player_profile_snapshot +from .rcon_historical_storage import initialize_rcon_historical_storage +from .sqlite_utils import connect_sqlite_writer + +CURRENT_MATCH_FALLBACK_FRESHNESS = timedelta(minutes=15) + + +def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path: + """Create SQLite structures for parsed RCON AdminLog events.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import initialize_postgres_rcon_storage + + initialize_postgres_rcon_storage() + return get_storage_path() + + resolved_path = initialize_rcon_historical_storage(db_path=db_path) + + with connect_sqlite_writer(resolved_path) as connection: + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS rcon_admin_log_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_key TEXT NOT NULL, + external_server_id TEXT, + event_timestamp TEXT, + server_time INTEGER, + relative_time TEXT, + event_type TEXT NOT NULL, + raw_message TEXT NOT NULL, + canonical_message TEXT NOT NULL, + parsed_payload_json TEXT NOT NULL, + raw_entry_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_rcon_admin_log_events_dedupe + ON rcon_admin_log_events(target_key, server_time, canonical_message); + + CREATE INDEX IF NOT EXISTS idx_rcon_admin_log_events_target_time + ON rcon_admin_log_events(target_key, server_time DESC); + + CREATE INDEX IF NOT EXISTS idx_rcon_admin_log_events_type + ON rcon_admin_log_events(event_type); + + CREATE TABLE IF NOT EXISTS rcon_player_profile_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_key TEXT NOT NULL, + external_server_id TEXT, + player_id TEXT NOT NULL, + player_name TEXT NOT NULL, + source_server_time INTEGER NOT NULL, + event_timestamp TEXT, + first_seen TEXT, + sessions INTEGER, + matches_played INTEGER, + play_time TEXT, + total_kills INTEGER, + total_deaths INTEGER, + teamkills_done INTEGER, + teamkills_received INTEGER, + kd_ratio REAL, + favorite_weapons_json TEXT NOT NULL DEFAULT '{}', + victims_json TEXT NOT NULL DEFAULT '{}', + nemesis_json TEXT NOT NULL DEFAULT '{}', + averages_json TEXT NOT NULL DEFAULT '{}', + sanctions_json TEXT NOT NULL DEFAULT '{}', + raw_content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(target_key, player_id, source_server_time) + ); + + CREATE INDEX IF NOT EXISTS idx_rcon_player_profile_snapshots_player + ON rcon_player_profile_snapshots(target_key, player_id, source_server_time DESC); + """ + ) + _ensure_canonical_message_column(connection) + + return resolved_path + + +def persist_rcon_admin_log_entries( + *, + target: Mapping[str, object], + entries: list[dict[str, object]], + db_path: Path | None = None, +) -> dict[str, int]: + """Persist raw and parsed AdminLog entries idempotently.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + return _persist_rcon_admin_log_entries_postgres(target=target, entries=entries) + + resolved_path = initialize_rcon_admin_log_storage(db_path=db_path) + target_key = str(target.get("target_key") or target.get("external_server_id") or "") + if not target_key: + raise ValueError("target must include target_key or external_server_id") + + external_server_id = target.get("external_server_id") + inserted = 0 + duplicates = 0 + + with connect_sqlite_writer(resolved_path) as connection: + for entry in entries: + parsed = parse_rcon_admin_log_entry(entry) + raw_message = str(parsed.get("raw_message") or "") + canonical_message = _canonicalize_admin_log_message(raw_message) + cursor = connection.execute( + """ + INSERT INTO rcon_admin_log_events ( + target_key, + external_server_id, + event_timestamp, + server_time, + relative_time, + event_type, + raw_message, + canonical_message, + parsed_payload_json, + raw_entry_json + ) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + WHERE NOT EXISTS ( + SELECT 1 + FROM rcon_admin_log_events + WHERE target_key = ? + AND server_time IS ? + AND canonical_message = ? + ) + """, + ( + target_key, + external_server_id, + parsed.get("timestamp"), + parsed.get("server_time"), + parsed.get("relative_time"), + parsed.get("event_type") or "unknown", + raw_message, + canonical_message, + json.dumps(parsed, ensure_ascii=False, separators=(",", ":")), + json.dumps(entry, ensure_ascii=False, separators=(",", ":")), + target_key, + parsed.get("server_time"), + canonical_message, + ), + ) + if int(cursor.rowcount or 0): + inserted += 1 + else: + duplicates += 1 + _persist_profile_snapshot_if_present( + connection, + target_key=target_key, + external_server_id=external_server_id, + parsed=parsed, + ) + + return { + "events_seen": len(entries), + "events_inserted": inserted, + "duplicate_events": duplicates, + } + + +def _persist_profile_snapshot_if_present( + connection: sqlite3.Connection, + *, + target_key: str, + external_server_id: object, + parsed: dict[str, object], +) -> None: + snapshot = parse_rcon_player_profile_snapshot(parsed) + if snapshot is None: + return + connection.execute( + """ + INSERT INTO rcon_player_profile_snapshots ( + target_key, + external_server_id, + player_id, + player_name, + source_server_time, + event_timestamp, + first_seen, + sessions, + matches_played, + play_time, + total_kills, + total_deaths, + teamkills_done, + teamkills_received, + kd_ratio, + favorite_weapons_json, + victims_json, + nemesis_json, + averages_json, + sanctions_json, + raw_content + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(target_key, player_id, source_server_time) DO UPDATE SET + external_server_id = excluded.external_server_id, + player_name = excluded.player_name, + event_timestamp = excluded.event_timestamp, + first_seen = excluded.first_seen, + sessions = excluded.sessions, + matches_played = excluded.matches_played, + play_time = excluded.play_time, + total_kills = excluded.total_kills, + total_deaths = excluded.total_deaths, + teamkills_done = excluded.teamkills_done, + teamkills_received = excluded.teamkills_received, + kd_ratio = excluded.kd_ratio, + favorite_weapons_json = excluded.favorite_weapons_json, + victims_json = excluded.victims_json, + nemesis_json = excluded.nemesis_json, + averages_json = excluded.averages_json, + sanctions_json = excluded.sanctions_json, + raw_content = excluded.raw_content, + updated_at = CURRENT_TIMESTAMP + """, + ( + target_key, + external_server_id, + snapshot.player_id, + snapshot.player_name, + snapshot.source_server_time, + snapshot.event_timestamp, + snapshot.first_seen, + snapshot.sessions, + snapshot.matches_played, + snapshot.play_time, + snapshot.total_kills, + snapshot.total_deaths, + snapshot.teamkills_done, + snapshot.teamkills_received, + snapshot.kd_ratio, + json.dumps(snapshot.favorite_weapons, ensure_ascii=False, separators=(",", ":")), + json.dumps(snapshot.victims, ensure_ascii=False, separators=(",", ":")), + json.dumps(snapshot.nemesis, ensure_ascii=False, separators=(",", ":")), + json.dumps(snapshot.averages, ensure_ascii=False, separators=(",", ":")), + json.dumps(snapshot.sanctions, ensure_ascii=False, separators=(",", ":")), + snapshot.raw_content, + ), + ) + + +_PREFIX_RE = re.compile(r"^\[.*?\(\d+\)\]\s+", re.DOTALL) + + +def _canonicalize_admin_log_message(raw_message: str) -> str: + """Return a stable message body for deduplication across repeated AdminLog reads.""" + normalized = str(raw_message or "").strip() + return _PREFIX_RE.sub("", normalized).strip() + + +def _ensure_canonical_message_column(connection: sqlite3.Connection) -> None: + columns = { + row["name"] + for row in connection.execute("PRAGMA table_info(rcon_admin_log_events)").fetchall() + } + if "canonical_message" not in columns: + connection.execute( + "ALTER TABLE rcon_admin_log_events ADD COLUMN canonical_message TEXT NOT NULL DEFAULT ''" + ) + connection.execute( + """ + UPDATE rcon_admin_log_events + SET canonical_message = raw_message + WHERE canonical_message = '' + """ + ) + connection.execute( + """ + CREATE INDEX IF NOT EXISTS idx_rcon_admin_log_events_dedupe + ON rcon_admin_log_events(target_key, server_time, canonical_message) + """ + ) + + +def list_rcon_admin_log_event_counts(*, db_path: Path | None = None) -> list[dict[str, object]]: + """Return event counts grouped by target and event type.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import connect_postgres_compat + + with connect_postgres_compat() as connection: + rows = connection.execute( + """ + SELECT + target_key, + event_type, + COUNT(*) AS event_count, + MIN(server_time) AS first_server_time, + MAX(server_time) AS last_server_time + FROM rcon_admin_log_events + GROUP BY target_key, event_type + ORDER BY target_key ASC, event_count DESC + """ + ).fetchall() + return [dict(row) for row in rows] + + resolved_path = db_path or get_storage_path() + initialize_rcon_admin_log_storage(db_path=resolved_path) + + with sqlite3.connect(resolved_path) as connection: + connection.row_factory = sqlite3.Row + rows = connection.execute( + """ + SELECT + target_key, + event_type, + COUNT(*) AS event_count, + MIN(server_time) AS first_server_time, + MAX(server_time) AS last_server_time + FROM rcon_admin_log_events + GROUP BY target_key, event_type + ORDER BY target_key ASC, event_count DESC + """ + ).fetchall() + + return [dict(row) for row in rows] + + +def list_current_match_kill_feed( + *, + server_key: str, + limit: int = 30, + since_event_id: str | None = None, + db_path: Path | None = None, + now: datetime | None = None, +) -> dict[str, object]: + """Return safe recent kill rows for one AdminLog server window.""" + resolved_path = initialize_rcon_admin_log_storage(db_path=db_path) + since_row_id = _parse_current_match_event_row_id(since_event_id) + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import connect_postgres_compat + + connection_scope = connect_postgres_compat() + else: + connection_scope = closing(sqlite3.connect(resolved_path)) + + with connection_scope as connection: + if isinstance(connection, sqlite3.Connection): + connection.row_factory = sqlite3.Row + boundary = connection.execute( + """ + SELECT event_type, server_time + FROM rcon_admin_log_events + WHERE (target_key = ? OR external_server_id = ?) + AND event_type IN ('match_start', 'match_end') + AND server_time IS NOT NULL + ORDER BY server_time DESC, id DESC + LIMIT 1 + """, + (server_key, server_key), + ).fetchone() + open_start_time = ( + boundary["server_time"] + if boundary is not None and boundary["event_type"] == "match_start" + else None + ) + if open_start_time is None: + if since_row_id is None: + rows = connection.execute( + """ + SELECT id, target_key, external_server_id, event_timestamp, server_time, + parsed_payload_json + FROM rcon_admin_log_events + WHERE (target_key = ? OR external_server_id = ?) + AND event_type = 'kill' + ORDER BY server_time DESC, id DESC + LIMIT ? + """, + (server_key, server_key, limit), + ).fetchall() + else: + rows = connection.execute( + """ + SELECT id, target_key, external_server_id, event_timestamp, server_time, + parsed_payload_json + FROM rcon_admin_log_events + WHERE (target_key = ? OR external_server_id = ?) + AND event_type = 'kill' + AND id > ? + ORDER BY server_time DESC, id DESC + LIMIT ? + """, + (server_key, server_key, since_row_id, limit), + ).fetchall() + scope = "recent-admin-log-window" + confidence = "partial" + else: + if since_row_id is None: + rows = connection.execute( + """ + SELECT id, target_key, external_server_id, event_timestamp, server_time, + parsed_payload_json + FROM rcon_admin_log_events + WHERE (target_key = ? OR external_server_id = ?) + AND event_type = 'kill' + AND server_time >= ? + ORDER BY server_time DESC, id DESC + LIMIT ? + """, + (server_key, server_key, open_start_time, limit), + ).fetchall() + else: + rows = connection.execute( + """ + SELECT id, target_key, external_server_id, event_timestamp, server_time, + parsed_payload_json + FROM rcon_admin_log_events + WHERE (target_key = ? OR external_server_id = ?) + AND event_type = 'kill' + AND server_time >= ? + AND id > ? + ORDER BY server_time DESC, id DESC + LIMIT ? + """, + (server_key, server_key, open_start_time, since_row_id, limit), + ).fetchall() + scope = "open-admin-log-match-window" + confidence = "admin-log-boundary" + + stale_events_filtered = 0 + if scope == "recent-admin-log-window": + freshness_anchor = _as_utc_datetime(now) or datetime.now(timezone.utc) + fresh_rows = [ + row + for row in rows + if _row_is_current_match_fallback_fresh(row, freshness_anchor) + ] + stale_events_filtered = len(rows) - len(fresh_rows) + rows = fresh_rows + if not rows: + scope = "no-current-match-events" + confidence = "stale-filtered" if stale_events_filtered else "none" + + return { + "scope": scope, + "confidence": confidence, + "stale_events_filtered": stale_events_filtered, + "items": [_serialize_kill_feed_row(row) for row in rows], + } + + +def list_current_match_player_stats( + *, + server_key: str, + db_path: Path | None = None, + now: datetime | None = None, +) -> dict[str, object]: + """Return partial current player stats derived from safe AdminLog kill rows.""" + feed = list_current_match_kill_feed( + server_key=server_key, + limit=100, + db_path=db_path, + now=now, + ) + players: dict[str, dict[str, object]] = {} + weapon_counts: dict[str, Counter[str]] = {} + for item in feed["items"]: + if not isinstance(item, Mapping): + continue + killer = _ensure_current_match_player( + players, + item.get("killer_name"), + team=item.get("killer_team"), + event_timestamp=item.get("event_timestamp"), + ) + victim = _ensure_current_match_player( + players, + item.get("victim_name"), + team=item.get("victim_team"), + event_timestamp=item.get("event_timestamp"), + ) + if killer is not None: + weapon = _safe_event_field(item.get("weapon")) or "UNKNOWN" + weapon_counts.setdefault(str(killer["player_name"]), Counter())[weapon] += 1 + if item.get("is_teamkill"): + killer["teamkills"] = int(killer["teamkills"]) + 1 + else: + killer["kills"] = int(killer["kills"]) + 1 + if victim is not None: + victim["deaths"] = int(victim["deaths"]) + 1 + if item.get("is_teamkill"): + victim["deaths_by_teamkill"] = int(victim["deaths_by_teamkill"]) + 1 + + items = [] + for player in players.values(): + items.append( + { + **player, + "favorite_weapon": _favorite_weapon_for_player( + weapon_counts.get(str(player["player_name"])) + ), + "source": "rcon-admin-log-kill-events", + "confidence": "event-derived-partial", + } + ) + items.sort( + key=lambda player: ( + -int(player["kills"]), + int(player["deaths"]), + str(player["player_name"]).casefold(), + ) + ) + return { + "scope": feed["scope"], + "confidence": "event-derived-partial" if items else feed["confidence"], + "source": "rcon-admin-log-kill-events", + "updated_at": max( + (str(item["last_seen_at"]) for item in items if item.get("last_seen_at")), + default=None, + ), + "stale_events_filtered": feed["stale_events_filtered"], + "items": items, + } + + +def get_latest_rcon_player_profile_summaries( + *, + target_key: str, + player_ids: list[str], + db_path: Path | None = None, +) -> dict[str, dict[str, object]]: + """Return safe latest profile summaries keyed by player id.""" + requested_ids = [str(player_id).strip() for player_id in player_ids if str(player_id).strip()] + if not target_key or not requested_ids: + return {} + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import connect_postgres_compat + + placeholders = ",".join("?" for _ in requested_ids) + with connect_postgres_compat() as connection: + rows = connection.execute( + f""" + SELECT snapshots.* + FROM rcon_player_profile_snapshots AS snapshots + INNER JOIN ( + SELECT player_id, MAX(source_server_time) AS latest_source_server_time + FROM rcon_player_profile_snapshots + WHERE target_key = ? + AND player_id IN ({placeholders}) + GROUP BY player_id + ) AS latest + ON latest.player_id = snapshots.player_id + AND latest.latest_source_server_time = snapshots.source_server_time + WHERE snapshots.target_key = ? + """, + [target_key, *requested_ids, target_key], + ).fetchall() + return {str(row["player_id"]): _build_safe_profile_summary(row) for row in rows} + + resolved_path = db_path or get_storage_path() + initialize_rcon_admin_log_storage(db_path=resolved_path) + placeholders = ",".join("?" for _ in requested_ids) + with sqlite3.connect(resolved_path) as connection: + connection.row_factory = sqlite3.Row + rows = connection.execute( + f""" + SELECT snapshots.* + FROM rcon_player_profile_snapshots AS snapshots + INNER JOIN ( + SELECT player_id, MAX(source_server_time) AS latest_source_server_time + FROM rcon_player_profile_snapshots + WHERE target_key = ? + AND player_id IN ({placeholders}) + GROUP BY player_id + ) AS latest + ON latest.player_id = snapshots.player_id + AND latest.latest_source_server_time = snapshots.source_server_time + WHERE snapshots.target_key = ? + """, + [target_key, *requested_ids, target_key], + ).fetchall() + + return {str(row["player_id"]): _build_safe_profile_summary(row) for row in rows} + + +def _build_safe_profile_summary(row: sqlite3.Row) -> dict[str, object]: + return { + "player_name": row["player_name"], + "source_server_time": row["source_server_time"], + "event_timestamp": row["event_timestamp"], + "first_seen": row["first_seen"], + "sessions": row["sessions"], + "matches_played": row["matches_played"], + "play_time": row["play_time"], + "totals": { + "kills": row["total_kills"], + "deaths": row["total_deaths"], + "teamkills_done": row["teamkills_done"], + "teamkills_received": row["teamkills_received"], + "kd_ratio": row["kd_ratio"], + }, + "favorite_weapons": _json_mapping(row["favorite_weapons_json"]), + "victims": _json_mapping(row["victims_json"]), + "nemesis": _json_mapping(row["nemesis_json"]), + "averages": _json_mapping(row["averages_json"]), + "sanctions": _json_mapping(row["sanctions_json"]), + } + + +def _json_mapping(raw_value: object) -> dict[str, object]: + if not isinstance(raw_value, str) or not raw_value.strip(): + return {} + try: + parsed = json.loads(raw_value) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _serialize_kill_feed_row(row: Mapping[str, object]) -> dict[str, object]: + payload = _json_mapping(row["parsed_payload_json"]) + target_key = str(row["external_server_id"] or row["target_key"] or "unknown") + killer_team = _safe_event_field(payload.get("killer_team")) + victim_team = _safe_event_field(payload.get("victim_team")) + return { + "event_id": f"rcon-admin-log:{target_key}:{row['id']}", + "event_timestamp": row["event_timestamp"], + "server_time": row["server_time"], + "killer_name": _safe_event_field(payload.get("killer_name")), + "killer_team": killer_team, + "victim_name": _safe_event_field(payload.get("victim_name")), + "victim_team": victim_team, + "weapon": _safe_event_field(payload.get("weapon")), + "is_teamkill": bool( + killer_team + and killer_team != "None" + and killer_team == victim_team + ), + } + + +def _parse_current_match_event_row_id(value: object) -> int | None: + prefix, separator, row_id = str(value or "").rpartition(":") + if separator != ":" or not prefix.startswith("rcon-admin-log:"): + return None + try: + parsed = int(row_id) + except ValueError: + return None + return parsed if parsed > 0 else None + + +def _safe_event_field(value: object) -> str | None: + normalized = str(value or "").strip() + return normalized or None + + +def _ensure_current_match_player( + players: dict[str, dict[str, object]], + player_name: object, + *, + team: object, + event_timestamp: object, +) -> dict[str, object] | None: + safe_name = _safe_event_field(player_name) + if safe_name is None: + return None + player = players.setdefault( + safe_name, + { + "player_name": safe_name, + "team": None, + "kills": 0, + "deaths": 0, + "teamkills": 0, + "deaths_by_teamkill": 0, + "last_seen_at": None, + }, + ) + safe_team = _safe_event_field(team) + if safe_team: + player["team"] = safe_team + safe_timestamp = _safe_event_field(event_timestamp) + if safe_timestamp and ( + player["last_seen_at"] is None or safe_timestamp > str(player["last_seen_at"]) + ): + player["last_seen_at"] = safe_timestamp + return player + + +def _favorite_weapon_for_player(weapons: Counter[str] | None) -> str | None: + if not weapons: + return None + return min(weapons.items(), key=lambda item: (-item[1], item[0]))[0] + + +def _row_is_current_match_fallback_fresh( + row: Mapping[str, object], + freshness_anchor: datetime, +) -> bool: + event_time = _as_utc_datetime(row["event_timestamp"]) + if event_time is None: + return False + age = freshness_anchor - event_time + return timedelta(0) <= age <= CURRENT_MATCH_FALLBACK_FRESHNESS + + +def _as_utc_datetime(value: object) -> datetime | None: + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str) and value.strip(): + try: + parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError: + return None + else: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _persist_rcon_admin_log_entries_postgres( + *, + target: Mapping[str, object], + entries: list[dict[str, object]], +) -> dict[str, int]: + from .postgres_rcon_storage import connect_postgres_compat + + target_key = str(target.get("target_key") or target.get("external_server_id") or "") + if not target_key: + raise ValueError("target must include target_key or external_server_id") + + external_server_id = target.get("external_server_id") + inserted = 0 + duplicates = 0 + with connect_postgres_compat() as connection: + for entry in entries: + parsed = parse_rcon_admin_log_entry(entry) + raw_message = str(parsed.get("raw_message") or "") + canonical_message = _canonicalize_admin_log_message(raw_message) + cursor = connection.execute( + """ + INSERT INTO rcon_admin_log_events ( + target_key, + external_server_id, + event_timestamp, + server_time, + relative_time, + event_type, + raw_message, + canonical_message, + parsed_payload_json, + raw_entry_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING + """, + ( + target_key, + external_server_id, + parsed.get("timestamp"), + parsed.get("server_time"), + parsed.get("relative_time"), + parsed.get("event_type") or "unknown", + raw_message, + canonical_message, + json.dumps(parsed, ensure_ascii=False, separators=(",", ":")), + json.dumps(entry, ensure_ascii=False, separators=(",", ":")), + ), + ) + if int(cursor.rowcount or 0): + inserted += 1 + else: + duplicates += 1 + _persist_profile_snapshot_if_present( + connection, + target_key=target_key, + external_server_id=external_server_id, + parsed=parsed, + ) + return { + "events_seen": len(entries), + "events_inserted": inserted, + "duplicate_events": duplicates, + } diff --git a/backend/app/rcon_client.py b/backend/app/rcon_client.py new file mode 100644 index 0000000..a35ddbc --- /dev/null +++ b/backend/app/rcon_client.py @@ -0,0 +1,660 @@ +"""Minimal Hell Let Loose RCON client for live server state queries.""" + +from __future__ import annotations + +import base64 +import itertools +import json +import socket +import struct +from collections.abc import Mapping +from dataclasses import dataclass + +from .config import ( + DEFAULT_RCON_SOURCE_NAME, + get_rcon_request_timeout_seconds, + get_rcon_targets_payload, +) + + +RCON_BUFFER_SIZE = 32768 +RCON_HEADER_FORMAT = " None: + super().__init__(message) + self.error_type = error_type + self.error_stage = error_stage + + +class HllRconConnection: + """Synchronous HLL RCON v2 connection for lightweight live status queries.""" + + def __init__(self, *, timeout_seconds: float) -> None: + self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._socket.settimeout(timeout_seconds) + self._xor_key: bytes | None = None + self._auth_token: str | None = None + self._request_ids = itertools.count(1) + self._current_stage = "tcp_connect" + + def connect(self, *, host: str, port: int, password: str) -> None: + self._run_socket_stage( + "tcp_connect", + lambda: self._socket.connect((host, port)), + ) + + server_connect_response = self._exchange( + "ServerConnect", + "", + request_stage="server_connect_request", + response_stage="server_connect_response", + ) + self._current_stage = "xor_key_decode" + xor_key_b64 = _expect_text_content(server_connect_response, command_name="ServerConnect") + try: + self._xor_key = base64.b64decode(xor_key_b64) + except (ValueError, TypeError) as error: + raise RconQueryError( + "payload-invalid", + "The HLL server returned an invalid RCON XOR key.", + error_stage="xor_key_decode", + ) from error + if not self._xor_key: + raise RconQueryError( + "unexpected-response", + "The HLL server returned an empty RCON XOR key.", + error_stage="xor_key_decode", + ) + + login_response = self._exchange( + "Login", + password, + request_stage="login_request", + response_stage="login_response", + ) + self._auth_token = _expect_text_content(login_response, command_name="Login") + if not self._auth_token: + raise RconQueryError( + "unexpected-response", + "The HLL server returned an empty RCON auth token.", + error_stage="login_response", + ) + + def execute_json( + self, + command: str, + content: dict[str, object] | str = "", + ) -> dict[str, object]: + stage_prefix = _resolve_command_stage_prefix(command) + response = self._exchange( + command, + content, + request_stage=f"{stage_prefix}_request", + response_stage=f"{stage_prefix}_response", + ) + self._current_stage = "payload_decode" + content_body = response.get("contentBody") + if isinstance(content_body, dict): + return content_body + if isinstance(content_body, str): + try: + parsed = json.loads(content_body) + except json.JSONDecodeError as error: + raise RconQueryError( + "payload-invalid", + f"The HLL server returned invalid JSON content for {command}.", + error_stage="payload_decode", + ) from error + if isinstance(parsed, dict): + return parsed + raise RconQueryError( + "unexpected-response", + f"The HLL server returned an unexpected payload for {command}.", + error_stage="unexpected_response", + ) + + def close(self) -> None: + try: + self._socket.shutdown(socket.SHUT_RDWR) + except OSError: + pass + self._socket.close() + + def _exchange( + self, + command: str, + content: dict[str, object] | str = "", + *, + request_stage: str, + response_stage: str, + ) -> dict[str, object]: + request_id = next(self._request_ids) + self._send_request( + request_id=request_id, + command=command, + content=content, + request_stage=request_stage, + ) + response = self._receive_response(response_stage=response_stage) + response_request_id = int(response.get("requestId") or 0) + if response_request_id != request_id: + raise RconQueryError( + "unexpected-response", + f"Unexpected RCON response id {response_request_id} for request {request_id}.", + error_stage="unexpected_response", + ) + _raise_for_status(response, command_name=command, error_stage=response_stage) + return response + + def _send_request( + self, + *, + request_id: int, + command: str, + content: dict[str, object] | str, + request_stage: str, + ) -> None: + content_body = ( + content + if isinstance(content, str) + else json.dumps(content, separators=(",", ":")) + ) + body = json.dumps( + { + "authToken": self._auth_token or "", + "version": RCON_PROTOCOL_VERSION, + "name": command, + "contentBody": content_body, + }, + separators=(",", ":"), + ).encode("utf-8") + header = struct.pack( + RCON_HEADER_FORMAT, + RCON_MAGIC_HEADER_VALUE, + request_id, + len(body), + ) + self._run_socket_stage( + request_stage, + lambda: self._socket.sendall(header + self._xor(body)), + ) + + def _receive_response(self, *, response_stage: str) -> dict[str, object]: + header_size = struct.calcsize(RCON_HEADER_FORMAT) + header_bytes = self._recv_exact( + header_size, + stage=response_stage, + receive_context="response header", + ) + try: + magic_value, request_id, body_length = struct.unpack( + RCON_HEADER_FORMAT, + header_bytes, + ) + except struct.error as error: + raise RconQueryError( + "payload-invalid", + "The HLL server returned an invalid RCON response header.", + error_stage=response_stage, + ) from error + if magic_value != RCON_MAGIC_HEADER_VALUE: + raise RconQueryError( + "invalid-magic", + ( + "The HLL server returned an unexpected RCON magic value: " + f"{magic_value:#x} (expected {RCON_MAGIC_HEADER_VALUE:#x})." + ), + error_stage=response_stage, + ) + if body_length <= 0: + raise RconQueryError( + "unexpected-response", + "The HLL server returned an empty RCON response body.", + error_stage=response_stage, + ) + + body = self._xor(self._recv_body(body_length, stage=response_stage)) + try: + parsed = json.loads(body.decode("utf-8", errors="replace")) + except json.JSONDecodeError as error: + raise RconQueryError( + "payload-invalid", + "The HLL server returned malformed RCON JSON.", + error_stage="payload_decode", + ) from error + if not isinstance(parsed, dict): + raise RconQueryError( + "unexpected-response", + "The HLL server returned a non-object RCON response.", + error_stage="unexpected_response", + ) + + parsed["requestId"] = request_id + return parsed + + def _recv_body(self, expected_length: int, *, stage: str) -> bytes: + chunks = bytearray() + original_timeout = self._socket.gettimeout() + body_timeout_seconds = min(3.0, original_timeout or 3.0) + self._socket.settimeout(body_timeout_seconds) + try: + while len(chunks) < expected_length: + self._current_stage = stage + try: + chunk = self._socket.recv( + min(RCON_BUFFER_SIZE, expected_length - len(chunks)) + ) + except (TimeoutError, socket.timeout) as error: + raise RconQueryError( + "timeout", + ( + f"Timed out during {stage} while waiting for response body " + f"({len(chunks)}/{expected_length} bytes received)." + ), + error_stage=stage, + ) from error + except OSError as error: + raise RconQueryError( + _classify_socket_error_type(error), + f"RCON socket error during {stage}: {error}", + error_stage=stage, + ) from error + if not chunk: + raise RconQueryError( + "connection-closed", + ( + "The HLL RCON connection closed unexpectedly while waiting for " + f"response body ({len(chunks)}/{expected_length} bytes received)." + ), + error_stage=stage, + ) + chunks.extend(chunk) + finally: + self._socket.settimeout(original_timeout) + return bytes(chunks) + + def _recv_exact( + self, + expected_length: int, + *, + stage: str, + receive_context: str, + ) -> bytes: + chunks = bytearray() + while len(chunks) < expected_length: + self._current_stage = stage + try: + chunk = self._socket.recv(min(RCON_BUFFER_SIZE, expected_length - len(chunks))) + except (TimeoutError, socket.timeout) as error: + raise RconQueryError( + "timeout", + ( + f"Timed out during {stage} while waiting for {receive_context} " + f"({len(chunks)}/{expected_length} bytes received)." + ), + error_stage=stage, + ) from error + except OSError as error: + raise RconQueryError( + _classify_socket_error_type(error), + f"RCON socket error during {stage}: {error}", + error_stage=stage, + ) from error + if not chunk: + raise RconQueryError( + "connection-closed", + ( + "The HLL RCON connection closed unexpectedly while waiting for " + f"{receive_context} ({len(chunks)}/{expected_length} bytes received)." + ), + error_stage=stage, + ) + chunks.extend(chunk) + return bytes(chunks) + + def _xor(self, payload: bytes) -> bytes: + if not self._xor_key: + return payload + return bytes( + value ^ self._xor_key[index % len(self._xor_key)] + for index, value in enumerate(payload) + ) + + def __enter__(self) -> HllRconConnection: + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + self.close() + + def _run_socket_stage(self, stage: str, operation: object) -> object: + self._current_stage = stage + try: + return operation() + except (TimeoutError, socket.timeout) as error: + raise RconQueryError( + "timeout", + f"Timed out during {stage}.", + error_stage=stage, + ) from error + except OSError as error: + raise RconQueryError( + _classify_socket_error_type(error), + f"RCON socket error during {stage}: {error}", + error_stage=stage, + ) from error + + +def load_rcon_targets() -> tuple[RconServerTarget, ...]: + """Load RCON targets from JSON env payload.""" + raw_payload = get_rcon_targets_payload() + if raw_payload is None: + return () + parsed = json.loads(raw_payload) + if not isinstance(parsed, list): + raise ValueError("HLL_BACKEND_RCON_TARGETS must be a JSON array.") + return tuple(_coerce_rcon_target(item) for item in parsed if isinstance(item, dict)) + + +def query_live_server_state( + target: RconServerTarget, + *, + timeout_seconds: float | None = None, +) -> dict[str, object]: + """Query one HLL server via RCON and normalize it to the live snapshot shape.""" + sample = query_live_server_sample(target, timeout_seconds=timeout_seconds) + return dict(sample["normalized"]) + + +def query_live_server_sample( + target: RconServerTarget, + *, + timeout_seconds: float | None = None, +) -> dict[str, object]: + """Query one HLL server and return both normalized and raw session data.""" + resolved_timeout = timeout_seconds or get_rcon_request_timeout_seconds() + try: + with HllRconConnection(timeout_seconds=resolved_timeout) as connection: + connection.connect(host=target.host, port=target.port, password=target.password) + session = connection.execute_json( + "GetServerInformation", + {"Name": "session", "Value": ""}, + ) + except RconQueryError: + raise + except (TimeoutError, socket.timeout) as error: + raise RconQueryError( + "timeout", + f"Timed out after {resolved_timeout:.1f}s while querying {target.host}:{target.port}.", + ) from error + except ConnectionRefusedError as error: + raise RconQueryError( + "connection-refused", + f"Connection refused by {target.host}:{target.port}.", + ) from error + except OSError as error: + raise RconQueryError( + _classify_socket_error_type(error), + f"RCON socket error against {target.host}:{target.port}: {error}", + ) from error + except RuntimeError as error: + raise RconQueryError( + _classify_runtime_error_type(error), + str(error), + error_stage=getattr(error, "error_stage", None), + ) from error + + resolved_external_id = target.external_server_id or f"rcon:{target.host}:{target.port}" + return { + "target": { + "target_key": build_rcon_target_key(target), + "name": target.name, + "host": target.host, + "port": target.port, + "external_server_id": target.external_server_id, + "region": target.region, + "game_port": target.game_port, + "query_port": target.query_port, + "source_name": target.source_name, + }, + "normalized": { + "external_server_id": resolved_external_id, + "server_name": _string_or_none(session.get("serverName")) or target.name, + "status": "online", + "players": _coerce_optional_int(session.get("playerCount")), + "max_players": _coerce_optional_int(session.get("maxPlayerCount")), + "current_map": ( + _string_or_none(session.get("mapId")) or _string_or_none(session.get("mapName")) + ), + "game_mode": _string_or_none(session.get("gameMode")), + "allied_score": _coerce_optional_int(session.get("alliedScore")), + "axis_score": _coerce_optional_int(session.get("axisScore")), + "winner": _resolve_rcon_winner( + _coerce_optional_int(session.get("alliedScore")), + _coerce_optional_int(session.get("axisScore")), + ), + "allied_faction": _string_or_none(session.get("alliedFaction")), + "axis_faction": _string_or_none(session.get("axisFaction")), + "allied_players": _coerce_optional_int(session.get("alliedPlayerCount")), + "axis_players": _coerce_optional_int(session.get("axisPlayerCount")), + "remaining_match_time_seconds": _coerce_optional_int(session.get("remainingMatchTime")), + "match_time_seconds": _coerce_optional_int(session.get("matchTime")), + "queue_count": _coerce_optional_int(session.get("queueCount")), + "max_queue_count": _coerce_optional_int(session.get("maxQueueCount")), + "vip_queue_count": _coerce_optional_int(session.get("vipQueueCount")), + "max_vip_queue_count": _coerce_optional_int(session.get("maxVipQueueCount")), + "region": target.region, + "source_name": target.source_name, + "snapshot_origin": "real-rcon", + "source_ref": f"rcon://{target.host}:{target.port}", + }, + "raw_session": session, + } + + +def build_rcon_target_key(target: RconServerTarget) -> str: + """Build a stable local key for one configured RCON target.""" + external_server_id = _string_or_none(target.external_server_id) + if external_server_id: + return external_server_id + return f"rcon:{target.host}:{target.port}" + + +def _coerce_rcon_target(raw_target: dict[str, object]) -> RconServerTarget: + slug = _string_or_none(raw_target.get("slug")) + external_server_id = _string_or_none(raw_target.get("external_server_id")) or slug + name = _string_or_none(raw_target.get("name")) or _slug_to_display_name(slug) or "Unnamed RCON target" + host = _required_string(raw_target, "host") + password = _required_string(raw_target, "password") + source_name = _string_or_none(raw_target.get("source_name")) or DEFAULT_RCON_SOURCE_NAME + port = _required_positive_int(raw_target, "port") + if not host: + raise ValueError("Each RCON target must define a non-empty 'host'.") + if port <= 0: + raise ValueError("Each RCON target must define a positive 'port'.") + if not password: + raise ValueError("Each RCON target must define a non-empty 'password'.") + + return RconServerTarget( + name=name, + host=host, + port=port, + password=password, + source_name=source_name or DEFAULT_RCON_SOURCE_NAME, + external_server_id=external_server_id, + region=_string_or_none(raw_target.get("region")), + game_port=_coerce_optional_positive_int(raw_target.get("game_port")), + query_port=_coerce_optional_positive_int(raw_target.get("query_port")), + ) + + +def _raise_for_status( + response: dict[str, object], + *, + command_name: str, + error_stage: str, +) -> None: + status_code = int(response.get("statusCode") or 0) + if status_code == 200: + return + status_message = _string_or_none(response.get("statusMessage")) or "Unknown RCON error." + if command_name == "Login" and status_code in {401, 403}: + raise RconQueryError( + "auth/login", + f"{command_name} failed with RCON status {status_code}: {status_message}", + error_stage=error_stage, + ) + raise RconQueryError( + "unexpected-response", + f"{command_name} failed with RCON status {status_code}: {status_message}", + error_stage=error_stage, + ) + + +def _expect_text_content(response: dict[str, object], *, command_name: str) -> str: + content = response.get("contentBody") + if isinstance(content, str): + return content + raise RconQueryError( + "unexpected-response", + f"The HLL server returned unexpected text content for {command_name}.", + error_stage="unexpected_response", + ) + + +def _resolve_command_stage_prefix(command: str) -> str: + normalized_command = str(command or "").strip().lower() + stage_prefix_by_command = { + "serverconnect": "server_connect", + "login": "login", + "getserverinformation": "get_server_information", + } + return stage_prefix_by_command.get(normalized_command, normalized_command or "rcon_command") + + +def _string_or_none(value: object) -> str | None: + if not isinstance(value, str): + return None + normalized = value.strip() + return normalized or None + + +def _resolve_rcon_winner(allied_score: int | None, axis_score: int | None) -> str | None: + if allied_score is None or axis_score is None: + return None + if allied_score > axis_score: + return "allied" + if axis_score > allied_score: + return "axis" + return "draw" + + +def _coerce_optional_int(value: object) -> int | None: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _coerce_optional_positive_int(value: object) -> int | None: + if value is None: + return None + coerced = int(value) + if coerced <= 0: + raise ValueError("Configured RCON target ports must be positive when defined.") + return coerced + + +def _required_string(raw_target: Mapping[str, object], field_name: str) -> str: + value = _string_or_none(raw_target.get(field_name)) + if value is None: + available_fields = ", ".join(sorted(raw_target.keys())) + raise ValueError( + f"Each RCON target must define a non-empty '{field_name}'. " + f"Available fields: {available_fields or 'none'}." + ) + return value + + +def _required_positive_int(raw_target: Mapping[str, object], field_name: str) -> int: + raw_value = raw_target.get(field_name) + try: + value = int(raw_value) + except (TypeError, ValueError) as error: + available_fields = ", ".join(sorted(raw_target.keys())) + raise ValueError( + f"Each RCON target must define a valid integer '{field_name}'. " + f"Available fields: {available_fields or 'none'}." + ) from error + if value <= 0: + raise ValueError(f"Each RCON target must define a positive '{field_name}'.") + return value + + +def _slug_to_display_name(slug: str | None) -> str | None: + normalized_slug = _string_or_none(slug) + if normalized_slug is None: + return None + if normalized_slug.startswith("comunidad-hispana-"): + suffix = normalized_slug.removeprefix("comunidad-hispana-") + if suffix.isdigit(): + return f"Comunidad Hispana #{suffix.zfill(2)}" + parts = [part for part in normalized_slug.replace("_", "-").split("-") if part] + if not parts: + return None + return " ".join(part.upper() if part.isdigit() else part.capitalize() for part in parts) + + +def _classify_socket_error_type(error: OSError) -> str: + if isinstance(error, TimeoutError): + return "timeout" + if isinstance(error, ConnectionRefusedError): + return "connection-refused" + if getattr(error, "errno", None) in {10060, 110, 60}: + return "timeout" + return "other-error" + + +def _classify_runtime_error_type(error: RuntimeError) -> str: + message = str(error).lower() + if "auth token" in message or "login failed" in message or "status 401" in message or "status 403" in message: + return "auth/login" + if "invalid magic" in message: + return "invalid-magic" + if "closed unexpectedly" in message or "closed connection" in message: + return "connection-closed" + if "invalid json" in message or "unexpected payload" in message or "malformed" in message or "invalid rcon" in message: + return "payload-invalid" + if "timed out" in message: + return "timeout" + if "unexpected" in message: + return "unexpected-response" + return "other-error" diff --git a/backend/app/rcon_historical_backfill.py b/backend/app/rcon_historical_backfill.py new file mode 100644 index 0000000..0363be2 --- /dev/null +++ b/backend/app/rcon_historical_backfill.py @@ -0,0 +1,484 @@ +"""Explicit RCON/AdminLog historical backfill command.""" + +from __future__ import annotations + +import argparse +import json +import time +from dataclasses import dataclass +from contextlib import closing +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Iterable + +from .config import ( + get_rcon_backfill_chunk_hours, + get_rcon_backfill_max_days_back, + get_rcon_backfill_sleep_seconds, + get_rcon_request_timeout_seconds, + use_postgres_rcon_storage, +) +from .historical_runner import generate_historical_snapshots +from .historical_storage import ALL_SERVERS_SLUG +from .rcon_admin_log_materialization import ( + MATCH_RESULT_SOURCE, + initialize_rcon_materialized_storage, + materialize_rcon_admin_log, +) +from .rcon_admin_log_storage import persist_rcon_admin_log_entries +from .rcon_client import HllRconConnection, RconServerTarget, build_rcon_target_key, load_rcon_targets +from .rcon_historical_leaderboards import list_rcon_materialized_leaderboard +from .sqlite_utils import connect_sqlite_readonly +from .writer_lock import backend_writer_lock, build_writer_lock_holder + +DEFAULT_ALLOWED_SERVER_KEYS = frozenset({"comunidad-hispana-01", "comunidad-hispana-02"}) +EXCLUDED_BY_DEFAULT_SERVER_KEYS = frozenset({"comunidad-hispana-03"}) + + +@dataclass(frozen=True, slots=True) +class BackfillWindow: + start: datetime + end: datetime + + @property + def lookback_seconds(self) -> int: + now = datetime.now(timezone.utc) + return max(1, int((now - self.start).total_seconds())) + + +def run_rcon_historical_backfill( + *, + servers: str | None = None, + from_value: str | None = None, + to_value: str | None = None, + ensure_recent_matches: int | None = None, + ensure_current_month: bool = False, + ensure_leaderboard_windows: bool = False, + chunk_hours: int | None = None, + sleep_seconds: float | None = None, + max_days_back: int | None = None, + dry_run: bool = False, + regenerate_snapshots: bool = False, + db_path: Path | None = None, +) -> dict[str, object]: + """Backfill AdminLog events and materialized RCON matches on explicit operator command.""" + anchor = datetime.now(timezone.utc) + resolved_chunk_hours = chunk_hours or get_rcon_backfill_chunk_hours() + resolved_sleep_seconds = ( + get_rcon_backfill_sleep_seconds() if sleep_seconds is None else sleep_seconds + ) + resolved_max_days_back = max_days_back or get_rcon_backfill_max_days_back() + selected_targets = select_backfill_targets(servers) + recent_before = count_recent_materialized_closed_matches(db_path=db_path) + monthly_before = _window_diagnostic("monthly", db_path=db_path, now=anchor) + weekly_before = _window_diagnostic("weekly", db_path=db_path, now=anchor) + requested_range = _resolve_requested_range( + anchor=anchor, + from_value=from_value, + to_value=to_value, + ensure_recent_matches=ensure_recent_matches, + ensure_current_month=ensure_current_month, + ensure_leaderboard_windows=ensure_leaderboard_windows, + max_days_back=resolved_max_days_back, + ) + windows = _build_backfill_windows( + start=requested_range["start"], + end=requested_range["end"], + chunk_hours=resolved_chunk_hours, + ) + + result: dict[str, object] = { + "status": "dry-run" if dry_run else "ok", + "dry_run": dry_run, + "servers_processed": [build_rcon_target_key(target) for target in selected_targets], + "requested_range": { + "from": _to_iso(requested_range["start"]), + "to": _to_iso(requested_range["end"]), + "reason": requested_range["reason"], + "admin_log_api": "lookback-only", + }, + "actual_windows_scanned": [], + "events_seen": 0, + "events_inserted": 0, + "duplicate_events": 0, + "matches_materialized": 0, + "matches_updated": 0, + "player_stats_materialized": 0, + "player_stats_updated": 0, + "recent_materialized_closed_match_count_before": recent_before, + "recent_materialized_closed_match_count_after": recent_before, + "monthly_selected_window_before": monthly_before, + "monthly_selected_window": monthly_before, + "weekly_selected_window_before": weekly_before, + "weekly_selected_window": weekly_before, + "snapshot_regeneration_result": None, + "errors": [], + } + + if dry_run: + result["actual_windows_scanned"] = [ + _serialize_window(window) for window in _limit_windows_for_recent_need( + windows, + ensure_recent_matches=ensure_recent_matches, + db_path=db_path, + ) + ] + return result + + try: + with backend_writer_lock( + holder=build_writer_lock_holder("app.rcon_historical_backfill") + ): + windows_to_scan = _limit_windows_for_recent_need( + windows, + ensure_recent_matches=ensure_recent_matches, + db_path=db_path, + ) + for window in windows_to_scan: + for target in selected_targets: + window_result = _scan_target_window(target, window) + result["actual_windows_scanned"].append(window_result["window"]) + result["events_seen"] = int(result["events_seen"]) + int( + window_result["events_seen"] + ) + result["events_inserted"] = int(result["events_inserted"]) + int( + window_result["events_inserted"] + ) + result["duplicate_events"] = int(result["duplicate_events"]) + int( + window_result["duplicate_events"] + ) + if window_result.get("error"): + result["errors"].append(window_result["error"]) + if resolved_sleep_seconds > 0: + time.sleep(resolved_sleep_seconds) + + materialized = materialize_rcon_admin_log(db_path=db_path) + result["matches_materialized"] = int(result["matches_materialized"]) + int( + materialized.get("matches_materialized") or 0 + ) + result["matches_updated"] = int(result["matches_updated"]) + int( + materialized.get("matches_updated") or 0 + ) + result["player_stats_materialized"] = int( + result["player_stats_materialized"] + ) + int(materialized.get("player_stats_materialized") or 0) + result["player_stats_updated"] = int(result["player_stats_updated"]) + int( + materialized.get("player_stats_updated") or 0 + ) + + if ensure_recent_matches and count_recent_materialized_closed_matches( + db_path=db_path + ) >= ensure_recent_matches: + break + + if regenerate_snapshots: + result["snapshot_regeneration_result"] = generate_historical_snapshots( + server_slug=None, + run_number=1, + ) + except Exception as exc: # noqa: BLE001 - CLI reports structured operator diagnostics + result["status"] = "error" + result["errors"].append({"error_type": type(exc).__name__, "message": str(exc)}) + + recent_after = count_recent_materialized_closed_matches(db_path=db_path) + result["recent_materialized_closed_match_count_after"] = recent_after + result["monthly_selected_window"] = _window_diagnostic("monthly", db_path=db_path, now=anchor) + result["weekly_selected_window"] = _window_diagnostic("weekly", db_path=db_path, now=anchor) + if result["errors"] and result["status"] == "ok": + result["status"] = "partial" + return result + + +def select_backfill_targets(servers: str | None) -> list[RconServerTarget]: + """Load configured RCON targets and apply safe server selection rules.""" + configured_targets = list(load_rcon_targets()) + if not configured_targets: + raise RuntimeError("No RCON targets configured in HLL_BACKEND_RCON_TARGETS.") + by_key = {build_rcon_target_key(target): target for target in configured_targets} + requested_keys = _parse_server_keys(servers) + if requested_keys: + unknown = sorted(key for key in requested_keys if key not in by_key) + if unknown: + raise ValueError(f"Unknown RCON server key(s): {', '.join(unknown)}") + return [by_key[key] for key in requested_keys] + selected = [ + target + for key, target in by_key.items() + if key in DEFAULT_ALLOWED_SERVER_KEYS and key not in EXCLUDED_BY_DEFAULT_SERVER_KEYS + ] + if not selected: + raise RuntimeError( + "No default backfill targets selected. Pass --servers with configured keys explicitly." + ) + return selected + + +def count_recent_materialized_closed_matches( + *, + server_key: str | None = None, + db_path: Path | None = None, +) -> int: + """Count materialized closed AdminLog matches available for recent-match UI.""" + resolved_path = initialize_rcon_materialized_storage(db_path=db_path) + scope_sql = "" + params: list[object] = [MATCH_RESULT_SOURCE] + if server_key and server_key != ALL_SERVERS_SLUG: + scope_sql = "AND (target_key = ? OR external_server_id = ?)" + params.extend([server_key, server_key]) + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import connect_postgres_compat + + connection_scope = connect_postgres_compat() + else: + connection_scope = closing(connect_sqlite_readonly(resolved_path)) + with connection_scope as connection: + row = connection.execute( + f""" + SELECT COUNT(*) AS count + FROM rcon_materialized_matches + WHERE source_basis = ? + AND ended_at IS NOT NULL + {scope_sql} + """, + params, + ).fetchone() + return int(row["count"] or 0) if row else 0 + + +def _scan_target_window(target: RconServerTarget, window: BackfillWindow) -> dict[str, object]: + target_metadata = _serialize_target(target) + serialized_window = _serialize_window(window) + try: + with HllRconConnection(timeout_seconds=get_rcon_request_timeout_seconds()) as connection: + connection.connect(host=target.host, port=target.port, password=target.password) + payload = connection.execute_json( + "GetAdminLog", + { + "LogBackTrackTime": window.lookback_seconds, + "Filters": [], + }, + ) + entries = payload.get("entries") + if not isinstance(entries, list): + entries = [] + normalized_entries = [entry for entry in entries if isinstance(entry, dict)] + delta = persist_rcon_admin_log_entries( + target=target_metadata, + entries=normalized_entries, + ) + return {"window": serialized_window, "error": None, **delta} + except Exception as exc: # noqa: BLE001 - per-window errors must not hide neighboring windows + return { + "window": serialized_window, + "events_seen": 0, + "events_inserted": 0, + "duplicate_events": 0, + "error": { + **target_metadata, + **serialized_window, + "error_type": type(exc).__name__, + "message": str(exc), + }, + } + + +def _resolve_requested_range( + *, + anchor: datetime, + from_value: str | None, + to_value: str | None, + ensure_recent_matches: int | None, + ensure_current_month: bool, + ensure_leaderboard_windows: bool, + max_days_back: int, +) -> dict[str, object]: + end = _parse_datetime_argument(to_value, default=anchor) + starts = [] + reasons = [] + if from_value: + starts.append(_parse_datetime_argument(from_value, default=anchor)) + reasons.append("explicit-range") + if ensure_current_month: + starts.append(_month_start(anchor)) + reasons.append("ensure-current-month") + if ensure_leaderboard_windows: + starts.append(_previous_month_start(_month_start(anchor))) + starts.append(_week_start(anchor) - timedelta(days=7)) + reasons.append("ensure-leaderboard-windows") + if ensure_recent_matches: + starts.append(anchor - timedelta(days=max_days_back)) + reasons.append(f"ensure-recent-matches-{ensure_recent_matches}") + if not starts: + starts.append(anchor - timedelta(days=max_days_back)) + reasons.append("default-max-days-back") + start = max(min(starts), anchor - timedelta(days=max_days_back)) + return {"start": start, "end": end, "reason": ",".join(reasons)} + + +def _build_backfill_windows( + *, + start: datetime, + end: datetime, + chunk_hours: int, +) -> list[BackfillWindow]: + windows: list[BackfillWindow] = [] + cursor = _as_utc(end) + lower = _as_utc(start) + chunk = timedelta(hours=chunk_hours) + while cursor > lower: + window_start = max(lower, cursor - chunk) + windows.append(BackfillWindow(start=window_start, end=cursor)) + cursor = window_start + return windows + + +def _limit_windows_for_recent_need( + windows: list[BackfillWindow], + *, + ensure_recent_matches: int | None, + db_path: Path | None, +) -> list[BackfillWindow]: + if not ensure_recent_matches: + return windows + if count_recent_materialized_closed_matches(db_path=db_path) >= ensure_recent_matches: + return [] + return windows + + +def _window_diagnostic( + timeframe: str, + *, + db_path: Path | None, + now: datetime, +) -> dict[str, object]: + payload = list_rcon_materialized_leaderboard( + server_key=ALL_SERVERS_SLUG, + timeframe=timeframe, + metric="kills", + limit=1, + db_path=db_path, + now=now, + ) + return { + "window_kind": payload.get("window_kind"), + "window_label": payload.get("window_label"), + "window_start": payload.get("window_start"), + "window_end": payload.get("window_end"), + "selection_reason": payload.get("selection_reason"), + "current_week_closed_matches": payload.get("current_week_closed_matches"), + "previous_week_closed_matches": payload.get("previous_week_closed_matches"), + "selected_month_start": payload.get("selected_month_start"), + "selected_month_end": payload.get("selected_month_end"), + "current_month_closed_matches": payload.get("current_month_closed_matches"), + "previous_month_closed_matches": payload.get("previous_month_closed_matches"), + "sufficient_sample": payload.get("sufficient_sample"), + } + + +def _parse_server_keys(value: str | None) -> list[str]: + return [part.strip() for part in str(value or "").split(",") if part.strip()] + + +def _parse_datetime_argument(value: str | None, *, default: datetime) -> datetime: + if value is None or str(value).strip().lower() == "now": + return default + raw = str(value).strip() + if len(raw) == 10: + raw = f"{raw}T00:00:00+00:00" + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + return _as_utc(parsed) + + +def _month_start(value: datetime) -> datetime: + point = _as_utc(value) + return point.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + +def _previous_month_start(current_month_start: datetime) -> datetime: + return _month_start(current_month_start - timedelta(days=1)) + + +def _week_start(value: datetime) -> datetime: + point = _as_utc(value) + return (point - timedelta(days=point.weekday())).replace( + hour=0, + minute=0, + second=0, + microsecond=0, + ) + + +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _serialize_target(target: RconServerTarget) -> dict[str, object]: + return { + "target_key": build_rcon_target_key(target), + "external_server_id": target.external_server_id, + "name": target.name, + "host": target.host, + "port": target.port, + "source_name": target.source_name, + } + + +def _serialize_window(window: BackfillWindow) -> dict[str, object]: + return { + "start": _to_iso(window.start), + "end": _to_iso(window.end), + "requested_log_backtrack_seconds": window.lookback_seconds, + } + + +def _to_iso(value: datetime) -> str: + return _as_utc(value).isoformat().replace("+00:00", "Z") + + +def _main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Backfill RCON AdminLog historical materialized matches.") + parser.add_argument("--from", dest="from_value", default=None) + parser.add_argument("--to", dest="to_value", default=None) + parser.add_argument("--servers", default=None) + parser.add_argument("--ensure-recent-matches", type=int, default=None) + parser.add_argument("--ensure-current-month", action="store_true") + parser.add_argument("--ensure-leaderboard-windows", action="store_true") + parser.add_argument("--chunk-hours", type=int, default=get_rcon_backfill_chunk_hours()) + parser.add_argument("--sleep-seconds", type=float, default=get_rcon_backfill_sleep_seconds()) + parser.add_argument("--max-days-back", type=int, default=get_rcon_backfill_max_days_back()) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--regenerate-snapshots", action="store_true") + parser.add_argument("--db-path", type=Path, default=None) + args = parser.parse_args(list(argv) if argv is not None else None) + + if args.ensure_recent_matches is not None and args.ensure_recent_matches <= 0: + raise ValueError("--ensure-recent-matches must be positive.") + if args.chunk_hours <= 0: + raise ValueError("--chunk-hours must be positive.") + if args.sleep_seconds < 0: + raise ValueError("--sleep-seconds must be zero or positive.") + if args.max_days_back <= 0: + raise ValueError("--max-days-back must be positive.") + + payload = run_rcon_historical_backfill( + servers=args.servers, + from_value=args.from_value, + to_value=args.to_value, + ensure_recent_matches=args.ensure_recent_matches, + ensure_current_month=args.ensure_current_month, + ensure_leaderboard_windows=args.ensure_leaderboard_windows, + chunk_hours=args.chunk_hours, + sleep_seconds=args.sleep_seconds, + max_days_back=args.max_days_back, + dry_run=args.dry_run, + regenerate_snapshots=args.regenerate_snapshots, + db_path=args.db_path, + ) + print(json.dumps(payload, ensure_ascii=False, indent=2, default=str)) + return 0 if payload.get("status") != "error" else 1 + + +if __name__ == "__main__": + raise SystemExit(_main()) diff --git a/backend/app/rcon_historical_backfill_operational.py b/backend/app/rcon_historical_backfill_operational.py new file mode 100644 index 0000000..7017a65 --- /dev/null +++ b/backend/app/rcon_historical_backfill_operational.py @@ -0,0 +1,173 @@ +"""Observable operator backfill for RCON AdminLog. + +This command is intentionally simple and explicit. It is meant to be run after stopping +`historical-runner` and `rcon-historical-worker`, so it does not compete with the shared +writer lock loop. It prints one JSON line per step, which makes progress visible in +PowerShell and Docker logs. +""" + +from __future__ import annotations + +import argparse +import json +import time +from datetime import datetime, timezone +from typing import Iterable + +from .historical_runner import generate_historical_snapshots +from .rcon_admin_log_ingestion import ingest_rcon_admin_logs +from .rcon_admin_log_materialization import materialize_rcon_admin_log +from .rcon_historical_backfill import count_recent_materialized_closed_matches, select_backfill_targets +from .rcon_client import build_rcon_target_key + + +def run_operational_backfill( + *, + ensure_recent_matches: int, + servers: str, + max_days_back: int, + chunk_hours: int, + sleep_seconds: float, + regenerate_snapshots: bool, +) -> dict[str, object]: + started_at = datetime.now(timezone.utc) + targets = select_backfill_targets(servers) + target_keys = [build_rcon_target_key(target) for target in targets] + before = count_recent_materialized_closed_matches() + result: dict[str, object] = { + "status": "ok", + "started_at": _iso(started_at), + "admin_log_api": "lookback-only", + "exact_historical_range_supported": False, + "servers_processed": target_keys, + "ensure_recent_matches": ensure_recent_matches, + "max_days_back": max_days_back, + "chunk_hours": chunk_hours, + "recent_materialized_closed_match_count_before": before, + "recent_materialized_closed_match_count_after": before, + "events_seen": 0, + "events_inserted": 0, + "duplicate_events": 0, + "matches_materialized": 0, + "matches_updated": 0, + "windows_scanned": [], + "errors": [], + "snapshot_regeneration_result": None, + } + _log("backfill-started", result=result) + + max_minutes = max_days_back * 24 * 60 + step_minutes = chunk_hours * 60 + minutes = step_minutes + + while minutes <= max_minutes: + current_count = count_recent_materialized_closed_matches() + result["recent_materialized_closed_match_count_after"] = current_count + if current_count >= ensure_recent_matches: + result["termination_reason"] = "recent-match-target-reached" + break + + for target_key in target_keys: + _log("target-lookback-started", target_key=target_key, lookback_minutes=minutes) + try: + ingestion = ingest_rcon_admin_logs(minutes=minutes, target_key=target_key) + totals = ingestion.get("totals") if isinstance(ingestion.get("totals"), dict) else {} + materialized = materialize_rcon_admin_log() + window_summary = { + "target_key": target_key, + "lookback_minutes": minutes, + "events_seen": int(totals.get("events_seen") or 0), + "events_inserted": int(totals.get("events_inserted") or 0), + "duplicate_events": int(totals.get("duplicate_events") or 0), + "matches_materialized": int(materialized.get("matches_materialized") or 0), + "matches_updated": int(materialized.get("matches_updated") or 0), + } + result["windows_scanned"].append(window_summary) + _add(result, window_summary) + result["recent_materialized_closed_match_count_after"] = count_recent_materialized_closed_matches() + _log( + "target-lookback-finished", + **window_summary, + recent_materialized_closed_match_count_after=result["recent_materialized_closed_match_count_after"], + ) + except Exception as exc: # noqa: BLE001 - operator command must continue reporting + error = { + "target_key": target_key, + "lookback_minutes": minutes, + "error_type": type(exc).__name__, + "message": str(exc), + } + result["errors"].append(error) + _log("target-lookback-failed", error=error) + + if sleep_seconds > 0: + time.sleep(sleep_seconds) + + minutes += step_minutes + + if result["recent_materialized_closed_match_count_after"] < ensure_recent_matches: + result["status"] = "partial" + result.setdefault("termination_reason", "exhausted_available_admin_log_or_max_days_back") + + if regenerate_snapshots: + _log("snapshot-regeneration-started") + try: + result["snapshot_regeneration_result"] = generate_historical_snapshots(server_slug=None, run_number=1) + _log("snapshot-regeneration-finished", snapshot_regeneration_result=result["snapshot_regeneration_result"]) + except Exception as exc: # noqa: BLE001 + result["status"] = "partial" + error = {"phase": "snapshot-regeneration", "error_type": type(exc).__name__, "message": str(exc)} + result["errors"].append(error) + _log("snapshot-regeneration-failed", error=error) + + result["finished_at"] = _iso(datetime.now(timezone.utc)) + _log("backfill-finished", result=result) + return result + + +def _add(result: dict[str, object], window_summary: dict[str, object]) -> None: + for key in ("events_seen", "events_inserted", "duplicate_events", "matches_materialized", "matches_updated"): + result[key] = int(result.get(key) or 0) + int(window_summary.get(key) or 0) + + +def _log(event: str, **payload: object) -> None: + print(json.dumps({"event": event, **payload}, ensure_ascii=False, default=str), flush=True) + + +def _iso(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Observable RCON AdminLog backfill operator command.") + parser.add_argument("--ensure-recent-matches", type=int, default=100) + parser.add_argument("--servers", default="comunidad-hispana-01,comunidad-hispana-02") + parser.add_argument("--chunk-hours", type=int, default=3) + parser.add_argument("--sleep-seconds", type=float, default=1.0) + parser.add_argument("--max-days-back", type=int, default=45) + parser.add_argument("--regenerate-snapshots", action="store_true") + args = parser.parse_args(list(argv) if argv is not None else None) + + if args.ensure_recent_matches <= 0: + raise ValueError("--ensure-recent-matches must be positive.") + if args.chunk_hours <= 0: + raise ValueError("--chunk-hours must be positive.") + if args.sleep_seconds < 0: + raise ValueError("--sleep-seconds must be zero or positive.") + if args.max_days_back <= 0: + raise ValueError("--max-days-back must be positive.") + + payload = run_operational_backfill( + ensure_recent_matches=args.ensure_recent_matches, + servers=args.servers, + chunk_hours=args.chunk_hours, + sleep_seconds=args.sleep_seconds, + max_days_back=args.max_days_back, + regenerate_snapshots=args.regenerate_snapshots, + ) + print(json.dumps(payload, ensure_ascii=False, indent=2, default=str), flush=True) + return 0 if payload.get("status") != "error" else 1 + + +if __name__ == "__main__": + raise SystemExit(_main()) diff --git a/backend/app/rcon_historical_leaderboards.py b/backend/app/rcon_historical_leaderboards.py new file mode 100644 index 0000000..4da272e --- /dev/null +++ b/backend/app/rcon_historical_leaderboards.py @@ -0,0 +1,600 @@ +"""Leaderboard read model over materialized RCON/AdminLog match stats.""" + +from __future__ import annotations + +from contextlib import closing +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Literal + +from .config import get_storage_path, use_postgres_rcon_storage +from .config import get_historical_weekly_fallback_min_matches +from .historical_storage import ALL_SERVERS_SLUG +from .rcon_admin_log_materialization import ( + MATCH_RESULT_SOURCE, + initialize_rcon_materialized_storage, +) +from .sqlite_utils import connect_sqlite_readonly + +LeaderboardTimeframe = Literal["weekly", "monthly"] +LeaderboardMetric = Literal["kills", "deaths", "matches_over_100_kills", "support"] + + +def build_rcon_materialized_leaderboard_snapshot_payload( + *, + server_id: str | None = None, + timeframe: str = "weekly", + metric: str = "kills", + limit: int = 10, +) -> dict[str, object]: + """Return an API payload for RCON-backed leaderboard snapshots. + + This is a runtime fast read over the materialized AdminLog tables. It intentionally + avoids the old public-scoreboard fallback because the UI is running in RCON mode. + """ + + normalized_timeframe = _normalize_timeframe(timeframe) + normalized_metric = _normalize_metric(metric) + result = list_rcon_materialized_leaderboard( + server_key=server_id, + timeframe=normalized_timeframe, + metric=normalized_metric, + limit=limit, + ) + items = list(result.get("items") or [])[:limit] + return { + "status": "ok", + "data": { + "title": _build_title( + metric=normalized_metric, + timeframe=normalized_timeframe, + server_id=server_id, + ), + "context": f"historical-{normalized_timeframe}-leaderboard-snapshot", + "source": "rcon-materialized-admin-log-leaderboard", + "server_slug": server_id, + "timeframe": normalized_timeframe, + "metric": normalized_metric, + "found": True, + "snapshot_status": "ready", + "missing_reason": None, + "request_path_policy": "runtime-rcon-materialized-fast-path", + "generation_policy": "runtime-materialized-read", + "generated_at": _to_iso(datetime.now(timezone.utc)), + "source_range_start": result.get("source_range_start"), + "source_range_end": result.get("source_range_end"), + "is_stale": False, + "freshness": "runtime", + "window_days": result.get("window_days"), + "window_start": result.get("window_start"), + "window_end": result.get("window_end"), + "window_kind": result.get("window_kind"), + "window_label": result.get("window_label"), + "uses_fallback": False, + "selection_reason": result.get("selection_reason"), + "current_week_start": result.get("current_week_start"), + "current_week_closed_matches": result.get("current_week_closed_matches"), + "previous_week_closed_matches": result.get("previous_week_closed_matches"), + "current_month_start": result.get("current_month_start"), + "selected_month_start": result.get("selected_month_start"), + "selected_month_end": result.get("selected_month_end"), + "current_month_closed_matches": result.get("current_month_closed_matches"), + "previous_month_closed_matches": result.get("previous_month_closed_matches"), + "sufficient_sample": result.get("sufficient_sample"), + "snapshot_limit": result.get("limit"), + "limit": limit, + "runtime_enrichment": { + "applied": False, + "reason": None, + }, + "primary_source": "rcon", + "selected_source": "rcon", + "fallback_used": False, + "fallback_reason": None, + "source_attempts": [ + { + "source": "rcon", + "role": "primary", + "status": "success", + "reason": "leaderboard-served-by-rcon-materialized-admin-log", + "message": None, + } + ], + "items": items, + }, + } + + +def list_rcon_materialized_leaderboard( + *, + server_key: str | None = None, + timeframe: str = "weekly", + metric: str = "kills", + limit: int = 10, + db_path: Path | None = None, + now: datetime | None = None, +) -> dict[str, object]: + """Return a leaderboard built from materialized RCON/AdminLog player stats. + + RCON/AdminLog materialization currently has reliable kill/death/teamkill counters, + but not public-scoreboard support points. For support, return an explicitly empty + supported payload rather than falling back to unrelated public scoreboard storage. + """ + + normalized_timeframe = _normalize_timeframe(timeframe) + normalized_metric = _normalize_metric(metric) + normalized_limit = max(1, int(limit or 10)) + anchor = _as_utc(now or datetime.now(timezone.utc)) + + resolved_path = initialize_rcon_materialized_storage(db_path=db_path) + connection_scope = _connect_scope(resolved_path, db_path=db_path) + with connection_scope as connection: + window = select_leaderboard_window( + connection=connection, + server_key=server_key, + timeframe=normalized_timeframe, + now=anchor, + ) + if normalized_metric == "support": + return _empty_payload( + server_key=server_key, + timeframe=normalized_timeframe, + metric=normalized_metric, + limit=normalized_limit, + window=window, + reason="rcon-materialized-stats-do-not-include-support-score", + ) + rows = _fetch_leaderboard_rows( + connection, + server_key=server_key, + metric=normalized_metric, + limit=normalized_limit, + window_start=window["start"], + window_end=window["end"], + ) + source_range = _fetch_source_range( + connection, + server_key=server_key, + window_start=window["start"], + window_end=window["end"], + ) + + items = [_build_item(row, index=index + 1) for index, row in enumerate(rows)] + return { + "source": "rcon-materialized-admin-log-leaderboard", + "server_key": server_key, + "metric": normalized_metric, + "limit": normalized_limit, + "window_days": window["days"], + "window_start": _to_iso(window["start"]), + "window_end": _to_iso(window["end"]), + "window_kind": window["kind"], + "window_label": window["label"], + "uses_fallback": False, + "selection_reason": window["selection_reason"], + "current_week_start": _to_iso(window["current_week_start"]), + "current_week_closed_matches": window["current_week_closed_matches"], + "previous_week_closed_matches": window["previous_week_closed_matches"], + "current_month_start": _to_iso(window["current_month_start"]), + "selected_month_start": _to_iso(window["selected_month_start"]), + "selected_month_end": _to_iso(window["selected_month_end"]), + "current_month_closed_matches": window["current_month_closed_matches"], + "previous_month_closed_matches": window["previous_month_closed_matches"], + "sufficient_sample": window["sufficient_sample"], + "source_range_start": _to_iso(source_range[0]) if source_range[0] else None, + "source_range_end": _to_iso(source_range[1]) if source_range[1] else None, + "items": items, + } + + +def _fetch_leaderboard_rows( + connection: object, + *, + server_key: str | None, + metric: str, + limit: int, + window_start: datetime, + window_end: datetime, +) -> list[dict[str, object]]: + scope_sql, scope_params = _build_scope_sql(server_key) + metric_sql = { + "kills": "SUM(COALESCE(stats.kills, 0))", + "deaths": "SUM(COALESCE(stats.deaths, 0))", + "matches_over_100_kills": "SUM(CASE WHEN COALESCE(stats.kills, 0) >= 100 THEN 1 ELSE 0 END)", + }[metric] + having_sql = f"HAVING {metric_sql} > 0" + params: list[object] = [ + _to_iso(window_start), + _to_iso(window_end), + *scope_params, + limit, + ] + rows = connection.execute( + f""" + SELECT + stats.player_id, + stats.player_name, + {metric_sql} AS metric_value, + COUNT(DISTINCT stats.match_key) AS matches_considered, + SUM(COALESCE(stats.kills, 0)) AS kills, + SUM(COALESCE(stats.deaths, 0)) AS deaths, + SUM(COALESCE(stats.teamkills, 0)) AS teamkills + FROM rcon_match_player_stats AS stats + INNER JOIN rcon_materialized_matches AS matches + ON matches.target_key = stats.target_key + AND matches.match_key = stats.match_key + WHERE matches.source_basis = ? + AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ? + AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) <= ? + {scope_sql} + AND TRIM(COALESCE(stats.player_name, '')) != '' + GROUP BY stats.player_id, stats.player_name + {having_sql} + ORDER BY metric_value DESC, matches_considered DESC, stats.player_name ASC + LIMIT ? + """, + [MATCH_RESULT_SOURCE, *params], + ).fetchall() + return [dict(row) for row in rows] + + +def _fetch_match_counts( + connection: object, + *, + server_key: str | None, + timeframe: str, + window_start: datetime, + window_end: datetime, +) -> dict[str, int]: + current_week_start = _week_start(window_end) + previous_week_start = current_week_start - timedelta(days=7) + current_month_start = _month_start(window_end) + previous_month_start = _previous_month_start(current_month_start) + return { + "current_week_closed_matches": _count_matches( + connection, + server_key=server_key, + start=current_week_start, + end=window_end, + ), + "previous_week_closed_matches": _count_matches( + connection, + server_key=server_key, + start=previous_week_start, + end=current_week_start, + ), + "current_month_closed_matches": _count_matches( + connection, + server_key=server_key, + start=current_month_start, + end=window_end, + ), + "previous_month_closed_matches": _count_matches( + connection, + server_key=server_key, + start=previous_month_start, + end=current_month_start, + ), + } + + +def select_leaderboard_window( + *, + connection: object, + server_key: str | None, + timeframe: str, + now: datetime | None = None, +) -> dict[str, object]: + """Select the RCON leaderboard window using weekly/monthly fallback policy.""" + anchor = _as_utc(now or datetime.now(timezone.utc)) + current_week_start = _week_start(anchor) + previous_week_start = current_week_start - timedelta(days=7) + current_month_start = _month_start(anchor) + previous_month_start = _previous_month_start(current_month_start) + minimum_week_matches = get_historical_weekly_fallback_min_matches() + current_week_count = _count_matches( + connection, + server_key=server_key, + start=current_week_start, + end=anchor, + ) + previous_week_count = _count_matches( + connection, + server_key=server_key, + start=previous_week_start, + end=current_week_start, + ) + current_month_count = _count_matches( + connection, + server_key=server_key, + start=current_month_start, + end=anchor, + ) + previous_month_count = _count_matches( + connection, + server_key=server_key, + start=previous_month_start, + end=current_month_start, + ) + + if timeframe == "monthly": + use_previous_month = anchor.day <= 7 + start = previous_month_start if use_previous_month else current_month_start + end = current_month_start if use_previous_month else anchor + return { + "start": start, + "end": end, + "days": max(1, (end.date() - start.date()).days), + "kind": "previous-month" if use_previous_month else "current-month", + "label": "Mes anterior" if use_previous_month else "Mes actual", + "selection_reason": ( + "monthly-uses-previous-month-until-day-8" + if use_previous_month + else "monthly-uses-current-month-after-day-7" + ), + "current_week_start": current_week_start, + "current_week_closed_matches": current_week_count, + "previous_week_closed_matches": previous_week_count, + "current_month_start": current_month_start, + "selected_month_start": start, + "selected_month_end": end, + "current_month_closed_matches": current_month_count, + "previous_month_closed_matches": previous_month_count, + "sufficient_sample": { + "minimum_closed_matches": 1, + "current_month_closed_matches": current_month_count, + "previous_month_closed_matches": previous_month_count, + "current_month_has_sufficient_sample": current_month_count >= 1, + "uses_previous_month_until_day": 7, + }, + } + + current_week_has_sample = current_week_count >= minimum_week_matches + start = current_week_start if current_week_has_sample else previous_week_start + end = anchor if current_week_has_sample else current_week_start + return { + "start": start, + "end": end, + "days": max(1, (end.date() - start.date()).days), + "kind": "current-week" if current_week_has_sample else "previous-week", + "label": "Semana actual" if current_week_has_sample else "Semana anterior", + "selection_reason": ( + "weekly-current-week-has-sufficient-closed-matches" + if current_week_has_sample + else "weekly-fallback-previous-week-insufficient-current-week-data" + ), + "current_week_start": current_week_start, + "current_week_closed_matches": current_week_count, + "previous_week_closed_matches": previous_week_count, + "current_month_start": current_month_start, + "selected_month_start": current_month_start, + "selected_month_end": anchor, + "current_month_closed_matches": current_month_count, + "previous_month_closed_matches": previous_month_count, + "sufficient_sample": { + "minimum_closed_matches": minimum_week_matches, + "current_week_closed_matches": current_week_count, + "current_week_has_sufficient_sample": current_week_has_sample, + "previous_week_closed_matches": previous_week_count, + }, + } + + +def _fetch_source_range( + connection: object, + *, + server_key: str | None, + window_start: datetime, + window_end: datetime, +) -> tuple[datetime | None, datetime | None]: + scope_sql, scope_params = _build_scope_sql(server_key, table_alias="matches") + row = connection.execute( + f""" + SELECT + MIN(COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT))) AS source_range_start, + MAX(COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT))) AS source_range_end + FROM rcon_materialized_matches AS matches + WHERE matches.source_basis = ? + AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ? + AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) <= ? + {scope_sql} + """, + [MATCH_RESULT_SOURCE, _to_iso(window_start), _to_iso(window_end), *scope_params], + ).fetchone() + if not row: + return None, None + return _parse_datetime(row["source_range_start"]), _parse_datetime(row["source_range_end"]) + + +def _count_matches( + connection: object, + *, + server_key: str | None, + start: datetime, + end: datetime, +) -> int: + scope_sql, scope_params = _build_scope_sql(server_key, table_alias="matches") + row = connection.execute( + f""" + SELECT COUNT(*) AS count + FROM rcon_materialized_matches AS matches + WHERE matches.source_basis = ? + AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ? + AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) < ? + {scope_sql} + """, + [MATCH_RESULT_SOURCE, _to_iso(start), _to_iso(end), *scope_params], + ).fetchone() + return int(row["count"] or 0) if row else 0 + + +def _build_item(row: dict[str, object], *, index: int) -> dict[str, object]: + kills = _coerce_int(row.get("kills")) + deaths = _coerce_int(row.get("deaths")) + return { + "ranking_position": index, + "player": { + "id": row.get("player_id"), + "name": row.get("player_name"), + }, + "player_id": row.get("player_id"), + "player_name": row.get("player_name"), + "metric_value": _coerce_int(row.get("metric_value")), + "matches_considered": _coerce_int(row.get("matches_considered")), + "kills": kills, + "deaths": deaths, + "teamkills": _coerce_int(row.get("teamkills")), + "kd_ratio": round(kills / deaths, 2) if deaths else float(kills), + } + + +def _build_scope_sql( + server_key: str | None, + *, + table_alias: str = "matches", +) -> tuple[str, list[object]]: + if not server_key or server_key == ALL_SERVERS_SLUG: + return "", [] + return f"AND ({table_alias}.target_key = ? OR {table_alias}.external_server_id = ?)", [ + server_key, + server_key, + ] + + +def _connect_scope(resolved_path: Path, *, db_path: Path | None): + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import connect_postgres_compat + + return connect_postgres_compat() + return closing(connect_sqlite_readonly(resolved_path)) + + +def _empty_payload( + *, + server_key: str | None, + timeframe: str, + metric: str, + limit: int, + window: dict[str, object], + reason: str, +) -> dict[str, object]: + return { + "source": "rcon-materialized-admin-log-leaderboard", + "server_key": server_key, + "metric": metric, + "limit": limit, + "window_days": window["days"], + "window_start": _to_iso(window["start"]), + "window_end": _to_iso(window["end"]), + "window_kind": window["kind"], + "window_label": window["label"], + "uses_fallback": False, + "selection_reason": reason, + "current_week_start": _to_iso(window["current_week_start"]), + "current_week_closed_matches": window["current_week_closed_matches"], + "previous_week_closed_matches": window["previous_week_closed_matches"], + "current_month_start": _to_iso(window["current_month_start"]), + "selected_month_start": _to_iso(window["selected_month_start"]), + "selected_month_end": _to_iso(window["selected_month_end"]), + "current_month_closed_matches": window["current_month_closed_matches"], + "previous_month_closed_matches": window["previous_month_closed_matches"], + "sufficient_sample": window["sufficient_sample"], + "source_range_start": None, + "source_range_end": None, + "items": [], + } + + +def _build_window(timeframe: str) -> dict[str, object]: + now = datetime.now(timezone.utc) + if timeframe == "monthly": + start = _month_start(now) + return { + "start": start, + "end": now, + "days": max(1, (now.date() - start.date()).days + 1), + "kind": "current-month", + "label": "Mes actual", + } + start = _week_start(now) + return { + "start": start, + "end": now, + "days": max(1, (now.date() - start.date()).days + 1), + "kind": "current-week", + "label": "Semana actual", + } + + +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _week_start(value: datetime) -> datetime: + point = value.astimezone(timezone.utc) + start = point - timedelta(days=point.weekday()) + return start.replace(hour=0, minute=0, second=0, microsecond=0) + + +def _month_start(value: datetime) -> datetime: + point = value.astimezone(timezone.utc) + return point.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + +def _previous_month_start(current_month_start: datetime) -> datetime: + previous_month_end = current_month_start - timedelta(days=1) + return _month_start(previous_month_end) + + +def _normalize_timeframe(value: str) -> LeaderboardTimeframe: + return "monthly" if str(value or "").strip().lower() == "monthly" else "weekly" + + +def _normalize_metric(value: str) -> LeaderboardMetric: + normalized = str(value or "kills").strip().lower() + if normalized in {"kills", "deaths", "matches_over_100_kills", "support"}: + return normalized # type: ignore[return-value] + return "kills" + + +def _build_title(*, metric: str, timeframe: str, server_id: str | None) -> str: + timeframe_label = "mensual" if timeframe == "monthly" else "semanal" + scope = "totales" if server_id == ALL_SERVERS_SLUG else "por servidor" + metric_label = { + "kills": "Top kills", + "deaths": "Top muertes", + "matches_over_100_kills": "Partidas 100+ kills", + "support": "Top soporte", + }.get(metric, "Top kills") + return f"Snapshot {metric_label} {timeframe_label} {scope}" + + +def _coerce_int(value: object) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def _parse_datetime(value: object) -> datetime | None: + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str) and value.strip(): + try: + parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError: + return None + else: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _to_iso(value: object) -> str: + parsed = _parse_datetime(value) + if parsed is None: + parsed = datetime.now(timezone.utc) + return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/backend/app/rcon_historical_read_model.py b/backend/app/rcon_historical_read_model.py new file mode 100644 index 0000000..9434ba7 --- /dev/null +++ b/backend/app/rcon_historical_read_model.py @@ -0,0 +1,627 @@ +"""Read-only minimal HTTP model over prospective RCON historical persistence.""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone + +from .historical_storage import ALL_SERVERS_SLUG +from .normalizers import normalize_map_name +from .player_external_profiles import build_external_player_profile_fields +from .rcon_scoreboard_correlation import resolve_rcon_scoreboard_match_url +from .rcon_historical_storage import ( + find_rcon_historical_competitive_window, + get_rcon_historical_competitive_window_by_session, + list_rcon_historical_competitive_summary_rows, + list_rcon_historical_competitive_windows, +) + +MATCH_RESULT_SOURCE = "admin-log-match-ended" +SESSION_RESULT_SOURCE = "rcon-session" + + +def list_rcon_historical_server_summaries( + *, + server_key: str | None = None, +) -> list[dict[str, object]]: + """Return per-target coverage and freshness from RCON-backed competitive storage.""" + items = list_rcon_historical_competitive_summary_rows() + if server_key and server_key != ALL_SERVERS_SLUG: + normalized = server_key.strip() + items = [ + item + for item in items + if item["target_key"] == normalized or item["external_server_id"] == normalized + ] + + summaries = [_build_server_summary(item) for item in items] + if server_key == ALL_SERVERS_SLUG: + return [_build_all_servers_summary(summaries)] + return summaries + + +def list_rcon_historical_recent_activity( + *, + server_key: str | None = None, + limit: int = 20, +) -> list[dict[str, object]]: + """Return recent RCON-backed competitive windows for one or all targets.""" + from .rcon_admin_log_materialization import list_materialized_rcon_matches + + normalized_server_key = None if server_key == ALL_SERVERS_SLUG else server_key + materialized_items = list_materialized_rcon_matches( + target_key=normalized_server_key, + only_ended=True, + limit=limit, + ) + primary_items = [_build_materialized_recent_item(item) for item in materialized_items] + if primary_items: + return primary_items[:limit] + + session_items = list_rcon_historical_competitive_windows( + target_key=normalized_server_key, + limit=limit, + ) + fallback_items = [ + { + "server": { + "slug": item["target_key"], + "name": item["display_name"], + "external_server_id": item["external_server_id"], + "region": item["region"], + }, + "match_id": item["session_key"], + "internal_detail_match_id": item["session_key"], + "started_at": item["first_seen_at"], + "ended_at": item["last_seen_at"], + "closed_at": item["last_seen_at"], + "map": { + "name": item.get("map_name"), + "pretty_name": normalize_map_name(item.get("map_pretty_name") or item.get("map_name")), + }, + "result": _build_rcon_result(item.get("latest_payload")), + "gamestate": _build_rcon_gamestate(item.get("latest_payload")), + "player_count": int(round(float(item.get("average_players") or 0))), + "peak_players": item.get("peak_players"), + "sample_count": item.get("sample_count"), + "duration_seconds": item.get("duration_seconds"), + "capture_basis": "rcon-competitive-window", + "result_source": SESSION_RESULT_SOURCE, + "capabilities": item.get("capabilities"), + "minutes_since_capture": _minutes_since_timestamp(item.get("last_seen_at")), + } + for item in session_items + ] + return _merge_recent_items(primary_items, fallback_items, limit=limit) + + +def describe_rcon_historical_read_model() -> dict[str, object]: + """Describe what the minimal RCON historical read model currently supports.""" + return { + "source": "rcon-historical-competitive-read-model", + "supported_endpoints": [ + "/api/historical/server-summary", + "/api/historical/recent-matches", + ], + "unsupported_endpoints": [ + "/api/historical/weekly-top-kills", + "/api/historical/weekly-leaderboard", + "/api/historical/leaderboard", + "/api/historical/monthly-mvp", + "/api/historical/monthly-mvp-v2", + "/api/historical/elo-mmr/leaderboard", + "/api/historical/elo-mmr/player", + "/api/historical/player-events", + "/api/historical/player-profile", + "/api/historical/snapshots/*", + ], + "capabilities": { + "server_summary": "exact", + "recent_matches": "exact-when-admin-log-match-ended", + "competitive_quality": "partial", + "result": "admin-log-match-ended", + "gamestate": "session-fallback", + "player_stats": "admin-log-derived", + }, + "limitations": [ + "No retroactive backfill of closed matches.", + "No weekly or monthly competitive leaderboards.", + "No MVP or player-event parity with public-scoreboard.", + "No player-level scoreboard parity from RCON samples alone.", + ], + } + + +def get_rcon_historical_competitive_match_context( + *, + server_key: str, + ended_at: str | None, + map_name: str | None = None, +) -> dict[str, object] | None: + """Return the closest RCON-backed competitive context for one historical match.""" + return find_rcon_historical_competitive_window( + server_key=server_key, + ended_at=ended_at, + map_name=map_name, + ) + + +def get_rcon_historical_match_detail( + *, + server_key: str, + match_id: str, +) -> dict[str, object] | None: + """Return one RCON competitive window as a match-detail compatible payload.""" + from .rcon_admin_log_materialization import get_materialized_rcon_match_detail + + materialized = get_materialized_rcon_match_detail(server_key=server_key, match_key=match_id) + if materialized is not None: + return _build_materialized_detail_item(materialized) + + item = get_rcon_historical_competitive_window_by_session( + server_key=server_key, + session_key=match_id, + ) + if item is None: + return None + player_count = int(round(float(item.get("average_players") or 0))) + server_slug = item["external_server_id"] or item["target_key"] + return { + "server": { + "slug": item["target_key"], + "name": item["display_name"], + "external_server_id": item["external_server_id"], + "region": item["region"], + }, + "match_id": item["session_key"], + "started_at": item["first_seen_at"], + "ended_at": item["last_seen_at"], + "closed_at": item["last_seen_at"], + "duration_seconds": item.get("duration_seconds"), + "map": { + "name": item.get("map_name"), + "pretty_name": normalize_map_name(item.get("map_pretty_name") or item.get("map_name")), + }, + "result": _build_rcon_result(item.get("latest_payload")), + "gamestate": _build_rcon_gamestate(item.get("latest_payload")), + "player_count": int(round(float(item.get("average_players") or 0))), + "peak_players": item.get("peak_players"), + "sample_count": item.get("sample_count"), + "players": [], + "capture_basis": "rcon-competitive-window", + "confidence": item.get("confidence_mode"), + "source_basis": "rcon-session", + "result_source": SESSION_RESULT_SOURCE, + "capabilities": item.get("capabilities"), + "match_url": resolve_rcon_scoreboard_match_url( + server_slug=server_slug, + map_name=item.get("map_pretty_name") or item.get("map_name"), + started_at=item["first_seen_at"], + ended_at=item["last_seen_at"], + duration_seconds=item.get("duration_seconds"), + player_count=player_count, + peak_players=item.get("peak_players"), + ), + } + + +def _build_materialized_recent_item(item: dict[str, object]) -> dict[str, object]: + timestamps = _build_materialized_timestamp_payload(item) + player_count = _resolve_materialized_player_count(item) + scoreboard_correlation = build_materialized_scoreboard_correlation_input(item) + return { + "server": { + "slug": item.get("target_key"), + "name": _server_display_name(item.get("external_server_id") or item.get("target_key")), + "external_server_id": item.get("external_server_id"), + "region": None, + }, + "match_id": item.get("match_key"), + "internal_detail_match_id": item.get("match_key"), + "started_at": timestamps["started_at"], + "ended_at": timestamps["ended_at"], + "closed_at": timestamps["closed_at"], + "timestamp_confidence": timestamps["timestamp_confidence"], + "map": { + "name": item.get("map_name"), + "pretty_name": item.get("map_pretty_name") or normalize_map_name(item.get("map_name")), + }, + "game_mode": item.get("game_mode"), + "result": { + "allied_score": item.get("allied_score"), + "axis_score": item.get("axis_score"), + "winner": item.get("winner"), + }, + "winner": item.get("winner"), + "player_count": player_count, + "peak_players": None, + "sample_count": None, + "duration_seconds": _calculate_match_duration_seconds(item), + "capture_basis": "rcon-materialized-admin-log", + "confidence": item.get("confidence_mode"), + "source_basis": item.get("source_basis"), + "result_source": ( + MATCH_RESULT_SOURCE + if item.get("source_basis") == MATCH_RESULT_SOURCE + else SESSION_RESULT_SOURCE + ), + "match_url": resolve_rcon_scoreboard_match_url( + **scoreboard_correlation, + ), + "capabilities": describe_rcon_historical_read_model()["capabilities"], + } + + +def _build_materialized_detail_item(materialized: dict[str, object]) -> dict[str, object]: + from .rcon_admin_log_storage import get_latest_rcon_player_profile_summaries + + match = materialized["match"] + recent_item = _build_materialized_recent_item(match) + profile_summaries = get_latest_rcon_player_profile_summaries( + target_key=str(match["target_key"]), + player_ids=[str(row["player_id"]) for row in materialized["players"] if row.get("player_id")], + ) + players = [ + _build_player_row( + row, + profile_summary=profile_summaries.get(str(row.get("player_id"))), + ) + for row in materialized["players"] + ] + player_count = len(players) if players else recent_item.get("player_count") + return { + **recent_item, + "match_id": match["match_key"], + "game_mode": match.get("game_mode"), + "winner": match.get("winner"), + "confidence": match.get("confidence_mode"), + "source_basis": match.get("source_basis"), + "player_count": player_count, + "players": players, + "timeline": { + "event_counts": materialized.get("timeline", []), + }, + } + + +def _resolve_materialized_player_count(item: dict[str, object]) -> int | None: + for key in ( + "player_count", + "materialized_player_count", + "materialized_distinct_player_count", + ): + value = _coerce_optional_int(item.get(key)) + if value is not None and value > 0: + return value + return None + + +def _build_player_row( + row: dict[str, object], + *, + profile_summary: dict[str, object] | None = None, +) -> dict[str, object]: + kills = _coerce_optional_int(row.get("kills")) or 0 + deaths = _coerce_optional_int(row.get("deaths")) or 0 + player = { + "player_name": row.get("player_name"), + "team": row.get("team"), + "kills": kills, + "deaths": deaths, + "teamkills": _coerce_optional_int(row.get("teamkills")) or 0, + "kd_ratio": round(kills / deaths, 2) if deaths else float(kills), + "top_weapons": _top_counter(row.get("weapons_json")), + "most_killed": _top_counter(row.get("most_killed_json")), + "death_by": _top_counter(row.get("death_by_json")), + **build_external_player_profile_fields(player_id=row.get("player_id")), + } + if profile_summary: + player["profile_summary"] = profile_summary + return player + + +def _top_counter(raw_value: object, *, limit: int = 5) -> list[dict[str, object]]: + if not isinstance(raw_value, str) or not raw_value.strip(): + return [] + try: + payload = json.loads(raw_value) + except (NameError, ValueError, TypeError): + return [] + if not isinstance(payload, dict): + return [] + rows = [ + {"name": str(name), "count": int(count)} + for name, count in payload.items() + if _coerce_optional_int(count) is not None + ] + rows.sort(key=lambda item: (-int(item["count"]), str(item["name"]))) + return rows[:limit] + + +def _build_materialized_timestamp_payload(item: dict[str, object]) -> dict[str, object]: + started_at = item.get("started_at") + ended_at = item.get("ended_at") + duration_seconds = _calculate_match_duration_seconds(item) + has_server_time_duration = bool(duration_seconds and duration_seconds > 0) + if started_at and ended_at and started_at == ended_at and has_server_time_duration: + return { + "started_at": None, + "ended_at": None, + "closed_at": ended_at, + "timestamp_confidence": "server-time-only", + } + return { + "started_at": started_at, + "ended_at": ended_at, + "closed_at": ended_at or started_at, + "timestamp_confidence": "absolute" if started_at or ended_at else "server-time-only", + } + + +def _build_materialized_scoreboard_correlation_window( + item: dict[str, object], + timestamps: dict[str, object], +) -> dict[str, object]: + started_at = timestamps.get("started_at") + ended_at = timestamps.get("ended_at") + if started_at and ended_at: + return {"started_at": started_at, "ended_at": ended_at} + + closed_at = timestamps.get("closed_at") or item.get("ended_at") or item.get("started_at") + duration_seconds = _calculate_match_duration_seconds(item) + closed_point = _parse_datetime(closed_at) + if closed_point is None or not duration_seconds: + return {"started_at": started_at, "ended_at": ended_at} + + started_point = closed_point - timedelta(seconds=int(duration_seconds)) + return { + "started_at": started_point.isoformat().replace("+00:00", "Z"), + "ended_at": closed_point.isoformat().replace("+00:00", "Z"), + } + + +def build_materialized_scoreboard_correlation_input( + item: dict[str, object], +) -> dict[str, object]: + """Build safe candidate correlation inputs for one materialized RCON match.""" + timestamps = _build_materialized_timestamp_payload(item) + correlation_window = _build_materialized_scoreboard_correlation_window(item, timestamps) + return { + "server_slug": item.get("external_server_id") or item.get("target_key"), + "map_name": item.get("map_pretty_name") or item.get("map_name"), + "started_at": correlation_window["started_at"], + "ended_at": correlation_window["ended_at"], + "duration_seconds": _calculate_match_duration_seconds(item), + "allied_score": item.get("allied_score"), + "axis_score": item.get("axis_score"), + } + + +def _merge_recent_items( + primary_items: list[dict[str, object]], + fallback_items: list[dict[str, object]], + *, + limit: int, +) -> list[dict[str, object]]: + merged: list[dict[str, object]] = [] + seen: set[tuple[object, object]] = set() + for item in primary_items + fallback_items: + map_payload = item.get("map") if isinstance(item.get("map"), dict) else {} + key = ( + item.get("server", {}).get("slug") if isinstance(item.get("server"), dict) else None, + normalize_map_name(map_payload.get("pretty_name") or map_payload.get("name")), + ) + if key in seen: + continue + seen.add(key) + merged.append(item) + merged.sort(key=lambda row: str(row.get("closed_at") or row.get("ended_at") or row.get("started_at") or ""), reverse=True) + return merged[:limit] + + +def _server_display_name(server_slug: object) -> str: + slug = str(server_slug or "").strip() + if slug == "comunidad-hispana-01": + return "Comunidad Hispana #01" + if slug == "comunidad-hispana-02": + return "Comunidad Hispana #02" + return slug or "RCON" + + +def _build_rcon_result(latest_payload: object) -> dict[str, object]: + payload = latest_payload if isinstance(latest_payload, dict) else {} + allied_score = _coerce_optional_int(payload.get("allied_score")) + axis_score = _coerce_optional_int(payload.get("axis_score")) + winner = payload.get("winner") + if not isinstance(winner, str) or not winner: + winner = _resolve_result_winner(allied_score, axis_score) + return { + "allied_score": allied_score, + "axis_score": axis_score, + "winner": winner, + } + + +def _build_rcon_gamestate(latest_payload: object) -> dict[str, object]: + payload = latest_payload if isinstance(latest_payload, dict) else {} + return { + "game_mode": payload.get("game_mode"), + "allied_faction": payload.get("allied_faction"), + "axis_faction": payload.get("axis_faction"), + "allied_players": _coerce_optional_int(payload.get("allied_players")), + "axis_players": _coerce_optional_int(payload.get("axis_players")), + "remaining_match_time_seconds": _coerce_optional_int( + payload.get("remaining_match_time_seconds") + ), + "match_time_seconds": _coerce_optional_int(payload.get("match_time_seconds")), + "queue_count": _coerce_optional_int(payload.get("queue_count")), + "max_queue_count": _coerce_optional_int(payload.get("max_queue_count")), + "vip_queue_count": _coerce_optional_int(payload.get("vip_queue_count")), + "max_vip_queue_count": _coerce_optional_int(payload.get("max_vip_queue_count")), + } + + +def _resolve_result_winner(allied_score: int | None, axis_score: int | None) -> str | None: + if allied_score is None or axis_score is None: + return None + if allied_score > axis_score: + return "allied" + if axis_score > allied_score: + return "axis" + return "draw" + + +def _coerce_optional_int(value: object) -> int | None: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _build_server_summary(item: dict[str, object]) -> dict[str, object]: + sample_count = int(item.get("sample_count") or 0) + first_last_points = list_rcon_historical_recent_activity( + server_key=str(item["target_key"]), + limit=1, + ) + last_sample_at = item.get("last_seen_at") + latest_activity = first_last_points[0] if first_last_points else None + + return { + "server": { + "slug": item["target_key"], + "name": item["display_name"], + "external_server_id": item["external_server_id"], + "region": item["region"], + }, + "coverage": { + "basis": "rcon-competitive-windows", + "status": "available" if int(item.get("window_count") or 0) > 0 else "empty", + "window_count": int(item.get("window_count") or 0), + "sample_count": sample_count, + "first_sample_at": item.get("first_seen_at"), + "last_sample_at": last_sample_at, + "coverage_hours": _calculate_coverage_hours(item.get("first_seen_at"), last_sample_at), + }, + "freshness": { + "last_successful_capture_at": item.get("last_successful_capture_at"), + "minutes_since_last_capture": _minutes_since_timestamp(last_sample_at), + "last_run_status": item.get("last_run_status"), + "last_error": item.get("last_error"), + "last_error_at": item.get("last_error_at"), + }, + "activity": { + "latest_players": latest_activity.get("player_count") if latest_activity else None, + "latest_peak_players": latest_activity.get("peak_players") if latest_activity else None, + "latest_map": latest_activity.get("map", {}).get("pretty_name") if latest_activity else None, + "latest_status": "captured" if latest_activity else None, + }, + "time_range": { + "start": item.get("first_seen_at"), + "end": last_sample_at, + }, + "capabilities": describe_rcon_historical_read_model()["capabilities"], + } + + +def _build_all_servers_summary(items: list[dict[str, object]]) -> dict[str, object]: + total_samples = sum(int(item["coverage"].get("sample_count") or 0) for item in items) + last_points = [ + item["time_range"].get("end") + for item in items + if item["time_range"].get("end") + ] + last_capture_at = max(last_points) if last_points else None + return { + "server": { + "slug": ALL_SERVERS_SLUG, + "name": "Todos", + "external_server_id": None, + "region": None, + }, + "coverage": { + "basis": "rcon-competitive-windows-aggregate", + "status": "available" if total_samples > 0 else "empty", + "sample_count": total_samples, + "first_sample_at": None, + "last_sample_at": last_capture_at, + "coverage_hours": None, + }, + "freshness": { + "last_successful_capture_at": last_capture_at, + "minutes_since_last_capture": _minutes_since_timestamp(last_capture_at), + "last_run_status": None, + "last_error": None, + "last_error_at": None, + }, + "activity": { + "latest_players": None, + "latest_max_players": None, + "latest_map": None, + "latest_status": None, + }, + "time_range": { + "start": None, + "end": last_capture_at, + }, + "server_count": len(items), + "capabilities": describe_rcon_historical_read_model()["capabilities"], + } + + +def _minutes_since_timestamp(timestamp: str | None) -> int | None: + if not timestamp: + return None + captured_at = _parse_datetime(timestamp) + if captured_at is None: + return None + delta = datetime.now(timezone.utc) - captured_at.astimezone(timezone.utc) + return max(0, int(delta.total_seconds() // 60)) + + +def _parse_datetime(value: object) -> datetime | None: + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str) and value.strip(): + try: + parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError: + return None + else: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _calculate_coverage_hours( + first_sample_at: object, + last_sample_at: object, +) -> float | None: + first_point = _parse_datetime(first_sample_at) + last_point = _parse_datetime(last_sample_at) + if first_point is None or last_point is None: + return None + delta = last_point - first_point + return round(delta.total_seconds() / 3600, 2) + + +def _calculate_duration_seconds(first_seen_at: object, last_seen_at: object) -> int | None: + first_point = _parse_datetime(first_seen_at) + last_point = _parse_datetime(last_seen_at) + if first_point is None or last_point is None: + return None + return max(0, int((last_point - first_point).total_seconds())) + + +def _calculate_match_duration_seconds(item: dict[str, object]) -> int | None: + duration = _calculate_duration_seconds(item.get("started_at"), item.get("ended_at")) + if duration: + return duration + started_server_time = _coerce_optional_int(item.get("started_server_time")) + ended_server_time = _coerce_optional_int(item.get("ended_server_time")) + if started_server_time is None or ended_server_time is None: + return duration + return max(0, ended_server_time - started_server_time) diff --git a/backend/app/rcon_historical_storage.py b/backend/app/rcon_historical_storage.py new file mode 100644 index 0000000..30d75b4 --- /dev/null +++ b/backend/app/rcon_historical_storage.py @@ -0,0 +1,1109 @@ +"""Separate storage and run tracking for prospective RCON historical capture.""" + +from __future__ import annotations + +import json +import sqlite3 +from collections.abc import Mapping +from datetime import datetime, timezone +from pathlib import Path + +from .config import get_storage_path, use_postgres_rcon_storage +from .normalizers import normalize_map_name +from .rcon_client import load_rcon_targets +from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer + + +COMPETITIVE_WINDOW_GAP_SECONDS = 1800 +COMPETITIVE_MODE_PARTIAL = "partial" +COMPETITIVE_MODE_APPROXIMATE = "approximate" +COMPETITIVE_MODE_EXACT = "exact" + + +def initialize_rcon_historical_storage(*, db_path: Path | None = None) -> Path: + """Create the SQLite structures used by prospective RCON capture.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import initialize_postgres_rcon_storage + + initialize_postgres_rcon_storage() + return get_storage_path() + + 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 rcon_historical_targets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_key TEXT NOT NULL UNIQUE, + external_server_id TEXT, + display_name TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER NOT NULL, + region TEXT, + game_port INTEGER, + query_port INTEGER, + source_name TEXT NOT NULL, + last_configured_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS rcon_historical_capture_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mode TEXT NOT NULL, + status TEXT NOT NULL, + target_scope TEXT, + started_at TEXT NOT NULL, + completed_at TEXT, + targets_seen INTEGER NOT NULL DEFAULT 0, + samples_inserted INTEGER NOT NULL DEFAULT 0, + duplicate_samples INTEGER NOT NULL DEFAULT 0, + failed_targets INTEGER NOT NULL DEFAULT 0, + notes TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS rcon_historical_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_id INTEGER NOT NULL, + capture_run_id INTEGER, + captured_at TEXT NOT NULL, + source_kind TEXT NOT NULL, + status TEXT NOT NULL, + players INTEGER, + max_players INTEGER, + current_map TEXT, + normalized_payload_json TEXT NOT NULL, + raw_payload_json TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(target_id, captured_at), + FOREIGN KEY (target_id) REFERENCES rcon_historical_targets(id), + FOREIGN KEY (capture_run_id) REFERENCES rcon_historical_capture_runs(id) + ); + + CREATE TABLE IF NOT EXISTS rcon_historical_checkpoints ( + target_id INTEGER PRIMARY KEY, + last_successful_capture_at TEXT, + last_sample_at TEXT, + last_run_id INTEGER, + last_run_status TEXT, + last_error TEXT, + last_error_at TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (target_id) REFERENCES rcon_historical_targets(id), + FOREIGN KEY (last_run_id) REFERENCES rcon_historical_capture_runs(id) + ); + + CREATE INDEX IF NOT EXISTS idx_rcon_historical_samples_target_time + ON rcon_historical_samples(target_id, captured_at DESC); + + CREATE TABLE IF NOT EXISTS rcon_historical_competitive_windows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_id INTEGER NOT NULL, + session_key TEXT NOT NULL UNIQUE, + source_kind TEXT NOT NULL, + map_name TEXT, + map_pretty_name TEXT, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + sample_count INTEGER NOT NULL DEFAULT 0, + total_players INTEGER NOT NULL DEFAULT 0, + peak_players INTEGER NOT NULL DEFAULT 0, + last_players INTEGER, + max_players INTEGER, + status TEXT NOT NULL, + confidence_mode TEXT NOT NULL, + capabilities_json TEXT NOT NULL, + latest_payload_json TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (target_id) REFERENCES rcon_historical_targets(id) + ); + + CREATE INDEX IF NOT EXISTS idx_rcon_historical_windows_target_time + ON rcon_historical_competitive_windows(target_id, last_seen_at DESC); + """ + ) + + return resolved_path + + +def start_rcon_historical_capture_run( + *, + mode: str, + target_scope: str, + db_path: Path | None = None, +) -> int: + """Create one run row for prospective RCON capture.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import start_capture_run + + return start_capture_run(mode=mode, target_scope=target_scope) + + resolved_path = initialize_rcon_historical_storage(db_path=db_path) + with _connect(resolved_path) as connection: + cursor = connection.execute( + """ + INSERT INTO rcon_historical_capture_runs ( + mode, + status, + target_scope, + started_at + ) VALUES (?, 'running', ?, ?) + """, + (mode, target_scope, _utc_now_iso()), + ) + return int(cursor.lastrowid) + + +def finalize_rcon_historical_capture_run( + run_id: int, + *, + status: str, + targets_seen: int, + samples_inserted: int, + duplicate_samples: int, + failed_targets: int, + notes: str | None = None, + db_path: Path | None = None, +) -> None: + """Finalize one prospective RCON capture run.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import finalize_capture_run + + finalize_capture_run( + run_id, + status=status, + targets_seen=targets_seen, + samples_inserted=samples_inserted, + duplicate_samples=duplicate_samples, + failed_targets=failed_targets, + notes=notes, + ) + return + + resolved_path = initialize_rcon_historical_storage(db_path=db_path) + with _connect(resolved_path) as connection: + connection.execute( + """ + UPDATE rcon_historical_capture_runs + SET status = ?, + completed_at = ?, + targets_seen = ?, + samples_inserted = ?, + duplicate_samples = ?, + failed_targets = ?, + notes = ? + WHERE id = ? + """, + ( + status, + _utc_now_iso(), + targets_seen, + samples_inserted, + duplicate_samples, + failed_targets, + notes, + run_id, + ), + ) + + +def persist_rcon_historical_sample( + *, + run_id: int, + captured_at: str, + target: Mapping[str, object], + normalized_payload: Mapping[str, object], + raw_payload: Mapping[str, object] | None, + db_path: Path | None = None, +) -> dict[str, int]: + """Persist one prospective RCON sample and refresh its checkpoint.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import persist_sample + + return persist_sample( + run_id=run_id, + captured_at=captured_at, + target=target, + normalized_payload=normalized_payload, + raw_payload=raw_payload, + ) + + resolved_path = initialize_rcon_historical_storage(db_path=db_path) + with _connect(resolved_path) as connection: + target_id = _upsert_target(connection, target=target) + cursor = connection.execute( + """ + INSERT OR IGNORE INTO rcon_historical_samples ( + target_id, + capture_run_id, + captured_at, + source_kind, + status, + players, + max_players, + current_map, + normalized_payload_json, + raw_payload_json + ) VALUES (?, ?, ?, 'rcon-live-sample', ?, ?, ?, ?, ?, ?) + """, + ( + target_id, + run_id, + captured_at, + normalized_payload.get("status") or "unknown", + normalized_payload.get("players"), + normalized_payload.get("max_players"), + normalized_payload.get("current_map"), + json.dumps(dict(normalized_payload), separators=(",", ":")), + json.dumps(dict(raw_payload), separators=(",", ":")) if raw_payload else None, + ), + ) + inserted = int(cursor.rowcount or 0) + _upsert_checkpoint_success( + connection, + target_id=target_id, + run_id=run_id, + captured_at=captured_at, + ) + if inserted: + _upsert_competitive_window( + connection, + target_id=target_id, + captured_at=captured_at, + normalized_payload=normalized_payload, + ) + return { + "samples_inserted": inserted, + "duplicate_samples": 0 if inserted else 1, + } + + +def mark_rcon_historical_capture_failure( + *, + run_id: int, + target: Mapping[str, object], + error_message: str, + db_path: Path | None = None, +) -> None: + """Persist failure metadata for one target inside a capture run.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import mark_capture_failure + + mark_capture_failure(run_id=run_id, target=target, error_message=error_message) + return + + resolved_path = initialize_rcon_historical_storage(db_path=db_path) + with _connect(resolved_path) as connection: + target_id = _upsert_target(connection, target=target) + connection.execute( + """ + INSERT INTO rcon_historical_checkpoints ( + target_id, + last_run_id, + last_run_status, + last_error, + last_error_at + ) VALUES (?, ?, 'failed', ?, ?) + ON CONFLICT(target_id) DO UPDATE SET + last_run_id = excluded.last_run_id, + last_run_status = excluded.last_run_status, + last_error = excluded.last_error, + last_error_at = excluded.last_error_at, + updated_at = CURRENT_TIMESTAMP + """, + (target_id, run_id, error_message, _utc_now_iso()), + ) + + +def list_rcon_historical_target_statuses( + *, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """Return per-target coverage and freshness for prospective RCON capture.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import list_target_statuses + + return list_target_statuses() + + resolved_path = _resolve_db_path(db_path) + try: + with _connect_readonly(resolved_path) as connection: + rows = connection.execute( + """ + SELECT + targets.target_key, + targets.external_server_id, + targets.display_name, + targets.host, + targets.port, + targets.region, + targets.source_name, + checkpoints.last_successful_capture_at, + checkpoints.last_sample_at, + checkpoints.last_run_id, + checkpoints.last_run_status, + checkpoints.last_error, + checkpoints.last_error_at, + ( + SELECT MIN(samples.captured_at) + FROM rcon_historical_samples AS samples + WHERE samples.target_id = targets.id + ) AS first_sample_at, + ( + SELECT MAX(samples.captured_at) + FROM rcon_historical_samples AS samples + WHERE samples.target_id = targets.id + ) AS latest_sample_at, + ( + SELECT COUNT(*) + FROM rcon_historical_samples AS samples + WHERE samples.target_id = targets.id + ) AS sample_count + FROM rcon_historical_targets AS targets + LEFT JOIN rcon_historical_checkpoints AS checkpoints + ON checkpoints.target_id = targets.id + ORDER BY targets.display_name ASC, targets.target_key ASC + """ + ).fetchall() + except sqlite3.OperationalError: + return [] + return [ + { + "target_key": row["target_key"], + "external_server_id": row["external_server_id"], + "display_name": row["display_name"], + "host": row["host"], + "port": row["port"], + "region": row["region"], + "source_name": row["source_name"], + "sample_count": int(row["sample_count"] or 0), + "first_sample_at": row["first_sample_at"], + "last_successful_capture_at": row["last_successful_capture_at"], + "last_sample_at": row["latest_sample_at"] or row["last_sample_at"], + "last_run_id": row["last_run_id"], + "last_run_status": row["last_run_status"], + "last_error": row["last_error"], + "last_error_at": row["last_error_at"], + } + for row in rows + ] + + +def list_recent_rcon_historical_samples( + *, + target_key: str | None = None, + limit: int = 20, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """Return recent prospective RCON samples for one or all configured targets.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import list_recent_samples + + return list_recent_samples(target_key=target_key, limit=limit) + + resolved_path = _resolve_db_path(db_path) + where_clause = "" + params: list[object] = [limit] + if target_key: + aliases = _expand_target_key_aliases(target_key) + alias_placeholders = ", ".join("?" for _ in aliases) + where_clause = ( + "WHERE targets.target_key IN " + f"({alias_placeholders}) OR targets.external_server_id IN ({alias_placeholders})" + ) + params = [*aliases, *aliases, limit] + + try: + with _connect_readonly(resolved_path) as connection: + rows = connection.execute( + f""" + SELECT + targets.target_key, + targets.external_server_id, + targets.display_name, + targets.region, + samples.captured_at, + samples.status, + samples.players, + samples.max_players, + samples.current_map + FROM rcon_historical_samples AS samples + INNER JOIN rcon_historical_targets AS targets + ON targets.id = samples.target_id + {where_clause} + ORDER BY samples.captured_at DESC, targets.display_name ASC + LIMIT ? + """, + params, + ).fetchall() + except sqlite3.OperationalError: + return [] + return [ + { + "target_key": row["target_key"], + "external_server_id": row["external_server_id"], + "display_name": row["display_name"], + "region": row["region"], + "captured_at": row["captured_at"], + "status": row["status"], + "players": row["players"], + "max_players": row["max_players"], + "current_map": row["current_map"], + } + for row in rows + ] + + +def list_rcon_historical_competitive_windows( + *, + target_key: str | None = None, + limit: int = 20, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """Return recent RCON-backed competitive windows derived from persisted samples.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import list_competitive_windows + + return list_competitive_windows(target_key=target_key, limit=limit) + + resolved_path = _resolve_db_path(db_path) + where_clause = "" + params: list[object] = [limit] + if target_key: + aliases = _expand_target_key_aliases(target_key) + alias_placeholders = ", ".join("?" for _ in aliases) + where_clause = ( + "WHERE targets.target_key IN " + f"({alias_placeholders}) OR targets.external_server_id IN ({alias_placeholders})" + ) + params = [*aliases, *aliases, limit] + + try: + with _connect_readonly(resolved_path) as connection: + rows = connection.execute( + f""" + SELECT + targets.target_key, + targets.external_server_id, + targets.display_name, + targets.region, + windows.session_key, + windows.map_name, + windows.map_pretty_name, + windows.first_seen_at, + windows.last_seen_at, + windows.sample_count, + windows.total_players, + windows.peak_players, + windows.last_players, + windows.max_players, + windows.status, + windows.confidence_mode, + windows.capabilities_json, + windows.latest_payload_json + FROM rcon_historical_competitive_windows AS windows + INNER JOIN rcon_historical_targets AS targets + ON targets.id = windows.target_id + {where_clause} + ORDER BY windows.last_seen_at DESC, targets.display_name ASC + LIMIT ? + """, + params, + ).fetchall() + except sqlite3.OperationalError: + return [] + items: list[dict[str, object]] = [] + for row in rows: + sample_count = int(row["sample_count"] or 0) + average_players = round((int(row["total_players"] or 0) / sample_count), 2) if sample_count > 0 else 0.0 + items.append( + { + "target_key": row["target_key"], + "external_server_id": row["external_server_id"], + "display_name": row["display_name"], + "region": row["region"], + "session_key": row["session_key"], + "map_name": row["map_name"], + "map_pretty_name": row["map_pretty_name"] or row["map_name"], + "first_seen_at": row["first_seen_at"], + "last_seen_at": row["last_seen_at"], + "duration_seconds": _calculate_duration_seconds( + row["first_seen_at"], + row["last_seen_at"], + ), + "sample_count": sample_count, + "average_players": average_players, + "peak_players": int(row["peak_players"] or 0), + "last_players": row["last_players"], + "max_players": row["max_players"], + "status": row["status"], + "confidence_mode": row["confidence_mode"], + "capabilities": _deserialize_json_object(row["capabilities_json"]), + "latest_payload": _deserialize_json_object(row["latest_payload_json"]), + } + ) + return items + + +def count_rcon_historical_samples_since( + since: str | None, + *, + db_path: Path | None = None, +) -> int: + """Return how many RCON samples were captured after one timestamp.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import count_samples_since + + return count_samples_since(since) + + if not since: + return 0 + resolved_path = _resolve_db_path(db_path) + try: + with _connect_readonly(resolved_path) as connection: + row = connection.execute( + """ + SELECT COUNT(*) AS sample_count + FROM rcon_historical_samples + WHERE captured_at > ? + """, + (since,), + ).fetchone() + except sqlite3.OperationalError: + return 0 + return int(row["sample_count"] or 0) if row else 0 + + +def list_rcon_historical_competitive_summary_rows( + *, + target_key: str | None = None, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """Return RCON-backed per-target summary rows over competitive windows.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import list_competitive_summary_rows + + return list_competitive_summary_rows(target_key=target_key) + + resolved_path = _resolve_db_path(db_path) + where_clause = "" + params: list[object] = [] + if target_key: + aliases = _expand_target_key_aliases(target_key) + alias_placeholders = ", ".join("?" for _ in aliases) + where_clause = ( + "WHERE targets.target_key IN " + f"({alias_placeholders}) OR targets.external_server_id IN ({alias_placeholders})" + ) + params = [*aliases, *aliases] + + try: + with _connect_readonly(resolved_path) as connection: + rows = connection.execute( + f""" + SELECT + targets.target_key, + targets.external_server_id, + targets.display_name, + targets.region, + checkpoints.last_successful_capture_at, + checkpoints.last_run_status, + checkpoints.last_error, + checkpoints.last_error_at, + COUNT(windows.id) AS window_count, + COALESCE(SUM(windows.sample_count), 0) AS sample_count, + MIN(windows.first_seen_at) AS first_seen_at, + MAX(windows.last_seen_at) AS last_seen_at, + COALESCE(MAX(windows.peak_players), 0) AS peak_players + FROM rcon_historical_targets AS targets + LEFT JOIN rcon_historical_checkpoints AS checkpoints + ON checkpoints.target_id = targets.id + LEFT JOIN rcon_historical_competitive_windows AS windows + ON windows.target_id = targets.id + {where_clause} + GROUP BY targets.id + ORDER BY targets.display_name ASC, targets.target_key ASC + """, + params, + ).fetchall() + except sqlite3.OperationalError: + return [] + return [ + { + "target_key": row["target_key"], + "external_server_id": row["external_server_id"], + "display_name": row["display_name"], + "region": row["region"], + "window_count": int(row["window_count"] or 0), + "sample_count": int(row["sample_count"] or 0), + "first_seen_at": row["first_seen_at"], + "last_seen_at": row["last_seen_at"], + "peak_players": int(row["peak_players"] or 0), + "last_successful_capture_at": row["last_successful_capture_at"], + "last_run_status": row["last_run_status"], + "last_error": row["last_error"], + "last_error_at": row["last_error_at"], + } + for row in rows + ] + + +def find_rcon_historical_competitive_window( + *, + server_key: str, + ended_at: str | None, + map_name: str | None = None, + db_path: Path | None = None, +) -> dict[str, object] | None: + """Return the closest competitive window for one server/match if coverage exists.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import find_competitive_window + + return find_competitive_window( + server_key=server_key, + ended_at=ended_at, + map_name=map_name, + ) + + if not ended_at: + return None + resolved_path = _resolve_db_path(db_path) + normalized_map_name = normalize_map_name(map_name) + aliases = _expand_target_key_aliases(server_key) + alias_placeholders = ", ".join("?" for _ in aliases) + try: + with _connect_readonly(resolved_path) as connection: + candidates = connection.execute( + f""" + SELECT + windows.session_key, + windows.first_seen_at, + windows.last_seen_at, + windows.map_name, + windows.map_pretty_name, + windows.sample_count, + windows.total_players, + windows.peak_players, + windows.confidence_mode, + windows.capabilities_json, + windows.latest_payload_json + FROM rcon_historical_competitive_windows AS windows + INNER JOIN rcon_historical_targets AS targets + ON targets.id = windows.target_id + WHERE ( + targets.target_key IN ({alias_placeholders}) + OR targets.external_server_id IN ({alias_placeholders}) + ) + ORDER BY windows.last_seen_at DESC + LIMIT 12 + """, + [*aliases, *aliases], + ).fetchall() + except sqlite3.OperationalError: + return None + if not candidates: + return None + + ended_point = _parse_timestamp(ended_at) + best_row: sqlite3.Row | None = None + best_distance: float | None = None + for row in candidates: + row_map_name = normalize_map_name(row["map_pretty_name"] or row["map_name"]) + if normalized_map_name and row_map_name and normalized_map_name != row_map_name: + continue + row_last = _parse_timestamp(row["last_seen_at"]) + distance = abs((row_last - ended_point).total_seconds()) + if best_distance is None or distance < best_distance: + best_row = row + best_distance = distance + if best_row is None or best_distance is None or best_distance > 21600: + return None + sample_count = int(best_row["sample_count"] or 0) + return { + "session_key": best_row["session_key"], + "first_seen_at": best_row["first_seen_at"], + "last_seen_at": best_row["last_seen_at"], + "duration_seconds": _calculate_duration_seconds( + best_row["first_seen_at"], + best_row["last_seen_at"], + ), + "map_name": best_row["map_name"], + "map_pretty_name": best_row["map_pretty_name"] or best_row["map_name"], + "sample_count": sample_count, + "average_players": round((int(best_row["total_players"] or 0) / sample_count), 2) if sample_count > 0 else 0.0, + "peak_players": int(best_row["peak_players"] or 0), + "confidence_mode": best_row["confidence_mode"], + "capabilities": _deserialize_json_object(best_row["capabilities_json"]), + } + + +def get_rcon_historical_competitive_window_by_session( + *, + server_key: str, + session_key: str, + db_path: Path | None = None, +) -> dict[str, object] | None: + """Return one persisted competitive RCON window by its synthetic session key.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import get_competitive_window_by_session + + return get_competitive_window_by_session( + server_key=server_key, + session_key=session_key, + ) + + normalized_session_key = str(session_key or "").strip() + if not normalized_session_key: + return None + resolved_path = _resolve_db_path(db_path) + aliases = _expand_target_key_aliases(server_key) + alias_placeholders = ", ".join("?" for _ in aliases) + try: + with _connect_readonly(resolved_path) as connection: + row = connection.execute( + f""" + SELECT + targets.target_key, + targets.external_server_id, + targets.display_name, + targets.region, + windows.session_key, + windows.map_name, + windows.map_pretty_name, + windows.first_seen_at, + windows.last_seen_at, + windows.sample_count, + windows.total_players, + windows.peak_players, + windows.confidence_mode, + windows.capabilities_json, + windows.latest_payload_json + FROM rcon_historical_competitive_windows AS windows + INNER JOIN rcon_historical_targets AS targets + ON targets.id = windows.target_id + WHERE windows.session_key = ? + AND ( + targets.target_key IN ({alias_placeholders}) + OR targets.external_server_id IN ({alias_placeholders}) + ) + LIMIT 1 + """, + [normalized_session_key, *aliases, *aliases], + ).fetchone() + except sqlite3.OperationalError: + return None + if row is None: + return None + sample_count = int(row["sample_count"] or 0) + return { + "target_key": row["target_key"], + "external_server_id": row["external_server_id"], + "display_name": row["display_name"], + "region": row["region"], + "session_key": row["session_key"], + "first_seen_at": row["first_seen_at"], + "last_seen_at": row["last_seen_at"], + "duration_seconds": _calculate_duration_seconds( + row["first_seen_at"], + row["last_seen_at"], + ), + "map_name": row["map_name"], + "map_pretty_name": row["map_pretty_name"] or row["map_name"], + "sample_count": sample_count, + "average_players": round((int(row["total_players"] or 0) / sample_count), 2) + if sample_count > 0 + else 0.0, + "peak_players": int(row["peak_players"] or 0), + "confidence_mode": row["confidence_mode"], + "capabilities": _deserialize_json_object(row["capabilities_json"]), + "latest_payload": _deserialize_json_object(row["latest_payload_json"]), + } + + +def _connect(db_path: Path) -> sqlite3.Connection: + return connect_sqlite_writer(db_path) + + +def _connect_readonly(db_path: Path) -> sqlite3.Connection: + return connect_sqlite_readonly(db_path) + + +def _resolve_db_path(db_path: Path | None) -> Path: + return db_path or get_storage_path() + + +def _expand_target_key_aliases(target_key: str) -> list[str]: + normalized_target_key = str(target_key or "").strip() + if not normalized_target_key: + return [normalized_target_key] + + aliases = {normalized_target_key} + try: + configured_targets = load_rcon_targets() + except Exception: + configured_targets = () + + for target in configured_targets: + external_server_id = str(target.external_server_id or "").strip() + legacy_target_key = f"rcon:{target.host}:{target.port}" + if external_server_id and external_server_id == normalized_target_key: + aliases.add(legacy_target_key) + aliases.add(external_server_id) + elif legacy_target_key == normalized_target_key: + aliases.add(legacy_target_key) + if external_server_id: + aliases.add(external_server_id) + + return sorted(alias for alias in aliases if alias) + + +def _upsert_target(connection: sqlite3.Connection, *, target: Mapping[str, object]) -> int: + target_key = str(target.get("target_key") or "").strip() + if not target_key: + raise ValueError("Prospective RCON targets require a non-empty target_key.") + display_name = str(target.get("name") or target.get("display_name") or target_key).strip() + host = str(target.get("host") or "").strip() + port = int(target.get("port") or 0) + if not host or port <= 0: + raise ValueError("Prospective RCON targets require host and port.") + + connection.execute( + """ + INSERT INTO rcon_historical_targets ( + target_key, + external_server_id, + display_name, + host, + port, + region, + game_port, + query_port, + source_name, + last_configured_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(target_key) DO UPDATE SET + external_server_id = excluded.external_server_id, + display_name = excluded.display_name, + host = excluded.host, + port = excluded.port, + region = excluded.region, + game_port = excluded.game_port, + query_port = excluded.query_port, + source_name = excluded.source_name, + last_configured_at = excluded.last_configured_at, + updated_at = CURRENT_TIMESTAMP + """, + ( + target_key, + target.get("external_server_id"), + display_name, + host, + port, + target.get("region"), + target.get("game_port"), + target.get("query_port"), + str(target.get("source_name") or "community-hispana-rcon"), + _utc_now_iso(), + ), + ) + row = connection.execute( + "SELECT id FROM rcon_historical_targets WHERE target_key = ?", + (target_key,), + ).fetchone() + if row is None: + raise RuntimeError("Failed to resolve prospective RCON target id.") + return int(row["id"]) + + +def _upsert_checkpoint_success( + connection: sqlite3.Connection, + *, + target_id: int, + run_id: int, + captured_at: str, +) -> None: + connection.execute( + """ + INSERT INTO rcon_historical_checkpoints ( + target_id, + last_successful_capture_at, + last_sample_at, + last_run_id, + last_run_status, + last_error, + last_error_at + ) VALUES (?, ?, ?, ?, 'success', NULL, NULL) + ON CONFLICT(target_id) DO UPDATE SET + last_successful_capture_at = excluded.last_successful_capture_at, + last_sample_at = excluded.last_sample_at, + last_run_id = excluded.last_run_id, + last_run_status = excluded.last_run_status, + last_error = NULL, + last_error_at = NULL, + updated_at = CURRENT_TIMESTAMP + """, + (target_id, captured_at, captured_at, run_id), + ) + + +def _upsert_competitive_window( + connection: sqlite3.Connection, + *, + target_id: int, + captured_at: str, + normalized_payload: Mapping[str, object], +) -> None: + current_map_raw = str(normalized_payload.get("current_map") or "").strip() + if not current_map_raw: + return + map_pretty_name = normalize_map_name(current_map_raw) or current_map_raw + players = int(normalized_payload.get("players") or 0) + max_players = normalized_payload.get("max_players") + status = str(normalized_payload.get("status") or "unknown") + latest_window = connection.execute( + """ + SELECT * + FROM rcon_historical_competitive_windows + WHERE target_id = ? + ORDER BY last_seen_at DESC, id DESC + LIMIT 1 + """, + (target_id,), + ).fetchone() + if latest_window and _should_extend_competitive_window( + latest_window=latest_window, + captured_at=captured_at, + current_map=current_map_raw, + ): + connection.execute( + """ + UPDATE rcon_historical_competitive_windows + SET map_name = ?, + map_pretty_name = ?, + last_seen_at = ?, + sample_count = sample_count + 1, + total_players = total_players + ?, + peak_players = CASE WHEN peak_players > ? THEN peak_players ELSE ? END, + last_players = ?, + max_players = ?, + status = ?, + confidence_mode = ?, + capabilities_json = ?, + latest_payload_json = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + ( + current_map_raw, + map_pretty_name, + captured_at, + players, + players, + players, + players, + max_players, + status, + COMPETITIVE_MODE_APPROXIMATE, + json.dumps(_build_competitive_capabilities(), ensure_ascii=True, separators=(",", ":")), + json.dumps(dict(normalized_payload), ensure_ascii=True, separators=(",", ":")), + latest_window["id"], + ), + ) + return + + session_key = f"{target_id}:{captured_at}" + connection.execute( + """ + INSERT INTO rcon_historical_competitive_windows ( + target_id, + session_key, + source_kind, + map_name, + map_pretty_name, + first_seen_at, + last_seen_at, + sample_count, + total_players, + peak_players, + last_players, + max_players, + status, + confidence_mode, + capabilities_json, + latest_payload_json + ) VALUES (?, ?, 'rcon-historical-samples', ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + target_id, + session_key, + current_map_raw, + map_pretty_name, + captured_at, + captured_at, + players, + players, + players, + max_players, + status, + COMPETITIVE_MODE_APPROXIMATE, + json.dumps(_build_competitive_capabilities(), ensure_ascii=True, separators=(",", ":")), + json.dumps(dict(normalized_payload), ensure_ascii=True, separators=(",", ":")), + ), + ) + + +def _should_extend_competitive_window( + *, + latest_window: sqlite3.Row, + captured_at: str, + current_map: str, +) -> bool: + latest_map = str(latest_window["map_name"] or "").strip() + if normalize_map_name(latest_map) != normalize_map_name(current_map): + return False + latest_seen = _parse_timestamp(str(latest_window["last_seen_at"])) + captured_point = _parse_timestamp(captured_at) + return (captured_point - latest_seen).total_seconds() <= COMPETITIVE_WINDOW_GAP_SECONDS + + +def _build_competitive_capabilities() -> dict[str, object]: + return { + "recent_matches": COMPETITIVE_MODE_APPROXIMATE, + "server_summary": COMPETITIVE_MODE_EXACT, + "competitive_quality": COMPETITIVE_MODE_PARTIAL, + "result": "session-score", + "gamestate": "session", + "player_stats": "unavailable", + } + + +def _deserialize_json_object(raw_value: object) -> dict[str, object]: + if isinstance(raw_value, str) and raw_value.strip(): + try: + parsed = json.loads(raw_value) + except json.JSONDecodeError: + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +def _parse_timestamp(raw_value: str) -> datetime: + timestamp = datetime.fromisoformat(raw_value.replace("Z", "+00:00")) + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=timezone.utc) + return timestamp.astimezone(timezone.utc) + + +def _calculate_duration_seconds(first_seen_at: str | None, last_seen_at: str | None) -> int | None: + if not first_seen_at or not last_seen_at: + return None + return max(0, int((_parse_timestamp(last_seen_at) - _parse_timestamp(first_seen_at)).total_seconds())) + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/backend/app/rcon_historical_worker.py b/backend/app/rcon_historical_worker.py new file mode 100644 index 0000000..d308e7a --- /dev/null +++ b/backend/app/rcon_historical_worker.py @@ -0,0 +1,554 @@ +"""Dedicated prospective RCON historical capture worker.""" + +from __future__ import annotations + +import argparse +from datetime import date, datetime +import json +import os +import time +from dataclasses import dataclass +from typing import Iterable + +from .config import ( + get_rcon_historical_capture_interval_seconds, + get_rcon_historical_capture_max_retries, + get_rcon_historical_capture_retry_delay_seconds, + get_rcon_request_timeout_seconds, +) +from .rcon_admin_log_ingestion import ingest_rcon_admin_logs +from .rcon_admin_log_materialization import materialize_rcon_admin_log +from .rcon_client import ( + RconQueryError, + build_rcon_target_key, + load_rcon_targets, + query_live_server_sample, +) +from .rcon_historical_storage import ( + finalize_rcon_historical_capture_run, + initialize_rcon_historical_storage, + list_rcon_historical_target_statuses, + mark_rcon_historical_capture_failure, + persist_rcon_historical_sample, + start_rcon_historical_capture_run, +) +from .snapshots import utc_now +from .writer_lock import backend_writer_lock, build_writer_lock_holder + + +@dataclass(slots=True) +class RconHistoricalCaptureStats: + targets_seen: int = 0 + samples_inserted: int = 0 + duplicate_samples: int = 0 + failed_targets: int = 0 + admin_log_events_seen: int = 0 + admin_log_events_inserted: int = 0 + admin_log_duplicate_events: int = 0 + admin_log_failed_targets: int = 0 + materialized_matches_inserted: int = 0 + materialized_matches_updated: int = 0 + + +def run_rcon_historical_capture( + *, + target_key: str | None = None, +) -> dict[str, object]: + """Capture one prospective RCON sample for one or all configured targets.""" + with backend_writer_lock( + holder=build_writer_lock_holder( + f"app.rcon_historical_worker capture:{target_key or 'all-targets'}" + ) + ): + return run_rcon_historical_capture_unlocked(target_key=target_key) + + +def run_rcon_historical_capture_unlocked( + *, + target_key: str | None = None, +) -> dict[str, object]: + """Capture one prospective RCON sample assuming the shared writer lock is already held.""" + initialize_rcon_historical_storage() + selected_targets = _select_targets(target_key) + selected_target_keys = {build_rcon_target_key(target) for target in selected_targets} + admin_log_lookback_minutes = get_rcon_admin_log_lookback_minutes() + captured_at = utc_now().isoformat().replace("+00:00", "Z") + target_scope = target_key or "all-configured-rcon-targets" + run_id = start_rcon_historical_capture_run(mode="capture", target_scope=target_scope) + stats = RconHistoricalCaptureStats() + items: list[dict[str, object]] = [] + errors: list[dict[str, object]] = [] + admin_log_errors: list[dict[str, object]] = [] + timeout_seconds = get_rcon_request_timeout_seconds() + + try: + for target in selected_targets: + target_metadata = _serialize_target(target) + stats.targets_seen += 1 + try: + sample = query_live_server_sample( + target, + timeout_seconds=timeout_seconds, + ) + delta = persist_rcon_historical_sample( + run_id=run_id, + captured_at=captured_at, + target=target_metadata, + normalized_payload=sample["normalized"], + raw_payload=sample["raw_session"], + ) + stats.samples_inserted += int(delta["samples_inserted"]) + stats.duplicate_samples += int(delta["duplicate_samples"]) + items.append( + { + "target_key": target_metadata["target_key"], + "external_server_id": target.external_server_id, + "name": target.name, + "host": target.host, + "port": target.port, + "timeout_seconds": timeout_seconds, + "captured_at": captured_at, + "sample_inserted": bool(delta["samples_inserted"]), + "normalized": sample["normalized"], + } + ) + except Exception as exc: # noqa: BLE001 - controlled worker failures + stats.failed_targets += 1 + mark_rcon_historical_capture_failure( + run_id=run_id, + target=target_metadata, + error_message=_format_error_message(exc), + ) + errors.append(_serialize_capture_error(target, exc, timeout_seconds=timeout_seconds)) + + admin_log_result = _ingest_target_admin_log( + target_key=str(target_metadata["target_key"]), + minutes=admin_log_lookback_minutes, + ) + _merge_admin_log_result( + stats=stats, + admin_log_errors=admin_log_errors, + target=target_metadata, + result=admin_log_result, + ) + + materialization_result = materialize_rcon_admin_log() + stats.materialized_matches_inserted = int( + materialization_result.get("matches_materialized") or 0 + ) + stats.materialized_matches_updated = int( + materialization_result.get("matches_updated") or 0 + ) + + status = "success" if not errors else ("partial" if items else "failed") + finalize_rcon_historical_capture_run( + run_id, + status=status, + targets_seen=stats.targets_seen, + samples_inserted=stats.samples_inserted, + duplicate_samples=stats.duplicate_samples, + failed_targets=stats.failed_targets, + notes=None if not errors else json.dumps(errors, separators=(",", ":")), + ) + except Exception as exc: + finalize_rcon_historical_capture_run( + run_id, + status="failed", + targets_seen=stats.targets_seen, + samples_inserted=stats.samples_inserted, + duplicate_samples=stats.duplicate_samples, + failed_targets=max(1, stats.failed_targets), + notes=str(exc), + ) + raise + + return { + "status": "ok" if items else "error", + "run_status": status, + "captured_at": captured_at, + "target_scope": target_scope, + "admin_log_lookback_minutes": admin_log_lookback_minutes, + "targets": items, + "errors": errors, + "admin_log_errors": admin_log_errors, + "materialization_result": materialization_result, + "storage_status": [ + status + for status in list_rcon_historical_target_statuses() + if status.get("target_key") in selected_target_keys + ], + "totals": { + "targets_seen": stats.targets_seen, + "samples_inserted": stats.samples_inserted, + "duplicate_samples": stats.duplicate_samples, + "failed_targets": stats.failed_targets, + "admin_log_events_seen": stats.admin_log_events_seen, + "admin_log_events_inserted": stats.admin_log_events_inserted, + "admin_log_duplicate_events": stats.admin_log_duplicate_events, + "admin_log_failed_targets": stats.admin_log_failed_targets, + "materialized_matches_inserted": stats.materialized_matches_inserted, + "materialized_matches_updated": stats.materialized_matches_updated, + }, + } + + +def run_periodic_rcon_historical_capture( + *, + interval_seconds: int, + max_retries: int, + retry_delay_seconds: int, + target_key: str | None = None, + max_runs: int | None = None, +) -> None: + """Run prospective RCON capture in a local loop.""" + completed_runs = 0 + startup_targets = _describe_loop_targets(target_key) + _emit_worker_event( + "rcon-historical-capture-worker-started", + interval_seconds=interval_seconds, + max_retries=max_retries, + retry_delay_seconds=retry_delay_seconds, + target_scope=target_key or "all-configured-rcon-targets", + target_count=len(startup_targets), + targets=startup_targets, + ) + print("Press Ctrl+C to stop.") + + try: + while max_runs is None or completed_runs < max_runs: + completed_runs += 1 + _emit_worker_event( + "rcon-historical-capture-cycle-started", + run=completed_runs, + ) + payload = _run_capture_with_retries( + max_retries=max_retries, + retry_delay_seconds=retry_delay_seconds, + target_key=target_key, + ) + _emit_worker_event( + "rcon-historical-capture-cycle-finished", + run=completed_runs, + result=payload, + ) + if max_runs is not None and completed_runs >= max_runs: + break + _emit_worker_event( + "rcon-historical-capture-sleep-started", + run=completed_runs, + sleep_seconds=interval_seconds, + ) + time.sleep(interval_seconds) + except KeyboardInterrupt: + print("\nRCON historical capture loop stopped by user.") + except Exception as exc: + _emit_worker_event( + "rcon-historical-capture-worker-exited-unexpectedly", + error_type=type(exc).__name__, + message=str(exc), + ) + raise + + +def _run_capture_with_retries( + *, + max_retries: int, + retry_delay_seconds: int, + target_key: str | None, +) -> dict[str, object]: + attempt = 0 + while True: + attempt += 1 + try: + return { + "status": "ok", + "attempts_used": attempt, + "capture_result": run_rcon_historical_capture(target_key=target_key), + } + except Exception as exc: + if attempt > max_retries: + _emit_worker_event( + "rcon-historical-capture-attempt-failed", + attempt=attempt, + max_retries=max_retries, + error_type=type(exc).__name__, + message=str(exc), + retries_exhausted=True, + ) + return { + "status": "error", + "attempts_used": attempt, + "error": str(exc), + } + _emit_worker_event( + "rcon-historical-capture-attempt-failed", + attempt=attempt, + max_retries=max_retries, + error_type=type(exc).__name__, + message=str(exc), + ) + if retry_delay_seconds > 0: + _emit_worker_event( + "rcon-historical-capture-retry-sleep-started", + attempt=attempt, + sleep_seconds=retry_delay_seconds, + ) + time.sleep(retry_delay_seconds) + + +def _select_targets(target_key: str | None) -> list[object]: + configured_targets = list(load_rcon_targets()) + if not configured_targets: + raise RuntimeError("No RCON targets configured in HLL_BACKEND_RCON_TARGETS.") + if target_key is None: + return configured_targets + + normalized = target_key.strip() + selected = [ + target + for target in configured_targets + if build_rcon_target_key(target) == normalized + ] + if not selected: + raise ValueError(f"Unknown RCON target key: {target_key}") + return selected + + +def _describe_loop_targets(target_key: str | None) -> list[dict[str, str]]: + """Describe configured worker targets without exposing credentials.""" + try: + targets = _select_targets(target_key) + except Exception as exc: # noqa: BLE001 - startup logging must not hide capture error + return [ + { + "status": "unavailable", + "error_type": type(exc).__name__, + "message": str(exc), + } + ] + return [ + { + "target_key": build_rcon_target_key(target), + "external_server_id": str(target.external_server_id or ""), + "name": str(target.name or ""), + } + for target in targets + ] + + +def _emit_worker_event(event: str, **fields: object) -> None: + """Print one JSON worker event using safe date/time serialization.""" + print( + json.dumps({"event": event, **fields}, indent=2, default=_json_default), + flush=True, + ) + + +def _json_default(value: object) -> str: + if isinstance(value, (date, datetime)): + return value.isoformat() + return str(value) + + +def get_rcon_admin_log_lookback_minutes() -> int: + """Return the AdminLog lookback window used by periodic RCON capture.""" + configured_value = os.getenv("HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES", "60") + lookback_minutes = int(configured_value) + if lookback_minutes <= 0: + raise ValueError("HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES must be positive.") + return lookback_minutes + + +def _ingest_target_admin_log( + *, + target_key: str, + minutes: int, +) -> dict[str, object]: + try: + return ingest_rcon_admin_logs(minutes=minutes, target_key=target_key) + except Exception as exc: # noqa: BLE001 - worker reports per-target AdminLog failures + return { + "status": "error", + "errors": [ + { + "target_key": target_key, + "status": "error", + "error_type": type(exc).__name__, + "message": str(exc), + } + ], + "totals": { + "events_seen": 0, + "events_inserted": 0, + "duplicate_events": 0, + "failed_targets": 1, + }, + } + + +def _merge_admin_log_result( + *, + stats: RconHistoricalCaptureStats, + admin_log_errors: list[dict[str, object]], + target: dict[str, object], + result: dict[str, object], +) -> None: + totals = result.get("totals") + if isinstance(totals, dict): + stats.admin_log_events_seen += int(totals.get("events_seen") or 0) + stats.admin_log_events_inserted += int(totals.get("events_inserted") or 0) + stats.admin_log_duplicate_events += int(totals.get("duplicate_events") or 0) + stats.admin_log_failed_targets += int(totals.get("failed_targets") or 0) + + errors = result.get("errors") + if isinstance(errors, list): + for error in errors: + if isinstance(error, dict): + admin_log_errors.append( + { + "target_key": target["target_key"], + "external_server_id": target.get("external_server_id"), + "name": target.get("name"), + "status": "error", + "error_type": error.get("error_type"), + "message": error.get("message"), + } + ) + + +def _serialize_target(target: object) -> dict[str, object]: + return { + "target_key": build_rcon_target_key(target), + "external_server_id": target.external_server_id, + "name": target.name, + "host": target.host, + "port": target.port, + "region": target.region, + "game_port": target.game_port, + "query_port": target.query_port, + "source_name": target.source_name, + } + + +def _serialize_capture_error( + target: object, + error: Exception, + *, + timeout_seconds: float, +) -> dict[str, object]: + error_type = _classify_capture_error_type(error) + error_stage = _classify_capture_error_stage(error) + return { + "target_key": build_rcon_target_key(target), + "external_server_id": target.external_server_id, + "name": target.name, + "host": target.host, + "port": target.port, + "timeout_seconds": timeout_seconds, + "error_type": error_type, + "error_stage": error_stage, + "message": str(error), + } + + +def _classify_capture_error_type(error: Exception) -> str: + if isinstance(error, RconQueryError): + return error.error_type + message = str(error).lower() + if "timed out" in message or "timeout" in message: + return "timeout" + if "401" in message or "403" in message or "login" in message or "auth" in message: + return "auth/login" + if "refused" in message: + return "connection-refused" + if "payload" in message or "json" in message or "malformed" in message: + return "payload-invalid" + return "other-error" + + +def _classify_capture_error_stage(error: Exception) -> str | None: + if isinstance(error, RconQueryError): + return error.error_stage + return None + + +def _format_error_message(error: Exception) -> str: + error_type = _classify_capture_error_type(error) + error_stage = _classify_capture_error_stage(error) + if error_stage: + return f"[{error_type}:{error_stage}] {error}" + return f"[{error_type}] {error}" + + +def build_arg_parser() -> argparse.ArgumentParser: + """Create the CLI parser for manual or periodic prospective RCON capture.""" + parser = argparse.ArgumentParser( + description="Prospective RCON historical capture for HLL Vietnam.", + ) + parser.add_argument( + "mode", + choices=("capture", "loop"), + help="capture runs once; loop keeps collecting periodically", + ) + parser.add_argument( + "--target", + dest="target_key", + help="optional target key; defaults to all configured RCON targets", + ) + parser.add_argument( + "--interval", + type=int, + default=get_rcon_historical_capture_interval_seconds(), + help="seconds to wait between loop runs", + ) + parser.add_argument( + "--retries", + type=int, + default=get_rcon_historical_capture_max_retries(), + help="retry attempts after a failed capture", + ) + parser.add_argument( + "--retry-delay", + type=int, + default=get_rcon_historical_capture_retry_delay_seconds(), + help="seconds to wait between failed attempts", + ) + parser.add_argument( + "--max-runs", + type=int, + help="optional safety cap for loop mode", + ) + return parser + + +def main(argv: Iterable[str] | None = None) -> int: + """Run the prospective RCON historical capture CLI.""" + parser = build_arg_parser() + args = parser.parse_args(list(argv) if argv is not None else None) + + if args.mode == "capture": + result = run_rcon_historical_capture(target_key=args.target_key) + print(json.dumps(result, indent=2, default=_json_default)) + return 0 + + if args.interval <= 0: + raise ValueError("--interval must be a positive integer.") + if args.retries < 0: + raise ValueError("--retries must be zero or positive.") + if args.retry_delay < 0: + raise ValueError("--retry-delay must be zero or positive.") + if args.max_runs is not None and args.max_runs <= 0: + raise ValueError("--max-runs must be positive when provided.") + + run_periodic_rcon_historical_capture( + interval_seconds=args.interval, + max_retries=args.retries, + retry_delay_seconds=args.retry_delay, + target_key=args.target_key, + max_runs=args.max_runs, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/app/rcon_scoreboard_correlation.py b/backend/app/rcon_scoreboard_correlation.py new file mode 100644 index 0000000..4465fca --- /dev/null +++ b/backend/app/rcon_scoreboard_correlation.py @@ -0,0 +1,448 @@ +"""Correlate RCON competitive windows with trusted persisted scoreboard matches.""" + +from __future__ import annotations + +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +from .config import get_storage_path, use_postgres_rcon_storage +from .normalizers import normalize_map_name +from .scoreboard_origins import resolve_trusted_scoreboard_match_url +from .sqlite_utils import connect_sqlite_readonly + + +MIN_CONFIDENCE_SCORE = 5 +MAX_CANDIDATES = 200 + + +def resolve_rcon_scoreboard_match_url( + *, + server_slug: object, + map_name: object, + started_at: object, + ended_at: object, + duration_seconds: object = None, + player_count: object = None, + peak_players: object = None, + allied_score: object = None, + axis_score: object = None, + db_path: Path | None = None, +) -> str | None: + """Return a trusted scoreboard URL for an RCON window only on strong evidence.""" + resolution = resolve_rcon_scoreboard_correlation( + server_slug=server_slug, + map_name=map_name, + started_at=started_at, + ended_at=ended_at, + duration_seconds=duration_seconds, + player_count=player_count, + peak_players=peak_players, + allied_score=allied_score, + axis_score=axis_score, + db_path=db_path, + ) + match_url = resolution.get("match_url") + return str(match_url) if match_url else None + + +def resolve_rcon_scoreboard_correlation( + *, + server_slug: object, + map_name: object, + started_at: object, + ended_at: object, + duration_seconds: object = None, + player_count: object = None, + peak_players: object = None, + allied_score: object = None, + axis_score: object = None, + db_path: Path | None = None, +) -> dict[str, object]: + """Return a safe candidate selection summary for one RCON match window.""" + normalized_server_slug = str(server_slug or "").strip() + normalized_map = normalize_map_name(map_name) + rcon_start = _parse_timestamp(started_at) + rcon_end = _parse_timestamp(ended_at) + if not normalized_server_slug or not normalized_map or not rcon_start or not rcon_end: + return {"match_url": None, "candidate_count": 0, "reason": "invalid-rcon-window"} + if rcon_end < rcon_start: + rcon_start, rcon_end = rcon_end, rcon_start + + candidates = _list_persisted_scoreboard_candidates( + server_slug=normalized_server_slug, + db_path=db_path or get_storage_path(), + ) + scored_candidates = [ + scored + for candidate in candidates + if (scored := _score_candidate( + candidate, + normalized_map=normalized_map, + rcon_start=rcon_start, + rcon_end=rcon_end, + duration_seconds=_coerce_int(duration_seconds), + player_count=_coerce_int(player_count), + peak_players=_coerce_int(peak_players), + allied_score=_coerce_int(allied_score), + axis_score=_coerce_int(axis_score), + )) + is not None + ] + if not scored_candidates: + return { + "match_url": None, + "candidate_count": len(candidates), + "reason": "no-safe-candidate", + } + + scored_candidates.sort(key=lambda item: item["score"], reverse=True) + best = scored_candidates[0] + if int(best["score"]) < MIN_CONFIDENCE_SCORE: + return { + "match_url": None, + "candidate_count": len(candidates), + "reason": "low-confidence", + } + if len(scored_candidates) > 1 and int(scored_candidates[1]["score"]) >= int(best["score"]): + return { + "match_url": None, + "candidate_count": len(candidates), + "reason": "ambiguous-candidate", + } + return { + "match_url": str(best["match_url"]), + "candidate_count": len(candidates), + "reason": "linked", + "selected_candidate": { + "external_match_id": best.get("external_match_id"), + "correlation_score": int(best["score"]), + }, + } + + +def diagnose_rcon_scoreboard_correlation( + *, + server_slug: object, + map_name: object, + started_at: object, + ended_at: object, + duration_seconds: object = None, + player_count: object = None, + peak_players: object = None, + allied_score: object = None, + axis_score: object = None, + db_path: Path | None = None, +) -> dict[str, object]: + """Describe safe candidate scoring for a single RCON correlation window.""" + normalized_server_slug = str(server_slug or "").strip() + normalized_map = normalize_map_name(map_name) + rcon_start = _parse_timestamp(started_at) + rcon_end = _parse_timestamp(ended_at) + if not normalized_server_slug or not normalized_map or not rcon_start or not rcon_end: + return { + "candidate_search_window": { + "started_at": started_at, + "ended_at": ended_at, + "candidate_limit": MAX_CANDIDATES, + }, + "candidate_count": 0, + "top_candidates": [], + "selected_candidate": None, + "final_reason": "invalid-rcon-window", + } + if rcon_end < rcon_start: + rcon_start, rcon_end = rcon_end, rcon_start + + candidates = _list_persisted_scoreboard_candidates( + server_slug=normalized_server_slug, + db_path=db_path or get_storage_path(), + ) + resolution = resolve_rcon_scoreboard_correlation( + server_slug=server_slug, + map_name=map_name, + started_at=started_at, + ended_at=ended_at, + duration_seconds=duration_seconds, + player_count=player_count, + peak_players=peak_players, + allied_score=allied_score, + axis_score=axis_score, + db_path=db_path, + ) + summaries = [ + _diagnostic_candidate_summary( + candidate, + server_slug=normalized_server_slug, + normalized_map=normalized_map, + rcon_start=rcon_start, + rcon_end=rcon_end, + duration_seconds=_coerce_int(duration_seconds), + player_count=_coerce_int(player_count), + peak_players=_coerce_int(peak_players), + allied_score=_coerce_int(allied_score), + axis_score=_coerce_int(axis_score), + ) + for candidate in candidates + ] + summaries.sort( + key=lambda item: ( + -int(item["correlation_score"] or -1), + str(item.get("external_match_id") or ""), + ) + ) + selected_id = ( + resolution.get("selected_candidate", {}).get("external_match_id") + if isinstance(resolution.get("selected_candidate"), dict) + else None + ) + selected_candidate = next( + (item for item in summaries if item.get("external_match_id") == selected_id), + None, + ) + return { + "candidate_search_window": { + "started_at": rcon_start.isoformat().replace("+00:00", "Z"), + "ended_at": rcon_end.isoformat().replace("+00:00", "Z"), + "candidate_limit": MAX_CANDIDATES, + }, + "candidate_count": len(candidates), + "top_candidates": summaries[:5], + "selected_candidate": selected_candidate, + "final_reason": resolution["reason"], + } + + +def _list_persisted_scoreboard_candidates( + *, + server_slug: str, + db_path: Path, +) -> list[dict[str, object]]: + if use_postgres_rcon_storage(): + from .postgres_rcon_storage import list_scoreboard_candidates + + postgres_candidates = list_scoreboard_candidates( + server_slug=server_slug, + limit=MAX_CANDIDATES, + ) + if postgres_candidates: + return postgres_candidates + + try: + with connect_sqlite_readonly(db_path) as connection: + rows = connection.execute( + """ + SELECT + historical_matches.external_match_id, + historical_matches.started_at, + historical_matches.ended_at, + historical_matches.map_name, + historical_matches.map_pretty_name, + historical_matches.allied_score, + historical_matches.axis_score, + historical_matches.raw_payload_ref, + historical_servers.slug AS server_slug, + COUNT(historical_player_match_stats.id) AS player_count + FROM historical_matches + INNER JOIN historical_servers + ON historical_servers.id = historical_matches.historical_server_id + LEFT JOIN historical_player_match_stats + ON historical_player_match_stats.historical_match_id = historical_matches.id + WHERE historical_servers.slug = ? + AND historical_matches.raw_payload_ref IS NOT NULL + GROUP BY historical_matches.id + ORDER BY COALESCE(historical_matches.ended_at, historical_matches.started_at) DESC + LIMIT ? + """, + (server_slug, MAX_CANDIDATES), + ).fetchall() + except sqlite3.Error: + return [] + + items: list[dict[str, object]] = [] + for row in rows: + match_url = resolve_trusted_scoreboard_match_url( + row["raw_payload_ref"], + row["server_slug"], + ) + if not match_url: + continue + items.append( + { + "external_match_id": row["external_match_id"], + "started_at": row["started_at"], + "ended_at": row["ended_at"], + "map_name": row["map_name"], + "map_pretty_name": row["map_pretty_name"], + "allied_score": row["allied_score"], + "axis_score": row["axis_score"], + "player_count": row["player_count"], + "match_url": match_url, + } + ) + if items and use_postgres_rcon_storage(): + from .postgres_rcon_storage import upsert_scoreboard_candidates + + upsert_scoreboard_candidates(server_slug=server_slug, candidates=items) + return items + + +def _score_candidate( + candidate: dict[str, object], + *, + normalized_map: str, + rcon_start: datetime, + rcon_end: datetime, + duration_seconds: int | None, + player_count: int | None, + peak_players: int | None, + allied_score: int | None, + axis_score: int | None, +) -> dict[str, object] | None: + candidate_map = normalize_map_name( + candidate.get("map_pretty_name") or candidate.get("map_name") + ) + if candidate_map != normalized_map: + return None + + candidate_start = _parse_timestamp(candidate.get("started_at")) + candidate_end = _parse_timestamp(candidate.get("ended_at")) + if not candidate_start or not candidate_end: + return None + if candidate_end < candidate_start: + candidate_start, candidate_end = candidate_end, candidate_start + + score = 0 + overlap_seconds = _overlap_seconds(rcon_start, rcon_end, candidate_start, candidate_end) + rcon_midpoint = rcon_start + (rcon_end - rcon_start) / 2 + if overlap_seconds > 0: + score += 3 + if candidate_start <= rcon_midpoint <= candidate_end: + score += 2 + + closest_edge_distance = min( + abs((rcon_start - candidate_start).total_seconds()), + abs((rcon_start - candidate_end).total_seconds()), + abs((rcon_end - candidate_start).total_seconds()), + abs((rcon_end - candidate_end).total_seconds()), + ) + if closest_edge_distance <= 1800: + score += 2 + elif closest_edge_distance <= 3600: + score += 1 + + candidate_duration = int((candidate_end - candidate_start).total_seconds()) + if duration_seconds and candidate_duration > 0: + if abs(candidate_duration - duration_seconds) <= 1800: + score += 1 + elif overlap_seconds > 0 and duration_seconds <= candidate_duration: + score += 1 + + candidate_allied_score = _coerce_int(candidate.get("allied_score")) + candidate_axis_score = _coerce_int(candidate.get("axis_score")) + if ( + allied_score is not None + and axis_score is not None + and candidate_allied_score is not None + and candidate_axis_score is not None + ): + if candidate_allied_score == allied_score and candidate_axis_score == axis_score: + score += 2 + elif sorted((candidate_allied_score, candidate_axis_score)) == sorted((allied_score, axis_score)): + score += 1 + + candidate_players = _coerce_int(candidate.get("player_count")) + reference_players = peak_players or player_count + if candidate_players and reference_players: + if abs(candidate_players - reference_players) <= 20: + score += 1 + elif candidate_players >= int(reference_players * 0.75): + score += 1 + + if score <= 0: + return None + return { + "score": score, + "external_match_id": candidate.get("external_match_id"), + "match_url": candidate["match_url"], + } + + +def _diagnostic_candidate_summary( + candidate: dict[str, object], + *, + server_slug: str, + normalized_map: str, + rcon_start: datetime, + rcon_end: datetime, + duration_seconds: int | None, + player_count: int | None, + peak_players: int | None, + allied_score: int | None, + axis_score: int | None, +) -> dict[str, object]: + match_url = resolve_trusted_scoreboard_match_url(candidate.get("match_url"), server_slug) + safe_candidate = {**candidate, "match_url": match_url} if match_url else None + scored = ( + _score_candidate( + safe_candidate, + normalized_map=normalized_map, + rcon_start=rcon_start, + rcon_end=rcon_end, + duration_seconds=duration_seconds, + player_count=player_count, + peak_players=peak_players, + allied_score=allied_score, + axis_score=axis_score, + ) + if safe_candidate + else None + ) + map_label = candidate.get("map_pretty_name") or candidate.get("map_name") + summary = { + "external_match_id": candidate.get("external_match_id"), + "started_at": candidate.get("started_at"), + "ended_at": candidate.get("ended_at"), + "map": map_label, + "score": { + "allied_score": _coerce_int(candidate.get("allied_score")), + "axis_score": _coerce_int(candidate.get("axis_score")), + }, + "match_url": match_url, + "correlation_score": int(scored["score"]) if scored else None, + } + if not match_url: + summary["rejection_reason"] = "unsafe-url" + elif scored is None: + summary["rejection_reason"] = "map-or-window-mismatch" + return summary + + +def _overlap_seconds( + first_start: datetime, + first_end: datetime, + second_start: datetime, + second_end: datetime, +) -> int: + return max(0, int((min(first_end, second_end) - max(first_start, second_start)).total_seconds())) + + +def _parse_timestamp(value: object) -> datetime | None: + if not isinstance(value, str) or not value.strip(): + return None + try: + parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _coerce_int(value: object) -> int | None: + if value is None: + return None + try: + return int(round(float(value))) + except (TypeError, ValueError): + return None diff --git a/backend/app/rcon_scoreboard_relink.py b/backend/app/rcon_scoreboard_relink.py new file mode 100644 index 0000000..3835cb2 --- /dev/null +++ b/backend/app/rcon_scoreboard_relink.py @@ -0,0 +1,78 @@ +"""Report safe scoreboard links for existing materialized RCON matches.""" + +from __future__ import annotations + +import argparse +import json +from collections.abc import Iterable +from pathlib import Path + +from .rcon_admin_log_materialization import list_materialized_rcon_matches +from .rcon_historical_read_model import build_materialized_scoreboard_correlation_input +from .rcon_scoreboard_correlation import resolve_rcon_scoreboard_correlation + + +DEFAULT_LIMIT = 500 + + +def relink_materialized_matches( + *, + server_key: str | None = None, + limit: int = DEFAULT_LIMIT, + db_path: Path | None = None, +) -> dict[str, object]: + """Scan existing matches against trusted candidates used by the detail read model.""" + matches = list_materialized_rcon_matches( + target_key=server_key, + only_ended=True, + limit=limit, + db_path=db_path, + ) + report: dict[str, object] = { + "matches_scanned": len(matches), + "candidates_scanned": 0, + "matches_linked": 0, + "matches_skipped_no_candidate": 0, + "matches_skipped_ambiguous": 0, + "errors": [], + } + for match in matches: + try: + resolution = resolve_rcon_scoreboard_correlation( + **build_materialized_scoreboard_correlation_input(match), + db_path=db_path, + ) + except Exception as exc: + report["errors"].append( + {"match_key": match.get("match_key"), "message": str(exc)} + ) + continue + report["candidates_scanned"] += int(resolution.get("candidate_count") or 0) + if resolution.get("match_url"): + report["matches_linked"] += 1 + elif resolution.get("reason") == "ambiguous-candidate": + report["matches_skipped_ambiguous"] += 1 + else: + report["matches_skipped_no_candidate"] += 1 + return report + + +def main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Resolve trusted scoreboard links for materialized RCON matches." + ) + parser.add_argument("--server", dest="server_key") + parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT) + parser.add_argument("--db-path", type=Path, default=None) + args = parser.parse_args(list(argv) if argv is not None else None) + report = relink_materialized_matches( + server_key=args.server_key, + limit=max(1, args.limit), + db_path=args.db_path, + ) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 if not report["errors"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/app/routes.py b/backend/app/routes.py new file mode 100644 index 0000000..c906413 --- /dev/null +++ b/backend/app/routes.py @@ -0,0 +1,395 @@ +"""Route resolution helpers for the HLL Vietnam backend bootstrap.""" + +from __future__ import annotations + +from http import HTTPStatus +from urllib.parse import parse_qs, urlparse + +from .config import get_historical_data_source_kind +from .payloads import ( + build_community_payload, + build_current_match_kill_feed_payload, + build_current_match_player_stats_payload, + build_current_match_payload, + build_discord_payload, + build_elo_mmr_leaderboard_payload, + build_elo_mmr_player_payload, + build_error_payload, + build_health_payload, + build_historical_leaderboard_payload, + build_historical_match_detail_payload, + build_monthly_mvp_payload, + build_monthly_mvp_v2_payload, + build_monthly_leaderboard_payload, + build_monthly_leaderboard_snapshot_payload, + build_monthly_mvp_snapshot_payload, + build_monthly_mvp_v2_snapshot_payload, + build_player_event_payload, + build_player_event_snapshot_payload, + build_historical_server_summary_snapshot_payload, + build_historical_player_profile_payload, + build_historical_server_summary_payload, + build_leaderboard_snapshot_payload, + build_recent_historical_matches_snapshot_payload, + build_recent_historical_matches_payload, + build_server_detail_history_payload, + build_server_history_payload, + build_server_latest_payload, + build_servers_payload, + build_trailer_payload, + build_weekly_leaderboard_snapshot_payload, + build_weekly_leaderboard_payload, + build_weekly_top_kills_payload, +) +from .rcon_historical_leaderboards import build_rcon_materialized_leaderboard_snapshot_payload +from .scoreboard_origins import get_trusted_public_scoreboard_origin + + +GET_ROUTES = { + "/health": build_health_payload, + "/api/community": build_community_payload, + "/api/trailer": build_trailer_payload, + "/api/discord": build_discord_payload, + "/api/servers": build_servers_payload, +} + + +def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]]: + """Resolve the JSON payload for a supported GET route.""" + parsed = urlparse(path) + if parsed.path == "/api/servers/latest": + return HTTPStatus.OK, build_server_latest_payload() + + if parsed.path == "/api/servers/history": + limit = _parse_limit(parsed.query) + if limit is None: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") + return HTTPStatus.OK, build_server_history_payload(limit=limit) + + if parsed.path == "/api/current-match": + server_slug = parse_qs(parsed.query).get("server", [None])[0] + if not server_slug: + return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required") + if get_trusted_public_scoreboard_origin(server_slug) is None: + return HTTPStatus.NOT_FOUND, build_error_payload("Current match server is not supported") + return HTTPStatus.OK, build_current_match_payload(server_slug=server_slug) + + if parsed.path == "/api/current-match/kills": + limit = _parse_limit(parsed.query) + if limit is None: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") + params = parse_qs(parsed.query) + server_slug = params.get("server", [None])[0] + if not server_slug: + return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required") + if get_trusted_public_scoreboard_origin(server_slug) is None: + return HTTPStatus.NOT_FOUND, build_error_payload("Current match server is not supported") + return HTTPStatus.OK, build_current_match_kill_feed_payload( + server_slug=server_slug, + limit=limit, + since_event_id=params.get("since_event_id", [None])[0], + ) + + if parsed.path == "/api/current-match/players": + server_slug = parse_qs(parsed.query).get("server", [None])[0] + if not server_slug: + return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required") + if get_trusted_public_scoreboard_origin(server_slug) is None: + return HTTPStatus.NOT_FOUND, build_error_payload("Current match server is not supported") + return HTTPStatus.OK, build_current_match_player_stats_payload(server_slug=server_slug) + + if parsed.path == "/api/historical/weekly-top-kills": + 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", [None])[0] + return HTTPStatus.OK, build_weekly_top_kills_payload(limit=limit, server_id=server_id) + + if parsed.path == "/api/historical/leaderboard": + limit = _parse_limit(parsed.query) + if limit is None: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") + params = parse_qs(parsed.query) + server_id = params.get("server", [None])[0] + metric = params.get("metric", ["kills"])[0] + timeframe = params.get("timeframe", ["weekly"])[0] + if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter") + if timeframe not in {"weekly", "monthly"}: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter") + return HTTPStatus.OK, build_historical_leaderboard_payload( + limit=limit, + server_id=server_id, + metric=metric, + timeframe=timeframe, + ) + + if parsed.path == "/api/historical/weekly-leaderboard": + limit = _parse_limit(parsed.query) + if limit is None: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") + params = parse_qs(parsed.query) + server_id = params.get("server", [None])[0] + metric = params.get("metric", ["kills"])[0] + if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter") + return HTTPStatus.OK, build_weekly_leaderboard_payload( + limit=limit, + server_id=server_id, + metric=metric, + ) + + if parsed.path == "/api/historical/monthly-leaderboard": + limit = _parse_limit(parsed.query) + if limit is None: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") + params = parse_qs(parsed.query) + server_id = params.get("server", [None])[0] + metric = params.get("metric", ["kills"])[0] + if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter") + return HTTPStatus.OK, build_monthly_leaderboard_payload( + limit=limit, + server_id=server_id, + metric=metric, + ) + + if parsed.path == "/api/historical/monthly-mvp": + 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", [None])[0] + return HTTPStatus.OK, build_monthly_mvp_payload( + limit=limit, + server_id=server_id, + ) + + if parsed.path == "/api/historical/monthly-mvp-v2": + 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", [None])[0] + return HTTPStatus.OK, build_monthly_mvp_v2_payload( + limit=limit, + server_id=server_id, + ) + + if parsed.path == "/api/historical/player-events": + limit = _parse_limit(parsed.query) + if limit is None: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") + params = parse_qs(parsed.query) + server_id = params.get("server", [None])[0] + view = params.get("view", ["most-killed"])[0] + if view not in {"most-killed", "death-by", "duels", "weapon-kills", "teamkills"}: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid view parameter") + return HTTPStatus.OK, build_player_event_payload( + limit=limit, + server_id=server_id, + view=view, + ) + + if parsed.path == "/api/historical/snapshots/leaderboard": + limit = _parse_limit(parsed.query) + if limit is None: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") + params = parse_qs(parsed.query) + server_id = params.get("server", [None])[0] + metric = params.get("metric", ["kills"])[0] + timeframe = params.get("timeframe", ["weekly"])[0] + if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter") + if timeframe not in {"weekly", "monthly"}: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter") + if get_historical_data_source_kind() == "rcon": + return HTTPStatus.OK, build_rcon_materialized_leaderboard_snapshot_payload( + limit=limit, + server_id=server_id, + metric=metric, + timeframe=timeframe, + ) + return HTTPStatus.OK, build_leaderboard_snapshot_payload( + limit=limit, + server_id=server_id, + metric=metric, + timeframe=timeframe, + ) + + if parsed.path == "/api/historical/snapshots/monthly-leaderboard": + limit = _parse_limit(parsed.query) + if limit is None: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") + params = parse_qs(parsed.query) + server_id = params.get("server", [None])[0] + metric = params.get("metric", ["kills"])[0] + if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter") + if get_historical_data_source_kind() == "rcon": + return HTTPStatus.OK, build_rcon_materialized_leaderboard_snapshot_payload( + limit=limit, + server_id=server_id, + metric=metric, + timeframe="monthly", + ) + return HTTPStatus.OK, build_monthly_leaderboard_snapshot_payload( + limit=limit, + server_id=server_id, + metric=metric, + ) + + if parsed.path == "/api/historical/snapshots/monthly-mvp": + 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", [None])[0] + return HTTPStatus.OK, build_monthly_mvp_snapshot_payload( + limit=limit, + server_id=server_id, + ) + + if parsed.path == "/api/historical/snapshots/monthly-mvp-v2": + 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", [None])[0] + return HTTPStatus.OK, build_monthly_mvp_v2_snapshot_payload( + limit=limit, + server_id=server_id, + ) + + if parsed.path == "/api/historical/snapshots/player-events": + limit = _parse_limit(parsed.query) + if limit is None: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") + params = parse_qs(parsed.query) + server_id = params.get("server", [None])[0] + view = params.get("view", ["most-killed"])[0] + if view not in {"most-killed", "death-by", "duels", "weapon-kills", "teamkills"}: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid view parameter") + return HTTPStatus.OK, build_player_event_snapshot_payload( + limit=limit, + server_id=server_id, + view=view, + ) + + if parsed.path == "/api/historical/snapshots/weekly-leaderboard": + limit = _parse_limit(parsed.query) + if limit is None: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") + params = parse_qs(parsed.query) + server_id = params.get("server", [None])[0] + metric = params.get("metric", ["kills"])[0] + if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter") + if get_historical_data_source_kind() == "rcon": + return HTTPStatus.OK, build_rcon_materialized_leaderboard_snapshot_payload( + limit=limit, + server_id=server_id, + metric=metric, + timeframe="weekly", + ) + return HTTPStatus.OK, build_weekly_leaderboard_snapshot_payload( + limit=limit, + server_id=server_id, + metric=metric, + ) + + if parsed.path == "/api/historical/recent-matches": + limit = _parse_limit(parsed.query) + if limit is None: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") + server_slug = parse_qs(parsed.query).get("server", [None])[0] + return HTTPStatus.OK, build_recent_historical_matches_payload( + limit=limit, + server_slug=server_slug, + ) + + if parsed.path == "/api/historical/snapshots/recent-matches": + limit = _parse_limit(parsed.query) + if limit is None: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") + server_slug = parse_qs(parsed.query).get("server", [None])[0] + return HTTPStatus.OK, build_recent_historical_matches_snapshot_payload( + limit=limit, + server_slug=server_slug, + ) + + if parsed.path == "/api/historical/matches/detail": + params = parse_qs(parsed.query) + server_slug = params.get("server", [None])[0] + match_id = params.get("match", [None])[0] + if not server_slug: + return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required") + if not match_id: + return HTTPStatus.BAD_REQUEST, build_error_payload("Match parameter is required") + return HTTPStatus.OK, build_historical_match_detail_payload( + server_slug=server_slug, + match_id=match_id, + ) + + if parsed.path == "/api/historical/server-summary": + server_slug = parse_qs(parsed.query).get("server", [None])[0] + return HTTPStatus.OK, build_historical_server_summary_payload(server_slug=server_slug) + + if parsed.path == "/api/historical/snapshots/server-summary": + server_slug = parse_qs(parsed.query).get("server", [None])[0] + return HTTPStatus.OK, build_historical_server_summary_snapshot_payload( + server_slug=server_slug + ) + + if parsed.path == "/api/historical/player-profile": + player_id = parse_qs(parsed.query).get("player", [None])[0] + if not player_id: + return HTTPStatus.BAD_REQUEST, build_error_payload("Player parameter is required") + return HTTPStatus.OK, build_historical_player_profile_payload(player_id) + + if parsed.path == "/api/historical/elo-mmr/leaderboard": + 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", [None])[0] + return HTTPStatus.OK, build_elo_mmr_leaderboard_payload( + limit=limit, + server_id=server_id, + ) + + if parsed.path == "/api/historical/elo-mmr/player": + params = parse_qs(parsed.query) + player_id = params.get("player", [None])[0] + if not player_id: + return HTTPStatus.BAD_REQUEST, build_error_payload("Player parameter is required") + server_id = params.get("server", [None])[0] + return HTTPStatus.OK, build_elo_mmr_player_payload( + player_id=player_id, + 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"): + server_id = parsed.path.removeprefix("/api/servers/").removesuffix("/history") + server_id = server_id.strip("/") + if not server_id: + return HTTPStatus.BAD_REQUEST, build_error_payload("Server id is required") + + limit = _parse_limit(parsed.query) + if limit is None: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") + + return HTTPStatus.OK, build_server_detail_history_payload(server_id, limit=limit) + return None, {} + + return HTTPStatus.OK, builder() + + +def _parse_limit(query: str) -> int | None: + raw_limit = parse_qs(query).get("limit", ["20"])[0] + try: + limit = int(raw_limit) + except ValueError: + return None + + if limit < 1 or limit > 100: + return None + + return limit diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py new file mode 100644 index 0000000..249aa28 --- /dev/null +++ b/backend/app/scheduler.py @@ -0,0 +1,100 @@ +"""Local development loop for periodic snapshot refreshes.""" + +from __future__ import annotations + +import argparse +import json +import time + +from .a2s_client import DEFAULT_A2S_TIMEOUT +from .collector import collect_server_snapshots +from .config import get_refresh_interval_seconds + + +def run_local_refresh_loop( + *, + interval_seconds: int, + source_mode: str, + timeout: float, + allow_controlled_fallback: bool, + max_runs: int | None = None, +) -> None: + """Run the collector periodically until interrupted or the run limit is reached.""" + completed_runs = 0 + print( + "Starting local snapshot refresh loop " + f"(interval={interval_seconds}s, source={source_mode}, persist=true)." + ) + print("Press Ctrl+C to stop.") + + try: + while max_runs is None or completed_runs < max_runs: + completed_runs += 1 + payload = collect_server_snapshots( + source_mode=source_mode, + timeout=timeout, + allow_controlled_fallback=allow_controlled_fallback, + persist=True, + ) + print(json.dumps({"run": completed_runs, **payload}, indent=2)) + + if max_runs is not None and completed_runs >= max_runs: + break + + time.sleep(interval_seconds) + except KeyboardInterrupt: + print("\nLocal snapshot refresh loop stopped by user.") + + +def main() -> None: + """Allow local scheduled refresh execution without adding external infrastructure.""" + parser = argparse.ArgumentParser( + description="Run periodic local snapshot refreshes for development and landing demos.", + ) + parser.add_argument( + "--interval", + type=int, + default=get_refresh_interval_seconds(), + help="Seconds to wait between persisted refresh runs. Defaults to env value or 60.", + ) + parser.add_argument( + "--source", + choices=("controlled", "a2s", "auto"), + default="auto", + help="Choose controlled data, configured A2S targets, or auto with fallback.", + ) + parser.add_argument( + "--timeout", + type=float, + default=DEFAULT_A2S_TIMEOUT, + help="Socket timeout in seconds for A2S probes.", + ) + parser.add_argument( + "--no-fallback", + action="store_true", + help="Disable fallback to controlled data when A2S fails.", + ) + parser.add_argument( + "--max-runs", + type=int, + default=None, + help="Optional safety limit for the number of refresh cycles to execute.", + ) + args = parser.parse_args() + + if args.interval <= 0: + raise ValueError("--interval must be a positive integer.") + if args.max_runs is not None and args.max_runs <= 0: + raise ValueError("--max-runs must be positive when provided.") + + run_local_refresh_loop( + interval_seconds=args.interval, + source_mode=args.source, + timeout=args.timeout, + allow_controlled_fallback=not args.no_fallback, + max_runs=args.max_runs, + ) + + +if __name__ == "__main__": + main() diff --git a/backend/app/scoreboard_candidate_backfill.py b/backend/app/scoreboard_candidate_backfill.py new file mode 100644 index 0000000..5db8a08 --- /dev/null +++ b/backend/app/scoreboard_candidate_backfill.py @@ -0,0 +1,259 @@ +"""Backfill public scoreboard candidates for RCON match link correlation.""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from collections.abc import Mapping +from typing import Iterable + +from .historical_storage import initialize_historical_storage, list_historical_servers, upsert_historical_match +from .postgres_rcon_storage import upsert_scoreboard_candidate +from .providers.public_scoreboard_provider import PublicScoreboardHistoricalDataSource +from .scoreboard_origins import ( + build_trusted_scoreboard_match_url, + get_trusted_public_scoreboard_origin, + list_trusted_public_scoreboard_origins, +) + +DEFAULT_MAX_PAGES = 20 +DEFAULT_PAGE_SIZE = 100 +DEFAULT_DETAIL_WORKERS = 4 + + +def main(argv: Iterable[str] | None = None) -> int: + parser = build_arg_parser() + args = parser.parse_args(list(argv) if argv is not None else None) + start_at = _parse_timestamp(args.start_at, option_name="--from") + end_at = _parse_timestamp(args.end_at, option_name="--to") + if end_at <= start_at: + parser.error("--to must be later than --from") + server = _resolve_server(args.server_slug, parser) + report = run_backfill(server=server, start_at=start_at, end_at=end_at, max_pages=args.max_pages, page_size=args.page_size, detail_workers=args.detail_workers) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 if not report["errors"] else 1 + + +def run_backfill(*, server: dict[str, object], start_at: datetime, end_at: datetime, max_pages: int, page_size: int, detail_workers: int) -> dict[str, object]: + initialize_historical_storage() + provider = PublicScoreboardHistoricalDataSource() + server_slug = str(server["slug"]) + base_url = str(server["scoreboard_base_url"]) + counters = { + "pages_processed": 0, + "candidates_seen": 0, + "list_candidates_inserted": 0, + "list_candidates_updated": 0, + "list_candidates_skipped": 0, + "candidates_inserted": 0, + "candidates_updated": 0, + "player_rows_inserted": 0, + "player_rows_updated": 0, + } + errors: list[dict[str, object]] = [] + skipped_unsafe_urls = 0 + stopped_after_window = False + for page in range(1, max_pages + 1): + try: + page_payload = provider.fetch_match_page(base_url=base_url, page=page, limit=page_size) + except Exception as exc: + errors.append({"stage": "fetch_match_page", "page": page, "message": str(exc)}) + break + matches = _coerce_match_list(page_payload.get("maps")) + if not matches: + break + counters["pages_processed"] += 1 + ids: list[str] = [] + for match in matches: + counters["candidates_seen"] += 1 + ref_time = _parse_optional_timestamp(_pick_match_timestamp(match)) + if ref_time and ref_time < start_at: + stopped_after_window = True + continue + if ref_time and ref_time >= end_at: + continue + candidate = _build_list_candidate(server=server, match=match) + if candidate is None: + counters["list_candidates_skipped"] += 1 + skipped_unsafe_urls += int(_list_candidate_url_is_unsafe(server=server, match=match)) + else: + try: + outcome = upsert_scoreboard_candidate( + server_slug=server_slug, + candidate=candidate, + ) + except Exception as exc: + counters["list_candidates_skipped"] += 1 + errors.append( + { + "stage": "upsert_list_scoreboard_candidate", + "match_id": candidate["external_match_id"], + "message": str(exc), + } + ) + else: + counters[f"list_candidates_{outcome}"] += 1 + match_id = _stringify(match.get("id")) + if match_id: + ids.append(match_id) + if ids: + try: + details = provider.fetch_match_details(base_url=base_url, match_ids=ids, max_workers=detail_workers) + except Exception as exc: + errors.append({"stage": "fetch_match_details", "page": page, "message": str(exc)}) + details = [] + for detail in details: + try: + delta = upsert_historical_match(server_slug=server_slug, match_payload=detail) + except Exception as exc: + errors.append({"stage": "upsert_historical_match", "match_id": _stringify(detail.get("id")), "message": str(exc)}) + continue + counters["candidates_inserted"] += _coerce_int(delta.get("matches_inserted")) + counters["candidates_updated"] += _coerce_int(delta.get("matches_updated")) + counters["player_rows_inserted"] += _coerce_int(delta.get("player_rows_inserted")) + counters["player_rows_updated"] += _coerce_int(delta.get("player_rows_updated")) + if stopped_after_window: + break + return {"status": "ok" if not errors else "partial", "server": server_slug, "scoreboard_base_url": base_url, "requested_window": {"from": _format_timestamp(start_at), "to": _format_timestamp(end_at)}, "stopped_after_window": stopped_after_window, "skipped_unsafe_urls": skipped_unsafe_urls, "errors": errors, **counters} + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Backfill public scoreboard match candidates for RCON link correlation.") + parser.add_argument("--server", dest="server_slug", required=True) + parser.add_argument("--from", dest="start_at", required=True) + parser.add_argument("--to", dest="end_at", required=True) + parser.add_argument("--max-pages", type=int, default=DEFAULT_MAX_PAGES) + parser.add_argument("--page-size", type=int, default=DEFAULT_PAGE_SIZE) + parser.add_argument("--detail-workers", type=int, default=DEFAULT_DETAIL_WORKERS) + return parser + + +def _resolve_server(server_slug: str, parser: argparse.ArgumentParser) -> dict[str, object]: + trusted = {origin.slug for origin in list_trusted_public_scoreboard_origins()} + if server_slug not in trusted: + parser.error(f"unknown or untrusted server '{server_slug}'") + for server in list_historical_servers(): + if server.get("slug") == server_slug: + return server + parser.error(f"trusted server '{server_slug}' is not present in historical storage") + raise AssertionError("unreachable") + + +def _parse_timestamp(value: str, *, option_name: str) -> datetime: + try: + parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"{option_name} must be an ISO timestamp") from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _parse_optional_timestamp(value: object) -> datetime | None: + if not isinstance(value, str) or not value.strip(): + return None + try: + return _parse_timestamp(value, option_name="timestamp") + except argparse.ArgumentTypeError: + return None + + +def _format_timestamp(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _coerce_match_list(payload: object) -> list[dict[str, object]]: + return [item for item in payload if isinstance(item, dict)] if isinstance(payload, list) else [] + + +def _pick_match_timestamp(match: dict[str, object]) -> object: + for key in ("end", "start", "creation_time"): + value = match.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _build_list_candidate( + *, + server: Mapping[str, object], + match: Mapping[str, object], +) -> dict[str, object] | None: + server_slug = _stringify(server.get("slug")) + external_match_id = _stringify(match.get("id")) + origin = get_trusted_public_scoreboard_origin(server_slug) + map_payload = match.get("map") + result_payload = match.get("result") + if ( + origin is None + or not external_match_id + or not external_match_id.isdigit() + or str(server.get("scoreboard_base_url") or "").strip() != origin.base_url + or _coerce_optional_int(server.get("server_number")) != origin.server_number + or _coerce_optional_int(match.get("server_number")) != origin.server_number + or not isinstance(map_payload, Mapping) + or not isinstance(result_payload, Mapping) + ): + return None + + started_at = _stringify(match.get("start")) + ended_at = _stringify(match.get("end")) + match_url = build_trusted_scoreboard_match_url( + server_slug=server_slug, + external_match_id=external_match_id, + ) + if not started_at or not ended_at or not match_url: + return None + return { + "external_match_id": external_match_id, + "started_at": started_at, + "ended_at": ended_at, + "map_name": _stringify(map_payload.get("id") or map_payload.get("name")), + "map_pretty_name": _stringify(map_payload.get("pretty_name")), + "allied_score": _coerce_optional_int(result_payload.get("allied")), + "axis_score": _coerce_optional_int(result_payload.get("axis")), + "player_count": _coerce_optional_int(match.get("player_count")), + "match_url": match_url, + } + + +def _list_candidate_url_is_unsafe( + *, + server: Mapping[str, object], + match: Mapping[str, object], +) -> bool: + external_match_id = _stringify(match.get("id")) + return bool( + external_match_id + and build_trusted_scoreboard_match_url( + server_slug=server.get("slug"), + external_match_id=external_match_id, + ) + is None + ) + + +def _stringify(value: object) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + +def _coerce_int(value: object) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def _coerce_optional_int(value: object) -> int | None: + try: + return None if value is None else int(value) + except (TypeError, ValueError): + return None + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/app/scoreboard_correlation_diagnostics.py b/backend/app/scoreboard_correlation_diagnostics.py new file mode 100644 index 0000000..0b88d8f --- /dev/null +++ b/backend/app/scoreboard_correlation_diagnostics.py @@ -0,0 +1,83 @@ +"""JSON diagnostics for missing materialized RCON scoreboard links.""" + +from __future__ import annotations + +import argparse +import json +from collections.abc import Iterable +from pathlib import Path + +from .rcon_admin_log_materialization import get_materialized_rcon_match_detail +from .rcon_historical_read_model import build_materialized_scoreboard_correlation_input +from .rcon_scoreboard_correlation import diagnose_rcon_scoreboard_correlation + + +def inspect_materialized_match_correlation( + *, + server_key: str, + match_key: str, + db_path: Path | None = None, +) -> dict[str, object]: + """Return safe scoreboard correlation diagnostics for one materialized match.""" + materialized = get_materialized_rcon_match_detail( + server_key=server_key, + match_key=match_key, + db_path=db_path, + ) + if materialized is None: + return { + "rcon_match_key": match_key, + "server": server_key, + "candidate_count": 0, + "top_candidates": [], + "selected_candidate": None, + "final_reason": "rcon-match-not-found", + } + + match = materialized["match"] + correlation_input = build_materialized_scoreboard_correlation_input(match) + correlation = diagnose_rcon_scoreboard_correlation( + **correlation_input, + db_path=db_path, + ) + return { + "rcon_match_key": match.get("match_key"), + "server": match.get("external_server_id") or match.get("target_key"), + "map": match.get("map_pretty_name") or match.get("map_name"), + "started_at": match.get("started_at"), + "ended_at": match.get("ended_at"), + "closed_at": match.get("ended_at") or match.get("started_at"), + "duration_seconds": correlation_input.get("duration_seconds"), + "score": { + "allied_score": match.get("allied_score"), + "axis_score": match.get("axis_score"), + "winner": match.get("winner"), + }, + **correlation, + } + + +def main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Explain scoreboard candidate correlation for one RCON match." + ) + parser.add_argument("--server", required=True) + parser.add_argument("--match", dest="match_key", required=True) + parser.add_argument("--db-path", type=Path, default=None) + args = parser.parse_args(list(argv) if argv is not None else None) + print( + json.dumps( + inspect_materialized_match_correlation( + server_key=args.server, + match_key=args.match_key, + db_path=args.db_path, + ), + ensure_ascii=False, + indent=2, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/app/scoreboard_origins.py b/backend/app/scoreboard_origins.py new file mode 100644 index 0000000..1aaf1b3 --- /dev/null +++ b/backend/app/scoreboard_origins.py @@ -0,0 +1,97 @@ +"""Trusted public scoreboard origins for active community servers.""" + +from __future__ import annotations + +from dataclasses import dataclass +import re +from urllib.parse import urlparse + + +@dataclass(frozen=True, slots=True) +class TrustedScoreboardOrigin: + """Public scoreboard origin trusted for one active community server.""" + + slug: str + display_name: str + base_url: str + server_number: int + source_kind: str = "crcon-scoreboard-json" + + +TRUSTED_PUBLIC_SCOREBOARD_ORIGINS = ( + TrustedScoreboardOrigin( + slug="comunidad-hispana-01", + display_name="Comunidad Hispana #01", + base_url="https://scoreboard.comunidadhll.es", + server_number=1, + ), + TrustedScoreboardOrigin( + slug="comunidad-hispana-02", + display_name="Comunidad Hispana #02", + base_url="https://scoreboard.comunidadhll.es:5443", + server_number=2, + ), +) + +_TRUSTED_GAME_PATH_RE = re.compile(r"^/games/\d+/?$") + + +def list_trusted_public_scoreboard_origins() -> tuple[TrustedScoreboardOrigin, ...]: + """Return trusted public scoreboard origins for active default servers.""" + return TRUSTED_PUBLIC_SCOREBOARD_ORIGINS + + +def get_trusted_public_scoreboard_origin( + server_slug: object, +) -> TrustedScoreboardOrigin | None: + """Return the trusted public scoreboard origin for one active server.""" + normalized_slug = str(server_slug or "").strip() + if not normalized_slug: + return None + for origin in TRUSTED_PUBLIC_SCOREBOARD_ORIGINS: + if origin.slug == normalized_slug: + return origin + return None + + +def resolve_trusted_scoreboard_match_url( + raw_payload_ref: object, + server_slug: object, +) -> str | None: + """Return a match URL only when it belongs to the trusted server origin.""" + origin = get_trusted_public_scoreboard_origin(server_slug) + candidate = str(raw_payload_ref or "").strip() + if origin is None or not candidate: + return None + + candidate_parts = urlparse(candidate) + origin_parts = urlparse(origin.base_url) + if candidate_parts.scheme not in {"http", "https"}: + return None + if candidate_parts.scheme != origin_parts.scheme: + return None + if candidate_parts.netloc != origin_parts.netloc: + return None + if candidate_parts.username or candidate_parts.password: + return None + if not _TRUSTED_GAME_PATH_RE.match(candidate_parts.path): + return None + if candidate_parts.params or candidate_parts.query or candidate_parts.fragment: + return None + return candidate + + +def build_trusted_scoreboard_match_url( + *, + server_slug: object, + external_match_id: object, +) -> str | None: + """Build a trusted scoreboard match URL from one numeric public match id.""" + origin = get_trusted_public_scoreboard_origin(server_slug) + match_id = str(external_match_id or "").strip() + if origin is None or not match_id.isdigit(): + return None + return resolve_trusted_scoreboard_match_url( + f"{origin.base_url}/games/{match_id}", + origin.slug, + ) diff --git a/backend/app/server_targets.py b/backend/app/server_targets.py new file mode 100644 index 0000000..8a349e4 --- /dev/null +++ b/backend/app/server_targets.py @@ -0,0 +1,106 @@ +"""Registry helpers for development-time A2S probe targets.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + +from .config import DEFAULT_A2S_SOURCE_NAME, get_a2s_targets_payload + + +DEFAULT_A2S_TARGETS = ( + { + "name": "Comunidad Hispana #01", + "host": "152.114.195.174", + "query_port": 7778, + "game_port": 7777, + "source_name": DEFAULT_A2S_SOURCE_NAME, + "external_server_id": "comunidad-hispana-01", + "region": "ES", + }, + { + "name": "Comunidad Hispana #02", + "host": "152.114.195.150", + "query_port": 7878, + "game_port": 7877, + "source_name": DEFAULT_A2S_SOURCE_NAME, + "external_server_id": "comunidad-hispana-02", + "region": "ES", + }, +) + + +@dataclass(frozen=True, slots=True) +class A2SServerTarget: + """Minimal configuration needed to query one A2S target.""" + + name: str + host: str + query_port: int + game_port: int | None + source_name: str + external_server_id: str | None = None + region: str | None = None + + +def load_a2s_targets() -> tuple[A2SServerTarget, ...]: + """Load configured A2S targets from env JSON or the local default registry.""" + raw_payload = get_a2s_targets_payload() + raw_targets = DEFAULT_A2S_TARGETS if raw_payload is None else _parse_targets(raw_payload) + return tuple(_coerce_target(item) for item in raw_targets) + + +def _parse_targets(raw_payload: str) -> list[dict[str, object]]: + try: + parsed = json.loads(raw_payload) + except json.JSONDecodeError as error: + raise ValueError("HLL_BACKEND_A2S_TARGETS must be valid JSON.") from error + + if not isinstance(parsed, list): + raise ValueError("HLL_BACKEND_A2S_TARGETS must be a JSON array.") + + return [item for item in parsed if isinstance(item, dict)] + + +def _coerce_target(raw_target: dict[str, object]) -> A2SServerTarget: + name = str(raw_target.get("name") or "Unnamed target").strip() + host = str(raw_target.get("host") or "").strip() + source_name = str(raw_target.get("source_name") or DEFAULT_A2S_SOURCE_NAME).strip() + query_port = int(raw_target.get("query_port") or 0) + game_port = _coerce_optional_positive_int(raw_target.get("game_port")) + external_server_id = _string_or_none(raw_target.get("external_server_id")) + region = _string_or_none(raw_target.get("region")) + + if not host: + raise ValueError("Each A2S target must define a non-empty host.") + if query_port <= 0: + raise ValueError("Each A2S target must define a valid query_port.") + + return A2SServerTarget( + name=name, + host=host, + query_port=query_port, + game_port=game_port, + source_name=source_name or DEFAULT_A2S_SOURCE_NAME, + external_server_id=external_server_id, + region=region, + ) + + +def _string_or_none(value: object) -> str | None: + if not isinstance(value, str): + return None + + normalized = value.strip() + return normalized or None + + +def _coerce_optional_positive_int(value: object) -> int | None: + if value is None: + return None + + coerced = int(value) + if coerced <= 0: + raise ValueError("Each A2S target game_port must be positive when defined.") + + return coerced diff --git a/backend/app/snapshots.py b/backend/app/snapshots.py new file mode 100644 index 0000000..234e832 --- /dev/null +++ b/backend/app/snapshots.py @@ -0,0 +1,54 @@ +"""Snapshot builders for normalized provisional server data.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Iterable, Mapping + + +def build_server_snapshot( + normalized_record: Mapping[str, object], + *, + captured_at: datetime, +) -> dict[str, object]: + """Build a consistent snapshot payload for one normalized server.""" + timestamp = _as_utc_timestamp(captured_at) + return { + "external_server_id": normalized_record.get("external_server_id"), + "server_name": normalized_record.get("server_name"), + "status": normalized_record.get("status"), + "players": normalized_record.get("players"), + "max_players": normalized_record.get("max_players"), + "current_map": normalized_record.get("current_map"), + "region": normalized_record.get("region"), + "source_name": normalized_record.get("source_name"), + "snapshot_origin": normalized_record.get("snapshot_origin"), + "source_ref": normalized_record.get("source_ref"), + "captured_at": timestamp, + } + + +def build_snapshot_batch( + normalized_records: Iterable[Mapping[str, object]], + *, + captured_at: datetime, +) -> list[dict[str, object]]: + """Build snapshots for a batch captured at the same timestamp.""" + return [ + build_server_snapshot(record, captured_at=captured_at) + for record in normalized_records + ] + + +def utc_now() -> datetime: + """Return the current UTC timestamp for snapshot capture.""" + return datetime.now(timezone.utc) + + +def _as_utc_timestamp(value: datetime) -> str: + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + else: + value = value.astimezone(timezone.utc) + + return value.isoformat().replace("+00:00", "Z") diff --git a/backend/app/sqlite_to_postgres_migration.py b/backend/app/sqlite_to_postgres_migration.py new file mode 100644 index 0000000..a28d102 --- /dev/null +++ b/backend/app/sqlite_to_postgres_migration.py @@ -0,0 +1,368 @@ +"""Idempotent phase-2 migration from displayed SQLite/files into PostgreSQL.""" + +from __future__ import annotations + +import json +import sqlite3 +from collections import defaultdict +from contextlib import closing +from pathlib import Path +from typing import Any + +from .config import get_storage_path +from .postgres_display_storage import ( + connect_postgres as connect_display_postgres, + initialize_postgres_display_storage, + persist_snapshot_record, +) +from .postgres_rcon_storage import initialize_postgres_rcon_storage + + +RCON_TABLES = ( + "rcon_historical_targets", + "rcon_historical_capture_runs", + "rcon_historical_samples", + "rcon_historical_checkpoints", + "rcon_historical_competitive_windows", + "rcon_admin_log_events", + "rcon_player_profile_snapshots", + "rcon_materialized_matches", + "rcon_match_player_stats", + "rcon_scoreboard_match_candidates", +) +DISPLAY_TABLES = ( + "game_sources", + "servers", + "server_snapshots", + "historical_servers", + "historical_maps", + "historical_matches", + "historical_players", + "historical_player_match_stats", + "player_event_raw_ledger", +) +SKIP_SLUG = "comunidad-hispana-03" + + +def migrate_sqlite_to_postgres() -> dict[str, object]: + """Copy displayed legacy data to PostgreSQL without deleting legacy sources.""" + initialize_postgres_rcon_storage() + initialize_postgres_display_storage() + summary: dict[str, object] = { + "status": "ok", + "source_paths": [], + "migrated_tables": [], + "migrated_domains": [], + "rows_read": {}, + "rows_inserted": {}, + "rows_updated": {}, + "rows_skipped": {}, + "errors": [], + } + table_totals: dict[str, dict[str, int]] = defaultdict( + lambda: {"read": 0, "inserted": 0, "updated": 0, "skipped": 0} + ) + for db_path in _discover_sqlite_paths(): + summary["source_paths"].append(str(db_path)) + try: + _migrate_sqlite_path(db_path, table_totals) + except Exception as error: # noqa: BLE001 - report all source failures + summary["errors"].append({"source_path": str(db_path), "error": str(error)}) + + snapshots_root = get_storage_path().parent / "snapshots" + if snapshots_root.exists(): + summary["source_paths"].append(str(snapshots_root)) + _migrate_snapshot_files(snapshots_root, table_totals, summary["errors"]) + _sync_sequences() + summary["migrated_tables"] = sorted(table_totals) + summary["migrated_domains"] = [ + "rcon-admin-log-events", + "rcon-player-profile-snapshots", + "rcon-historical-capture-samples-and-windows", + "rcon-materialized-matches", + "rcon-materialized-player-stats", + "rcon-safe-scoreboard-candidates", + "public-scoreboard-historical-matches-and-player-stats", + "weekly-and-monthly-scoreboard-rankings", + "displayed-historical-snapshots", + "live-server-summary-cache", + "player-event-ledger", + ] + for table_name, totals in sorted(table_totals.items()): + summary["rows_read"][table_name] = totals["read"] + summary["rows_inserted"][table_name] = totals["inserted"] + summary["rows_updated"][table_name] = totals["updated"] + summary["rows_skipped"][table_name] = totals["skipped"] + summary["status"] = "ok" if not summary["errors"] else "completed-with-errors" + return summary + + +def _migrate_sqlite_path(db_path: Path, totals: dict[str, dict[str, int]]) -> None: + with closing(sqlite3.connect(db_path)) as sqlite_connection: + sqlite_connection.row_factory = sqlite3.Row + available_tables = { + row["name"] + for row in sqlite_connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ).fetchall() + } + tables = [table for table in (*RCON_TABLES, *DISPLAY_TABLES) if table in available_tables] + with connect_display_postgres() as postgres_connection: + postgres_columns = { + table: _postgres_columns(postgres_connection, table) + for table in tables + } + historical_server_ids = _legacy_server03_ids(sqlite_connection) + historical_match_ids = _legacy_match_ids(sqlite_connection, historical_server_ids) + legacy_rcon_target_ids = _legacy_rcon_target03_ids(sqlite_connection) + for table_name in tables: + _copy_table( + sqlite_connection, + postgres_connection, + table_name=table_name, + postgres_columns=postgres_columns[table_name], + totals=totals[table_name], + historical_server_ids=historical_server_ids, + historical_match_ids=historical_match_ids, + legacy_rcon_target_ids=legacy_rcon_target_ids, + ) + + +def _copy_table( + sqlite_connection: sqlite3.Connection, + postgres_connection: Any, + *, + table_name: str, + postgres_columns: list[str], + totals: dict[str, int], + historical_server_ids: set[int], + historical_match_ids: set[int], + legacy_rcon_target_ids: set[int], +) -> None: + sqlite_columns = [ + str(row["name"]) + for row in sqlite_connection.execute(f"PRAGMA table_info({table_name})").fetchall() + ] + columns = [column for column in sqlite_columns if column in postgres_columns] + if not columns: + return + rows = sqlite_connection.execute( + f"SELECT {', '.join(columns)} FROM {table_name}" + ).fetchall() + placeholders = ", ".join(["%s"] * len(columns)) + sql = ( + f"INSERT INTO {table_name} ({', '.join(columns)}) " + f"VALUES ({placeholders}) ON CONFLICT DO NOTHING" + ) + values: list[tuple[object, ...]] = [] + for row in rows: + totals["read"] += 1 + row_dict = dict(row) + if _skip_row( + table_name, + row_dict, + historical_server_ids=historical_server_ids, + historical_match_ids=historical_match_ids, + legacy_rcon_target_ids=legacy_rcon_target_ids, + ): + totals["skipped"] += 1 + continue + values.append(tuple(_postgres_value(column, row_dict[column]) for column in columns)) + with postgres_connection.cursor() as cursor: + for start in range(0, len(values), 1000): + batch = values[start : start + 1000] + cursor.executemany(sql, batch) + inserted = max(0, int(cursor.rowcount or 0)) + totals["inserted"] += inserted + totals["skipped"] += len(batch) - inserted + + +def _migrate_snapshot_files( + snapshots_root: Path, + totals: dict[str, dict[str, int]], + errors: list[object], +) -> None: + snapshot_totals = totals["displayed_historical_snapshots"] + for snapshot_path in sorted(snapshots_root.glob("*/*.json")): + snapshot_totals["read"] += 1 + try: + document = json.loads(snapshot_path.read_text(encoding="utf-8")) + if str(document.get("server_key") or "") == SKIP_SLUG: + snapshot_totals["skipped"] += 1 + continue + before = _snapshot_exists(document) + persist_snapshot_record(document) + snapshot_totals["updated" if before else "inserted"] += 1 + except Exception as error: # noqa: BLE001 - keep migrating neighboring snapshots + snapshot_totals["skipped"] += 1 + errors.append({"source_path": str(snapshot_path), "error": str(error)}) + + +def _snapshot_exists(document: dict[str, object]) -> bool: + with connect_display_postgres() as connection: + row = connection.execute( + """ + SELECT 1 FROM displayed_historical_snapshots + WHERE server_key = %s AND snapshot_type = %s AND metric = %s AND snapshot_window = %s + """, + ( + str(document.get("server_key") or ""), + str(document.get("snapshot_type") or ""), + str(document.get("metric") or ""), + str(document.get("window") or ""), + ), + ).fetchone() + return bool(row) + + +def _skip_row( + table_name: str, + row: dict[str, object], + *, + historical_server_ids: set[int], + historical_match_ids: set[int], + legacy_rcon_target_ids: set[int], +) -> bool: + if row.get("server_slug") == SKIP_SLUG or row.get("slug") == SKIP_SLUG: + return True + if row.get("external_server_id") == SKIP_SLUG or row.get("target_key") == SKIP_SLUG: + return True + if table_name == "historical_matches" and row.get("historical_server_id") in historical_server_ids: + return True + if ( + table_name == "historical_player_match_stats" + and row.get("historical_match_id") in historical_match_ids + ): + return True + if table_name == "rcon_historical_samples" and row.get("target_id") in legacy_rcon_target_ids: + return True + if table_name == "rcon_historical_checkpoints" and row.get("target_id") in legacy_rcon_target_ids: + return True + if table_name == "rcon_historical_competitive_windows" and row.get("target_id") in legacy_rcon_target_ids: + return True + return False + + +def _legacy_server03_ids(connection: sqlite3.Connection) -> set[int]: + if not _has_table(connection, "historical_servers"): + return set() + return { + int(row["id"]) + for row in connection.execute( + "SELECT id FROM historical_servers WHERE slug = ?", + (SKIP_SLUG,), + ).fetchall() + } + + +def _legacy_rcon_target03_ids(connection: sqlite3.Connection) -> set[int]: + if not _has_table(connection, "rcon_historical_targets"): + return set() + return { + int(row["id"]) + for row in connection.execute( + """ + SELECT id FROM rcon_historical_targets + WHERE external_server_id = ? OR target_key = ? + """, + (SKIP_SLUG, SKIP_SLUG), + ).fetchall() + } + + +def _legacy_match_ids(connection: sqlite3.Connection, historical_server_ids: set[int]) -> set[int]: + if not historical_server_ids or not _has_table(connection, "historical_matches"): + return set() + placeholders = ", ".join(["?"] * len(historical_server_ids)) + return { + int(row["id"]) + for row in connection.execute( + f"SELECT id FROM historical_matches WHERE historical_server_id IN ({placeholders})", + tuple(sorted(historical_server_ids)), + ).fetchall() + } + + +def _postgres_columns(connection: Any, table_name: str) -> list[str]: + rows = connection.execute( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = %s + ORDER BY ordinal_position + """, + (table_name,), + ).fetchall() + return [str(row["column_name"]) for row in rows] + + +def _sync_sequences() -> None: + tables = ( + "game_sources", + "servers", + "server_snapshots", + "historical_servers", + "historical_maps", + "historical_matches", + "historical_players", + "historical_player_match_stats", + "player_event_raw_ledger", + "rcon_historical_targets", + "rcon_historical_capture_runs", + "rcon_historical_samples", + "rcon_historical_competitive_windows", + "rcon_admin_log_events", + "rcon_player_profile_snapshots", + "rcon_materialized_matches", + "rcon_match_player_stats", + "rcon_scoreboard_match_candidates", + ) + with connect_display_postgres() as connection: + for table_name in tables: + connection.execute( + f""" + SELECT setval( + pg_get_serial_sequence(%s, 'id'), + GREATEST(COALESCE((SELECT MAX(id) FROM {table_name}), 1), 1), + TRUE + ) + """, + (table_name,), + ) + + +def _discover_sqlite_paths() -> list[Path]: + configured = get_storage_path() + candidates = {configured} + if configured.parent.exists(): + candidates.update(configured.parent.glob("*.sqlite*")) + return sorted( + path + for path in candidates + if path.exists() + and path.is_file() + and not str(path).endswith(("-shm", "-wal")) + ) + + +def _has_table(connection: sqlite3.Connection, table_name: str) -> bool: + return bool( + connection.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + (table_name,), + ).fetchone() + ) + + +def _postgres_value(column: str, value: object) -> object: + if column in {"is_active", "is_teamkill"}: + return bool(value) + return value + + +def main() -> None: + print(json.dumps(migrate_sqlite_to_postgres(), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/backend/app/sqlite_utils.py b/backend/app/sqlite_utils.py new file mode 100644 index 0000000..7bdf40f --- /dev/null +++ b/backend/app/sqlite_utils.py @@ -0,0 +1,41 @@ +"""Shared SQLite connection helpers for backend persistence layers.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from .config import get_sqlite_busy_timeout_ms, get_sqlite_writer_timeout_seconds + + +def connect_sqlite_writer( + db_path: Path, + *, + timeout_seconds: float | None = None, + busy_timeout_ms: int | None = None, +) -> sqlite3.Connection: + """Open one SQLite connection with the common writer policy.""" + resolved_timeout_seconds = ( + get_sqlite_writer_timeout_seconds() + if timeout_seconds is None + else timeout_seconds + ) + resolved_busy_timeout_ms = ( + get_sqlite_busy_timeout_ms() + if busy_timeout_ms is None + else busy_timeout_ms + ) + + connection = sqlite3.connect(db_path, timeout=resolved_timeout_seconds) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + connection.execute("PRAGMA journal_mode = WAL") + connection.execute(f"PRAGMA busy_timeout = {resolved_busy_timeout_ms}") + return connection + + +def connect_sqlite_readonly(db_path: Path) -> sqlite3.Connection: + """Open one read-only SQLite connection with row access enabled.""" + connection = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + connection.row_factory = sqlite3.Row + return connection diff --git a/backend/app/storage.py b/backend/app/storage.py new file mode 100644 index 0000000..e64c4ad --- /dev/null +++ b/backend/app/storage.py @@ -0,0 +1,549 @@ +"""Local SQLite persistence for provisional server snapshots.""" + +from __future__ import annotations + +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterable, Mapping + +from .config import get_storage_path, use_postgres_rcon_storage +from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer + + +DEFAULT_GAME_SOURCE = { + "slug": "current-hll", + "display_name": "Current Hell Let Loose", + "provider_kind": "development", +} +SUMMARY_SNAPSHOT_LIMIT = 6 + + +def resolve_storage_path(*, db_path: Path | None = None) -> Path: + """Resolve the SQLite path used by live snapshot persistence.""" + return db_path or get_storage_path() + + +def initialize_storage(*, db_path: Path | None = None) -> Path: + """Create the local database file and minimal schema when missing.""" + resolved_path = resolve_storage_path(db_path=db_path) + resolved_path.parent.mkdir(parents=True, exist_ok=True) + + with _connect(resolved_path) as connection: + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS game_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + provider_kind TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS servers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + game_source_id INTEGER NOT NULL, + external_server_id TEXT, + server_name TEXT NOT NULL, + region TEXT, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (game_source_id, external_server_id), + FOREIGN KEY (game_source_id) REFERENCES game_sources(id) + ); + + CREATE TABLE IF NOT EXISTS server_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL, + captured_at TEXT NOT NULL, + status TEXT NOT NULL, + players INTEGER, + max_players INTEGER, + current_map TEXT, + source_name TEXT NOT NULL, + snapshot_origin TEXT, + source_ref TEXT, + raw_payload_ref TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (server_id) REFERENCES servers(id) + ); + + CREATE INDEX IF NOT EXISTS idx_server_snapshots_server_time + ON server_snapshots(server_id, captured_at); + """ + ) + _ensure_server_snapshot_columns(connection) + + return resolved_path + + +def persist_snapshot_batch( + snapshots: Iterable[Mapping[str, object]], + *, + source_name: str, + captured_at: str, + game_source: Mapping[str, str] | None = None, + db_path: Path | None = None, +) -> dict[str, object]: + """Persist a batch of normalized snapshots into local SQLite storage.""" + source_definition = dict(DEFAULT_GAME_SOURCE) + if game_source is not None: + source_definition.update(game_source) + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import persist_server_snapshots + + return persist_server_snapshots( + snapshots, + source_name=source_name, + captured_at=captured_at, + game_source=source_definition, + ) + resolved_path = initialize_storage(db_path=db_path) + + persisted = 0 + with _connect(resolved_path) as connection: + game_source_id = _upsert_game_source(connection, source_definition) + for snapshot in snapshots: + server_id = _upsert_server( + connection, + game_source_id=game_source_id, + snapshot=snapshot, + captured_at=captured_at, + ) + connection.execute( + """ + INSERT INTO server_snapshots ( + server_id, + captured_at, + status, + players, + max_players, + current_map, + source_name, + snapshot_origin, + source_ref, + raw_payload_ref + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + server_id, + captured_at, + snapshot.get("status"), + snapshot.get("players"), + snapshot.get("max_players"), + snapshot.get("current_map"), + snapshot.get("source_name") or source_name, + snapshot.get("snapshot_origin"), + snapshot.get("source_ref"), + None, + ), + ) + persisted += 1 + + return { + "db_path": str(resolved_path), + "captured_at": captured_at, + "persisted_snapshots": persisted, + "game_source_slug": source_definition["slug"], + } + + +def list_latest_snapshots(*, db_path: Path | None = None) -> list[dict[str, object]]: + """Return the latest persisted snapshot for each known server.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import list_latest_server_snapshots + + return list_latest_server_snapshots() + resolved_path = resolve_storage_path(db_path=db_path) + if not resolved_path.exists(): + return [] + with _connect_readonly(resolved_path) as connection: + rows = connection.execute( + """ + SELECT + servers.id AS server_id, + servers.external_server_id, + servers.server_name, + servers.region, + game_sources.slug AS context, + server_snapshots.source_name, + server_snapshots.snapshot_origin, + server_snapshots.source_ref, + server_snapshots.captured_at, + server_snapshots.status, + server_snapshots.players, + server_snapshots.max_players, + server_snapshots.current_map + FROM servers + INNER JOIN game_sources + ON game_sources.id = servers.game_source_id + INNER JOIN server_snapshots + ON server_snapshots.server_id = servers.id + INNER JOIN ( + SELECT server_id, MAX(captured_at) AS latest_captured_at + FROM server_snapshots + GROUP BY server_id + ) AS latest + ON latest.server_id = server_snapshots.server_id + AND latest.latest_captured_at = server_snapshots.captured_at + ORDER BY servers.server_name ASC + """ + ).fetchall() + items = [_serialize_snapshot_row(row) for row in rows] + return _attach_history_summaries(connection, items) + + +def list_snapshot_history( + *, + db_path: Path | None = None, + limit: int = 20, +) -> list[dict[str, object]]: + """Return recent persisted snapshots across all servers.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import list_server_snapshot_history + + return list_server_snapshot_history(limit=limit) + resolved_path = resolve_storage_path(db_path=db_path) + if not resolved_path.exists(): + return [] + with _connect_readonly(resolved_path) as connection: + rows = connection.execute( + """ + SELECT + servers.id AS server_id, + servers.external_server_id, + servers.server_name, + servers.region, + game_sources.slug AS context, + server_snapshots.source_name, + server_snapshots.snapshot_origin, + server_snapshots.source_ref, + server_snapshots.captured_at, + server_snapshots.status, + server_snapshots.players, + server_snapshots.max_players, + server_snapshots.current_map + FROM server_snapshots + INNER JOIN servers + ON servers.id = server_snapshots.server_id + INNER JOIN game_sources + ON game_sources.id = servers.game_source_id + ORDER BY server_snapshots.captured_at DESC, servers.server_name ASC + LIMIT ? + """, + (limit,), + ).fetchall() + return [_serialize_snapshot_row(row) for row in rows] + + +def list_server_history( + server_id: str, + *, + db_path: Path | None = None, + limit: int = 20, +) -> list[dict[str, object]]: + """Return recent history for one server by numeric id or external id.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import list_server_snapshot_history + + return list_server_snapshot_history(server_id=server_id, limit=limit) + resolved_path = resolve_storage_path(db_path=db_path) + if not resolved_path.exists(): + return [] + server_filter, server_value = _build_server_filter(server_id) + with _connect_readonly(resolved_path) as connection: + rows = connection.execute( + f""" + SELECT + servers.id AS server_id, + servers.external_server_id, + servers.server_name, + servers.region, + game_sources.slug AS context, + server_snapshots.source_name, + server_snapshots.snapshot_origin, + server_snapshots.source_ref, + server_snapshots.captured_at, + server_snapshots.status, + server_snapshots.players, + server_snapshots.max_players, + server_snapshots.current_map + FROM server_snapshots + INNER JOIN servers + ON servers.id = server_snapshots.server_id + INNER JOIN game_sources + ON game_sources.id = servers.game_source_id + WHERE {server_filter} = ? + ORDER BY server_snapshots.captured_at DESC + LIMIT ? + """, + (server_value, limit), + ).fetchall() + return [_serialize_snapshot_row(row) for row in rows] + + +def _connect(db_path: Path) -> sqlite3.Connection: + return connect_sqlite_writer(db_path) + + +def _connect_readonly(db_path: Path) -> sqlite3.Connection: + return connect_sqlite_readonly(db_path) + + +def _upsert_game_source( + connection: sqlite3.Connection, + game_source: Mapping[str, str], +) -> int: + connection.execute( + """ + INSERT INTO game_sources (slug, display_name, provider_kind, is_active) + VALUES (?, ?, ?, 1) + ON CONFLICT(slug) DO UPDATE SET + display_name = excluded.display_name, + provider_kind = excluded.provider_kind, + is_active = 1, + updated_at = CURRENT_TIMESTAMP + """, + ( + game_source["slug"], + game_source["display_name"], + game_source["provider_kind"], + ), + ) + row = connection.execute( + "SELECT id FROM game_sources WHERE slug = ?", + (game_source["slug"],), + ).fetchone() + if row is None: + raise RuntimeError("Failed to resolve game source during snapshot persistence.") + + return int(row["id"]) + + +def _upsert_server( + connection: sqlite3.Connection, + *, + game_source_id: int, + snapshot: Mapping[str, object], + captured_at: str, +) -> int: + external_server_id = snapshot.get("external_server_id") + if not isinstance(external_server_id, str) or not external_server_id.strip(): + external_server_id = _build_fallback_external_id(snapshot) + + server_name = str(snapshot.get("server_name") or "Unknown server") + region = snapshot.get("region") + + connection.execute( + """ + INSERT INTO servers ( + game_source_id, + external_server_id, + server_name, + region, + first_seen_at, + last_seen_at + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(game_source_id, external_server_id) DO UPDATE SET + server_name = excluded.server_name, + region = excluded.region, + last_seen_at = excluded.last_seen_at, + updated_at = CURRENT_TIMESTAMP + """, + ( + game_source_id, + external_server_id, + server_name, + region, + captured_at, + captured_at, + ), + ) + row = connection.execute( + """ + SELECT id + FROM servers + WHERE game_source_id = ? AND external_server_id = ? + """, + (game_source_id, external_server_id), + ).fetchone() + if row is None: + raise RuntimeError("Failed to resolve server during snapshot persistence.") + + return int(row["id"]) + + +def _build_fallback_external_id(snapshot: Mapping[str, object]) -> str: + server_name = str(snapshot.get("server_name") or "unknown-server") + normalized = "".join( + character.lower() if character.isalnum() else "-" + for character in server_name + ) + compact = "-".join(part for part in normalized.split("-") if part) + return compact or "unknown-server" + + +def _ensure_server_snapshot_columns(connection: sqlite3.Connection) -> None: + columns = { + str(row["name"]) + for row in connection.execute("PRAGMA table_info(server_snapshots)").fetchall() + } + + if "snapshot_origin" not in columns: + connection.execute("ALTER TABLE server_snapshots ADD COLUMN snapshot_origin TEXT") + if "source_ref" not in columns: + connection.execute("ALTER TABLE server_snapshots ADD COLUMN source_ref TEXT") + + connection.execute( + """ + UPDATE server_snapshots + SET snapshot_origin = CASE + WHEN source_name = 'controlled-placeholder' THEN 'controlled-fallback' + WHEN source_name LIKE '%a2s%' THEN 'real-a2s' + ELSE 'unknown' + END + WHERE snapshot_origin IS NULL OR snapshot_origin = '' + """ + ) + connection.execute( + """ + UPDATE server_snapshots + SET source_ref = source_name + WHERE source_ref IS NULL OR source_ref = '' + """ + ) + _backfill_registered_a2s_source_refs(connection) + + +def _backfill_registered_a2s_source_refs(connection: sqlite3.Connection) -> None: + from .server_targets import load_a2s_targets + + for target in load_a2s_targets(): + if not target.external_server_id: + continue + + connection.execute( + """ + UPDATE server_snapshots + SET source_ref = ? + WHERE snapshot_origin = 'real-a2s' + AND source_ref = source_name + AND server_id IN ( + SELECT id + FROM servers + WHERE external_server_id = ? + ) + """, + ( + f"a2s://{target.host}:{target.query_port}", + target.external_server_id, + ), + ) + + +def _serialize_snapshot_row(row: sqlite3.Row) -> dict[str, object]: + return { + "server_id": row["server_id"], + "external_server_id": row["external_server_id"], + "server_name": row["server_name"], + "region": row["region"], + "context": row["context"], + "source_name": row["source_name"], + "snapshot_origin": row["snapshot_origin"], + "source_ref": row["source_ref"], + "captured_at": row["captured_at"], + "status": row["status"], + "players": row["players"], + "max_players": row["max_players"], + "current_map": row["current_map"], + } + + +def _attach_history_summaries( + connection: sqlite3.Connection, + items: list[dict[str, object]], +) -> list[dict[str, object]]: + enriched_items: list[dict[str, object]] = [] + for item in items: + enriched = dict(item) + enriched["history_summary"] = _build_history_summary( + connection, + int(item["server_id"]), + ) + enriched_items.append(enriched) + + return enriched_items + + +def _build_history_summary( + connection: sqlite3.Connection, + server_id: int, +) -> dict[str, object]: + rows = connection.execute( + """ + SELECT + captured_at, + status, + players + FROM server_snapshots + WHERE server_id = ? + ORDER BY captured_at DESC + LIMIT ? + """, + (server_id, SUMMARY_SNAPSHOT_LIMIT), + ).fetchall() + return _summarize_history_rows(rows) + + +def _summarize_history_rows(rows: list[sqlite3.Row]) -> dict[str, object]: + capture_count = len(rows) + player_values = [ + int(row["players"]) + for row in rows + if row["players"] is not None + ] + online_rows = [row for row in rows if row["status"] == "online"] + latest_captured_at = str(rows[0]["captured_at"]) if rows else None + last_seen_online_at = str(online_rows[0]["captured_at"]) if online_rows else None + + return { + "window_size": SUMMARY_SNAPSHOT_LIMIT, + "recent_capture_count": capture_count, + "recent_online_count": len(online_rows), + "recent_average_players": _round_average(player_values), + "recent_peak_players": max(player_values, default=None), + "last_seen_online_at": last_seen_online_at, + "minutes_since_last_capture": _minutes_since_timestamp(latest_captured_at), + } + + +def _round_average(values: list[int]) -> float | None: + if not values: + return None + + return round(sum(values) / len(values), 1) + + +def _minutes_since_timestamp(timestamp: str | None) -> int | None: + if not timestamp: + return None + + normalized = timestamp.replace("Z", "+00:00") + captured_at = datetime.fromisoformat(normalized) + if captured_at.tzinfo is None: + captured_at = captured_at.replace(tzinfo=timezone.utc) + + delta = datetime.now(timezone.utc) - captured_at.astimezone(timezone.utc) + return max(0, int(delta.total_seconds() // 60)) + + +def _build_server_filter(server_id: str) -> tuple[str, object]: + normalized = server_id.strip() + if normalized.isdigit(): + return "servers.id", int(normalized) + + return "servers.external_server_id", normalized diff --git a/backend/app/storage_diagnostics.py b/backend/app/storage_diagnostics.py new file mode 100644 index 0000000..1ee8042 --- /dev/null +++ b/backend/app/storage_diagnostics.py @@ -0,0 +1,164 @@ +"""Report active PostgreSQL/displayed storage backend and migration parity counts.""" + +from __future__ import annotations + +import json +import sqlite3 +from contextlib import closing + +from .config import get_database_url, get_storage_path, use_postgres_rcon_storage +from .rcon_admin_log_materialization import summarize_rcon_materialization_status +from .rcon_admin_log_storage import initialize_rcon_admin_log_storage +from .sqlite_utils import connect_sqlite_readonly + + +MIGRATED_RCON_TABLES = ( + "rcon_admin_log_events", + "rcon_player_profile_snapshots", + "rcon_materialized_matches", + "rcon_match_player_stats", + "rcon_historical_targets", + "rcon_historical_samples", + "rcon_historical_competitive_windows", + "rcon_scoreboard_match_candidates", +) + + +def build_storage_diagnostics() -> dict[str, object]: + """Return one JSON-safe diagnostic payload for the migrated domains.""" + if use_postgres_rcon_storage(): + from .postgres_rcon_storage import count_migrated_tables + from .postgres_display_storage import table_counts + + rcon_counts = count_migrated_tables() + displayed_counts = table_counts() + backend = "postgresql" + else: + rcon_counts = _count_sqlite_tables() + displayed_counts = {} + backend = "sqlite-fallback" + materialization = summarize_rcon_materialization_status() + return { + "active_storage_backend": backend, + "database_url_configured": bool(get_database_url()), + "sqlite_fallback_path": str(get_storage_path()), + "migrated_domains": [ + "rcon-admin-log-events", + "rcon-player-profile-snapshots", + "rcon-historical-capture-samples-and-windows", + "rcon-materialized-matches", + "rcon-materialized-player-stats", + "rcon-safe-scoreboard-candidates", + "public-scoreboard-historical-matches-and-player-stats", + "weekly-rankings", + "monthly-rankings", + "displayed-historical-snapshots", + "server-summary-and-live-server-cache", + "player-event-ledger", + ], + "table_counts": { + **rcon_counts, + **displayed_counts, + "admin_log_events": rcon_counts.get("rcon_admin_log_events", 0), + "materialized_matches": rcon_counts.get("rcon_materialized_matches", 0), + "player_stats": rcon_counts.get("rcon_match_player_stats", 0), + "public_scoreboard_historical_matches": displayed_counts.get( + "historical_matches", 0 + ), + "weekly_rankings_source_stats": displayed_counts.get( + "historical_player_match_stats", 0 + ), + "monthly_rankings_source_stats": displayed_counts.get( + "historical_player_match_stats", 0 + ), + "server_summary_cache": displayed_counts.get("displayed_historical_snapshots", 0), + "player_event_ledger": displayed_counts.get("player_event_raw_ledger", 0), + "scoreboard_candidates": rcon_counts.get("rcon_scoreboard_match_candidates", 0), + }, + "latest_materialized_matches": materialization["latest_materialized_matches"], + "latest_admin_log_match_end_events": materialization[ + "latest_admin_log_match_end_events" + ], + "match_end_status": materialization["match_end_status"], + "remaining_sqlite_or_file_backed_domains": [ + { + "domain": "public-scoreboard ingestion run and backfill checkpoints", + "displayed_in_frontend": False, + "reason": "operational import bookkeeping is not read by visible pages", + "planned_phase": "phase-3-or-when-scoreboard-import-runs-on-postgresql", + }, + { + "domain": "Elo/MMR tables", + "displayed_in_frontend": False, + "reason": "Elo/MMR remains paused and hidden from visible pages", + "planned_phase": "phase-3", + }, + ], + "sqlite_remaining": [ + "public-scoreboard ingestion run and backfill checkpoints", + "paused Elo/MMR tables", + ], + "scoreboard_correlation": "PostgreSQL safe candidates and migrated trusted historical match URLs are used.", + "external_player_ids": _postgres_external_player_id_diagnostics() + if backend == "postgresql" + else { + "available_in_postgresql": False, + "reason": "PostgreSQL storage is not active.", + }, + "migration_parity_summary": { + "available": backend == "postgresql", + "source_command": "python -m app.sqlite_to_postgres_migration", + "displayed_historical_storage": ( + "postgresql" if backend == "postgresql" else "sqlite-or-file-fallback" + ), + }, + } + + +def _count_sqlite_tables() -> dict[str, int]: + resolved_path = initialize_rcon_admin_log_storage() + counts: dict[str, int] = {} + with closing(connect_sqlite_readonly(resolved_path)) as connection: + for table_name in MIGRATED_RCON_TABLES: + try: + row = connection.execute( + f"SELECT COUNT(*) AS count FROM {table_name}" + ).fetchone() + except sqlite3.Error: + counts[table_name] = 0 + else: + counts[table_name] = int(row["count"] or 0) + return counts + + +def _postgres_external_player_id_diagnostics() -> dict[str, object]: + from .postgres_rcon_storage import connect_postgres + + with connect_postgres() as connection: + row = connection.execute( + """ + SELECT + (SELECT COUNT(*) FROM rcon_match_player_stats + WHERE player_id ~ '^[0-9]{17}$') AS rcon_match_steam_id64_rows, + (SELECT COUNT(*) FROM rcon_player_profile_snapshots + WHERE player_id ~ '^[0-9]{17}$') AS rcon_profile_steam_id64_rows, + (SELECT COUNT(*) FROM historical_players + WHERE steam_id ~ '^[0-9]{17}$') AS scoreboard_player_steam_id64_rows + """ + ).fetchone() + return { + "available_in_postgresql": True, + "rcon_match_steam_id64_rows": int(row["rcon_match_steam_id64_rows"] or 0), + "rcon_profile_steam_id64_rows": int(row["rcon_profile_steam_id64_rows"] or 0), + "scoreboard_player_steam_id64_rows": int( + row["scoreboard_player_steam_id64_rows"] or 0 + ), + } + + +def main() -> None: + print(json.dumps(build_storage_diagnostics(), ensure_ascii=False, indent=2, default=str)) + + +if __name__ == "__main__": + main() diff --git a/backend/app/writer_lock.py b/backend/app/writer_lock.py new file mode 100644 index 0000000..8186ee3 --- /dev/null +++ b/backend/app/writer_lock.py @@ -0,0 +1,255 @@ +"""Shared single-writer lock coordination for backend automation jobs.""" + +from __future__ import annotations + +import json +import os +import socket +import sys +import time +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from uuid import uuid4 + +from .config import ( + get_storage_path, + get_writer_lock_poll_interval_seconds, + get_writer_lock_timeout_seconds, +) + + +class BackendWriterLockTimeoutError(RuntimeError): + """Raised when the shared backend writer lock cannot be acquired in time.""" + + +_ACTIVE_LOCK_DEPTH_BY_PATH: dict[Path, int] = {} +_ACTIVE_LOCK_TOKEN_BY_PATH: dict[Path, str] = {} +CONTAINER_STALE_LOCK_GRACE_SECONDS = 300 + + +def resolve_backend_writer_lock_path(*, storage_path: Path | None = None) -> Path: + """Return the shared lock path derived from the configured SQLite storage path.""" + resolved_storage_path = storage_path or get_storage_path() + return resolved_storage_path.parent / f"{resolved_storage_path.stem}.writer.lock" + + +@contextmanager +def backend_writer_lock( + *, + holder: str, + storage_path: Path | None = None, + timeout_seconds: float | None = None, + poll_interval_seconds: float | None = None, +): + """Acquire the shared backend writer lock with reentrant safety per process.""" + lock_path = resolve_backend_writer_lock_path(storage_path=storage_path).resolve() + if lock_path in _ACTIVE_LOCK_DEPTH_BY_PATH: + _ACTIVE_LOCK_DEPTH_BY_PATH[lock_path] += 1 + try: + yield _read_lock_metadata(lock_path) + finally: + _ACTIVE_LOCK_DEPTH_BY_PATH[lock_path] -= 1 + if _ACTIVE_LOCK_DEPTH_BY_PATH[lock_path] <= 0: + _ACTIVE_LOCK_DEPTH_BY_PATH.pop(lock_path, None) + _ACTIVE_LOCK_TOKEN_BY_PATH.pop(lock_path, None) + return + + metadata = _acquire_backend_writer_lock( + lock_path=lock_path, + holder=holder, + timeout_seconds=get_writer_lock_timeout_seconds() + if timeout_seconds is None + else timeout_seconds, + poll_interval_seconds=get_writer_lock_poll_interval_seconds() + if poll_interval_seconds is None + else poll_interval_seconds, + ) + _ACTIVE_LOCK_DEPTH_BY_PATH[lock_path] = 1 + _ACTIVE_LOCK_TOKEN_BY_PATH[lock_path] = str(metadata["lock_token"]) + try: + yield metadata + finally: + _release_backend_writer_lock(lock_path) + _ACTIVE_LOCK_DEPTH_BY_PATH.pop(lock_path, None) + _ACTIVE_LOCK_TOKEN_BY_PATH.pop(lock_path, None) + + +def build_writer_lock_holder(label: str) -> str: + """Build one readable holder label from the current command line.""" + argv = " ".join(sys.argv).strip() + if argv: + return f"{label} [{argv}]" + return label + + +def _acquire_backend_writer_lock( + *, + lock_path: Path, + holder: str, + timeout_seconds: float, + poll_interval_seconds: float, +) -> dict[str, object]: + if timeout_seconds < 0: + raise ValueError("Writer lock timeout must be zero or positive.") + if poll_interval_seconds <= 0: + raise ValueError("Writer lock poll interval must be positive.") + + lock_path.parent.mkdir(parents=True, exist_ok=True) + deadline = time.monotonic() + timeout_seconds + metadata = _build_lock_metadata(holder=holder) + + while True: + try: + file_descriptor = os.open( + lock_path, + os.O_CREAT | os.O_EXCL | os.O_WRONLY, + ) + except FileExistsError: + existing_metadata = _read_lock_metadata(lock_path) + if _can_clear_stale_lock(existing_metadata): + _remove_lock_file(lock_path) + continue + if time.monotonic() >= deadline: + raise BackendWriterLockTimeoutError( + _build_lock_timeout_message( + lock_path=lock_path, + holder=holder, + timeout_seconds=timeout_seconds, + existing_metadata=existing_metadata, + ) + ) + time.sleep(poll_interval_seconds) + continue + + try: + with os.fdopen(file_descriptor, "w", encoding="utf-8") as handle: + json.dump(metadata, handle, ensure_ascii=True, indent=2) + handle.write("\n") + return metadata + except Exception: + _remove_lock_file(lock_path) + raise + + +def _release_backend_writer_lock(lock_path: Path) -> None: + expected_token = _ACTIVE_LOCK_TOKEN_BY_PATH.get(lock_path) + existing_metadata = _read_lock_metadata(lock_path) + if existing_metadata and expected_token and existing_metadata.get("lock_token") != expected_token: + return + _remove_lock_file(lock_path) + + +def _remove_lock_file(lock_path: Path) -> None: + try: + lock_path.unlink() + except FileNotFoundError: + return + + +def _build_lock_metadata(*, holder: str) -> dict[str, object]: + return { + "lock_token": uuid4().hex, + "holder": holder, + "started_at": _utc_now_iso(), + "hostname": socket.gethostname(), + "pid": os.getpid(), + "cwd": str(Path.cwd()), + } + + +def _read_lock_metadata(lock_path: Path) -> dict[str, object] | None: + try: + return json.loads(lock_path.read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, json.JSONDecodeError): + return None + + +def _can_clear_stale_lock(existing_metadata: dict[str, object] | None) -> bool: + if not existing_metadata: + return False + try: + holder_pid = int(existing_metadata.get("pid")) + except (TypeError, ValueError): + return False + if holder_pid <= 0: + return False + + holder_hostname = str(existing_metadata.get("hostname") or "").strip() + current_hostname = socket.gethostname() + if holder_hostname == current_hostname: + if _is_process_alive(holder_pid): + return False + return True + if not _looks_like_containerized_holder(existing_metadata): + return False + lock_age_seconds = _calculate_lock_age_seconds(existing_metadata) + if lock_age_seconds is None: + return False + if lock_age_seconds < CONTAINER_STALE_LOCK_GRACE_SECONDS: + return False + return True + + +def _is_process_alive(pid: int) -> bool: + if pid == os.getpid(): + return True + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError as exc: + winerror = getattr(exc, "winerror", None) + if winerror in {3, 87} or exc.errno in {3}: + return False + return True + return True + + +def _build_lock_timeout_message( + *, + lock_path: Path, + holder: str, + timeout_seconds: float, + existing_metadata: dict[str, object] | None, +) -> str: + if not existing_metadata: + return ( + f"Writer lock is busy at {lock_path} and could not be acquired within " + f"{timeout_seconds:.1f}s for {holder}." + ) + + existing_holder = existing_metadata.get("holder") or "unknown-holder" + started_at = existing_metadata.get("started_at") or "unknown-started-at" + hostname = existing_metadata.get("hostname") or "unknown-host" + pid = existing_metadata.get("pid") or "unknown-pid" + return ( + f"Writer lock is busy at {lock_path}. Held by {existing_holder} " + f"since {started_at} on {hostname} (pid {pid}). " + f"Timed out after waiting {timeout_seconds:.1f}s for {holder}." + ) + + +def _looks_like_containerized_holder(existing_metadata: dict[str, object]) -> bool: + holder_cwd = str(existing_metadata.get("cwd") or "").strip().lower() + return holder_cwd.startswith("/app") + + +def _calculate_lock_age_seconds(existing_metadata: dict[str, object]) -> float | None: + started_at_raw = str(existing_metadata.get("started_at") or "").strip() + if not started_at_raw: + return None + try: + started_at = datetime.fromisoformat(started_at_raw.replace("Z", "+00:00")) + except ValueError: + return None + if started_at.tzinfo is None: + started_at = started_at.replace(tzinfo=timezone.utc) + delta = datetime.now(timezone.utc) - started_at.astimezone(timezone.utc) + return max(0.0, delta.total_seconds()) + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/backend/data/.gitkeep b/backend/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/data/snapshots/.gitkeep b/backend/data/snapshots/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/data/snapshots/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..6ab0fc9 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,2 @@ +# PostgreSQL is used by the phase-1 RCON historical storage migration. +psycopg[binary]>=3.2,<4 diff --git a/backend/tests/test_current_match_payload.py b/backend/tests/test_current_match_payload.py new file mode 100644 index 0000000..6ad00c3 --- /dev/null +++ b/backend/tests/test_current_match_payload.py @@ -0,0 +1,323 @@ +from http import HTTPStatus +from datetime import datetime, timezone +from unittest.mock import patch + +from app.payloads import build_current_match_payload +from app.rcon_admin_log_storage import list_current_match_player_stats, persist_rcon_admin_log_entries +from app.rcon_client import RconServerTarget +from app.routes import resolve_get_payload + + +TARGET = RconServerTarget( + name="Comunidad Hispana #01", + host="127.0.0.1", + port=7779, + password="test-password", + source_name="test-rcon", + external_server_id="comunidad-hispana-01", +) + + +def test_current_match_payload_projects_rich_live_rcon_session_fields(): + data = _build_with_rcon_sample( + { + "normalized": { + "server_name": "Comunidad Hispana #01", + "status": "online", + "current_map": "carentan_warfare", + "game_mode": "Warfare", + "allied_score": 2, + "axis_score": 2, + "allied_players": 0, + "axis_players": 0, + "players": 0, + "max_players": 100, + "match_time_seconds": 5400, + "remaining_match_time_seconds": 0, + }, + "raw_session": {"mapId": "carentan_warfare", "mapName": "CARENTAN"}, + } + ) + + assert data["map"] == "Carentan" + assert data["map_id"] == "carentan_warfare" + assert data["map_pretty_name"] == "Carentan" + assert data["game_mode"] == "Warfare" + assert data["allied_score"] == 2 + assert data["axis_score"] == 2 + assert data["players"] == 0 + assert data["player_count_quality"] == "rcon-session-unverified" + assert data["player_count_source"] == "rcon-session" + assert data["score_source"] == "rcon-session" + assert data["map_source"] == "rcon-session" + assert data["public_scoreboard_url"] == "https://scoreboard.comunidadhll.es" + assert "/games" not in data["public_scoreboard_url"] + + +def test_current_match_payload_preserves_missing_values_as_null(): + data = _build_with_rcon_sample( + { + "normalized": { + "server_name": "Comunidad Hispana #01", + "status": "online", + "current_map": None, + "game_mode": None, + "players": None, + "max_players": None, + }, + "raw_session": {}, + } + ) + + assert data["map"] is None + assert data["map_id"] is None + assert data["game_mode"] is None + assert data["allied_score"] is None + assert data["axis_score"] is None + assert data["players"] is None + assert data["player_count_quality"] is None + assert data["player_count_source"] is None + assert data["score_source"] is None + assert data["map_source"] is None + + +def test_current_match_payload_keeps_explicit_zero_score(): + data = _build_with_rcon_sample( + { + "normalized": { + "server_name": "Comunidad Hispana #01", + "status": "online", + "current_map": "stmariedumont_warfare", + "allied_score": 0, + "axis_score": 0, + }, + "raw_session": { + "mapId": "stmariedumont_warfare", + "mapName": "ST MARIE DU MONT", + }, + } + ) + + assert data["map"] == "St. Marie Du Mont" + assert data["allied_score"] == 0 + assert data["axis_score"] == 0 + assert data["score_source"] == "rcon-session" + + +def test_current_match_payload_fallback_resolves_legacy_rcon_external_id_for_01(): + data = _build_with_snapshot_fallback( + "comunidad-hispana-01", + { + "external_server_id": "rcon:152.114.195.174:7779", + "server_name": "#01 [ESP] Comunidad Hispana", + "status": "online", + "current_map": "St. Marie Du Mont", + "players": 0, + "max_players": 100, + "captured_at": "2026-03-24T14:08:41.008487Z", + }, + ) + + assert data["found"] is True + assert data["map"] == "St. Marie Du Mont" + assert data["map_pretty_name"] == "St. Marie Du Mont" + assert data["status"] == "online" + assert data["players"] == 0 + assert data["max_players"] == 100 + assert data["captured_at"] == "2026-03-24T14:08:41.008487Z" + assert data["updated_at"] == "2026-03-24T14:08:41.008487Z" + assert data["public_scoreboard_url"] == "https://scoreboard.comunidadhll.es" + + +def test_current_match_payload_fallback_resolves_legacy_rcon_source_ref_for_02(): + data = _build_with_snapshot_fallback( + "comunidad-hispana-02", + { + "external_server_id": "snapshot-server-02", + "source_ref": "rcon://152.114.195.150:7879", + "status": "online", + "current_map": "Elsenborn Ridge", + "captured_at": "2026-03-24T14:08:41.008487Z", + }, + ) + + assert data["found"] is True + assert data["server_slug"] == "comunidad-hispana-02" + assert data["map"] == "Elsenborn Ridge" + assert data["map_pretty_name"] == "Elsenborn Ridge" + assert data["public_scoreboard_url"] == "https://scoreboard.comunidadhll.es:5443" + + +def test_current_match_payload_fallback_resolves_community_server_names(): + number_first = _build_with_snapshot_fallback( + "comunidad-hispana-01", + { + "external_server_id": "snapshot-server-01", + "server_name": "#01 [ESP] Comunidad Hispana - Spa Onl", + "current_map": "Mortain", + }, + ) + community_first = _build_with_snapshot_fallback( + "comunidad-hispana-02", + { + "external_server_id": "snapshot-server-02", + "name": "Comunidad Hispana #02", + "current_map": "Carentan", + }, + ) + + assert number_first["found"] is True + assert number_first["map"] == "Mortain" + assert community_first["found"] is True + assert community_first["map"] == "Carentan" + + +def test_current_match_payload_fallback_does_not_match_unknown_snapshot(): + data = _build_with_snapshot_fallback( + "comunidad-hispana-01", + { + "external_server_id": "rcon:203.0.113.10:9000", + "source_ref": "rcon://203.0.113.10:9000", + "server_name": "#03 Comunidad Hispana", + "current_map": "Unknown Match", + }, + ) + + assert data["found"] is False + assert data["map"] is None + assert data["status"] == "unavailable" + + +def test_current_match_route_rejects_unsupported_server(): + status, payload = resolve_get_payload("/api/current-match?server=not-trusted") + + assert status == HTTPStatus.NOT_FOUND + assert payload["status"] == "error" + + +def test_current_match_player_route_rejects_unsupported_server(): + status, payload = resolve_get_payload("/api/current-match/players?server=not-trusted") + + assert status == HTTPStatus.NOT_FOUND + assert payload["status"] == "error" + + +def test_current_match_player_stats_aggregate_safe_admin_log_rows(tmp_path): + db_path = tmp_path / "admin-log.sqlite3" + persist_rcon_admin_log_entries( + target={ + "target_key": "comunidad-hispana-01", + "external_server_id": "comunidad-hispana-01", + }, + entries=[ + { + "timestamp": "2026-05-21T10:00:00Z", + "message": "[1:00 min (100)] MATCH START Mortain Warfare", + }, + { + "timestamp": "2026-05-21T10:01:00Z", + "message": ( + "[2:00 min (120)] KILL: Bravo(Axis/steam-bravo) -> " + "Alpha(Allies/steam-alpha) with MP40" + ), + }, + { + "timestamp": "2026-05-21T10:02:00Z", + "message": ( + "[3:00 min (140)] KILL: Alpha(Allies/steam-alpha) -> " + "Charlie(Allies/steam-charlie) with M1 GARAND" + ), + }, + { + "timestamp": "2026-05-21T10:03:00Z", + "message": ( + "[4:00 min (160)] KILL: Alpha(Allies/steam-alpha) -> " + "Bravo(Axis/steam-bravo) with M1 GARAND" + ), + }, + ], + db_path=db_path, + ) + + stats = list_current_match_player_stats( + server_key="comunidad-hispana-01", + db_path=db_path, + ) + + assert stats["scope"] == "open-admin-log-match-window" + assert stats["confidence"] == "event-derived-partial" + assert stats["source"] == "rcon-admin-log-kill-events" + assert [item["player_name"] for item in stats["items"]] == ["Alpha", "Bravo", "Charlie"] + assert stats["items"][0] == { + "player_name": "Alpha", + "team": "Allies", + "kills": 1, + "deaths": 1, + "teamkills": 1, + "deaths_by_teamkill": 0, + "last_seen_at": "2026-05-21T10:03:00Z", + "favorite_weapon": "M1 GARAND", + "source": "rcon-admin-log-kill-events", + "confidence": "event-derived-partial", + } + assert "raw_message" not in stats["items"][0] + + +def test_current_match_player_stats_filter_stale_recent_events(tmp_path): + db_path = tmp_path / "admin-log.sqlite3" + persist_rcon_admin_log_entries( + target={ + "target_key": "comunidad-hispana-01", + "external_server_id": "comunidad-hispana-01", + }, + entries=[ + { + "timestamp": "2026-05-21T09:30:00Z", + "message": ( + "[1:00 min (1779355800)] KILL: Old Killer(Allies/steam-old) -> " + "Old Victim(Axis/steam-victim-old) with M1 GARAND" + ), + } + ], + db_path=db_path, + ) + + stats = list_current_match_player_stats( + server_key="comunidad-hispana-01", + db_path=db_path, + now=datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc), + ) + + assert stats["scope"] == "no-current-match-events" + assert stats["confidence"] == "stale-filtered" + assert stats["items"] == [] + + +def _build_with_rcon_sample(sample: dict[str, object]) -> dict[str, object]: + with ( + patch("app.payloads.load_rcon_targets", return_value=(TARGET,)), + patch("app.payloads.query_live_server_sample", return_value=sample), + ): + payload = build_current_match_payload(server_slug="comunidad-hispana-01") + return payload["data"] + + +def _build_with_snapshot_fallback( + server_slug: str, + item: dict[str, object], +) -> dict[str, object]: + with ( + patch("app.payloads._query_current_match_rcon_sample", return_value=None), + patch( + "app.payloads.build_servers_payload", + return_value={ + "status": "ok", + "data": { + "last_snapshot_at": "2026-03-24T14:08:41.008487Z", + "items": [item], + }, + }, + ), + ): + payload = build_current_match_payload(server_slug=server_slug) + return payload["data"] diff --git a/backend/tests/test_database_maintenance.py b/backend/tests/test_database_maintenance.py new file mode 100644 index 0000000..e0af8aa --- /dev/null +++ b/backend/tests/test_database_maintenance.py @@ -0,0 +1,448 @@ +from __future__ import annotations + +import io +import json +import sqlite3 +import tempfile +import unittest +from contextlib import closing, redirect_stdout +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from app.database_maintenance import run_database_maintenance_cleanup +from app.rcon_admin_log_materialization import MATCH_RESULT_SOURCE, initialize_rcon_materialized_storage +from app.rcon_admin_log_storage import initialize_rcon_admin_log_storage +from app.storage import initialize_storage + + +class DatabaseMaintenanceTests(unittest.TestCase): + def test_dry_run_does_not_delete(self) -> None: + with _temp_db() as db_path: + _insert_server_snapshot(db_path, snapshot_id=1, captured_at="2026-05-01T00:00:00Z") + + payload = run_database_maintenance_cleanup( + db_path=db_path, + now="2026-06-20T12:00:00Z", + ) + + self.assertEqual(payload["status"], "ok") + self.assertEqual(payload["mode"], "dry-run") + with closing(sqlite3.connect(db_path)) as connection: + self.assertEqual( + connection.execute("SELECT COUNT(*) FROM server_snapshots").fetchone()[0], + 1, + ) + + def test_apply_deletes_old_server_snapshots(self) -> None: + with _temp_db() as db_path: + _insert_server_snapshot(db_path, snapshot_id=1, captured_at="2026-05-01T00:00:00Z") + _insert_server_snapshot(db_path, snapshot_id=2, captured_at="2026-06-18T00:00:00Z") + + run_database_maintenance_cleanup( + apply=True, + db_path=db_path, + now="2026-06-20T12:00:00Z", + recent_matches_keep=1, + ) + + with closing(sqlite3.connect(db_path)) as connection: + ids = [row[0] for row in connection.execute("SELECT id FROM server_snapshots ORDER BY id")] + self.assertEqual(ids, [2]) + + def test_apply_deletes_old_noncritical_admin_log_events(self) -> None: + with _temp_db() as db_path: + _insert_admin_log_event( + db_path, + event_id=1, + event_type="chat", + event_timestamp="2026-04-01T00:00:00Z", + server_time=100, + ) + _insert_admin_log_event( + db_path, + event_id=2, + event_type="chat", + event_timestamp="2026-06-15T00:00:00Z", + server_time=200, + ) + + run_database_maintenance_cleanup( + apply=True, + db_path=db_path, + now="2026-06-20T12:00:00Z", + ) + + with closing(sqlite3.connect(db_path)) as connection: + remaining = [ + tuple(row) + for row in connection.execute( + "SELECT id, event_type FROM rcon_admin_log_events ORDER BY id" + ) + ] + self.assertEqual(remaining, [(2, "chat")]) + + def test_apply_preserves_critical_events_within_retention(self) -> None: + with _temp_db() as db_path: + _insert_admin_log_event( + db_path, + event_id=1, + event_type="kill", + event_timestamp="2026-06-10T00:00:00Z", + server_time=100, + ) + + run_database_maintenance_cleanup( + apply=True, + db_path=db_path, + now="2026-06-20T12:00:00Z", + ) + + with closing(sqlite3.connect(db_path)) as connection: + count = connection.execute( + "SELECT COUNT(*) FROM rcon_admin_log_events WHERE event_type = 'kill'" + ).fetchone()[0] + self.assertEqual(count, 1) + + def test_apply_preserves_latest_100_materialized_matches(self) -> None: + with _temp_db() as db_path: + for index in range(101): + ended_at = ( + datetime(2026, 1, 1, 12, tzinfo=timezone.utc) + timedelta(days=index) + ).isoformat().replace("+00:00", "Z") + _insert_materialized_match( + db_path, + match_id=index + 1, + match_key=f"match-{index + 1}", + ended_at=ended_at, + server_time_start=(index + 1) * 10, + server_time_end=(index + 1) * 10 + 5, + ) + + run_database_maintenance_cleanup( + apply=True, + db_path=db_path, + now="2026-06-20T12:00:00Z", + ) + + with closing(sqlite3.connect(db_path)) as connection: + remaining = connection.execute( + "SELECT COUNT(*) FROM rcon_materialized_matches" + ).fetchone()[0] + oldest = connection.execute( + "SELECT COUNT(*) FROM rcon_materialized_matches WHERE match_key = 'match-1'" + ).fetchone()[0] + self.assertEqual(remaining, 100) + self.assertEqual(oldest, 0) + + def test_apply_preserves_current_month_matches(self) -> None: + with _temp_db() as db_path: + _insert_materialized_match( + db_path, + match_id=1, + match_key="old", + ended_at="2026-01-10T12:00:00Z", + server_time_start=10, + server_time_end=20, + ) + _insert_materialized_match( + db_path, + match_id=2, + match_key="current-month", + ended_at="2026-06-03T12:00:00Z", + server_time_start=30, + server_time_end=40, + ) + + run_database_maintenance_cleanup( + apply=True, + db_path=db_path, + now="2026-06-20T12:00:00Z", + recent_matches_keep=1, + ) + + with closing(sqlite3.connect(db_path)) as connection: + keys = [row[0] for row in connection.execute("SELECT match_key FROM rcon_materialized_matches")] + self.assertEqual(keys, ["current-month"]) + + def test_apply_preserves_previous_month_when_now_day_is_early(self) -> None: + with _temp_db() as db_path: + _insert_materialized_match( + db_path, + match_id=1, + match_key="previous-month", + ended_at="2026-05-15T12:00:00Z", + server_time_start=10, + server_time_end=20, + ) + _insert_materialized_match( + db_path, + match_id=2, + match_key="older", + ended_at="2026-04-15T12:00:00Z", + server_time_start=30, + server_time_end=40, + ) + + run_database_maintenance_cleanup( + apply=True, + db_path=db_path, + now="2026-06-05T12:00:00Z", + recent_matches_keep=1, + ) + + with closing(sqlite3.connect(db_path)) as connection: + keys = [row[0] for row in connection.execute("SELECT match_key FROM rcon_materialized_matches")] + self.assertEqual(keys, ["previous-month"]) + + def test_apply_preserves_current_week(self) -> None: + with _temp_db() as db_path: + _insert_materialized_match( + db_path, + match_id=1, + match_key="current-week", + ended_at="2026-06-10T12:00:00Z", + server_time_start=10, + server_time_end=20, + ) + _insert_materialized_match( + db_path, + match_id=2, + match_key="older", + ended_at="2026-05-01T12:00:00Z", + server_time_start=30, + server_time_end=40, + ) + + run_database_maintenance_cleanup( + apply=True, + db_path=db_path, + now="2026-06-10T13:00:00Z", + recent_matches_keep=1, + ) + + with closing(sqlite3.connect(db_path)) as connection: + keys = [row[0] for row in connection.execute("SELECT match_key FROM rcon_materialized_matches")] + self.assertEqual(keys, ["current-week"]) + + def test_apply_preserves_previous_week_when_fallback_may_need_it(self) -> None: + with _temp_db() as db_path: + _insert_materialized_match( + db_path, + match_id=1, + match_key="previous-week", + ended_at="2026-06-03T12:00:00Z", + server_time_start=10, + server_time_end=20, + ) + _insert_materialized_match( + db_path, + match_id=2, + match_key="current-week-sample", + ended_at="2026-06-09T12:00:00Z", + server_time_start=30, + server_time_end=40, + ) + _insert_materialized_match( + db_path, + match_id=3, + match_key="older", + ended_at="2026-05-01T12:00:00Z", + server_time_start=50, + server_time_end=60, + ) + + run_database_maintenance_cleanup( + apply=True, + db_path=db_path, + now="2026-06-10T13:00:00Z", + recent_matches_keep=1, + ) + + with closing(sqlite3.connect(db_path)) as connection: + keys = { + row[0] + for row in connection.execute("SELECT match_key FROM rcon_materialized_matches") + } + self.assertEqual(keys, {"previous-week", "current-week-sample"}) + + def test_apply_deletes_old_non_protected_match_and_child_stats(self) -> None: + with _temp_db() as db_path: + _insert_materialized_match( + db_path, + match_id=1, + match_key="delete-me", + ended_at="2026-01-10T12:00:00Z", + server_time_start=10, + server_time_end=20, + ) + _insert_materialized_match( + db_path, + match_id=2, + match_key="keep-me", + ended_at="2026-06-18T12:00:00Z", + server_time_start=30, + server_time_end=40, + ) + _insert_player_stat(db_path, match_key="delete-me", player_id="player-1") + _insert_player_stat(db_path, match_key="keep-me", player_id="player-2") + + run_database_maintenance_cleanup( + apply=True, + db_path=db_path, + now="2026-06-20T12:00:00Z", + recent_matches_keep=1, + ) + + with closing(sqlite3.connect(db_path)) as connection: + deleted_match_count = connection.execute( + "SELECT COUNT(*) FROM rcon_materialized_matches WHERE match_key = 'delete-me'" + ).fetchone()[0] + deleted_stat_count = connection.execute( + "SELECT COUNT(*) FROM rcon_match_player_stats WHERE match_key = 'delete-me'" + ).fetchone()[0] + kept_stat_count = connection.execute( + "SELECT COUNT(*) FROM rcon_match_player_stats WHERE match_key = 'keep-me'" + ).fetchone()[0] + self.assertEqual(deleted_match_count, 0) + self.assertEqual(deleted_stat_count, 0) + self.assertEqual(kept_stat_count, 1) + + def test_missing_optional_tables_are_logged_and_do_not_crash(self) -> None: + with _temp_db(create_schema=False) as db_path: + stream = io.StringIO() + with redirect_stdout(stream): + payload = run_database_maintenance_cleanup( + db_path=db_path, + now="2026-06-20T12:00:00Z", + ) + + self.assertEqual(payload["status"], "ok") + self.assertIn("database-maintenance-table-skipped", stream.getvalue()) + + +def _temp_db(*, create_schema: bool = True): + class _TempDbContext: + def __enter__(self) -> Path: + self._tmpdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) + self.db_path = Path(self._tmpdir.name) / "maintenance.sqlite3" + if create_schema: + initialize_storage(db_path=self.db_path) + initialize_rcon_admin_log_storage(db_path=self.db_path) + initialize_rcon_materialized_storage(db_path=self.db_path) + return self.db_path + + def __exit__(self, exc_type, exc, tb) -> None: + self._tmpdir.cleanup() + + return _TempDbContext() + + +def _insert_server_snapshot(db_path: Path, *, snapshot_id: int, captured_at: str) -> None: + with closing(sqlite3.connect(db_path)) as connection: + connection.execute( + """ + INSERT OR IGNORE INTO game_sources ( + id, slug, display_name, provider_kind, is_active, created_at, updated_at + ) VALUES (1, 'current-hll', 'Current Hell Let Loose', 'development', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """ + ) + connection.execute( + """ + INSERT OR IGNORE INTO servers ( + id, game_source_id, external_server_id, server_name, region, first_seen_at, last_seen_at + ) VALUES (1, 1, 'server-1', 'Server 1', 'ES', ?, ?) + """, + (captured_at, captured_at), + ) + connection.execute( + """ + INSERT INTO server_snapshots ( + id, server_id, captured_at, status, players, max_players, current_map, source_name + ) VALUES (?, 1, ?, 'online', 10, 100, 'hurtgen', 'test') + """, + (snapshot_id, captured_at), + ) + connection.commit() + + +def _insert_admin_log_event( + db_path: Path, + *, + event_id: int, + event_type: str, + event_timestamp: str, + server_time: int, +) -> None: + with closing(sqlite3.connect(db_path)) as connection: + connection.execute( + """ + INSERT INTO rcon_admin_log_events ( + id, target_key, external_server_id, event_timestamp, server_time, + relative_time, event_type, raw_message, canonical_message, + parsed_payload_json, raw_entry_json + ) VALUES (?, 'comunidad-hispana-01', 'comunidad-hispana-01', ?, ?, '', ?, '', '', '{}', '{}') + """, + (event_id, event_timestamp, server_time, event_type), + ) + connection.commit() + + +def _insert_materialized_match( + db_path: Path, + *, + match_id: int, + match_key: str, + ended_at: str, + server_time_start: int, + server_time_end: int, +) -> None: + started_at = _shift_iso(ended_at, hours=-1) + with closing(sqlite3.connect(db_path)) as connection: + connection.execute( + """ + INSERT INTO rcon_materialized_matches ( + id, target_key, external_server_id, match_key, map_name, map_pretty_name, + game_mode, started_server_time, ended_server_time, started_at, ended_at, + allied_score, axis_score, winner, confidence_mode, source_basis + ) VALUES (?, 'comunidad-hispana-01', 'comunidad-hispana-01', ?, 'hurtgen', 'Hurtgen Forest', + 'warfare', ?, ?, ?, ?, 5, 3, 'allied', 'exact', ?) + """, + ( + match_id, + match_key, + server_time_start, + server_time_end, + started_at, + ended_at, + MATCH_RESULT_SOURCE, + ), + ) + connection.commit() + + +def _insert_player_stat(db_path: Path, *, match_key: str, player_id: str) -> None: + with closing(sqlite3.connect(db_path)) as connection: + connection.execute( + """ + INSERT INTO rcon_match_player_stats ( + target_key, match_key, player_id, player_name, team, + kills, deaths, teamkills, deaths_by_teamkill, + weapons_json, death_by_weapons_json, most_killed_json, death_by_json + ) VALUES ( + 'comunidad-hispana-01', ?, ?, ?, 'Allies', + 1, 1, 0, 0, '{}', '{}', '{}', '{}' + ) + """, + (match_key, player_id, player_id), + ) + connection.commit() + + +def _shift_iso(value: str, *, hours: int) -> str: + point = datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc) + shifted = point + timedelta(hours=hours) + return shifted.isoformat().replace("+00:00", "Z") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_historical_runner_maintenance.py b/backend/tests/test_historical_runner_maintenance.py new file mode 100644 index 0000000..10d0513 --- /dev/null +++ b/backend/tests/test_historical_runner_maintenance.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import io +import os +import unittest +from contextlib import nullcontext, redirect_stdout +from datetime import datetime, timezone +from unittest.mock import patch + +import app.historical_runner as historical_runner +from app.historical_runner import _maybe_run_database_maintenance, _run_refresh_with_retries + + +class HistoricalRunnerMaintenanceTests(unittest.TestCase): + def setUp(self) -> None: + historical_runner._LAST_DATABASE_MAINTENANCE_RUN_AT = None + + def tearDown(self) -> None: + historical_runner._LAST_DATABASE_MAINTENANCE_RUN_AT = None + + def test_scheduler_disabled_does_not_call_cleanup(self) -> None: + with ( + patch.dict(os.environ, {"HLL_DB_MAINTENANCE_ENABLED": "false"}, clear=False), + patch("app.historical_runner.run_database_maintenance_cleanup") as cleanup, + ): + result = _maybe_run_database_maintenance( + now=datetime(2026, 6, 20, 12, tzinfo=timezone.utc) + ) + + cleanup.assert_not_called() + self.assertEqual(result["status"], "skipped") + self.assertEqual(result["reason"], "disabled") + + def test_scheduler_enabled_but_not_due_does_not_call_cleanup(self) -> None: + with ( + patch.dict( + os.environ, + { + "HLL_DB_MAINTENANCE_ENABLED": "true", + "HLL_DB_MAINTENANCE_INTERVAL_SECONDS": "43200", + }, + clear=False, + ), + patch( + "app.historical_runner.run_database_maintenance_cleanup", + return_value={"status": "ok"}, + ) as cleanup, + ): + first = _maybe_run_database_maintenance( + now=datetime(2026, 6, 20, 0, tzinfo=timezone.utc) + ) + second = _maybe_run_database_maintenance( + now=datetime(2026, 6, 20, 1, tzinfo=timezone.utc) + ) + + self.assertEqual(first["status"], "ok") + self.assertEqual(second["status"], "skipped") + self.assertEqual(second["reason"], "not-due") + cleanup.assert_called_once() + + def test_scheduler_enabled_and_due_calls_cleanup(self) -> None: + with ( + patch.dict(os.environ, {"HLL_DB_MAINTENANCE_ENABLED": "true"}, clear=False), + patch( + "app.historical_runner.run_database_maintenance_cleanup", + return_value={"status": "ok"}, + ) as cleanup, + ): + result = _maybe_run_database_maintenance( + now=datetime(2026, 6, 20, 12, tzinfo=timezone.utc) + ) + + cleanup.assert_called_once() + self.assertEqual(result["status"], "ok") + + def test_cleanup_exception_is_logged_and_runner_continues(self) -> None: + stream = io.StringIO() + with ( + patch.dict(os.environ, {"HLL_DB_MAINTENANCE_ENABLED": "true"}, clear=False), + patch("app.historical_runner.backend_writer_lock", return_value=nullcontext()), + patch( + "app.historical_runner._run_primary_rcon_capture", + return_value={"status": "ok", "targets": []}, + ), + patch( + "app.historical_runner.run_incremental_refresh", + return_value={"status": "ok"}, + ), + patch( + "app.historical_runner.generate_historical_snapshots", + return_value={"status": "ok"}, + ), + patch( + "app.historical_runner.rebuild_elo_mmr_models", + return_value={"status": "ok"}, + ), + patch( + "app.historical_runner.run_database_maintenance_cleanup", + side_effect=RuntimeError("maintenance failed"), + ), + redirect_stdout(stream), + ): + result = _run_refresh_with_retries( + max_retries=0, + retry_delay_seconds=0, + server_slug="comunidad-hispana-01", + max_pages=None, + page_size=None, + run_number=1, + ) + + self.assertEqual(result["status"], "ok") + self.assertEqual(result["database_maintenance_result"]["status"], "error") + self.assertIn("database-maintenance-scheduler-failed", stream.getvalue()) + + def test_interval_parsing_handles_invalid_values_safely(self) -> None: + with patch.dict( + os.environ, + { + "HLL_DB_MAINTENANCE_ENABLED": "true", + "HLL_DB_MAINTENANCE_INTERVAL_SECONDS": "bad", + }, + clear=False, + ): + interval_seconds, source = historical_runner._resolve_db_maintenance_interval_seconds() + + self.assertEqual(interval_seconds, 43200) + self.assertEqual(source, "default-invalid-env-fallback") + + def test_maintenance_state_is_tracked_in_process(self) -> None: + with ( + patch.dict( + os.environ, + { + "HLL_DB_MAINTENANCE_ENABLED": "true", + "HLL_DB_MAINTENANCE_INTERVAL_SECONDS": "3600", + }, + clear=False, + ), + patch( + "app.historical_runner.run_database_maintenance_cleanup", + return_value={"status": "ok"}, + ), + ): + _maybe_run_database_maintenance(now=datetime(2026, 6, 20, 12, tzinfo=timezone.utc)) + self.assertEqual( + historical_runner._LAST_DATABASE_MAINTENANCE_RUN_AT, + datetime(2026, 6, 20, 12, tzinfo=timezone.utc), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_historical_snapshot_refresh.py b/backend/tests/test_historical_snapshot_refresh.py new file mode 100644 index 0000000..06c8c47 --- /dev/null +++ b/backend/tests/test_historical_snapshot_refresh.py @@ -0,0 +1,126 @@ +"""Regression coverage for historical snapshot runner refreshes.""" + +from __future__ import annotations + +import io +import json +import os +import unittest +from contextlib import nullcontext, redirect_stdout +from datetime import datetime, timezone +from unittest.mock import patch + +from app.config import ( + get_historical_refresh_interval_seconds, + get_historical_refresh_max_retries, + get_historical_refresh_retry_delay_seconds, +) +from app.historical_runner import _run_refresh_with_retries, run_periodic_historical_refresh +from app.historical_snapshots import _normalize_snapshot_limit +from app.postgres_display_storage import _json_payload_default +from app.rcon_historical_read_model import ( + _calculate_coverage_hours, + _calculate_duration_seconds, +) + + +class HistoricalSnapshotRefreshTests(unittest.TestCase): + def test_runner_numeric_env_values_are_parsed_before_use(self) -> None: + with patch.dict( + os.environ, + { + "HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS": "300", + "HLL_HISTORICAL_REFRESH_MAX_RETRIES": "4", + "HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS": "0.5", + }, + clear=False, + ): + self.assertEqual(get_historical_refresh_interval_seconds(), 300) + self.assertEqual(get_historical_refresh_max_retries(), 4) + self.assertEqual(get_historical_refresh_retry_delay_seconds(), 0.5) + + def test_runner_numeric_env_values_fail_with_clear_names(self) -> None: + with patch.dict( + os.environ, + {"HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS": "hourly"}, + clear=False, + ): + with self.assertRaisesRegex( + ValueError, + "HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS must be an integer", + ): + get_historical_refresh_interval_seconds() + + def test_rcon_coverage_accepts_postgres_datetime_values(self) -> None: + start = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc) + end = datetime(2026, 5, 21, 11, 30, tzinfo=timezone.utc) + + self.assertEqual(_calculate_coverage_hours(start, end), 1.5) + self.assertEqual(_calculate_duration_seconds(start, end), 5400) + + def test_snapshot_limits_are_numeric_before_snapshot_queries(self) -> None: + self.assertEqual(_normalize_snapshot_limit("recent_matches_limit", "10"), 10) + with self.assertRaisesRegex(ValueError, "recent_matches_limit"): + _normalize_snapshot_limit("recent_matches_limit", "ten") + + def test_postgres_snapshot_payload_serializes_datetime_values(self) -> None: + payload = { + "captured_at": datetime(2026, 5, 21, 20, 12, 54, tzinfo=timezone.utc), + } + + self.assertEqual( + json.loads(json.dumps(payload, default=_json_payload_default)), + {"captured_at": "2026-05-21T20:12:54Z"}, + ) + + def test_runner_failure_log_includes_exception_type_and_traceback(self) -> None: + stream = io.StringIO() + with ( + patch("app.historical_runner.backend_writer_lock", return_value=nullcontext()), + patch( + "app.historical_runner._run_primary_rcon_capture", + side_effect=TypeError("bad timestamp"), + ), + redirect_stdout(stream), + ): + result = _run_refresh_with_retries( + max_retries=0, + retry_delay_seconds=0, + server_slug=None, + max_pages=None, + page_size=None, + run_number=1, + ) + + self.assertEqual(result["status"], "error") + self.assertEqual(result["error_type"], "TypeError") + self.assertIn("Traceback", result["traceback"]) + self.assertIn('"event": "historical-refresh-attempt-failed"', stream.getvalue()) + + def test_runner_success_log_serializes_datetime_values(self) -> None: + stream = io.StringIO() + with ( + patch( + "app.historical_runner._run_refresh_with_retries", + return_value={ + "status": "ok", + "rcon_capture_result": { + "captured_at": datetime(2026, 5, 22, tzinfo=timezone.utc), + }, + }, + ), + redirect_stdout(stream), + ): + run_periodic_historical_refresh( + interval_seconds=1, + max_retries=0, + retry_delay_seconds=0, + max_runs=1, + ) + + self.assertIn('"status": "ok"', stream.getvalue()) + self.assertIn('"captured_at": "2026-05-22 00:00:00+00:00"', stream.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_json_serialization.py b/backend/tests/test_json_serialization.py new file mode 100644 index 0000000..be237e3 --- /dev/null +++ b/backend/tests/test_json_serialization.py @@ -0,0 +1,27 @@ +"""Regression coverage for API JSON encoding of PostgreSQL value types.""" + +from __future__ import annotations + +import json +import unittest +from datetime import date, datetime, timezone + +from app.main import _json_default + + +class JsonSerializationTests(unittest.TestCase): + def test_json_default_serializes_postgres_datetime_and_date_values(self) -> None: + payload = { + "started_at": datetime(2026, 5, 21, 10, 11, 12, tzinfo=timezone.utc), + "day": date(2026, 5, 21), + } + + encoded = json.loads(json.dumps(payload, default=_json_default)) + + self.assertEqual( + encoded, + { + "started_at": "2026-05-21T10:11:12+00:00", + "day": "2026-05-21", + }, + ) diff --git a/backend/tests/test_rcon_admin_log_parser.py b/backend/tests/test_rcon_admin_log_parser.py new file mode 100644 index 0000000..94176b6 --- /dev/null +++ b/backend/tests/test_rcon_admin_log_parser.py @@ -0,0 +1,166 @@ +from app.rcon_admin_log_parser import parse_rcon_admin_log_message + + +from app.rcon_admin_log_parser import parse_rcon_player_profile_snapshot + + +def test_parse_match_start(): + parsed = parse_rcon_admin_log_message( + "[2:09:15 hours (1779178245)] MATCH START UTAH BEACH Warfare" + ) + + assert parsed.event_type == "match_start" + assert parsed.server_time == 1779178245 + assert parsed.map_name == "UTAH BEACH" + assert parsed.game_mode == "Warfare" + + +def test_parse_match_end(): + parsed = parse_rcon_admin_log_message( + "[20:36:53 hours (1779111786)] MATCH ENDED `ST MARIE DU MONT Warfare` ALLIED (5 - 0) AXIS " + ) + + assert parsed.event_type == "match_end" + assert parsed.map_name == "ST MARIE DU MONT Warfare" + assert parsed.allied_score == 5 + assert parsed.axis_score == 0 + assert parsed.winner == "allied" + + +def test_parse_kill(): + parsed = parse_rcon_admin_log_message( + "[1:20:19 hours (1779181181)] KILL: AntonioPruna(Allies/76561198000000000) -> " + "[7DV] NEⓇA TACTICAL FEMB✡Y(Axis/76561199000000000) with M1 GARAND" + ) + + assert parsed.event_type == "kill" + assert parsed.killer_name == "AntonioPruna" + assert parsed.killer_team == "Allies" + assert parsed.killer_id == "76561198000000000" + assert parsed.victim_name == "[7DV] NEⓇA TACTICAL FEMB✡Y" + assert parsed.victim_team == "Axis" + assert parsed.victim_id == "76561199000000000" + assert parsed.weapon == "M1 GARAND" + + +def test_parse_team_switch(): + parsed = parse_rcon_admin_log_message( + "[21:34:19 hours (1779108340)] TEAMSWITCH Ekenef (None > Allies)" + ) + + assert parsed.event_type == "team_switch" + assert parsed.player_name == "Ekenef" + assert parsed.from_team == "None" + assert parsed.to_team == "Allies" + + +def test_parse_connected(): + parsed = parse_rcon_admin_log_message( + "[21:34:22 hours (1779108337)] CONNECTED Ekenef (76561198109813520)" + ) + + assert parsed.event_type == "connected" + assert parsed.player_name == "Ekenef" + assert parsed.player_id == "76561198109813520" + + +def test_parse_disconnected(): + parsed = parse_rcon_admin_log_message( + "[21:10:53 hours (1779109746)] DISCONNECTED [BxB] Rab◯l◯k◯ (76561198111111111)" + ) + + assert parsed.event_type == "disconnected" + assert parsed.player_name == "[BxB] Rab◯l◯k◯" + assert parsed.player_id == "76561198111111111" + + +def test_parse_chat(): + parsed = parse_rcon_admin_log_message( + "[18:38:35 hours (1779118884)] CHAT[Team][BXB Ivanxu(Axis/6215e24a1f05c5815ed9e8bf185f94fd)]: !vip" + ) + + assert parsed.event_type == "chat" + assert parsed.chat_scope == "Team" + assert parsed.player_name == "BXB Ivanxu" + assert parsed.chat_team == "Axis" + assert parsed.player_id == "6215e24a1f05c5815ed9e8bf185f94fd" + assert parsed.content == "!vip" + + +def test_parse_kick(): + parsed = parse_rcon_admin_log_message( + "[2:09:10 hours (1779178249)] KICK: [[7DV] NEⓇA TACTICAL FEMB✡Y] has been kicked. " + "[Making free spaces for members of the Spanish Discord community.]" + ) + + assert parsed.event_type == "kick" + assert parsed.player_name == "[7DV] NEⓇA TACTICAL FEMB✡Y" + assert "Making free spaces" in parsed.reason + + +def test_parse_message_profile(): + parsed = parse_rcon_admin_log_message( + "[21:34:19 hours (1779108340)] MESSAGE: player [Ekenef(76561198109813520)], " + "content [─ Ekenef ─\\n▒ Totales ▒\\nbajas : 141 (6 TKs)\\nmuertes : 268 (5 TKs)]" + ) + + assert parsed.event_type == "message" + assert parsed.player_name == "Ekenef" + assert parsed.player_id == "76561198109813520" + assert "bajas : 141" in parsed.content + + +def test_parse_player_profile_snapshot_spanish_sections(): + parsed = parse_rcon_admin_log_message( + "[21:34:19 hours (1779108340)] MESSAGE: player [Jugador Uno(steam-profile-1)], " + "content [─ Jugador Uno ─\n" + "▒ Totales ▒\n" + "Visto por primera vez : 2026-01-01\n" + "sesiones : 12\n" + "partidas jugadas : 9\n" + "tiempo jugado : 18 h 30 min\n" + "bajas : 141 (6 TKs)\n" + "muertes : 268 (5 TKs)\n" + "K/D : 0,53\n" + "▒ Víctimas ▒\n" + "Rival Dos : 7\n" + "▒ Némesis ▒\n" + "Rival Tres : 4\n" + "▒ Armas favoritas ▒\n" + "M1 GARAND : 31\n" + "▒ Promedios ▒\n" + "bajas por partida : 15,6\n" + "▒ Sanciones ▒\n" + "kicks : 1]" + ) + + snapshot = parse_rcon_player_profile_snapshot( + parsed, + event_timestamp="2026-05-19T10:00:00Z", + ) + + assert snapshot is not None + assert snapshot.player_name == "Jugador Uno" + assert snapshot.player_id == "steam-profile-1" + assert snapshot.source_server_time == 1779108340 + assert snapshot.sessions == 12 + assert snapshot.matches_played == 9 + assert snapshot.total_kills == 141 + assert snapshot.total_deaths == 268 + assert snapshot.teamkills_done == 6 + assert snapshot.teamkills_received == 5 + assert snapshot.kd_ratio == 0.53 + assert snapshot.favorite_weapons == {"M1 GARAND": 31} + assert snapshot.victims == {"Rival Dos": 7} + assert snapshot.nemesis == {"Rival Tres": 4} + assert snapshot.averages == {"bajas por partida": 15.6} + assert snapshot.sanctions == {"kicks": 1.0} + + +def test_non_profile_message_does_not_parse_as_profile_snapshot(): + parsed = parse_rcon_admin_log_message( + "[21:34:19 hours (1779108340)] MESSAGE: player [Jugador Uno(steam-profile-1)], " + "content [Bienvenido al servidor]" + ) + + assert parse_rcon_player_profile_snapshot(parsed) is None diff --git a/backend/tests/test_rcon_admin_log_storage.py b/backend/tests/test_rcon_admin_log_storage.py new file mode 100644 index 0000000..7e62b4e --- /dev/null +++ b/backend/tests/test_rcon_admin_log_storage.py @@ -0,0 +1,497 @@ +import gc +import json +import sqlite3 +from datetime import datetime, timezone +from unittest.mock import patch + +from app.rcon_admin_log_storage import ( + initialize_rcon_admin_log_storage, + list_current_match_kill_feed, + list_rcon_admin_log_event_counts, + persist_rcon_admin_log_entries, +) + + +TARGET = { + "target_key": "test-rcon-target", + "external_server_id": "test-rcon-target", +} + + +def test_initialize_rcon_admin_log_storage_creates_event_table(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + + resolved_path = initialize_rcon_admin_log_storage(db_path=db_path) + + assert resolved_path == db_path + connection = sqlite3.connect(db_path) + try: + table_names = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ).fetchall() + } + columns = { + row[1] + for row in connection.execute("PRAGMA table_info(rcon_admin_log_events)") + } + finally: + connection.close() + gc.collect() + + assert "rcon_admin_log_events" in table_names + assert "rcon_player_profile_snapshots" in table_names + assert { + "target_key", + "event_type", + "raw_message", + "canonical_message", + "parsed_payload_json", + "raw_entry_json", + }.issubset(columns) + + +def test_persist_rcon_admin_log_entries_inserts_then_reports_duplicates(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + entries = [ + { + "timestamp": "2026-05-19T10:00:00Z", + "message": "[1:00 min (100)] CONNECTED Player One (steam-1)", + }, + { + "timestamp": "2026-05-19T10:01:00Z", + "message": "[2:00 min (120)] DISCONNECTED Player One (steam-1)", + }, + ] + + first_delta = persist_rcon_admin_log_entries( + target=TARGET, + entries=entries, + db_path=db_path, + ) + second_delta = persist_rcon_admin_log_entries( + target=TARGET, + entries=entries, + db_path=db_path, + ) + + assert first_delta == { + "events_seen": 2, + "events_inserted": 2, + "duplicate_events": 0, + } + assert second_delta == { + "events_seen": 2, + "events_inserted": 0, + "duplicate_events": 2, + } + gc.collect() + + +def test_profile_message_snapshots_are_materialized_and_deduped(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + entry = { + "timestamp": "2026-05-19T10:00:00Z", + "message": ( + "[21:34:19 hours (1779108340)] MESSAGE: player [Jugador Uno(steam-profile-1)], " + "content [─ Jugador Uno ─\n" + "▒ Totales ▒\n" + "sesiones : 12\n" + "partidas jugadas : 9\n" + "bajas : 141 (6 TKs)\n" + "muertes : 268 (5 TKs)\n" + "K/D : 0.53\n" + "▒ Víctimas ▒\n" + "Rival Dos : 7\n" + "▒ Némesis ▒\n" + "Rival Tres : 4\n" + "▒ Armas favoritas ▒\n" + "M1 GARAND : 31\n" + "▒ Promedios ▒\n" + "bajas por partida : 15.6\n" + "▒ Sanciones ▒\n" + "kicks : 1]" + ), + } + + persist_rcon_admin_log_entries(target=TARGET, entries=[entry], db_path=db_path) + persist_rcon_admin_log_entries(target=TARGET, entries=[entry], db_path=db_path) + + connection = sqlite3.connect(db_path) + connection.row_factory = sqlite3.Row + try: + rows = connection.execute("SELECT * FROM rcon_player_profile_snapshots").fetchall() + finally: + connection.close() + gc.collect() + + assert len(rows) == 1 + row = rows[0] + assert row["target_key"] == "test-rcon-target" + assert row["player_id"] == "steam-profile-1" + assert row["source_server_time"] == 1779108340 + assert row["sessions"] == 12 + assert row["matches_played"] == 9 + assert row["total_kills"] == 141 + assert row["total_deaths"] == 268 + assert row["teamkills_done"] == 6 + assert row["teamkills_received"] == 5 + assert row["kd_ratio"] == 0.53 + assert json.loads(row["favorite_weapons_json"]) == {"M1 GARAND": 31} + assert json.loads(row["victims_json"]) == {"Rival Dos": 7} + assert json.loads(row["nemesis_json"]) == {"Rival Tres": 4} + assert "bajas : 141" in row["raw_content"] + + +def test_non_profile_messages_do_not_create_profile_snapshots(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + + persist_rcon_admin_log_entries( + target=TARGET, + entries=[ + { + "timestamp": "2026-05-19T10:00:00Z", + "message": "[1:00 min (100)] MESSAGE: player [Player One(steam-1)], content [hello]", + } + ], + db_path=db_path, + ) + + connection = sqlite3.connect(db_path) + try: + count = connection.execute( + "SELECT COUNT(*) FROM rcon_player_profile_snapshots" + ).fetchone()[0] + finally: + connection.close() + gc.collect() + + assert count == 0 + + +def test_canonical_message_dedupes_changing_relative_prefixes(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + original_entry = { + "timestamp": "2026-05-19T10:00:00Z", + "message": "[1:00 min (100)] MESSAGE: player [Player One(steam-1)], content [hello]", + } + repeated_read_entry = { + "timestamp": "2026-05-19T10:05:00Z", + "message": "[6:00 min (100)] MESSAGE: player [Player One(steam-1)], content [hello]", + } + + first_delta = persist_rcon_admin_log_entries( + target=TARGET, + entries=[original_entry], + db_path=db_path, + ) + second_delta = persist_rcon_admin_log_entries( + target=TARGET, + entries=[repeated_read_entry], + db_path=db_path, + ) + + assert first_delta["events_inserted"] == 1 + assert second_delta == { + "events_seen": 1, + "events_inserted": 0, + "duplicate_events": 1, + } + gc.collect() + + +def test_list_rcon_admin_log_event_counts_groups_by_target_and_event_type(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + other_target = { + "target_key": "other-rcon-target", + "external_server_id": "other-rcon-target", + } + + persist_rcon_admin_log_entries( + target=TARGET, + entries=[ + { + "timestamp": "2026-05-19T10:00:00Z", + "message": "[1:00 min (100)] CONNECTED Player One (steam-1)", + }, + { + "timestamp": "2026-05-19T10:01:00Z", + "message": "[2:00 min (120)] DISCONNECTED Player One (steam-1)", + }, + ], + db_path=db_path, + ) + persist_rcon_admin_log_entries( + target=other_target, + entries=[ + { + "timestamp": "2026-05-19T10:02:00Z", + "message": "[3:00 min (140)] CONNECTED Player Two (steam-2)", + } + ], + db_path=db_path, + ) + + counts = { + (row["target_key"], row["event_type"]): row + for row in list_rcon_admin_log_event_counts(db_path=db_path) + } + + assert counts == { + ("other-rcon-target", "connected"): { + "target_key": "other-rcon-target", + "event_type": "connected", + "event_count": 1, + "first_server_time": 140, + "last_server_time": 140, + }, + ("test-rcon-target", "connected"): { + "target_key": "test-rcon-target", + "event_type": "connected", + "event_count": 1, + "first_server_time": 100, + "last_server_time": 100, + }, + ("test-rcon-target", "disconnected"): { + "target_key": "test-rcon-target", + "event_type": "disconnected", + "event_count": 1, + "first_server_time": 120, + "last_server_time": 120, + }, + } + gc.collect() + + +def test_current_match_kill_feed_prefers_open_match_window_and_normalizes_rows(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + persist_rcon_admin_log_entries( + target=TARGET, + entries=[ + { + "timestamp": "2026-05-19T09:59:00Z", + "message": ( + "[0:59 min (90)] KILL: Old Killer(Allies/steam-old) -> " + "Old Victim(Axis/steam-victim-old) with M1 GARAND" + ), + }, + { + "timestamp": "2026-05-19T10:00:00Z", + "message": "[1:00 min (100)] MATCH START Mortain Warfare", + }, + { + "timestamp": "2026-05-19T10:01:00Z", + "message": ( + "[2:00 min (120)] KILL: Alpha(Allies/steam-alpha) -> " + "Bravo(Allies/steam-bravo) with GRENADE" + ), + }, + ], + db_path=db_path, + ) + + feed = list_current_match_kill_feed( + server_key="test-rcon-target", + db_path=db_path, + ) + + assert feed["scope"] == "open-admin-log-match-window" + assert feed["confidence"] == "admin-log-boundary" + assert len(feed["items"]) == 1 + assert feed["items"][0] == { + "event_id": "rcon-admin-log:test-rcon-target:3", + "event_timestamp": "2026-05-19T10:01:00Z", + "server_time": 120, + "killer_name": "Alpha", + "killer_team": "Allies", + "victim_name": "Bravo", + "victim_team": "Allies", + "weapon": "GRENADE", + "is_teamkill": True, + } + gc.collect() + + +def test_current_match_kill_feed_filters_stale_recent_fallback_rows(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + persist_rcon_admin_log_entries( + target=TARGET, + entries=[ + { + "timestamp": "2026-05-21T09:30:00Z", + "message": ( + "[1:00 min (1779355800)] KILL: Old Killer(Allies/steam-old) -> " + "Old Victim(Axis/steam-victim-old) with M1 GARAND" + ), + } + ], + db_path=db_path, + ) + + feed = list_current_match_kill_feed( + server_key="test-rcon-target", + db_path=db_path, + now=datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc), + ) + + assert feed["scope"] == "no-current-match-events" + assert feed["confidence"] == "stale-filtered" + assert feed["stale_events_filtered"] == 1 + assert feed["items"] == [] + gc.collect() + + +def test_current_match_kill_feed_marks_fresh_recent_fallback_rows_partial(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + persist_rcon_admin_log_entries( + target=TARGET, + entries=[ + { + "timestamp": "2026-05-21T09:50:00Z", + "message": ( + "[1:00 min (1779357000)] KILL: Fresh Killer(Allies/steam-fresh) -> " + "Fresh Victim(Axis/steam-victim-fresh) with M1 GARAND" + ), + } + ], + db_path=db_path, + ) + + feed = list_current_match_kill_feed( + server_key="test-rcon-target", + db_path=db_path, + now=datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc), + ) + + assert feed["scope"] == "recent-admin-log-window" + assert feed["confidence"] == "partial" + assert feed["stale_events_filtered"] == 0 + assert [item["killer_name"] for item in feed["items"]] == ["Fresh Killer"] + gc.collect() + + +def test_current_match_kill_feed_filters_rows_before_incremental_cursor(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + persist_rcon_admin_log_entries( + target=TARGET, + entries=[ + { + "timestamp": "2026-05-21T10:00:00Z", + "message": "[1:00 min (100)] MATCH START Mortain Warfare", + }, + { + "timestamp": "2026-05-21T10:01:00Z", + "message": ( + "[2:00 min (120)] KILL: First Killer(Allies/steam-first) -> " + "First Victim(Axis/steam-first-victim) with M1 GARAND" + ), + }, + { + "timestamp": "2026-05-21T10:02:00Z", + "message": ( + "[3:00 min (140)] KILL: Next Killer(Axis/steam-next) -> " + "Next Victim(Allies/steam-next-victim) with MP40" + ), + }, + ], + db_path=db_path, + ) + + feed = list_current_match_kill_feed( + server_key="test-rcon-target", + db_path=db_path, + since_event_id="rcon-admin-log:test-rcon-target:2", + ) + + assert [item["killer_name"] for item in feed["items"]] == ["Next Killer"] + gc.collect() + + +def test_current_match_kill_feed_without_cursor_omits_nullable_id_predicate(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + persist_rcon_admin_log_entries( + target=TARGET, + entries=[ + { + "timestamp": "2026-05-21T10:00:00Z", + "message": "[1:00 min (100)] MATCH START Mortain Warfare", + }, + { + "timestamp": "2026-05-21T10:01:00Z", + "message": ( + "[2:00 min (120)] KILL: Cursor Killer(Allies/steam-cursor) -> " + "Cursor Victim(Axis/steam-cursor-victim) with M1 GARAND" + ), + }, + ], + db_path=db_path, + ) + traced_sql = [] + connect = sqlite3.connect + + def connect_with_trace(*args, **kwargs): + connection = connect(*args, **kwargs) + connection.set_trace_callback(traced_sql.append) + return connection + + with patch( + "app.rcon_admin_log_storage.sqlite3.connect", + side_effect=connect_with_trace, + ): + feed = list_current_match_kill_feed( + server_key="test-rcon-target", + db_path=db_path, + ) + + kill_queries = [ + sql + for sql in traced_sql + if "FROM rcon_admin_log_events" in sql and "event_type = 'kill'" in sql + ] + assert [item["killer_name"] for item in feed["items"]] == ["Cursor Killer"] + assert kill_queries + assert all("IS NULL OR id >" not in sql for sql in kill_queries) + assert all("AND id >" not in sql for sql in kill_queries) + gc.collect() + + +def test_current_match_kill_feed_invalid_cursor_behaves_like_no_cursor(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + persist_rcon_admin_log_entries( + target=TARGET, + entries=[ + { + "timestamp": "2026-05-21T10:00:00Z", + "message": "[1:00 min (100)] MATCH START Mortain Warfare", + }, + { + "timestamp": "2026-05-21T10:01:00Z", + "message": ( + "[2:00 min (120)] KILL: First Killer(Allies/steam-first) -> " + "First Victim(Axis/steam-first-victim) with M1 GARAND" + ), + }, + { + "timestamp": "2026-05-21T10:02:00Z", + "message": ( + "[3:00 min (140)] KILL: Next Killer(Axis/steam-next) -> " + "Next Victim(Allies/steam-next-victim) with MP40" + ), + }, + ], + db_path=db_path, + ) + + without_cursor = list_current_match_kill_feed( + server_key="test-rcon-target", + db_path=db_path, + ) + with_invalid_cursor = list_current_match_kill_feed( + server_key="test-rcon-target", + db_path=db_path, + since_event_id="not-an-admin-log-event", + ) + + assert with_invalid_cursor == without_cursor + gc.collect() diff --git a/backend/tests/test_rcon_historical_backfill.py b/backend/tests/test_rcon_historical_backfill.py new file mode 100644 index 0000000..aa4663a --- /dev/null +++ b/backend/tests/test_rcon_historical_backfill.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import json +import os +import sqlite3 +import tempfile +import unittest +from datetime import datetime, timezone +from pathlib import Path +from contextlib import closing +from unittest.mock import patch + +from app.rcon_admin_log_materialization import ( + MATCH_RESULT_SOURCE, + initialize_rcon_materialized_storage, +) +from app.rcon_historical_backfill import ( + count_recent_materialized_closed_matches, + run_rcon_historical_backfill, + select_backfill_targets, +) +from app.rcon_historical_leaderboards import list_rcon_materialized_leaderboard + + +TARGETS_JSON = json.dumps( + [ + { + "name": "Comunidad Hispana #01", + "slug": "comunidad-hispana-01", + "external_server_id": "comunidad-hispana-01", + "host": "127.0.0.1", + "port": 7779, + "password": "secret", + }, + { + "name": "Comunidad Hispana #02", + "slug": "comunidad-hispana-02", + "external_server_id": "comunidad-hispana-02", + "host": "127.0.0.1", + "port": 7879, + "password": "secret", + }, + { + "name": "Comunidad Hispana #03", + "slug": "comunidad-hispana-03", + "external_server_id": "comunidad-hispana-03", + "host": "127.0.0.1", + "port": 7979, + "password": "secret", + }, + ] +) + + +class RconHistoricalBackfillTests(unittest.TestCase): + def test_monthly_window_selects_previous_month_on_days_1_to_7(self) -> None: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: + payload = list_rcon_materialized_leaderboard( + server_key="all-servers", + timeframe="monthly", + metric="kills", + db_path=Path(tmpdir) / "historical.sqlite3", + now=datetime(2026, 5, 7, 12, tzinfo=timezone.utc), + ) + + self.assertEqual(payload["window_kind"], "previous-month") + self.assertEqual(payload["selected_month_start"], "2026-04-01T00:00:00Z") + self.assertEqual(payload["selected_month_end"], "2026-05-01T00:00:00Z") + + def test_monthly_window_selects_current_month_on_day_8_plus(self) -> None: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: + payload = list_rcon_materialized_leaderboard( + server_key="all-servers", + timeframe="monthly", + metric="kills", + db_path=Path(tmpdir) / "historical.sqlite3", + now=datetime(2026, 5, 8, 12, tzinfo=timezone.utc), + ) + + self.assertEqual(payload["window_kind"], "current-month") + self.assertEqual(payload["selected_month_start"], "2026-05-01T00:00:00Z") + + def test_recent_match_ensure_stops_when_count_is_already_satisfied(self) -> None: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir, _patched_targets(): + db_path = Path(tmpdir) / "historical.sqlite3" + _insert_closed_matches(db_path, 100) + + payload = run_rcon_historical_backfill( + servers="comunidad-hispana-01,comunidad-hispana-02", + ensure_recent_matches=100, + dry_run=True, + db_path=db_path, + ) + + self.assertEqual(payload["recent_materialized_closed_match_count_before"], 100) + self.assertEqual(payload["actual_windows_scanned"], []) + + def test_unknown_server_is_rejected(self) -> None: + with _patched_targets(): + with self.assertRaises(ValueError): + select_backfill_targets("unknown-server") + + def test_comunidad_hispana_03_is_not_included_by_default(self) -> None: + with _patched_targets(): + selected = select_backfill_targets(None) + + self.assertEqual( + [target.external_server_id for target in selected], + ["comunidad-hispana-01", "comunidad-hispana-02"], + ) + + def test_dry_run_does_not_insert_data(self) -> None: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir, _patched_targets(): + db_path = Path(tmpdir) / "historical.sqlite3" + payload = run_rcon_historical_backfill( + servers="comunidad-hispana-01", + ensure_current_month=True, + dry_run=True, + db_path=db_path, + ) + + count_after = count_recent_materialized_closed_matches(db_path=db_path) + + self.assertEqual(payload["status"], "dry-run") + self.assertEqual(payload["events_inserted"], 0) + self.assertEqual(count_after, 0) + + def test_backfill_output_is_json_serializable(self) -> None: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir, _patched_targets(): + payload = run_rcon_historical_backfill( + servers="comunidad-hispana-01", + ensure_current_month=True, + dry_run=True, + db_path=Path(tmpdir) / "historical.sqlite3", + ) + + json.dumps(payload, ensure_ascii=True) + + +def _insert_closed_matches(db_path: Path, count: int) -> None: + initialize_rcon_materialized_storage(db_path=db_path) + with closing(sqlite3.connect(db_path)) as connection: + for index in range(count): + connection.execute( + """ + INSERT INTO rcon_materialized_matches ( + target_key, external_server_id, match_key, map_name, map_pretty_name, + started_at, ended_at, confidence_mode, source_basis + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "comunidad-hispana-01", + "comunidad-hispana-01", + f"match-{index}", + "stmariedumont", + "ST MARIE DU MONT", + "2026-05-01T10:00:00Z", + f"2026-05-{(index % 28) + 1:02d}T12:00:00Z", + "exact", + MATCH_RESULT_SOURCE, + ), + ) + connection.commit() + + +def _patched_targets(): + return patch.dict(os.environ, {"HLL_BACKEND_RCON_TARGETS": TARGETS_JSON}) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_rcon_materialization_pipeline.py b/backend/tests/test_rcon_materialization_pipeline.py new file mode 100644 index 0000000..6e1b9cf --- /dev/null +++ b/backend/tests/test_rcon_materialization_pipeline.py @@ -0,0 +1,400 @@ +"""Regression tests for the materialized RCON AdminLog pipeline.""" + +from __future__ import annotations + +import gc +import os +import tempfile +import unittest +from pathlib import Path + +from app.historical_storage import upsert_historical_match +from app.payloads import build_recent_historical_matches_payload +from app.rcon_admin_log_materialization import ( + get_materialized_rcon_match_detail, + materialize_rcon_admin_log, + summarize_rcon_materialization_status, +) +from app.rcon_admin_log_storage import persist_rcon_admin_log_entries +from app.rcon_historical_read_model import ( + get_rcon_historical_match_detail, + list_rcon_historical_recent_activity, +) +from app.scoreboard_origins import resolve_trusted_scoreboard_match_url + + +class RconMaterializationPipelineTests(unittest.TestCase): + def test_materializes_match_result_and_player_stats_idempotently(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + _persist_admin_log_fixture(db_path) + + first = materialize_rcon_admin_log(db_path=db_path) + second = materialize_rcon_admin_log(db_path=db_path) + detail = get_materialized_rcon_match_detail( + server_key="comunidad-hispana-01", + match_key="comunidad-hispana-01:100:500:stmariedumontwarfare", + db_path=db_path, + ) + status = summarize_rcon_materialization_status(db_path=db_path) + + self.assertEqual(first["matches_materialized"], 1) + self.assertEqual(second["matches_materialized"], 0) + self.assertEqual(second["matches_updated"], 1) + self.assertIsNotNone(detail) + match = detail["match"] + self.assertEqual(match["allied_score"], 5) + self.assertEqual(match["axis_score"], 0) + self.assertEqual(match["winner"], "allied") + players = {row["player_name"]: row for row in detail["players"]} + self.assertEqual(players["Alpha"]["kills"], 1) + self.assertEqual(players["Alpha"]["teamkills"], 1) + self.assertEqual(players["Bravo"]["deaths"], 1) + self.assertEqual(players["Charlie"]["deaths_by_teamkill"], 1) + self.assertEqual(status["materialized_matches"], 1) + self.assertEqual(status["matches_with_player_stats"], 1) + gc.collect() + + def test_match_detail_read_model_hides_raw_player_ids(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH") + os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path) + try: + _persist_admin_log_fixture(db_path) + materialize_rcon_admin_log(db_path=db_path) + detail = get_rcon_historical_match_detail( + server_key="comunidad-hispana-01", + match_id="comunidad-hispana-01:100:500:stmariedumontwarfare", + ) + finally: + _restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path) + + self.assertIsNotNone(detail) + self.assertEqual(detail["result_source"], "admin-log-match-ended") + self.assertEqual(detail["result"]["allied_score"], 5) + self.assertEqual(detail["timestamp_confidence"], "absolute") + players = {row["player_name"]: row for row in detail["players"]} + self.assertNotIn("player_id", players["Alpha"]) + self.assertIn("kd_ratio", players["Alpha"]) + self.assertEqual(players["Alpha"]["steam_id_64"], "76561198000000001") + self.assertEqual(players["Alpha"]["platform"], "steam") + self.assertEqual( + players["Alpha"]["external_profile_links"]["hellor"], + "https://hellor.pro/player/76561198000000001", + ) + self.assertEqual(players["Charlie"]["platform"], "unknown") + self.assertNotIn("steam_id_64", players["Charlie"]) + self.assertNotIn("external_profile_links", players["Charlie"]) + gc.collect() + + def test_match_detail_marks_equal_materialized_timestamps_as_server_time_only(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH") + os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path) + try: + persist_rcon_admin_log_entries( + target={ + "target_key": "comunidad-hispana-01", + "external_server_id": "comunidad-hispana-01", + }, + entries=[ + { + "timestamp": "2026-05-01T12:00:00Z", + "message": "[1 min (100)] MATCH START ST MARIE DU MONT Warfare", + }, + { + "timestamp": "2026-05-01T12:00:00Z", + "message": "[91 min (5500)] MATCH ENDED `ST MARIE DU MONT Warfare` ALLIED (5 - 0) AXIS", + }, + ], + db_path=db_path, + ) + materialize_rcon_admin_log(db_path=db_path) + detail = get_rcon_historical_match_detail( + server_key="comunidad-hispana-01", + match_id="comunidad-hispana-01:100:5500:stmariedumontwarfare", + ) + finally: + _restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path) + + self.assertIsNotNone(detail) + self.assertIsNone(detail["started_at"]) + self.assertIsNone(detail["ended_at"]) + self.assertEqual(detail["closed_at"], "2026-05-01T12:00:00Z") + self.assertEqual(detail["timestamp_confidence"], "server-time-only") + self.assertEqual(detail["duration_seconds"], 5400) + gc.collect() + + def test_equal_timestamp_materialized_detail_uses_closed_at_window_for_scoreboard_link(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH") + os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path) + try: + upsert_historical_match( + server_slug="comunidad-hispana-02", + match_payload={ + "id": "1779183861", + "creation_time": "2026-05-01T10:30:00Z", + "start": "2026-05-01T10:30:00Z", + "end": "2026-05-01T12:00:00Z", + "map": {"name": "ST MARIE DU MONT Warfare"}, + "result": {"allied": 5, "axis": 0}, + "player_stats": [], + }, + db_path=db_path, + ) + persist_rcon_admin_log_entries( + target={ + "target_key": "comunidad-hispana-02", + "external_server_id": "comunidad-hispana-02", + }, + entries=[ + { + "timestamp": "2026-05-01T12:00:00Z", + "message": "[1 min (100)] MATCH START ST MARIE DU MONT Warfare", + }, + { + "timestamp": "2026-05-01T12:00:00Z", + "message": "[91 min (5500)] MATCH ENDED `ST MARIE DU MONT Warfare` ALLIED (5 - 0) AXIS", + }, + ], + db_path=db_path, + ) + materialize_rcon_admin_log(db_path=db_path) + detail = get_rcon_historical_match_detail( + server_key="comunidad-hispana-02", + match_id="comunidad-hispana-02:100:5500:stmariedumontwarfare", + ) + finally: + _restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path) + + self.assertIsNotNone(detail) + self.assertIsNone(detail["started_at"]) + self.assertIsNone(detail["ended_at"]) + self.assertEqual(detail["duration_seconds"], 5400) + self.assertEqual( + detail["match_url"], + "https://scoreboard.comunidadhll.es:5443/games/1779183861", + ) + gc.collect() + + def test_match_detail_adds_safe_profile_summary_when_snapshot_exists(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH") + os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path) + try: + _persist_admin_log_fixture(db_path) + persist_rcon_admin_log_entries( + target={ + "target_key": "comunidad-hispana-01", + "external_server_id": "comunidad-hispana-01", + }, + entries=[ + { + "timestamp": "2026-05-01T10:30:00Z", + "message": ( + "[31 min (300)] MESSAGE: player [Alpha(76561198000000001)], " + "content [─ Alpha ─\n" + "▒ Totales ▒\n" + "sesiones : 12\n" + "partidas jugadas : 9\n" + "bajas : 141 (6 TKs)\n" + "muertes : 268 (5 TKs)\n" + "K/D : 0.53\n" + "▒ Armas favoritas ▒\n" + "M1 Garand : 31]" + ), + } + ], + db_path=db_path, + ) + materialize_rcon_admin_log(db_path=db_path) + detail = get_rcon_historical_match_detail( + server_key="comunidad-hispana-01", + match_id="comunidad-hispana-01:100:500:stmariedumontwarfare", + ) + finally: + _restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path) + + self.assertIsNotNone(detail) + players = {row["player_name"]: row for row in detail["players"]} + self.assertIn("profile_summary", players["Alpha"]) + self.assertNotIn("profile_summary", players["Bravo"]) + profile_summary = players["Alpha"]["profile_summary"] + self.assertEqual(profile_summary["sessions"], 12) + self.assertEqual(profile_summary["matches_played"], 9) + self.assertEqual(profile_summary["totals"]["kills"], 141) + self.assertEqual(profile_summary["favorite_weapons"], {"M1 Garand": 31}) + self.assertNotIn("raw_content", profile_summary) + self.assertNotIn("player_id", players["Alpha"]) + gc.collect() + + def test_recent_matches_prefer_materialized_rcon_over_scoreboard_fallback(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH") + os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path) + try: + _persist_admin_log_fixture(db_path) + materialize_rcon_admin_log(db_path=db_path) + _persist_scoreboard_match(db_path) + + payload = build_recent_historical_matches_payload( + limit=5, + server_slug="comunidad-hispana-01", + ) + recent = list_rcon_historical_recent_activity( + server_key="comunidad-hispana-01", + limit=5, + ) + finally: + _restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path) + + self.assertEqual(payload["data"]["selected_source"], "rcon") + self.assertEqual(payload["data"]["items"][0]["result_source"], "admin-log-match-ended") + self.assertEqual(recent[0]["result_source"], "admin-log-match-ended") + self.assertNotEqual(payload["data"]["selected_source"], "public-scoreboard") + gc.collect() + + def test_recent_materialized_detail_id_resolves_through_detail_read_model(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH") + os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path) + try: + _persist_admin_log_fixture(db_path) + materialize_rcon_admin_log(db_path=db_path) + recent = list_rcon_historical_recent_activity( + server_key="comunidad-hispana-01", + limit=1, + )[0] + detail = get_rcon_historical_match_detail( + server_key="comunidad-hispana-01", + match_id=str(recent["internal_detail_match_id"]), + ) + finally: + _restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path) + + self.assertIsNotNone(detail) + self.assertEqual(detail["match_id"], recent["internal_detail_match_id"]) + gc.collect() + + def test_public_scoreboard_fallback_used_only_without_rcon_activity(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH") + os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path) + try: + _persist_scoreboard_match(db_path) + payload = build_recent_historical_matches_payload( + limit=5, + server_slug="comunidad-hispana-01", + ) + finally: + _restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path) + + self.assertTrue(payload["data"]["fallback_used"]) + self.assertEqual(payload["data"]["selected_source"], "public-scoreboard") + self.assertEqual(payload["data"]["items"][0]["result_source"], "public-scoreboard-fallback") + gc.collect() + + def test_safe_scoreboard_match_url_allowlist_for_active_origins(self) -> None: + self.assertEqual( + resolve_trusted_scoreboard_match_url( + "https://scoreboard.comunidadhll.es/games/1561515", + "comunidad-hispana-01", + ), + "https://scoreboard.comunidadhll.es/games/1561515", + ) + self.assertEqual( + resolve_trusted_scoreboard_match_url( + "https://scoreboard.comunidadhll.es:5443/games/222", + "comunidad-hispana-02", + ), + "https://scoreboard.comunidadhll.es:5443/games/222", + ) + self.assertIsNone( + resolve_trusted_scoreboard_match_url( + "https://example.com/games/222", + "comunidad-hispana-02", + ) + ) + self.assertIsNone( + resolve_trusted_scoreboard_match_url( + "https://scoreboard.comunidadhll.es:5443/admin/222", + "comunidad-hispana-02", + ) + ) + + +def _persist_admin_log_fixture(db_path: Path) -> None: + persist_rcon_admin_log_entries( + target={ + "target_key": "comunidad-hispana-01", + "external_server_id": "comunidad-hispana-01", + }, + entries=[ + { + "timestamp": "2026-05-01T10:00:00Z", + "message": "[1 min (100)] MATCH START ST MARIE DU MONT Warfare", + }, + { + "timestamp": "2026-05-01T10:05:00Z", + "message": "[6 min (150)] CONNECTED Alpha (76561198000000001)", + }, + { + "timestamp": "2026-05-01T10:06:00Z", + "message": "[7 min (160)] TEAMSWITCH Alpha (None > Allies)", + }, + { + "timestamp": "2026-05-01T10:10:00Z", + "message": ( + "[11 min (200)] KILL: Alpha(Allies/76561198000000001) -> " + "Bravo(Axis/76561198000000002) with M1 Garand" + ), + }, + { + "timestamp": "2026-05-01T10:12:00Z", + "message": ( + "[13 min (220)] KILL: Alpha(Allies/76561198000000001) -> " + "Charlie(Allies/nonsteam-local) with M1 Garand" + ), + }, + { + "timestamp": "2026-05-01T11:20:00Z", + "message": "[81 min (500)] MATCH ENDED `ST MARIE DU MONT Warfare` ALLIED (5 - 0) AXIS", + }, + ], + db_path=db_path, + ) + + +def _persist_scoreboard_match(db_path: Path) -> None: + upsert_historical_match( + server_slug="comunidad-hispana-01", + match_payload={ + "id": "1561515", + "creation_time": "2026-05-01T10:00:00Z", + "start": "2026-05-01T10:00:00Z", + "end": "2026-05-01T11:20:00Z", + "map": {"name": "ST MARIE DU MONT Warfare"}, + "result": {"allied": 2, "axis": 3}, + "player_stats": [], + }, + db_path=db_path, + ) + + +def _restore_env(name: str, previous_value: str | None) -> None: + if previous_value is None: + os.environ.pop(name, None) + else: + os.environ[name] = previous_value + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_scoreboard_match_links.py b/backend/tests/test_scoreboard_match_links.py new file mode 100644 index 0000000..2d30d9a --- /dev/null +++ b/backend/tests/test_scoreboard_match_links.py @@ -0,0 +1,436 @@ +"""Regression checks for persisted public-scoreboard match links.""" + +from __future__ import annotations + +import gc +import os +import sqlite3 +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from app.scoreboard_candidate_backfill import run_backfill +from app.historical_storage import ( + get_historical_match_detail, + initialize_historical_storage, + list_recent_historical_matches, + upsert_historical_match, +) +from app.rcon_historical_storage import initialize_rcon_historical_storage +from app.rcon_historical_storage import persist_rcon_historical_sample +from app.rcon_historical_storage import start_rcon_historical_capture_run +from app.rcon_historical_read_model import get_rcon_historical_match_detail +from app.rcon_admin_log_materialization import materialize_rcon_admin_log +from app.rcon_admin_log_storage import persist_rcon_admin_log_entries +from app.rcon_scoreboard_relink import relink_materialized_matches +from app.scoreboard_correlation_diagnostics import inspect_materialized_match_correlation + + +class PersistedScoreboardMatchLinkTests(unittest.TestCase): + def test_list_backfill_persists_foy_candidate_before_detail_fetch_failure(self) -> None: + stored: dict[tuple[str, str], dict[str, object]] = {} + + class FoyListProvider: + def fetch_match_page(self, *, base_url: str, page: int, limit: int) -> dict[str, object]: + return {"maps": [_foy_list_match()]} if page == 1 else {"maps": []} + + def fetch_match_details( + self, + *, + base_url: str, + match_ids: list[str], + max_workers: int, + ) -> list[dict[str, object]]: + raise RuntimeError("detail endpoint unavailable") + + def fake_upsert(*, server_slug: str, candidate: dict[str, object]) -> str: + key = (server_slug, str(candidate["external_match_id"])) + outcome = "updated" if key in stored else "inserted" + stored[key] = dict(candidate) + return outcome + + server = { + "slug": "comunidad-hispana-02", + "scoreboard_base_url": "https://scoreboard.comunidadhll.es:5443", + "server_number": 2, + } + with ( + patch("app.scoreboard_candidate_backfill.initialize_historical_storage"), + patch( + "app.scoreboard_candidate_backfill.PublicScoreboardHistoricalDataSource", + return_value=FoyListProvider(), + ), + patch( + "app.scoreboard_candidate_backfill.upsert_scoreboard_candidate", + side_effect=fake_upsert, + ), + ): + first = run_backfill( + server=server, + start_at=_backfill_timestamp("2026-05-20T00:00:00Z"), + end_at=_backfill_timestamp("2026-05-21T23:59:59Z"), + max_pages=2, + page_size=100, + detail_workers=1, + ) + second = run_backfill( + server=server, + start_at=_backfill_timestamp("2026-05-20T00:00:00Z"), + end_at=_backfill_timestamp("2026-05-21T23:59:59Z"), + max_pages=2, + page_size=100, + detail_workers=1, + ) + + candidate = stored[("comunidad-hispana-02", "1562115")] + self.assertEqual( + candidate["match_url"], + "https://scoreboard.comunidadhll.es:5443/games/1562115", + ) + self.assertEqual(first["list_candidates_inserted"], 1) + self.assertEqual(first["list_candidates_updated"], 0) + self.assertEqual(first["errors"][0]["stage"], "fetch_match_details") + self.assertEqual(second["list_candidates_inserted"], 0) + self.assertEqual(second["list_candidates_updated"], 1) + self.assertEqual(len(stored), 1) + + def test_recent_and_detail_payloads_expose_safe_persisted_match_url(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + match_url = "https://scoreboard.comunidadhll.es:5443/games/12345" + _persist_match(db_path, server_slug="comunidad-hispana-02", match_id="12345") + + recent_items = list_recent_historical_matches( + server_slug="comunidad-hispana-02", + limit=5, + db_path=db_path, + ) + detail = get_historical_match_detail( + server_slug="comunidad-hispana-02", + match_id="12345", + db_path=db_path, + ) + + self.assertEqual(recent_items[0]["match_url"], match_url) + self.assertIsNotNone(detail) + self.assertEqual(detail["match_url"], match_url) + gc.collect() + + def test_untrusted_persisted_match_url_is_not_exposed(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + _persist_match(db_path, server_slug="comunidad-hispana-01", match_id="999") + _set_raw_payload_ref( + db_path, + match_id="999", + raw_payload_ref="https://scoreboard.comunidadhll.es:3443/games/999", + ) + + recent_items = list_recent_historical_matches( + server_slug="comunidad-hispana-01", + limit=5, + db_path=db_path, + ) + detail = get_historical_match_detail( + server_slug="comunidad-hispana-01", + match_id="999", + db_path=db_path, + ) + + self.assertIsNone(recent_items[0]["match_url"]) + self.assertIsNotNone(detail) + self.assertIsNone(detail["match_url"]) + gc.collect() + + def test_detail_player_links_use_trusted_scoreboard_steam_id(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + _persist_match( + db_path, + server_slug="comunidad-hispana-02", + match_id="steam-player-match", + player_stats=[ + { + "player": "Steam Player", + "steaminfo": {"profile": {"steamid": "76561198000000009"}}, + "team": {"side": "allies"}, + "kills": 4, + "deaths": 2, + } + ], + ) + + detail = get_historical_match_detail( + server_slug="comunidad-hispana-02", + match_id="steam-player-match", + db_path=db_path, + ) + + self.assertIsNotNone(detail) + player = detail["players"][0] + self.assertEqual(player["steam_id_64"], "76561198000000009") + self.assertEqual(player["platform"], "steam") + self.assertEqual( + player["external_profile_links"]["hll_records"], + "https://hllrecords.com/profiles/76561198000000009", + ) + gc.collect() + + def test_rcon_match_detail_does_not_fabricate_external_scoreboard_url(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH") + os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path) + try: + initialize_rcon_historical_storage(db_path=db_path) + detail = get_rcon_historical_match_detail( + server_key="comunidad-hispana-01", + match_id="rcon:synthetic-window", + ) + finally: + if previous_storage_path is None: + os.environ.pop("HLL_BACKEND_STORAGE_PATH", None) + else: + os.environ["HLL_BACKEND_STORAGE_PATH"] = previous_storage_path + + self.assertIsNone(detail) + gc.collect() + + def test_rcon_match_detail_exposes_correlated_scoreboard_url_on_strong_evidence(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH") + os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path) + try: + _persist_match( + db_path, + server_slug="comunidad-hispana-01", + match_id="1561515", + map_name="St. Mere Eglise", + started_at="2026-04-12T16:20:00Z", + ended_at="2026-04-12T17:45:00Z", + ) + session_key = _persist_rcon_window( + db_path, + map_name="St. Mere Eglise", + first_seen_at="2026-04-12T16:28:55.761810Z", + last_seen_at="2026-04-12T16:43:55.761810Z", + players=94, + max_players=98, + ) + + detail = get_rcon_historical_match_detail( + server_key="comunidad-hispana-01", + match_id=session_key, + ) + finally: + if previous_storage_path is None: + os.environ.pop("HLL_BACKEND_STORAGE_PATH", None) + else: + os.environ["HLL_BACKEND_STORAGE_PATH"] = previous_storage_path + + self.assertIsNotNone(detail) + self.assertEqual( + detail["match_url"], + "https://scoreboard.comunidadhll.es/games/1561515", + ) + gc.collect() + + def test_rcon_match_detail_keeps_low_confidence_correlation_unlinked(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH") + os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path) + try: + _persist_match( + db_path, + server_slug="comunidad-hispana-01", + match_id="1561515", + map_name="Carentan", + started_at="2026-04-12T10:00:00Z", + ended_at="2026-04-12T11:30:00Z", + ) + session_key = _persist_rcon_window( + db_path, + map_name="St. Mere Eglise", + first_seen_at="2026-04-12T16:28:55.761810Z", + last_seen_at="2026-04-12T16:43:55.761810Z", + players=94, + max_players=98, + ) + + detail = get_rcon_historical_match_detail( + server_key="comunidad-hispana-01", + match_id=session_key, + ) + finally: + if previous_storage_path is None: + os.environ.pop("HLL_BACKEND_STORAGE_PATH", None) + else: + os.environ["HLL_BACKEND_STORAGE_PATH"] = previous_storage_path + + self.assertIsNotNone(detail) + self.assertIsNone(detail["match_url"]) + gc.collect() + + def test_foy_relink_reports_existing_materialized_match_url(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH") + os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path) + try: + _persist_match( + db_path, + server_slug="comunidad-hispana-02", + match_id="1562115", + map_name="Foy Warfare", + started_at="2026-05-20T20:54:11Z", + ended_at="2026-05-20T22:24:11Z", + ) + persist_rcon_admin_log_entries( + target={ + "target_key": "comunidad-hispana-02", + "external_server_id": "comunidad-hispana-02", + }, + entries=[ + { + "timestamp": "2026-05-20T20:54:11Z", + "message": "[1 min (1779310451)] MATCH START Foy Warfare", + }, + { + "timestamp": "2026-05-20T22:24:11Z", + "message": "[91 min (1779315851)] MATCH ENDED `Foy Warfare` ALLIED (4 - 1) AXIS", + }, + ], + db_path=db_path, + ) + materialize_rcon_admin_log(db_path=db_path) + report = relink_materialized_matches( + server_key="comunidad-hispana-02", + db_path=db_path, + ) + detail = get_rcon_historical_match_detail( + server_key="comunidad-hispana-02", + match_id="comunidad-hispana-02:1779310451:1779315851:foywarfare", + ) + diagnostics = inspect_materialized_match_correlation( + server_key="comunidad-hispana-02", + match_key="comunidad-hispana-02:1779310451:1779315851:foywarfare", + db_path=db_path, + ) + finally: + if previous_storage_path is None: + os.environ.pop("HLL_BACKEND_STORAGE_PATH", None) + else: + os.environ["HLL_BACKEND_STORAGE_PATH"] = previous_storage_path + + self.assertEqual(report["matches_scanned"], 1) + self.assertEqual(report["matches_linked"], 1) + self.assertGreaterEqual(report["candidates_scanned"], 1) + self.assertIsNotNone(detail) + self.assertEqual( + detail["match_url"], + "https://scoreboard.comunidadhll.es:5443/games/1562115", + ) + self.assertEqual(diagnostics["final_reason"], "linked") + self.assertEqual(diagnostics["selected_candidate"]["external_match_id"], "1562115") + self.assertEqual(diagnostics["top_candidates"][0]["map"], "Foy Warfare") + gc.collect() + + +def _persist_match( + db_path: Path, + *, + server_slug: str, + match_id: str, + map_name: str = "carentan", + started_at: str = "2026-05-01T10:00:00Z", + ended_at: str = "2026-05-01T11:20:00Z", + player_stats: list[dict[str, object]] | None = None, +) -> None: + upsert_historical_match( + server_slug=server_slug, + match_payload={ + "id": match_id, + "creation_time": started_at, + "start": started_at, + "end": ended_at, + "map": {"name": map_name}, + "result": {"allied": 3, "axis": 2}, + "player_stats": player_stats or [], + }, + db_path=db_path, + ) + + +def _foy_list_match() -> dict[str, object]: + return { + "id": 1562115, + "server_number": 2, + "start": "2026-05-20T20:54:11+00:00", + "end": "2026-05-20T22:24:11+00:00", + "map": {"id": "foywarfare", "pretty_name": "Foy Warfare"}, + "result": {"allied": 4, "axis": 1}, + } + + +def _backfill_timestamp(raw_value: str): + from app.scoreboard_candidate_backfill import _parse_timestamp + + return _parse_timestamp(raw_value, option_name="test") + + +def _persist_rcon_window( + db_path: Path, + *, + map_name: str, + first_seen_at: str, + last_seen_at: str, + players: int, + max_players: int, +) -> str: + initialize_rcon_historical_storage(db_path=db_path) + run_id = start_rcon_historical_capture_run( + mode="test", + target_scope="comunidad-hispana-01", + db_path=db_path, + ) + target = { + "target_key": "comunidad-hispana-01", + "external_server_id": "comunidad-hispana-01", + "name": "Comunidad Hispana #01", + "host": "127.0.0.1", + "port": 7779, + } + for captured_at in (first_seen_at, last_seen_at): + persist_rcon_historical_sample( + run_id=run_id, + captured_at=captured_at, + target=target, + normalized_payload={ + "status": "online", + "players": players, + "max_players": max_players, + "current_map": map_name, + }, + raw_payload={}, + db_path=db_path, + ) + return f"1:{first_seen_at}" + + +def _set_raw_payload_ref(db_path: Path, *, match_id: str, raw_payload_ref: str) -> None: + with sqlite3.connect(db_path) as connection: + connection.execute( + """ + UPDATE historical_matches + SET raw_payload_ref = ? + WHERE external_match_id = ? + """, + (raw_payload_ref, match_id), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/deploy/portainer/Caddyfile.example b/deploy/portainer/Caddyfile.example new file mode 100644 index 0000000..58d7dd5 --- /dev/null +++ b/deploy/portainer/Caddyfile.example @@ -0,0 +1,8 @@ +comunidadhll.devzamode.es { + encode zstd gzip + + reverse_proxy /health hll-vietnam-backend-1:8000 + reverse_proxy /api/* hll-vietnam-backend-1:8000 + + reverse_proxy hll-vietnam-frontend-1:8080 +} diff --git a/deploy/portainer/docker-compose.nas.yml b/deploy/portainer/docker-compose.nas.yml new file mode 100644 index 0000000..c01edde --- /dev/null +++ b/deploy/portainer/docker-compose.nas.yml @@ -0,0 +1,128 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-hll_vietnam} + POSTGRES_USER: ${POSTGRES_USER:-hll_vietnam} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 12 + networks: + - hll-internal + restart: unless-stopped + + backend: + build: + context: ../../backend + environment: + HLL_BACKEND_DATABASE_URL: ${HLL_BACKEND_DATABASE_URL:?HLL_BACKEND_DATABASE_URL is required} + HLL_BACKEND_HOST: ${HLL_BACKEND_HOST:-0.0.0.0} + HLL_BACKEND_PORT: ${HLL_BACKEND_PORT:-8000} + HLL_BACKEND_ALLOWED_ORIGINS: ${HLL_BACKEND_ALLOWED_ORIGINS:-https://comunidadhll.devzamode.es} + HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon} + HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon} + HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20} + HLL_BACKEND_RCON_TARGETS: ${HLL_BACKEND_RCON_TARGETS:?HLL_BACKEND_RCON_TARGETS is required} + expose: + - "8000" + depends_on: + postgres: + condition: service_healthy + volumes: + - backend-data:/app/data + networks: + - hll-internal + - caddy + restart: unless-stopped + + frontend: + build: + context: ../../frontend + command: + - sh + - -c + - | + python - <<'PY' + from pathlib import Path + for path in Path('/srv/frontend').glob('*.html'): + text = path.read_text(encoding='utf-8') + text = text.replace('data-backend-base-url="http://127.0.0.1:8000"', 'data-backend-base-url=""') + path.write_text(text, encoding='utf-8') + PY + python -m http.server 8080 --bind 0.0.0.0 --directory /srv/frontend + expose: + - "8080" + depends_on: + - backend + networks: + - caddy + restart: unless-stopped + + historical-runner: + profiles: + - advanced + build: + context: ../../backend + command: ["python", "-m", "app.historical_runner", "--hourly"] + environment: + HLL_BACKEND_DATABASE_URL: ${HLL_BACKEND_DATABASE_URL:?HLL_BACKEND_DATABASE_URL is required} + HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon} + HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon} + HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20} + HLL_BACKEND_RCON_TARGETS: ${HLL_BACKEND_RCON_TARGETS:?HLL_BACKEND_RCON_TARGETS is required} + HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS: ${HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS:-3600} + HLL_HISTORICAL_REFRESH_MAX_RETRIES: ${HLL_HISTORICAL_REFRESH_MAX_RETRIES:-2} + HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS: ${HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS:-15} + depends_on: + postgres: + condition: service_healthy + backend: + condition: service_started + volumes: + - backend-data:/app/data + networks: + - hll-internal + restart: unless-stopped + + rcon-historical-worker: + profiles: + - advanced + build: + context: ../../backend + command: ["python", "-m", "app.rcon_historical_worker", "loop"] + environment: + HLL_BACKEND_DATABASE_URL: ${HLL_BACKEND_DATABASE_URL:?HLL_BACKEND_DATABASE_URL is required} + HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon} + HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon} + HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20} + HLL_BACKEND_RCON_TARGETS: ${HLL_BACKEND_RCON_TARGETS:?HLL_BACKEND_RCON_TARGETS is required} + HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS:-600} + HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES: ${HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES:-2} + HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS:-15} + HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES: ${HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES:-10} + depends_on: + postgres: + condition: service_healthy + backend: + condition: service_started + volumes: + - backend-data:/app/data + networks: + - hll-internal + restart: unless-stopped + +volumes: + postgres-data: + backend-data: + +networks: + hll-internal: + driver: bridge + caddy: + external: true + name: ${CADDY_NETWORK:-stack-caddy} diff --git a/deploy/portainer/stack.env.example b/deploy/portainer/stack.env.example new file mode 100644 index 0000000..dc1f5fe --- /dev/null +++ b/deploy/portainer/stack.env.example @@ -0,0 +1,29 @@ +# Copy these values into Portainer Stack environment variables. +# Do not commit real production secrets. + +POSTGRES_DB=hll_vietnam +POSTGRES_USER=hll_vietnam +POSTGRES_PASSWORD=replace-with-strong-postgres-password + +HLL_BACKEND_DATABASE_URL=postgresql://hll_vietnam:replace-with-strong-postgres-password@postgres:5432/hll_vietnam +HLL_BACKEND_HOST=0.0.0.0 +HLL_BACKEND_PORT=8000 +HLL_BACKEND_ALLOWED_ORIGINS=https://comunidadhll.devzamode.es +HLL_BACKEND_LIVE_DATA_SOURCE=rcon +HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon +HLL_BACKEND_RCON_TIMEOUT_SECONDS=20 +HLL_BACKEND_RCON_TARGETS=[{"name":"Comunidad Hispana #01","slug":"comunidad-hispana-01","external_server_id":"comunidad-hispana-01","host":"replace-me-01.example","port":7779,"password":"replace-me-01","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #02","slug":"comunidad-hispana-02","external_server_id":"comunidad-hispana-02","host":"replace-me-02.example","port":7879,"password":"replace-me-02","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null}] + +CADDY_NETWORK=stack-caddy + +# Advanced profile only. Leave disabled unless you intentionally start the profile. +HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS=3600 +HLL_HISTORICAL_REFRESH_MAX_RETRIES=2 +HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS=15 +HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS=600 +HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES=2 +HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS=15 +HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES=10 +HLL_RCON_BACKFILL_CHUNK_HOURS=6 +HLL_RCON_BACKFILL_SLEEP_SECONDS=1 +HLL_RCON_BACKFILL_MAX_DAYS_BACK=45 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..557c60c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,107 @@ +services: + postgres: + image: postgres:16-alpine + container_name: hll-vietnam-postgres + environment: + POSTGRES_DB: hll_vietnam + POSTGRES_USER: hll_vietnam + POSTGRES_PASSWORD: hll_vietnam_dev + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U hll_vietnam -d hll_vietnam"] + interval: 5s + timeout: 5s + retries: 12 + restart: unless-stopped + + backend: + build: + context: ./backend + container_name: hll-vietnam-backend + env_file: + - ./backend/.env.example + environment: + HLL_BACKEND_DATABASE_URL: ${HLL_BACKEND_DATABASE_URL:-postgresql://hll_vietnam:hll_vietnam_dev@postgres:5432/hll_vietnam} + HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon} + HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon} + HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20} + HLL_BACKEND_RCON_TARGETS: >- + ${HLL_BACKEND_RCON_TARGETS:-[{"name":"Comunidad Hispana #01","slug":"comunidad-hispana-01","external_server_id":"comunidad-hispana-01","host":"152.114.195.174","port":7779,"password":"replace-me-01","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #02","slug":"comunidad-hispana-02","external_server_id":"comunidad-hispana-02","host":"152.114.195.150","port":7879,"password":"replace-me-02","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null}]} + ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy + volumes: + - ./backend/data:/app/data + restart: unless-stopped + + historical-runner: + profiles: + - advanced + build: + context: ./backend + container_name: hll-vietnam-historical-runner + command: ["python", "-m", "app.historical_runner", "--hourly"] + env_file: + - ./backend/.env.example + environment: + HLL_BACKEND_DATABASE_URL: ${HLL_BACKEND_DATABASE_URL:-postgresql://hll_vietnam:hll_vietnam_dev@postgres:5432/hll_vietnam} + HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon} + HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon} + HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20} + HLL_BACKEND_RCON_TARGETS: >- + ${HLL_BACKEND_RCON_TARGETS:-[{"name":"Comunidad Hispana #01","slug":"comunidad-hispana-01","external_server_id":"comunidad-hispana-01","host":"152.114.195.174","port":7779,"password":"replace-me-01","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #02","slug":"comunidad-hispana-02","external_server_id":"comunidad-hispana-02","host":"152.114.195.150","port":7879,"password":"replace-me-02","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null}]} + depends_on: + postgres: + condition: service_healthy + backend: + condition: service_started + volumes: + - ./backend/data:/app/data + restart: unless-stopped + + rcon-historical-worker: + profiles: + - advanced + build: + context: ./backend + container_name: hll-vietnam-rcon-historical-worker + command: ["python", "-m", "app.rcon_historical_worker", "loop"] + env_file: + - ./backend/.env.example + environment: + HLL_BACKEND_DATABASE_URL: ${HLL_BACKEND_DATABASE_URL:-postgresql://hll_vietnam:hll_vietnam_dev@postgres:5432/hll_vietnam} + HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon} + HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon} + HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20} + HLL_BACKEND_RCON_TARGETS: >- + ${HLL_BACKEND_RCON_TARGETS:-[{"name":"Comunidad Hispana #01","slug":"comunidad-hispana-01","external_server_id":"comunidad-hispana-01","host":"152.114.195.174","port":7779,"password":"replace-me-01","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #02","slug":"comunidad-hispana-02","external_server_id":"comunidad-hispana-02","host":"152.114.195.150","port":7879,"password":"replace-me-02","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null}]} + HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS:-600} + HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES: ${HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES:-2} + HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS:-15} + HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES: ${HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES:-10} + depends_on: + postgres: + condition: service_healthy + backend: + condition: service_started + volumes: + - ./backend/data:/app/data + restart: unless-stopped + + frontend: + build: + context: ./frontend + container_name: hll-vietnam-frontend + depends_on: + - backend + ports: + - "8080:8080" + restart: unless-stopped + +volumes: + postgres-data: diff --git a/docs/crcon-advanced-metrics-origin-audit.md b/docs/crcon-advanced-metrics-origin-audit.md new file mode 100644 index 0000000..9709f30 --- /dev/null +++ b/docs/crcon-advanced-metrics-origin-audit.md @@ -0,0 +1,223 @@ +# CRCON Advanced Metrics Origin Audit + +## Validation Date + +- 2026-03-24 + +## Scope + +Auditoria tecnica del origen probable de metricas avanzadas visibles en +ecosistemas tipo CRCON / HLL Records, separando: + +- RCON directo implementado hoy en esta repo +- campos historicos ya visibles en la capa publica tipo scoreboard +- metricas que solo resultan plausibles con eventos/logs y agregacion propia + +No se implementa captura nueva, tablas nuevas ni cambios de producto. + +## Evidence Reviewed + +- `docs/rcon-data-capability-audit.md` +- `docs/monthly-player-ranking-data-audit.md` +- `docs/historical-crcon-source-discovery.md` +- `backend/README.md` +- `backend/app/rcon_client.py` +- `backend/app/providers/rcon_provider.py` +- `backend/app/data_sources.py` + +## Confirmed Boundary In This Repository + +La evidencia local confirma dos superficies distintas: + +- RCON live directo para estado actual del servidor +- historico CRCON / scoreboard publico para partidas cerradas y metricas ricas + +El cliente RCON implementado en `backend/app/rcon_client.py` solo usa: + +- `ServerConnect` +- `Login` +- `GetServerInformation` + +El proveedor `RconLiveDataSource` solo convierte eso en: + +- nombre del servidor +- estado online +- jugadores actuales +- capacidad maxima +- mapa actual +- metadata de procedencia del snapshot + +La repo no contiene hoy evidencia de comandos RCON integrados para: + +- killer -> victim +- kills por arma +- teamkills por evento +- duelos jugador contra jugador +- ledger tactico de acciones +- reconstruccion historica de partidas cerradas + +## What The Historical Source Already Exposes + +La discovery historica local ya documenta que el detalle CRCON / scoreboard +publico expone campos avanzados como: + +- `kills_by_type` +- `most_killed` +- `death_by` +- `weapons` +- `death_by_weapons` + +Ademas, `docs/monthly-player-ranking-data-audit.md` confirma que esos campos +existen en el origen, aunque la persistencia actual del proyecto todavia no los +guarda. + +## Technical Interpretation + +La mejor lectura tecnica basada en la repo es esta: + +- RCON puro hoy solo cubre estado live operativo +- metricas como `most_killed` y `death_by` no salen del cliente RCON actual +- esas metricas ya existen en una capa historica enriquecida externa al cliente + RCON local +- para reproducirlas dentro del proyecto haria falta una persistencia propia o + una fuente historica equivalente que conserve eventos o agregados avanzados + +Esto no demuestra por si solo el mecanismo interno exacto de CRCON o HLL +Records, pero si permite descartar algo importante: en esta repo no hay base +para afirmar que esas metricas provengan de RCON directo ya listo para usar. + +## Plausible Origin Paths + +### 1. Direct RCON Commands + +Plausibilidad en esta repo: baja para metricas avanzadas. + +Motivo: + +- no hay comandos RCON avanzados integrados en codigo +- no hay provider historico RCON operativo +- `RconHistoricalDataSource` es solo un placeholder que falla con + `Historical RCON provider is not implemented yet.` + +Conclusion: + +- RCON directo es plausible para live state +- no hay evidencia local suficiente para atribuirle `most_killed`, + `death_by`, killer/victim o kills por arma + +### 2. Event Stream Or Server Logs + +Plausibilidad en esta repo: alta como origen tecnico necesario si el proyecto +quisiera reconstruir esas metricas por cuenta propia. + +Motivo: + +- killer/victim requiere granularidad por evento o al menos por encounter +- kills por arma requieren capturar el arma asociada al kill +- teamkills por evento requieren distinguir el evento individual +- clasificaciones como infantry / tank / artillery requieren una senal por tipo + de kill o contexto del evento + +Conclusion: + +- para producir estas metricas dentro de HLL Vietnam, un pipeline de eventos o + logs es la hipotesis tecnica mas consistente + +### 3. CRCON Internal Storage / Enriched Aggregation + +Plausibilidad en esta repo: alta para explicar lo que ya se observa en el +scoreboard publico. + +Motivo: + +- la fuente publica ya devuelve campos agregados que el proyecto no calcula +- esos campos no se derivan del snapshot live RCON implementado hoy +- `most_killed` y `death_by` parecen vistas agregadas de encounters, no simples + contadores live del servidor + +Conclusion: + +- CRCON / HLL Records probablemente sirve esos campos desde una capa historica + propia ya enriquecida y persistida, no desde la llamada live minima que esta + repo usa por RCON + +## Origin Matrix By Metric + +| Metrica | RCON directo hoy en esta repo | Requiere eventos/logs para reproducirla | Requiere agregacion/persistencia propia | Origen probable segun evidencia local | +| --- | --- | --- | --- | --- | +| Estado live del servidor | Si | No | No | RCON directo | +| Jugadores actuales | Si | No | No | RCON directo | +| Mapa actual | Si | No | No | RCON directo | +| Scoreboard live basico por jugador | No confirmado | Posiblemente no siempre | Posiblemente no | No confirmado en la repo | +| `most_killed` | No | Si o fuente historica equivalente | Si | Capa historica enriquecida | +| `death_by` | No | Si o fuente historica equivalente | Si | Capa historica enriquecida | +| killer -> victim | No | Si | Si | Eventos/logs + persistencia | +| kills por arma | No | Si | Si | Eventos/logs + persistencia | +| `kills_by_type` | No | Si | Si | Eventos/logs + persistencia | +| `death_by_weapons` | No | Si | Si | Eventos/logs + persistencia | +| teamkills por evento | No | Si | Si | Eventos/logs + persistencia | +| teamkills agregados historicos | No desde RCON actual | Si | Si | Agregacion historica | +| duelos reutilizables | No | Si | Si | Eventos/logs + persistencia | +| distincion infantry / tank / artillery | No | Si | Si | Eventos/logs + clasificacion propia | +| acciones tacticas finas | No confirmadas | Si | Si | No confirmadas, pero no salen del RCON actual | + +## What RCON Purely Can Plausibly Provide + +Con evidencia local, RCON puro queda limitado a: + +- estado actual del servidor +- jugadores presentes +- capacidad maxima +- mapa actual +- metadata live util para un panel operativo + +Eso sirve para monitoreo live, no para un MVP mensual V2 con rivalidades, +armas, killers, victims o taxonomias tacticas. + +## What Seems To Require Event Capture Or Logs + +Las metricas siguientes solo son defendibles si el proyecto capta eventos o +logs con granularidad suficiente: + +- killer -> victim +- `most_killed` +- `death_by` +- kills por arma +- `kills_by_type` +- `death_by_weapons` +- teamkills por evento +- segmentacion infantry / tank / artillery + +La razon comun es que todas dependen de relaciones o atributos de eventos +individuales, no solo de un snapshot agregado del servidor. + +## What Seems To Require Historical Aggregation + +Incluso con eventos capturados, haria falta una capa propia de persistencia y +agregacion para exponer de forma estable: + +- rivales mas frecuentes +- resumen `most_killed` +- resumen `death_by` +- perfiles de armas por jugador +- acumulados mensuales auditables por servidor + +Sin esa capa, la señal estaria dispersa en eventos crudos y no seria operativa +para un ranking MVP V2. + +## Final Conclusion + +La conclusion mas solida que soporta esta repo es: + +- `most_killed`, `death_by`, killer/victim y kills por arma no salen del RCON + directo implementado hoy +- esas metricas ya son visibles en una fuente historica enriquecida externa al + cliente RCON local +- para reproducirlas dentro del proyecto haria falta una canalizacion nueva de + eventos/logs y una persistencia historica propia con agregados derivados + +## Recommended Follow-Up + +La siguiente task tecnica correcta es disenar el pipeline minimo de eventos de +jugador necesario para alimentar una V2 del ranking mensual sin asumir que RCON +directo ya entrega esas metricas listas. diff --git a/docs/current-hll-data-ingestion-plan.md b/docs/current-hll-data-ingestion-plan.md new file mode 100644 index 0000000..f6026cc --- /dev/null +++ b/docs/current-hll-data-ingestion-plan.md @@ -0,0 +1,130 @@ +# Current HLL Data Ingestion Plan + +## Objective + +Definir una estrategia tecnica reutilizable para ingerir datos del Hell Let +Loose actual como banco de pruebas del futuro ecosistema HLL Vietnam, sin +implementar todavia una ingesta productiva completa. + +## Initial Data Scope + +Los primeros campos a capturar deben cubrir el bloque provisional de +servidores y preparar historicos minimos: + +- `server_name` +- `status` +- `players` +- `max_players` +- `current_map` si la fuente lo permite +- `captured_at` +- `source` +- `external_server_id` o identificador equivalente si la fuente lo ofrece + +Campos como `queue`, `ping`, `rotation` o `notes` quedan como opcionales para +fases posteriores y no deben bloquear el bootstrap. + +## Snapshot Concept + +Un snapshot representa el estado observado de un servidor en un momento +concreto. No es un perfil estatico del servidor, sino una captura puntual con +timestamp. + +Cada snapshot debe permitir: + +- reconstruir una serie temporal simple por servidor +- detectar cambios de estado online u offline +- medir evolucion basica de jugadores y capacidad +- conservar la procedencia de la captura + +El identificador estable del servidor y el `captured_at` deben separar la +identidad del servidor de cada observacion historica. + +## Ingestion Source Options + +### Phase-safe controlled payload + +- Fuente recomendada para el inicio. +- Permite probar el pipeline con datos mock o manuales servidos por backend. +- Fija el contrato de entrada y la normalizacion sin depender de terceros. + +### Public external source + +- Puede ser una API publica o un listado mantenido por terceros. +- Acerca el banco de pruebas a datos reales. +- Exige validar formato, disponibilidad, limites de uso y estabilidad antes de + consolidarlo. + +### Direct server query or intermediary adapter + +- Puede ofrecer datos mas cercanos al estado real del servidor. +- Introduce mayor complejidad tecnica, posibles timeouts y dependencia del + protocolo soportado. +- Debe encapsularse detras de un adaptador backend, no exponerse al frontend. + +## Normalization Baseline + +La captura y la fuente no deben definir el contrato interno final. La +arquitectura debe separar: + +1. lectura de datos crudos +2. normalizacion a un modelo comun +3. produccion de snapshots consistentes + +La normalizacion inicial debe garantizar: + +- naming estable en `snake_case` +- `status` reducido a valores controlados como `online`, `offline` o `unknown` +- enteros para `players` y `max_players` cuando existan +- `captured_at` generado en backend +- conservacion del nombre de fuente para trazabilidad + +## Risks And Limits + +- Disponibilidad de terceros: una fuente publica puede dejar de responder sin + aviso. +- Cambios de formato: scraping o APIs no oficiales pueden romper el adaptador. +- Rate limits: las consultas frecuentes pueden exigir cache o polling mas + espaciado. +- Latencia: una consulta lenta no debe trasladarse directamente al frontend. +- CORS: el frontend no debe llamar a fuentes externas para este flujo. +- Fiabilidad: diferentes fuentes pueden discrepar en jugadores, mapa o estado. +- Dependencia no oficial: una integracion fragil no debe convertirse en pieza + critica del producto. + +## Phased Architecture + +### Phase 1: controlled payload and stable structure + +- Mantener un payload controlado como base de `/api/servers`. +- Definir el modelo normalizado esperado para servidores y snapshots. +- No almacenar historico real todavia. + +### Phase 2: snapshot collector with real or near-real source + +- Introducir un colector backend desacoplado de la fuente concreta. +- Permitir ejecucion manual o periodica en entorno de desarrollo. +- Generar snapshots consistentes listos para futura persistencia. + +### Phase 3: historical use and basic statistics + +- Persistir snapshots. +- Calcular metricas iniciales como actividad por servidor, picos de jugadores o + ultima vez visto online. +- Mantener el modelo generico para reutilizarlo con HLL Vietnam cuando existan + datos mas representativos. + +## Explicitly Out Of Scope Now + +- ingesta real completa en produccion +- scraping productivo +- base de datos funcional +- tareas periodicas operativas +- metricas avanzadas o paneles analiticos +- cambios visibles en frontend + +## Handoff To Following Tasks + +- `TASK-019` debe convertir este plan en una base de esquema para persistir + servidores y snapshots. +- `TASK-020` debe preparar un bootstrap pequeno del colector en Python con + separacion entre fuente, normalizacion y snapshot. diff --git a/docs/current-hll-servers-source-plan.md b/docs/current-hll-servers-source-plan.md new file mode 100644 index 0000000..42883fe --- /dev/null +++ b/docs/current-hll-servers-source-plan.md @@ -0,0 +1,130 @@ +# Current HLL Servers Source Plan + +## Objective + +Definir como mostrar en la web de HLL Vietnam un bloque provisional con +servidores actuales de Hell Let Loose sin presentarlos como si fueran datos de +HLL Vietnam ni depender todavia de una integracion real externa. + +## Product Framing + +- El bloque debe presentarse como referencia provisional para la comunidad. +- El copy debe mencionar de forma explicita "servidores actuales de Hell Let + Loose" y evitar formulas ambiguas como "servidores HLL Vietnam". +- La UI debe dejar claro que el bloque sirve mientras no existan datos propios o + mas cercanos al contexto final de HLL Vietnam. +- Si no hay datos disponibles, el estado vacio debe ser neutral y honesto, sin + simular actividad inexistente. + +## Recommended Fields For This Phase + +Campos utiles para un bloque pequeno y entendible: + +- `server_name` +- `status` +- `players` +- `max_players` +- `current_map` +- `region` + +Campos opcionales solo si una fuente futura los ofrece de forma estable: + +- `queue` +- `ping` +- `notes` +- `last_updated` + +## Source Options + +### Public external source + +- Puede ser una API publica especializada, un listado publico o una consulta de + servidor compatible con el juego actual. +- Ventaja: acerca la web a datos mas reales. +- Riesgo: cambios de formato, limites de uso, CORS, disponibilidad y dependencia + de terceros. + +### Controlled placeholder data + +- Fuente recomendada para la primera implementacion. +- El backend expone un payload manual con forma realista y semantica estable. +- Permite validar UI, contrato y estados de error sin acoplar la web a una + fuente externa todavia no validada. + +### Stronger future integration + +- Un adaptador backend dedicado podra sustituir el placeholder cuando exista una + fuente fiable o un dataset controlado mantenido por la comunidad. +- La sustitucion debe preservar el contrato JSON para no romper al frontend. + +## Risks And Restrictions + +- Disponibilidad: una fuente externa puede caer o degradarse sin aviso. +- CORS: el frontend no debe depender de llamadas directas a terceros. +- Rate limits: una API publica puede limitar frecuencia o volumen. +- Formato: scraping o endpoints no oficiales pueden cambiar sin contrato. +- Mantenimiento: una integracion fragil crearia coste operativo prematuro. +- Identidad: el bloque no puede inducir a pensar que HLL Vietnam ya dispone de + servidores propios o datos oficiales. + +## Phased Strategy + +### Phase 1: controlled mock + +- `GET /api/servers` devuelve datos manuales con estructura estable. +- El payload debe incluir una marca de contexto provisional para indicar que los + datos pertenecen al HLL actual. +- La landing puede consumir el endpoint con fallback local si el backend no esta + disponible. + +### Phase 2: backend adapter + +- Sustituir el mock por un adaptador backend desacoplado de la fuente concreta. +- Mantener el mismo contrato principal de `items`. +- Introducir validacion basica de campos y fallback controlado si falla la + fuente. + +### Phase 3: replacement toward HLL Vietnam + +- Reemplazar o mezclar progresivamente el bloque cuando existan datos mas + representativos del contexto HLL Vietnam. +- Revisar naming, copy y campos para no arrastrar supuestos del juego actual. + +## Explicitly Out Of Scope Now + +- Integrar una fuente externa real. +- Hacer scraping. +- Consultar servidores reales desde el frontend. +- Anadir base de datos, cache o panel administrativo. +- Presentar el bloque como caracteristica definitiva del producto. + +## Recommended Contract Shape + +Ejemplo minimo de respuesta provisional: + +```json +{ + "status": "ok", + "data": { + "title": "Servidores actuales de Hell Let Loose", + "context": "current-hll-reference", + "source": "controlled-placeholder", + "items": [ + { + "server_name": "HLL ESP Tactical Rotation", + "status": "online", + "players": 74, + "max_players": 100, + "current_map": "Sainte-Marie-du-Mont", + "region": "EU" + } + ] + } +} +``` + +## Handoff To Following Tasks + +- Backend task: preparar el adaptador placeholder estable sobre este contrato. +- Frontend task: anadir un panel visual sobrio con etiqueta provisional y + fallback seguro si el endpoint falla o no devuelve items. diff --git a/docs/database-maintenance.md b/docs/database-maintenance.md new file mode 100644 index 0000000..ce0a521 --- /dev/null +++ b/docs/database-maintenance.md @@ -0,0 +1,307 @@ +# Database Maintenance + +## Overview + +HLL Vietnam keeps database cleanup at the application level. + +The current maintenance scope is intentionally narrow: + +- old `server_snapshots`; +- old non-critical `rcon_admin_log_events`; +- old critical `rcon_admin_log_events` only after retention and protected-match checks; +- old non-protected `rcon_materialized_matches`; +- dependent `rcon_match_player_stats` for deleted matches. + +The first maintenance pass does not routinely delete: + +- `displayed_historical_snapshots`; +- file-based snapshots under `backend/data/snapshots/`; +- public-scoreboard `historical_*` fallback tables; +- `player_event_raw_ledger` and its worker metadata; +- Elo/MMR tables; +- Comunidad Hispana #03 data reactivation or targets. + +## Why Application-Level And Not `pg_cron` + +Cleanup is versioned in backend code instead of delegated to `pg_cron`, host cron, or a separate container because the retention logic depends on product rules: + +- keep the latest 100 closed materialized matches; +- keep the current month; +- keep the previous month during the first 7 days of a new month; +- keep the current week; +- keep the previous week when weekly fallback may still need it; +- keep child stats for protected matches; +- avoid breaking current/live pages that still read recent AdminLog data. + +Those rules belong with the application’s read and write model, not inside database-only scheduling. + +## Scheduled Cleanup Inside `historical-runner` + +Database maintenance is scheduled inside `app.historical_runner`. + +Behavior: + +- disabled by default; +- no extra Docker service is added for maintenance; +- the runner checks whether maintenance is due; +- when enabled and due, the runner invokes `python -m app.database_maintenance cleanup --apply` behavior through the shared Python function; +- failures are logged and do not crash the historical runner loop; +- cleanup runs under the same writer-lock coordination used by the historical writer flows. + +Relevant structured log events: + +- `database-maintenance-scheduler-skipped-disabled` +- `database-maintenance-scheduler-skipped-not-due` +- `database-maintenance-scheduler-started` +- `database-maintenance-scheduler-completed` +- `database-maintenance-scheduler-failed` + +## Environment Variables + +Required maintenance-related variables: + +```text +HLL_DB_MAINTENANCE_ENABLED=false +HLL_DB_MAINTENANCE_INTERVAL_SECONDS=43200 +HLL_RECENT_MATCHES_KEEP=100 +HLL_ADMIN_LOG_NONCRITICAL_RETENTION_DAYS=30 +HLL_ADMIN_LOG_CRITICAL_RETENTION_DAYS=90 +HLL_SERVER_SNAPSHOT_RETENTION_DAYS=14 +HLL_DB_MAINTENANCE_BATCH_SIZE=5000 +``` + +Meaning: + +- `HLL_DB_MAINTENANCE_ENABLED` + Enables scheduled apply mode inside `historical-runner`. +- `HLL_DB_MAINTENANCE_INTERVAL_SECONDS` + Default scheduler interval. `43200` means every 12 hours. +- `HLL_RECENT_MATCHES_KEEP` + Number of latest closed materialized matches that must always be protected. +- `HLL_ADMIN_LOG_NONCRITICAL_RETENTION_DAYS` + Retention for non-critical AdminLog events such as chat/connect/disconnect. +- `HLL_ADMIN_LOG_CRITICAL_RETENTION_DAYS` + Retention for critical AdminLog events such as `kill`, `match_start`, `match_end`. +- `HLL_SERVER_SNAPSHOT_RETENTION_DAYS` + Retention for live server snapshots. +- `HLL_DB_MAINTENANCE_BATCH_SIZE` + Delete batch size for apply mode. + +## Protected Data + +The cleanup command protects: + +- latest 100 closed materialized matches by default; +- current month materialized matches; +- previous month materialized matches when the current day is `1` through `7`; +- current week materialized matches; +- previous week materialized matches when weekly fallback may still need them; +- `rcon_match_player_stats` belonging to protected matches; +- current/live AdminLog data required for visible current-match surfaces; +- `displayed_historical_snapshots`; +- file snapshots in `backend/data/snapshots/`. + +If a match timestamp cannot be interpreted safely, that match is skipped and protected instead of deleted. + +## Deleted Data + +Apply mode is currently allowed to delete: + +- `server_snapshots` older than retention; +- non-critical `rcon_admin_log_events` older than retention; +- critical `rcon_admin_log_events` older than retention only when they are not required by protected materialized match ranges; +- non-protected `rcon_materialized_matches`; +- dependent `rcon_match_player_stats` for deleted matches. + +Current critical AdminLog event types: + +- `kill` +- `match_start` +- `match_end` + +## Dry-Run Command + +From `backend/`: + +```powershell +python -m app.database_maintenance cleanup --dry-run +``` + +From the repository root with the backend package on `PYTHONPATH`: + +```powershell +$env:PYTHONPATH='backend' +python -m app.database_maintenance cleanup --dry-run +``` + +Inside Docker Compose: + +```powershell +docker compose exec backend python -m app.database_maintenance cleanup --dry-run +``` + +Useful dry-run options: + +```powershell +docker compose exec backend python -m app.database_maintenance cleanup --dry-run ` + --recent-matches-keep 100 ` + --admin-log-noncritical-retention-days 30 ` + --admin-log-critical-retention-days 90 ` + --server-snapshot-retention-days 14 ` + --batch-size 5000 +``` + +Dry-run is the safe preview path and should be reviewed before any production apply. + +## Apply Command + +Local module execution: + +```powershell +python -m app.database_maintenance cleanup --apply +``` + +Docker Compose: + +```powershell +docker compose exec backend python -m app.database_maintenance cleanup --apply +``` + +One-off local validation with a fixed time anchor: + +```powershell +python -m app.database_maintenance cleanup --apply --now 2026-06-20T12:00:00Z +``` + +Optional maintenance vacuum/analyze: + +```powershell +python -m app.database_maintenance cleanup --apply --vacuum-analyze +``` + +## Table-Size Audit SQL + +```sql +select + schemaname, + relname as table_name, + pg_size_pretty(pg_total_relation_size(relid)) as total_size, + pg_size_pretty(pg_relation_size(relid)) as table_size, + pg_size_pretty(pg_total_relation_size(relid) - pg_relation_size(relid)) as indexes_size, + n_live_tup as estimated_rows, + n_dead_tup as estimated_dead_rows +from pg_stat_user_tables +order by pg_total_relation_size(relid) desc; +``` + +## Row-Count And Age Audit SQL + +### AdminLog events by type/date + +```sql +select + event_type, + count(*) as row_count, + min(event_timestamp) as first_event_timestamp, + max(event_timestamp) as last_event_timestamp, + min(server_time) as first_server_time, + max(server_time) as last_server_time +from rcon_admin_log_events +group by event_type +order by row_count desc, event_type asc; +``` + +### Materialized matches by server/date + +```sql +select + target_key, + source_basis, + count(*) as matches, + min(coalesce(ended_at, started_at)) as first_closed_at, + max(coalesce(ended_at, started_at)) as last_closed_at +from rcon_materialized_matches +group by target_key, source_basis +order by target_key asc, source_basis asc; +``` + +### Server snapshots by date + +```sql +select + server_id, + min(captured_at) as first_captured_at, + max(captured_at) as last_captured_at, + count(*) as snapshot_rows +from server_snapshots +group by server_id +order by last_captured_at desc; +``` + +### Displayed snapshots count + +```sql +select + snapshot_type, + metric, + snapshot_window, + count(*) as snapshot_rows, + min(generated_at) as first_generated_at, + max(generated_at) as last_generated_at +from displayed_historical_snapshots +group by snapshot_type, metric, snapshot_window +order by snapshot_type asc, metric asc, snapshot_window asc; +``` + +## Logs To Inspect + +The cleanup command emits JSON logs. Minimum events to look for: + +- `database-maintenance-started` +- `database-maintenance-plan` +- `database-maintenance-table-skipped` +- `database-maintenance-delete-batch` +- `database-maintenance-completed` +- `database-maintenance-error` + +Examples: + +```powershell +docker compose logs --tail=200 backend +docker compose logs --tail=200 historical-runner +``` + +If scheduled cleanup is enabled: + +```powershell +docker compose logs --tail=200 historical-runner +``` + +## Docker And Portainer Warnings + +- Never use `docker compose down -v` unless you intentionally want to delete PostgreSQL and mounted volume data. +- Always review dry-run output before enabling apply in production. +- Do not manually delete protected match or player-stat rows from PostgreSQL. +- Keep backups before changing retention settings. +- Do not add Comunidad Hispana #03 back into RCON targets in this task. +- Do not add a separate maintenance container, host cron, or `pg_cron` job for this feature. + +For Portainer-style operations the same warning applies: + +- deleting volumes is destructive; +- maintenance should run through the application command, not through manual table purges. + +## Rollback And Restore Considerations + +- Retention changes are destructive when apply mode runs. +- Keep a PostgreSQL backup before enabling scheduled apply in production. +- If cleanup removes too much data, recovery is restore-based, not “undo last delete.” +- Favor dry-run, smaller batch sizes, and reviewed retention values before long-running scheduled apply. + +## Safe Operator Flow + +1. Audit table size and row ages with the SQL above. +2. Run dry-run locally or in Compose. +3. Review protected counts and candidate counts in JSON output. +4. Enable `HLL_DB_MAINTENANCE_ENABLED=true` only after dry-run review. +5. Monitor `historical-runner` logs for scheduler events and cleanup completion. diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..293f7d6 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,242 @@ +# Technical Decisions + +## Decision 001: frontend simple HTML/CSS/JS + +Se adopta una base estatica con HTML, CSS y JavaScript puro para priorizar simplicidad, velocidad de arranque y compatibilidad total al abrir el frontend directamente en navegador. + +## Decision 002: backend previsto en Python + +La estructura del repositorio reserva desde el inicio una carpeta de backend porque la implementacion futura se realizara en Python. + +## Decision 003: estructura preparada para orquestacion por agentes + +Se incluye una carpeta `ai/` y un documento `AGENTS.md` para facilitar una futura organizacion del trabajo por roles, tareas y orquestacion. + +## Decision 004: branding militar Vietnam + +La direccion visual inicial se alinea con una estetica sobria, tactica y militar inspirada en el contexto Vietnam para mantener coherencia tematica desde la primera iteracion. + +## Decision 005: AI Development Platform integrada de forma adaptada + +Se integra una capa de orquestacion por tasks inspirada en la plantilla de AI Development Platform, pero adaptada al contexto real de HLL Vietnam y sin arrastrar supuestos genericos de otros stacks. La plataforma se usa como soporte operativo del repositorio, no como funcionalidad del producto. + +## Decision 006: contrato API pequeno antes de integraciones reales + +Antes de implementar endpoints de comunidad o integraciones externas, se fija un contrato JSON minimo entre frontend y backend para evitar que la landing y el backend evolucionen con supuestos incompatibles. + +La unica ruta implementada hoy es `GET /health`. Las rutas `/api/community`, `/api/trailer`, `/api/discord` y `/api/servers` quedan definidas como contrato previsto o placeholder en `docs/frontend-backend-contract.md`, manteniendo el backend en Python y sin introducir todavia Discord real, servidores reales ni base de datos. + +## Decision 007: estrategia por fases para Discord y servidores + +Los datos de Discord y de servidores de juego se incorporaran por fases para evitar dependencias prematuras de credenciales, APIs externas o consultas de red todavia no validadas. + +La fase inicial debe usar datos manuales o placeholder controlados por el backend para mantener estable el contrato del frontend. Una fase intermedia podra anadir una integracion limitada con fuentes publicas o consultas tecnicas de bajo riesgo. Solo una fase posterior evaluara integraciones mas reales, siempre que queden claras las restricciones de seguridad, disponibilidad, latencia y mantenimiento. + +La estrategia detallada de bloques de datos, fuentes posibles, riesgos y orden recomendado de implementacion queda documentada en `docs/discord-and-server-data-plan.md`. + +## Decision 008: consumo frontend progresivo con fallback estatico + +El frontend no debe depender de datos dinamicos para renderizar la landing base mientras el proyecto siga en fase fundacional. + +Cuando se incorporen endpoints del backend, el consumo debe hacerse con `fetch` y JavaScript simple, priorizando bloques independientes y manteniendo contenido estatico o placeholders visuales si falla una llamada. `GET /health` queda reservado para comprobaciones tecnicas y no debe bloquear el render principal. + +La estrategia detallada de prioridades de endpoints, estados de carga, errores y orden de migracion queda en `docs/frontend-data-consumption-plan.md`. + +## Decision 009: servidores actuales de HLL como referencia provisional + +Mientras no existan datos reales o representativos de HLL Vietnam, la web puede +mostrar un bloque provisional con servidores actuales de Hell Let Loose siempre +que quede claramente etiquetado como referencia temporal. + +La primera version de ese bloque debe salir de un payload controlado del backend +Python, no de una integracion directa desde frontend ni de scraping prematuro. +Esto permite fijar campos utiles, preservar el tono del producto y evitar que la +landing dependa de una fuente externa aun no validada. + +La estrategia de campos, riesgos, fases y sustitucion futura queda documentada +en `docs/current-hll-servers-source-plan.md`. + +## Decision 010: ingesta por snapshots y adaptadores desacoplados + +La evolucion desde payloads placeholder hacia datos mas realistas debe hacerse +con una arquitectura de snapshots de servidor, no conectando el frontend a una +fuente externa ni acoplando el backend a una integracion unica desde el inicio. + +La unidad tecnica base sera un snapshot con `captured_at` y campos normalizados +como estado, jugadores, capacidad y mapa actual cuando exista. La lectura de +fuente, la normalizacion y la produccion del snapshot deben quedar separadas +para poder sustituir mocks por una fuente publica o consulta tecnica posterior +sin romper el contrato interno. + +La estrategia detallada de fuentes, riesgos, fases y limites queda documentada +en `docs/current-hll-data-ingestion-plan.md`. + +## Decision 011: modelo de almacenamiento logico antes de fijar tecnologia + +Antes de introducir una base de datos concreta, el proyecto debe fijar un +modelo logico minimo para identidad de servidores y snapshots historicos. + +La base inicial se apoya en entidades genericas como `game_sources`, `servers` +y `server_snapshots`. Las metricas iniciales deben derivarse primero de esos +snapshots en vez de materializar agregados prematuros. Esto mantiene el diseno +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 partidas desde CRCON scoreboard JSON + +El historico reutilizable para estadisticas por partida y por jugador debe +salir de la capa JSON publica expuesta por los scoreboards CRCON de la +comunidad, no de A2S ni del HTML renderizado de `/games`. + +La discovery tecnica confirma que ambos scoreboards sirven una SPA cuya fuente +real de datos usa `baseURL: "/api"` y endpoints como +`/get_scoreboard_maps` y `/get_map_scoreboard`. Esa capa permite obtener listas +de partidas, detalle por `map_id` y metricas por jugador suficientes para una +futura agregacion semanal por servidor. + +A2S se mantiene como fuente de estado actual de servidores. El historico de +partidas y rankings debe construirse en una linea separada basada en CRCON. La +discovery detallada queda en `docs/historical-crcon-source-discovery.md`. + +## Decision 013: persistencia historica local separada del flujo live + +El backend mantiene el estado live de servidores y el historico CRCON en el +mismo SQLite local de desarrollo para no introducir infraestructura prematura, +pero ambas lineas quedan separadas por tablas y contratos distintos. + +El flujo live sigue usando `server_snapshots` via A2S. El flujo historico usa +tablas `historical_*` para: + +- servidores historicos configurados +- partidas +- mapas +- jugadores +- estadisticas por jugador y partida +- ejecuciones de ingesta + +Las claves estables son: + +- servidor: `historical_servers.slug` +- partida: `(historical_server_id, external_match_id)` +- jugador: `stable_player_key` +- estadistica por partida: `(historical_match_id, historical_player_id)` + +Esto permite bootstrap, refresco incremental e idempotencia sin mezclar +semanticas de estado actual con historico persistido. El modelo detallado queda +en `docs/historical-domain-model.md`. + +## Decision 014: despliegue normal simplificado sin servidor #03 + +El despliegue operativo normal vuelve a quedar reducido a `backend` + +`frontend`. Los servicios `historical-runner` y `rcon-historical-worker` se +mantienen disponibles solo para uso avanzado y explicito mediante el perfil +Compose `advanced`. + +Comunidad Hispana #03 deja de formar parte de los targets RCON por defecto +porque ya no es una fuente operativa vigente. El codigo historico, los datos +persistidos, las migraciones y las piezas Elo/MMR no se eliminan; quedan +pausadas operativamente para esta fase y pueden reintroducirse mediante una +task futura si se valida de nuevo la fuente y el coste de mantenimiento. + +## Decision 015: historico RCON-first con fallback publico + +La politica por defecto para historico vuelve a ser RCON-first: +`HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon`. El scoreboard publico de CRCON se +mantiene como fallback controlado cuando RCON falla, no tiene cobertura util o +no soporta todavia una operacion competitiva concreta. + +La arquitectura historica RCON-first se compone de captura de sesiones RCON, +ingesta de AdminLog, parser de eventos, almacenamiento de eventos/snapshots y +materializacion de partidas y estadisticas por jugador. Los snapshots de perfil +procedentes de `MESSAGE` enriquecen lecturas de jugador, pero no sustituyen los +hechos de partida derivados de eventos RCON. + +Comandos operativos manuales: + +```powershell +docker compose exec backend python -m app.rcon_admin_log_ingestion --minutes 1440 +docker compose exec backend python -m app.rcon_historical_worker capture +``` + +Esta decision no reactiva Elo/MMR dentro del arranque normal del backend. Las +piezas Elo/MMR, migraciones, datos persistidos y modulos historicos se +conservan, pero su operativa compleja sigue pausada y desacoplada salvo task +explicita. + +## Decision 016: catalogo confiable de scoreboards publicos activos + +Los origenes publicos de scoreboard que el backend puede exponer o validar se +centralizan en un catalogo explicito de servidores activos. En esta fase solo +son confiables `comunidad-hispana-01`, con origen +`https://scoreboard.comunidadhll.es`, y `comunidad-hispana-02`, con origen +`https://scoreboard.comunidadhll.es:5443`. + +`comunidad-hispana-03` no forma parte de ese catalogo ni de los seeds por +defecto nuevos. Los datos historicos ya persistidos no se eliminan, pero las +URLs publicas de partidas solo se aceptan si el `raw_payload_ref` usa HTTP(S), +apunta al origen confiable del servidor activo y mantiene una ruta `/games/`. + +## Decision 017: PostgreSQL phase 1 for RCON historical persistence + +La primera migracion de persistencia a PostgreSQL cubre el camino que sufria +contencion SQLite entre `backend`, `historical-runner` y +`rcon-historical-worker`: + +- captura prospectiva RCON, muestras y ventanas competitivas +- eventos AdminLog deduplicados y snapshots de perfil derivados +- partidas RCON materializadas y estadisticas por jugador +- candidatos confiables de URL de scoreboard que puedan poblarse para + correlacion de detalle + +Docker Compose configura `HLL_BACKEND_DATABASE_URL` y usa PostgreSQL como +backend autoritativo para esas tablas. La ejecucion local sin esa variable sigue +usando SQLite como fallback temporal para preservar comandos y tests locales. + +Quedan SQLite-backed en esta fase porque no forman parte del lock-prone writer +path migrado y siguen cubriendo fallback publico o caches locales: + +- snapshots live y cache de `/api/servers` +- tablas `historical_*` de scoreboard publico, rankings y correlacion legacy +- snapshots historicos precalculados, ledger player-event y Elo/MMR pausado + +La correlacion de URL publica en detalle usa primero candidatos PostgreSQL +confiables cuando existan y puede seguir leyendo filas `historical_*` +persistidas en SQLite durante la transicion. El diagnostico operativo se expone +con `python -m app.storage_diagnostics`. + +## Decision 018: PostgreSQL phase 2 for displayed historical data + +PostgreSQL pasa a ser la fuente de lectura para los datos historicos visibles: + +- fallback publico `historical_*` de partidas, detalle y rankings +- snapshots historicos precalculados que consume `historico.html` +- cache live de servidores que consume `/api/servers` +- ledger player-event usado para reconstruir snapshots visibles +- tablas RCON de AdminLog, perfiles, ventanas, partidas materializadas, + estadisticas y candidatos seguros ya migradas en phase 1 + +La migracion se ejecuta de forma idempotente con: + +```powershell +cd backend +python -m app.sqlite_to_postgres_migration +python -m app.storage_diagnostics +``` + +El comando conserva IDs y `external_match_id` del scoreboard publico, claves +`match_key` materializadas y URLs seguras existentes. Copia SQLite y los JSON +historicos de `backend/data/snapshots` como fuentes legacy; no los vuelve a +usar como read model visible cuando `HLL_BACKEND_DATABASE_URL` esta definido. +Las filas legacy de `comunidad-hispana-03` se omiten en el read model visible +de esta migracion para no reactivar ese target. + +Permanecen fuera de phase 2: + +- checkpoints y runs operativos del import publico que no aparecen en frontend +- Elo/MMR pausado y oculto en la UI actual + +`app.storage_diagnostics` muestra conteos PostgreSQL, ultimas partidas +materializadas, ultimos `match_end`, dominios restantes y un resumen de paridad +para verificar la migracion antes de retirar fuentes legacy. diff --git a/docs/deployment/nas-portainer.md b/docs/deployment/nas-portainer.md new file mode 100644 index 0000000..72bd94f --- /dev/null +++ b/docs/deployment/nas-portainer.md @@ -0,0 +1,130 @@ +# NAS / Portainer deployment + +This deployment path is for the Proxmox NAS Docker/Portainer environment. It keeps the development `docker-compose.yml` unchanged and adds a production compose file under `deploy/portainer/`. + +## Files + +- `deploy/portainer/docker-compose.nas.yml`: production compose for Portainer. +- `deploy/portainer/stack.env.example`: safe environment template. Copy values into Portainer and replace placeholders. +- `deploy/portainer/Caddyfile.example`: Caddy reverse proxy block for `comunidadhll.devzamode.es`. + +## Portainer stack + +1. In Portainer, create a new Stack from the cloned repository. +2. Use compose file path: + + ```text + deploy/portainer/docker-compose.nas.yml + ``` + +3. Paste variables from `deploy/portainer/stack.env.example` into the stack environment editor. +4. Replace all placeholders, especially: + - `POSTGRES_PASSWORD` + - `HLL_BACKEND_DATABASE_URL` + - `HLL_BACKEND_RCON_TARGETS` + +The production compose does not publish host ports. Caddy is the only public entrypoint. Backend and frontend are attached to the external Docker network configured by `CADDY_NETWORK`, defaulting to `stack-caddy`. + +## External Caddy network + +Make sure the Caddy network exists: + +```bash +docker network ls | grep stack-caddy +``` + +If the network does not exist, create it from the Caddy stack or manually: + +```bash +docker network create stack-caddy +``` + +## Caddy configuration + +Add this block to `/mnt/data8tb/NAS/stack-caddy/Caddyfile`: + +```caddyfile +comunidadhll.devzamode.es { + encode zstd gzip + + reverse_proxy /health hll-vietnam-backend-1:8000 + reverse_proxy /api/* hll-vietnam-backend-1:8000 + + reverse_proxy hll-vietnam-frontend-1:8080 +} +``` + +Then format and reload Caddy: + +```bash +docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile +docker exec caddy caddy reload --config /etc/caddy/Caddyfile +``` + +## Verification + +From the NAS or another machine: + +```bash +curl -I https://comunidadhll.devzamode.es +curl https://comunidadhll.devzamode.es/health +curl https://comunidadhll.devzamode.es/api/servers +``` + +In Portainer, check logs for: + +- backend +- frontend +- postgres + +With Docker CLI: + +```bash +docker compose -f deploy/portainer/docker-compose.nas.yml ps +docker compose -f deploy/portainer/docker-compose.nas.yml logs --tail=100 backend +docker compose -f deploy/portainer/docker-compose.nas.yml logs --tail=100 frontend +``` + +## Updating after git pull + +From the repository directory on the NAS: + +```bash +git pull origin main +docker compose -f deploy/portainer/docker-compose.nas.yml build +docker compose -f deploy/portainer/docker-compose.nas.yml up -d +``` + +Or redeploy the stack from Portainer. + +## Advanced historical workers + +Normal production startup includes only: + +- postgres +- backend +- frontend + +Historical workers are opt-in through the `advanced` profile: + +```bash +docker compose -f deploy/portainer/docker-compose.nas.yml --profile advanced up -d historical-runner rcon-historical-worker +``` + +Stop them before running manual backfills or other long writer jobs: + +```bash +docker compose -f deploy/portainer/docker-compose.nas.yml --profile advanced stop historical-runner rcon-historical-worker +``` + +## Local validation commands + +Run from repository root: + +```bash +docker compose config +docker compose -f deploy/portainer/docker-compose.nas.yml config +docker compose -f deploy/portainer/docker-compose.nas.yml build +``` + +The development compose still exposes local ports for `http://localhost:8080` and `http://localhost:8000`. The NAS compose intentionally exposes no host ports. diff --git a/docs/discord-and-server-data-plan.md b/docs/discord-and-server-data-plan.md new file mode 100644 index 0000000..8cd37b6 --- /dev/null +++ b/docs/discord-and-server-data-plan.md @@ -0,0 +1,119 @@ +# Discord And Server Data Plan + +## Objective + +Definir una base tecnica para exponer en la web datos de Discord y de futuros servidores de juego sin implementar todavia integraciones reales ni depender de servicios externos en esta fase. + +## Discord Data Candidates + +Bloques con sentido para la web: + +- `invite_url`: enlace principal para entrar en la comunidad. +- `community_name`: nombre visible de la comunidad o del servidor. +- `cta_label`: texto de llamada a la accion para el boton de acceso. +- `approx_presence`: presencia aproximada o estado publico solo si existe una fuente publica fiable. +- `public_summary`: breve descripcion publica, reglas resumidas o mensaje de bienvenida. + +## Game Server Data Candidates + +Bloques con sentido para la web: + +- `server_name`: nombre visible del servidor. +- `status`: online u offline. +- `current_map`: mapa actual si la fuente lo permite. +- `rotation`: rotacion o proximo mapa si la fuente es estable. +- `players`: jugadores conectados. +- `max_players`: capacidad maxima. +- `ping`: latencia aproximada si la consulta la devuelve. +- `region` o `notes`: metadatos operativos simples para la comunidad. + +## Possible Discord Sources + +### Public widget + +- Util para obtener datos publicos basicos si el servidor lo tiene habilitado. +- Bueno para presencia aproximada o nombre visible. +- Limitado por la configuracion del propio servidor y por el alcance real del widget. + +### External API or third-party integration + +- Puede simplificar algunas lecturas, pero introduce dependencia de terceros, cambios de servicio y posibles limites de uso. +- Debe considerarse solo si aporta estabilidad y evita exponer credenciales en frontend. + +### Own bot + +- Da mas control a largo plazo. +- Exige credenciales, despliegue, permisos y operacion continua. +- No encaja en la fase actual del repositorio. + +### Manual configured data + +- Fuente mas segura para la primera fase. +- Sirve para `invite_url`, nombre de comunidad y textos publicos. +- Permite validar el contrato API y el consumo frontend sin depender de Discord real. + +## Possible Game Server Sources + +### Direct server queries + +- Pueden dar estado, jugadores, mapa o ping segun el protocolo disponible. +- Exigen validar compatibilidad real con el juego, frecuencia de consulta y tolerancia a timeouts. + +### External API + +- Puede simplificar el acceso si existe una fuente especializada. +- Introduce dependencia externa, disponibilidad ajena y posible coste o rate limit. + +### Mock or placeholder data + +- Opcion recomendada para la primera fase. +- Permite fijar formato JSON, estados y experiencia de frontend sin acoplarse a infraestructura real. + +### Manual updates + +- Util para mostrar estado controlado o informacion operativa minima mientras no exista integracion tecnica fiable. +- Reduce riesgo en una etapa donde el backend aun es preparatorio. + +## Risks And Restrictions + +- Credenciales: bots o APIs privadas requieren secretos y una estrategia de almacenamiento segura. +- Rate limits: Discord o terceros pueden limitar frecuencia de consulta. +- Availability: widgets, APIs o consultas de servidor pueden fallar o cambiar sin previo aviso. +- Security: nunca debe exponerse en frontend una credencial ni una ruta administrativa. +- CORS: el frontend no deberia depender de llamadas directas a servicios externos si eso obliga a resolver CORS en cliente. +- Latency: consultas en tiempo real pueden degradar la web si no se amortiguan en backend. +- External dependency: cada integracion nueva aumenta coste operativo y puntos de fallo. + +## Phased Strategy + +### Phase 1: controlled placeholders + +- Backend Python devuelve datos manuales o mock para `/api/discord` y `/api/servers`. +- La web usa esos datos solo cuando futuras tasks lo indiquen. +- No hay consultas reales a Discord ni a servidores. + +### Phase 2: limited technical integration + +- Evaluar una unica fuente publica o consulta sencilla por dominio. +- Mantener fallback manual si la fuente falla. +- Introducir observabilidad minima antes de ampliar alcance. + +### Phase 3: real integration if justified + +- Considerar bot propio, polling controlado o una integracion mas rica solo si aporta valor real a la comunidad. +- Revisar seguridad, operacion, cache y mantenimiento antes de consolidarlo. + +## What Is Explicitly Out Of Scope Now + +- Integrar Discord real. +- Consultar servidores reales de juego. +- Anadir base de datos. +- Implementar autenticacion o panel administrativo. +- Hacer llamadas directas desde el frontend a servicios externos. + +## Recommended Implementation Order + +1. Consolidar placeholders backend para `community`, `discord`, `trailer` y `servers`. +2. Definir consumo frontend con fallbacks visuales y orden de prioridad. +3. Validar una fuente publica o consulta tecnica pequena para Discord o servidores. +4. Decidir si merece la pena ampliar integraciones reales. diff --git a/docs/elo-mmr-monthly-ranking-design.md b/docs/elo-mmr-monthly-ranking-design.md new file mode 100644 index 0000000..9c3a896 --- /dev/null +++ b/docs/elo-mmr-monthly-ranking-design.md @@ -0,0 +1,214 @@ +# Elo/MMR Monthly Ranking Design + +## Scope + +This repository now exposes a first operational Elo/MMR-like system inspired by +`sistema_elo_mensual_hll.pdf`, but constrained to signals that are really +available today. + +The implementation keeps the same conceptual split: + +- persistent `MMR` +- monthly `MonthlyRankScore` + +It does **not** claim full parity with the PDF. Every major signal is labeled as: + +- `exact` +- `approximate` +- `not_available` + +## Real Inputs Available Today + +Exact today from persisted historical CRCON/public-scoreboard data: + +- closed match identity +- server scope +- player identity +- team side +- kills +- deaths +- support +- teamkills +- combat score +- offense score +- defense score +- match timestamps when present +- final allied/axis score + +Exact today from current product state but not required by the core engine: + +- player-event V2 summaries for duels, most-killed, death-by and weapon summaries + +Approximate only: + +- `role_bucket` + - inferred from the dominant scoreboard axis among `combat`, `offense`, + `defense` and `support` +- `ObjectiveIndex` + - proxied with `offense + defense` because there is no tactical event feed +- `StrengthOfSchedule` + - proxied with match quality and lobby density because there is no opponent MMR + model yet + +Not available today: + +- explicit squad role / commander / SL role +- garrisons and OPs destroyed +- revives +- AFK and leave events +- precise leadership telemetry +- exact tactical objective event stream +- exact opponent-strength graph by roster + +## Current Capability Contract + +### Match validity + +Current rule: + +- match must be closed +- match duration must be at least `15` minutes +- match must have at least `20` persisted player rows + +Duration source: + +- `exact` if `started_at` and `ended_at` exist +- `approximate` if we must fall back to max player `time_seconds` + +### Quality factor Q + +Current `Q` is a bounded mix of: + +- player density +- match duration +- score completeness + +This is an operational approximation of the PDF quality factor and is labelled: + +- `exact` for the density and score-completeness inputs +- `exact` or `approximate` for duration depending on timestamp availability + +### Buckets + +Implemented: + +- duration bucket +- mode retention through `game_mode` +- approximate `role_bucket` + +Not implemented yet: + +- literal class role bucket + +### Subindices + +Implemented now: + +- `OutcomeScore`: `exact` +- `CombatIndex`: `exact` +- `ObjectiveIndex`: `approximate` +- `UtilityIndex`: `exact` +- `LeadershipIndex`: `not_available` +- `DisciplineIndex`: `exact` for teamkills only + +### ImpactScore + +Implemented with role-inspired weights, but the role itself is approximate, so +the final `ImpactScore` is operationally `approximate`. + +### DeltaMMR + +Implemented from: + +- `OutcomeScore` +- `ImpactScore` +- quality factor `Q` + +The resulting `DeltaMMR` is real and persisted, but inherits the mixed +availability of the inputs above. + +## Storage Model + +Tables added in backend SQLite: + +- `elo_mmr_player_ratings` +- `elo_mmr_match_results` +- `elo_mmr_monthly_rankings` +- `elo_mmr_monthly_checkpoints` + +Meaning: + +- `elo_mmr_player_ratings` + - current persistent rating per player and scope +- `elo_mmr_match_results` + - per-match scoring trace used to explain rating movement +- `elo_mmr_monthly_rankings` + - monthly ranking rows ready for product/API +- `elo_mmr_monthly_checkpoints` + - generated-at metadata plus source policy and capability summary + +Scopes persisted: + +- per historical server +- `all-servers` + +## Runtime Source Policy + +The Elo/MMR engine follows the same historical policy as the rest of backend: + +- primary intent: `rcon` +- current competitive calculation fallback: `public-scoreboard` + +Why fallback still exists here: + +- the current RCON historical read model only supports coverage and recent + activity +- it does not yet expose enough competitive match detail to support this Elo/MMR + engine directly + +That fallback is exposed in API metadata through: + +- `primary_source` +- `selected_source` +- `fallback_used` +- `fallback_reason` +- `source_attempts` + +## Product Read Model + +Current API surfaces: + +- `/api/historical/elo-mmr/leaderboard` +- `/api/historical/elo-mmr/player` + +These payloads expose: + +- persistent rating +- monthly ranking score +- eligibility +- component breakdown +- exact/approximate/partial capability metadata + +## Important Limitations + +This first version should be treated as: + +- operational +- honest about accuracy +- compatible with future expansion + +It should **not** be described as: + +- a perfect Elo system +- full parity with the PDF +- a complete tactical rating model + +## Planned Expansion Path + +The current design is compatible with future upgrades once real telemetry exists: + +- replace approximate `ObjectiveIndex` with event-driven tactical signals +- add `LeadershipIndex` when squad/command telemetry exists +- replace approximate `StrengthOfSchedule` with opponent MMR graph logic +- feed V2 duels and weapon signals into richer combat weighting when their + coverage is sufficient diff --git a/docs/frontend-backend-contract.md b/docs/frontend-backend-contract.md new file mode 100644 index 0000000..00f21db --- /dev/null +++ b/docs/frontend-backend-contract.md @@ -0,0 +1,263 @@ +# Frontend Backend Contract + +## Objetivo + +Definir un contrato inicial y pequeno entre la landing actual y el futuro backend Python sin implementar todavia integraciones reales ni comprometer detalles de infraestructura antes de tiempo. + +## Estado actual + +- Frontend: landing estatica sin consumo de API +- Backend: bootstrap Python con `GET /health` +- Integraciones reales: no implementadas + +## Convenciones generales + +- Todas las respuestas usan JSON. +- Los nombres de campos usan `snake_case`. +- `status` es obligatorio en todas las respuestas. +- Las respuestas exitosas usan `status: "ok"`. +- Las respuestas de error usan `status: "error"` y un campo `message`. +- Cuando un endpoint sea solo placeholder o aun no tenga datos reales, puede responder datos controlados o quedar documentado como previsto hasta una task posterior. + +## Estructura base de respuesta + +Respuesta correcta: + +```json +{ + "status": "ok", + "data": {} +} +``` + +Respuesta de error minima: + +```json +{ + "status": "error", + "message": "Route not found" +} +``` + +## Endpoints + +### `GET /health` + +- Proposito: comprobar que el backend bootstrap esta levantado. +- Metodo HTTP: `GET` +- Ruta: `/health` +- Estado actual: implementado + +Ejemplo JSON: + +```json +{ + "status": "ok", + "service": "hll-vietnam-backend", + "phase": "bootstrap" +} +``` + +### `GET /api/community` + +- Proposito: devolver contenido resumido de presentacion de la comunidad para bloques de texto o estadisticas futuras. +- Metodo HTTP: `GET` +- Ruta: `/api/community` +- Estado actual: previsto + +Ejemplo JSON: + +```json +{ + "status": "ok", + "data": { + "title": "Comunidad Hispana HLL Vietnam", + "summary": "Punto de encuentro para jugadores, escuadras y comunidad.", + "discord_invite_url": "https://discord.com/invite/PedEqZ2Xsa" + } +} +``` + +### `GET /api/trailer` + +- Proposito: exponer la informacion del trailer que hoy esta fija en la landing. +- Metodo HTTP: `GET` +- Ruta: `/api/trailer` +- Estado actual: previsto + +Ejemplo JSON: + +```json +{ + "status": "ok", + "data": { + "video_url": "https://www.youtube.com/embed/JzYzYNVWZ_A", + "title": "Trailer HLL Vietnam", + "provider": "youtube" + } +} +``` + +### `GET /api/discord` + +- Proposito: centralizar la informacion publica del acceso a Discord sin integrar todavia datos reales del servidor. +- Metodo HTTP: `GET` +- Ruta: `/api/discord` +- Estado actual: placeholder + +Ejemplo JSON: + +```json +{ + "status": "ok", + "data": { + "invite_url": "https://discord.com/invite/PedEqZ2Xsa", + "label": "Unirse al Discord", + "availability": "manual" + } +} +``` + +### `GET /api/servers` + +- Proposito: exponer el estado actual de los 2 servidores reales de la comunidad desde backend, usando el ultimo snapshot valido y forzando refresco real cuando el cache local supere el objetivo de 120 segundos. +- Metodo HTTP: `GET` +- Ruta: `/api/servers` +- Estado actual: implementado con refresco A2S bajo demanda y fallback a snapshot persistido stale + +Ejemplo JSON: + +```json +{ + "status": "ok", + "data": { + "title": "Estado actual de servidores", + "context": "current-hll-status", + "source": "real-time-a2s-refresh", + "last_snapshot_at": "2026-03-20T18:37:58.628122Z", + "snapshot_age_seconds": 0, + "snapshot_age_minutes": 0, + "max_snapshot_age_seconds": 120, + "is_stale": false, + "freshness": "fresh", + "refresh_attempted": true, + "refresh_status": "success", + "refresh_errors": [], + "items": [ + { + "external_server_id": "comunidad-hispana-01", + "server_name": "Comunidad Hispana #01", + "status": "online", + "players": 74, + "max_players": 100, + "current_map": "Sainte-Marie-du-Mont", + "region": "ES", + "snapshot_origin": "real-a2s", + "captured_at": "2026-03-20T18:37:58.628122Z" + } + ] + } +} +``` + +Notas del comportamiento actual: + +- Si el snapshot persistido tiene `120` segundos o menos, puede reutilizarse sin refresco inmediato. +- Si el snapshot supera ese umbral, backend intenta una consulta A2S real antes de responder. +- Si la consulta real falla, backend devuelve el ultimo snapshot valido con `is_stale: true`. +- Si no existe ningun snapshot valido, backend responde `items: []` y no inventa servidores de referencia. + +### `GET /api/servers/latest` + +- Proposito: devolver el ultimo snapshot conocido por servidor desde la persistencia local. +- Metodo HTTP: `GET` +- Ruta: `/api/servers/latest` +- Estado actual: implementado para validacion tecnica + +Ejemplo JSON: + +```json +{ + "status": "ok", + "data": { + "title": "Ultimo estado conocido de servidores", + "context": "current-hll-history", + "source": "local-snapshot-storage", + "items": [ + { + "server_id": 1, + "external_server_id": "hll-esp-tactical-rotation", + "server_name": "HLL ESP Tactical Rotation", + "region": "EU", + "captured_at": "2026-03-20T08:45:20.802006Z", + "status": "online", + "players": 74, + "max_players": 100, + "current_map": "Sainte-Marie-du-Mont" + } + ] + } +} +``` + +### `GET /api/servers/history` + +- Proposito: devolver una ventana simple de snapshots recientes desde la persistencia local. +- Metodo HTTP: `GET` +- Ruta: `/api/servers/history` +- Parametros opcionales: `limit` entre `1` y `100` +- Estado actual: implementado para validacion tecnica + +Ejemplo JSON: + +```json +{ + "status": "ok", + "data": { + "title": "Historial reciente de servidores", + "context": "current-hll-history", + "source": "local-snapshot-storage", + "limit": 20, + "items": [] + } +} +``` + +### `GET /api/servers/{id}/history` + +- Proposito: devolver una historia basica de snapshots para un servidor concreto. +- Metodo HTTP: `GET` +- Ruta: `/api/servers/{id}/history` +- Parametros opcionales: `limit` entre `1` y `100` +- Identificadores aceptados: `server_id` numerico interno o `external_server_id` +- Estado actual: implementado para validacion tecnica + +Ejemplo JSON: + +```json +{ + "status": "ok", + "data": { + "title": "Historial por servidor", + "context": "current-hll-history", + "source": "local-snapshot-storage", + "server_id": "hll-esp-tactical-rotation", + "limit": 20, + "items": [] + } +} +``` + +## Consumo previsto desde frontend + +- El frontend deberia llamar primero a `GET /health` solo para comprobaciones tecnicas o entornos de desarrollo, no para condicionar el render basico de la landing. +- Los endpoints de contenido (`/api/community`, `/api/trailer`, `/api/discord`, `/api/servers`) deberian consumirse con `fetch`. +- Si una llamada falla, la landing debe conservar un fallback estatico mientras exista contenido fijo en `index.html`. +- La futura migracion debe reemplazar valores hardcoded de forma incremental, endpoint por endpoint. + +## Notas de alcance + +- Este contrato no introduce autenticacion. +- Este contrato no define base de datos. +- Este contrato no integra Discord ni servidores reales. +- La implementacion de estos endpoints queda para tasks posteriores. diff --git a/docs/frontend-data-consumption-plan.md b/docs/frontend-data-consumption-plan.md new file mode 100644 index 0000000..e9c487a --- /dev/null +++ b/docs/frontend-data-consumption-plan.md @@ -0,0 +1,73 @@ +# Frontend Data Consumption Plan + +## Objective + +Definir como evolucionara la landing de HLL Vietnam desde contenido estatico hacia bloques alimentados por el backend sin romper simplicidad, branding ni compatibilidad al abrir `frontend/index.html` directamente. + +## Current Frontend Blocks With Future Dynamic Potential + +- Hero principal: titulo, resumen y CTA de Discord podran leer `community` y `discord`. +- Bloque de trailer: podra leer `trailer` para desacoplar video y titulo del HTML. +- Estado de servidores: queda reservado para una futura seccion y no debe forzarse en la landing actual. + +## Recommended Consumption Strategy + +- Usar `fetch` nativo cuando una task habilite consumo real. +- Mantener JavaScript simple en `frontend/assets/js/main.js` o dividir en modulos ligeros solo si el numero de bloques dinamicos ya lo justifica. +- Centralizar la URL base del backend en una configuracion minima si el frontend deja de ser puramente estatico en un entorno concreto. +- No llamar a servicios externos desde el navegador; el frontend debe hablar con el backend Python. + +## UI State Rules + +### Loading + +- No bloquear el render inicial de la landing. +- Mostrar skeletons o placeholders ligeros solo en bloques futuros que ya dependan del backend. + +### Error + +- Si falla una llamada, conservar el contenido estatico existente o un mensaje tactico breve y no intrusivo. +- Registrar el error en consola durante desarrollo sin degradar toda la pagina. + +### Empty state + +- Si `servers.items` llega vacio, mostrar un estado neutral de "informacion disponible mas adelante". +- Si un bloque opcional no tiene datos, ocultarlo o dejar un placeholder discreto en lugar de mostrar errores tecnicos. + +### Fallback + +- Mantener el Discord CTA hardcoded hasta que `/api/discord` sea estable. +- Mantener el iframe del trailer fijo hasta validar `/api/trailer`. +- No hacer depender el hero de `/health`. + +## Endpoint Priority + +1. `/api/community` +2. `/api/trailer` +3. `/api/discord` +4. `/api/servers` +5. `/health` solo para checks tecnicos o diagnostico en desarrollo + +## Progressive Migration Path + +### Step 1 + +- Introducir una capa minima de lectura para `community` y `trailer`. +- Reutilizar el HTML actual como fallback. + +### Step 2 + +- Sustituir el CTA de Discord por datos de `/api/discord` cuando el placeholder backend sea estable. +- Mantener la URL actual como respaldo local. + +### Step 3 + +- Anadir una seccion de servidores solo cuando exista diseno, contrato y placeholder suficientemente claros. +- Evitar reservar complejidad en la landing antes de que ese bloque aporte valor real. + +## Explicitly Out Of Scope Now + +- Implementar `fetch` real. +- Cambiar el comportamiento visible de la landing. +- Introducir librerias de estado o frameworks frontend. +- Conectar el navegador directamente con Discord o con APIs de servidores. diff --git a/docs/historical-coverage-report.md b/docs/historical-coverage-report.md new file mode 100644 index 0000000..5000190 --- /dev/null +++ b/docs/historical-coverage-report.md @@ -0,0 +1,120 @@ +# Historical Coverage Report + +## Validation Date + +- 2026-03-21 +- 2026-03-23 + +## Scope + +Estado real de la cobertura historica persistida localmente en +`backend/data/hll_vietnam_dev.sqlite3` tras ejecutar el bootstrap CRCON con el +flujo reforzado de `backend/app/historical_ingestion.py`. + +## Commands Used + +Desde `backend/`: + +```powershell +python -m app.historical_ingestion bootstrap --max-pages 3 --detail-workers 16 +``` + +Bootstrap acotado y reanudable para `comunidad-hispana-03`: + +```powershell +python -m app.historical_ingestion bootstrap --server comunidad-hispana-03 --page-size 10 --max-pages 1 --detail-workers 8 +``` + +Verificacion puntual previa de idempotencia sobre la primera pagina ya +importada: + +```powershell +python -m app.historical_ingestion bootstrap --max-pages 1 --detail-workers 8 +``` + +Esa reejecucion devolvio `matches_inserted: 0` y solo `matches_updated` para +los matches ya persistidos, confirmando el comportamiento idempotente en el +tramo reimportado. + +## Source Depth Discovered + +La propia API CRCON reporto en pagina 1: + +- `comunidad-hispana-01`: `23029` matches historicos disponibles +- `comunidad-hispana-02`: `18221` matches historicos disponibles + +Esto confirma que la fuente publica tiene un archivo mucho mas profundo que la +semana movil usada por la UI y que un bootstrap completo real es una operacion +larga incluso con paralelismo. + +## Persisted Coverage After Bootstrap Validation + +### comunidad-hispana-01 + +- matches importados: `150` +- jugadores unicos: `3986` +- filas de estadisticas por jugador: `12650` +- primera partida persistida: `2026-03-04T22:11:18Z` +- ultima partida persistida: `2026-03-20T21:41:18Z` +- rango cubierto: `15.98` dias + +### comunidad-hispana-02 + +- matches importados: `150` +- jugadores unicos: `4468` +- filas de estadisticas por jugador: `12665` +- primera partida persistida: `2026-03-01T16:59:10Z` +- ultima partida persistida: `2026-03-20T21:14:21Z` +- rango cubierto: `19.18` dias + +### comunidad-hispana-03 + +- matches importados: `33` +- jugadores unicos: `1161` +- filas de estadisticas por jugador: `2547` +- primera partida persistida: `2026-02-24T18:16:11Z` +- ultima partida persistida: `2026-03-08T18:11:52Z` +- rango cubierto: `12.0` dias +- total descubierto en la fuente publica: `11652` matches +- checkpoint actual de bootstrap: `next_page = 2`, `last_completed_page = 1` + +## Interpretation + +- La base persistida ya supera claramente la ventana semanal en ambos + servidores, por lo que la UI historica ya puede distinguir entre "ranking de + ultimos 7 dias" y "cobertura total importada" sin fingir que ambos conceptos + son lo mismo. +- `comunidad-hispana-03` ya no esta vacio: existe historico real persistido, + snapshots de resumen y partidas recientes, y un checkpoint reanudable para + seguir ampliando cobertura sin repetir desde cero. +- El historico local sigue siendo parcial respecto al total reportado por la + fuente. Lo importado hoy es suficiente para seguir con semantica y revisiones + de UI, pero no representa aun el archivo completo disponible en CRCON. + +## Source Limits Observed + +- Bajo replays repetidos del mismo bootstrap, la fuente CRCON devolvio errores + `502 Bad Gateway` intermitentes en `get_public_info` y `get_map_scoreboard`. +- Con `--detail-workers 16` la carga validada fue estable para `3` paginas por + servidor. Con concurrencia mas alta se observaron payloads no validos con mas + frecuencia. + +## Operational Conclusion + +- El bootstrap queda reanudable por checkpoint persistido en + `historical_backfill_progress`; si no se pasa `--start-page`, una nueva + sesion continua desde `next_page`. +- Cada pagina completada actualiza por servidor: + - `last_completed_page` + - `next_page` + - `discovered_total_matches` + - `discovered_total_pages` + - `last_run` +- La estrategia operativa razonable para completar todo el archivo es ejecutar + varias sesiones consecutivas con el mismo comando hasta que + `archive_exhausted` pase a `true`. +- `--start-page` se conserva solo como override manual cuando haga falta + reprocesar un tramo concreto. +- Mientras no se complete todo el archivo, cualquier UI o API debe mostrar la + cobertura importada como cobertura real disponible y no como historico total + del servidor. diff --git a/docs/historical-crcon-source-discovery.md b/docs/historical-crcon-source-discovery.md new file mode 100644 index 0000000..1291dcf --- /dev/null +++ b/docs/historical-crcon-source-discovery.md @@ -0,0 +1,243 @@ +# Historical CRCON Source Discovery + +## Objective + +Documentar la fuente historica real y mas estable para los 2 servidores de la comunidad a partir de sus scoreboards publicos basados en CRCON, dejando claro que el historico reutilizable debe venir de esa capa y no de A2S ni de una implementacion previa ya descartada. + +## Discovery Date + +- Verificado el 2026-03-20 contra: + - `https://scoreboard.comunidadhll.es/games` + - `https://scoreboard.comunidadhll.es:5443/games` + +## Main Finding + +La fuente historica reutilizable mas estable disponible hoy es una API JSON publica expuesta por cada scoreboard, no el HTML renderizado de `/games`. + +Las dos URLs de historial cargan una SPA con el mismo bundle frontend. Ese bundle usa `axios` con `baseURL: "/api"` y consulta endpoints JSON concretos: + +- `GET /api/get_public_info` +- `GET /api/get_live_scoreboard` +- `GET /api/get_live_game_stats` +- `GET /api/get_scoreboard_maps?page={page}&limit={limit}` +- `GET /api/get_map_scoreboard?map_id={map_id}` + +Por tanto, la estrategia recomendada no es parsear HTML de `/games`, sino consumir la capa JSON que alimenta ese frontend. + +## Server Mapping + +Cada scoreboard representa un servidor distinto: + +- `https://scoreboard.comunidadhll.es` + - `GET /api/get_public_info` identifica `#01 [ESP] Comunidad Hispana - discord.comunidadhll.es - Spa Onl` + - `public_stats_port`: `7010` + - `public_stats_port_https`: `7011` +- `https://scoreboard.comunidadhll.es:5443` + - `GET /api/get_public_info` identifica `#02 [ESP] Comunidad Hispana - discord.comunidadhll.es - Spa Onl` + - `public_stats_port`: `7012` + - `public_stats_port_https`: `7013` +- `https://scoreboard.comunidadhll.es:3443` + - tercer scoreboard comunitario reservado para la identidad estable `comunidad-hispana-03` + - la capa de backend ya debe tratarlo como otra fuente CRCON independiente de las de `#01` y `#02` + +## How Historical Data Is Loaded + +### 1. History list + +`GET /api/get_scoreboard_maps?page=1&limit=5` + +Devuelve una lista paginada de partidas finalizadas con estructura JSON. Campos verificados: + +- `page` +- `page_size` +- `total` +- `maps[]` + +Cada item de `maps[]` incluye al menos: + +- `id` +- `creation_time` +- `start` +- `end` +- `server_number` +- `map.id` +- `map.pretty_name` +- `map.image_name` +- `map.game_mode` +- `result.axis` +- `result.allied` + +Observacion importante: + +- `player_stats` aparece vacio en la lista. Para metricas de jugadores hay que ir al endpoint de detalle. + +### 2. Match detail + +`GET /api/get_map_scoreboard?map_id={map_id}` + +Devuelve el detalle historico de una partida concreta. Ejemplos verificados: + +- servidor `#01`: `map_id=1561077` +- servidor `#02`: `map_id=1561076` + +Campos verificados a nivel de partida: + +- `id` +- `creation_time` +- `start` +- `end` +- `server_number` +- `map_name` +- `map.pretty_name` +- `result.axis` +- `result.allied` +- `player_stats[]` + +Campos verificados a nivel de jugador dentro de `player_stats[]`: + +- `id` +- `player_id` +- `player` +- `steaminfo.id` +- `steaminfo.profile.steamid` cuando existe +- `map_id` +- `kills` +- `kills_by_type` +- `kills_streak` +- `deaths` +- `deaths_by_type` +- `teamkills` +- `time_seconds` +- `kills_per_minute` +- `deaths_per_minute` +- `kill_death_ratio` +- `longest_life_secs` +- `shortest_life_secs` +- `combat` +- `offense` +- `defense` +- `support` +- `most_killed` +- `death_by` +- `weapons` +- `death_by_weapons` +- `team.side` +- `level` + +Esto confirma que el scoreboard ya expone la base necesaria para rankings semanales por servidor como "top kills", junto con otras metricas reutilizables. + +## Detail URLs And IDs + +- La UI publica usa rutas tipo `/games/{id}`. +- `GET https://scoreboard.comunidadhll.es/games/1561077` responde `200`. +- `GET https://scoreboard.comunidadhll.es:5443/games/1561076` responde `200`. + +Inferencia razonable: + +- `/games/{id}` es la URL publica de detalle de partida. +- el dato real se resuelve desde frontend llamando a `GET /api/get_map_scoreboard?map_id={id}`. + +## Stable Historical Data Actually Available + +A dia 2026-03-20, la capa JSON permite obtener de forma estable: + +- servidor + - por host del scoreboard + - por `server_number` + - por `get_public_info.name` +- partida + - `id` + - `start` + - `end` + - `creation_time` +- mapa + - `map.id` + - `map.pretty_name` + - `game_mode` + - `environment` +- jugador + - `player_id` + - `player` + - `steaminfo` parcial cuando existe +- metricas + - `kills` + - `deaths` + - `teamkills` + - `kills_per_minute` + - `kill_death_ratio` + - `combat` + - `offense` + - `defense` + - `support` + - desglose por armas y tipos cuando aparece + +## Pagination And Historical Depth + +- La lista historica es paginada mediante `page` y `limit`. +- El bundle observado usa por defecto `page=1` y `limit=50`. +- En la verificacion: + - servidor `#01` reporto `total: 23027` + - servidor `#02` reporto `total: 18219` + +Esto sugiere una profundidad historica amplia y apta para ingesta incremental paginada. + +## Risks And Limits + +- La fuente es publica, pero no hay contrato formal versionado publicado; sigue siendo una API no documentada externamente. +- El frontend depende de rutas `/api/...` observadas en el bundle actual `v11.9.0`; una actualizacion futura podria renombrarlas. +- `player_id` no parece homogeneo al 100%: + - a veces coincide con SteamID + - a veces aparece como hash o identificador alternativo +- `steaminfo` puede venir completo, parcial o `null`; no debe asumirse como obligatorio. +- Existen valores de calidad irregular en algunas partidas: + - `shortest_life_secs` negativos + - jugadores con tiempos atipicos + - campos vacios o `unknown` +- El HTML de `/games` no debe tomarse como base tecnica porque solo sirve la SPA shell y es mas fragil que consumir el JSON directo. +- A2S sigue siendo util para estado actual, no para reconstruir historico de partidas ni ranking semanal retroactivo. + +## Recommended Strategy For Following Tasks + +### Ideal historical source + +Usar directamente la API JSON publica de cada scoreboard CRCON: + +- listar partidas con `GET /api/get_scoreboard_maps` +- obtener detalle por partida con `GET /api/get_map_scoreboard` + +### Realistic initial operating plan + +1. Mantener separados los 2 orígenes: + - `https://scoreboard.comunidadhll.es/api` + - `https://scoreboard.comunidadhll.es:5443/api` +2. Registrar por servidor: + - host base del scoreboard + - nombre publico devuelto por `get_public_info` + - `server_number` +3. Ingerir paginas historicas de forma incremental. +4. Persistir una entidad de partida externa con `match_id = id`. +5. Persistir filas de estadistica por jugador asociadas a `match_id` y servidor. +6. Calcular agregados semanales desde esos datos persistidos, no consultando el scoreboard en cada request de frontend. + +### Fallback if the JSON layer changes + +- primer fallback: revalidar el bundle SPA para localizar las nuevas rutas `/api` +- segundo fallback: parsear HTML solo como ultimo recurso y solo si el JSON deja de ser accesible + +## Explicitly Not Recommended + +- No basar el historico en A2S. +- No reutilizar como base de arquitectura una implementacion historica previa ya descartada. +- No tomar el HTML de `/games` como fuente principal. +- No disenar todavia la UI historica final. + +## Repository Impact + +El repositorio ya tenia una pista correcta en la landing al enlazar ambos scoreboards, pero no existia documentacion tecnica del origen real de historico. + +Tambien se detecto un rastro de implementacion previa no reutilizable: + +- `backend/app/payloads.py` importa `.historical_storage` para un flujo de `weekly_top_kills` +- el archivo `backend/app/historical_storage.py` no existe + +Ese estado confirma que cualquier intento previo de ranking historico no debe considerarse base valida para la siguiente fase. La nueva fase debe reconstruirse desde la fuente CRCON JSON documentada aqui. diff --git a/docs/historical-data-quality-notes.md b/docs/historical-data-quality-notes.md new file mode 100644 index 0000000..ecd5c23 --- /dev/null +++ b/docs/historical-data-quality-notes.md @@ -0,0 +1,66 @@ +# Historical Data Quality Notes + +## Validation Date + +- 2026-03-20 + +## Scope + +Validacion local del historico CRCON persistido en `backend/data/hll_vietnam_dev.sqlite3` +para los servidores: + +- `comunidad-hispana-01` +- `comunidad-hispana-02` + +## Findings Before Correction + +- habia jugadores fragmentados entre claves `steam:*`, `steaminfo:*`, + `crcon-player:*` e incluso claves legacy sin prefijo +- algunas filas usaban `steaminfo.id` corto como si fuera `steam_id`, lo que no + representaba un SteamID real +- existian partidas duplicadas por sesion cuando una partida en curso quedaba + persistida con id sintetico y luego aparecia cerrada con id CRCON numerico +- el ranking semanal podia contar esas partidas transitorias porque aceptaba + filas sin `ended_at` + +## Corrections Applied + +- la identidad de jugador ahora prioriza: + - `steaminfo.profile.steamid` + - `player_id` cuando ya parece un SteamID real + - `player_id` como `crcon-player:*` + - `steaminfo.id` solo como ultimo fallback +- la inicializacion del storage fusiona jugadores duplicados y reasigna sus + estadisticas por partida +- la inicializacion del storage fusiona partidas duplicadas por + `(servidor, started_at, mapa)` cuando la fila mas completa ya representa la + partida final cerrada +- `weekly-top-kills` filtra solo partidas cerradas con `ended_at` + +## Final Local Snapshot After Correction + +- partidas historicas: `12` +- jugadores historicos: `510` +- filas `historical_player_match_stats`: `914` +- distribucion: + - `comunidad-hispana-01`: `7` partidas, `487` jugadores unicos, `859` filas + - `comunidad-hispana-02`: `5` partidas, `44` jugadores unicos, `55` filas + +## Checks Performed + +- sin duplicados por `steam_id` +- sin duplicados por `source_player_id` +- sin duplicados de nombre normalizado en el dataset local actual +- sin partidas abiertas restantes (`ended_at IS NULL`) +- sin duplicados por misma combinacion de servidor, `started_at` y mapa +- el ranking semanal devuelve resultados separados por servidor y basados solo + en partidas cerradas dentro de la ventana movil de 7 dias + +## Notes + +- el volumen actual sigue siendo pequeno y claramente parcial; la calidad + estructural queda validada, pero no sustituye un bootstrap historico mas + profundo cuando se quiera construir UI historica propia +- siguen existiendo partidas con muy pocos jugadores en el dataset local + actual; por ahora se conservan porque no son un problema de integridad, sino + una caracteristica del muestreo ingerido hasta hoy diff --git a/docs/historical-domain-model.md b/docs/historical-domain-model.md new file mode 100644 index 0000000..d9c2864 --- /dev/null +++ b/docs/historical-domain-model.md @@ -0,0 +1,194 @@ +# Historical Domain Model + +## Objective + +Definir la base minima de dominio y persistencia para historico de partidas y +metricas por jugador obtenidas desde la capa JSON publica de los scoreboards +CRCON de Comunidad Hispana. + +## Scope + +Esta capa cubre solo historico persistido en backend: + +- identidad estable de los 3 servidores historicos +- partidas cerradas o actualizadas desde CRCON +- mapas asociados a esas partidas +- identidad reutilizable de jugadores +- estadisticas de jugador por partida +- trazabilidad de ejecuciones de ingesta +- snapshots precalculados para lectura rapida + +No sustituye ni modifica el flujo actual de snapshots live via A2S. + +## Stable Identities + +### Server + +- tabla: `historical_servers` +- clave estable: `slug` +- ejemplos: + - `comunidad-hispana-01` + - `comunidad-hispana-02` + - `comunidad-hispana-03` +- atributos de soporte: + - `scoreboard_base_url` + - `server_number` + - `source_kind` + +La capa de lectura y snapshots admite además una clave lógica adicional: + +- `all-servers` + +Esta clave no representa una fila física extra en `historical_servers`; es una +vista agregada sobre los tres servidores históricos reales para rankings y +resúmenes globales. + +### Match + +- tabla: `historical_matches` +- clave estable: `(historical_server_id, external_match_id)` +- `external_match_id` corresponde al `id` devuelto por CRCON para cada partida +- razon: + - el `id` de partida es estable dentro de cada scoreboard + - se conserva separado por servidor para evitar asumir unicidad global sin + contrato formal + +### Player + +- tabla: `historical_players` +- clave estable: `stable_player_key` +- estrategia de identidad: + 1. `steam:{steamid}` cuando existe `steaminfo.profile.steamid` + 2. `steaminfo:{id}` cuando existe `steaminfo.id` + 3. `crcon-player:{player_id}` cuando existe `player_id` + 4. `name:{normalized-name}` como ultimo fallback + +La prioridad evita perder continuidad cuando CRCON expone SteamID. Los +fallbacks quedan documentados porque la calidad del origen no es totalmente +uniforme. + +### Player Stats Per Match + +- tabla: `historical_player_match_stats` +- clave estable: `(historical_match_id, historical_player_id)` +- efecto: + - la misma partida puede reingestarse sin duplicar filas + - si una partida cambia despues, la fila se actualiza por `UPSERT` + +### Ingestion Run + +- tabla: `historical_ingestion_runs` +- registra: + - tipo de ejecucion (`bootstrap` o `incremental`) + - inicio y fin + - estado + - paginas procesadas + - matches vistos + - inserts y updates + +### Precomputed Snapshot + +- directorio: `backend/data/snapshots//` +- identidad estable: + - `server_key` + - `snapshot_type` + - `metric` + - `window` +- razon: + - permite exponer resumen, rankings y partidas recientes sin recalcular + agregados pesados en cada request + - mantiene metadatos operativos sobre frescura y rango fuente como artefactos + JSON inspeccionables + +## Data Model + +### `historical_servers` + +Fuente historica por scoreboard CRCON. + +### `historical_maps` + +Catalogo reutilizable de mapas usando `map.id` cuando existe. + +### `historical_matches` + +Partida historica persistida con: + +- servidor +- identidad externa +- tiempos (`creation_time`, `start`, `end`) +- mapa y metadatos visibles +- resultado axis/allied +- referencia de procedencia + +### `historical_players` + +Identidad reutilizable del jugador entre partidas y servidores. + +### `historical_player_match_stats` + +Metricas por jugador y partida con al menos: + +- kills +- deaths +- teamkills +- time_seconds +- kills_per_minute +- deaths_per_minute +- kill_death_ratio +- combat +- offense +- defense +- support + +### `historical_ingestion_runs` + +Trazabilidad operativa para bootstrap y refresh incremental. + +### `backend/data/snapshots//*.json` + +Payloads JSON precalculados listos para lectura rapida desde API/UI con: + +- `server_key` +- `snapshot_type` +- `metric` +- `window` +- `payload` +- `generated_at` +- `source_range_start` +- `source_range_end` +- `is_stale` + +## Idempotency Strategy + +- servidores sembrados de forma declarativa y actualizables por `slug` +- partidas persistidas con `UPSERT` por `(historical_server_id, external_match_id)` +- jugadores persistidos con `UPSERT` por `stable_player_key` +- estadisticas por jugador actualizadas con `UPSERT` por + `(historical_match_id, historical_player_id)` +- el refresco incremental usa una ventana de solape temporal para volver a leer + partidas recientes y absorber cambios tardios sin rehacer todo el historico +- los snapshots precalculados usan reemplazo por identidad logica de archivo + para refrescar el payload sin crear duplicados + +## Query Readiness + +La estructura soporta ya consultas futuras como: + +- top kills de la ultima semana por servidor +- top muertes, soporte y partidas de 100+ kills desde una capa cacheada +- partidas recientes por servidor +- rankings y resumenes globales con la clave logica `all-servers` +- mapas jugados y frecuencia +- agregados por jugador sobre ventanas temporales + +## Separation From Live State + +- live state actual: `server_snapshots` via A2S +- historico persistido: `historical_*` via CRCON scoreboard JSON +- snapshots precalculados: archivos JSON bajo `backend/data/snapshots/` + generados desde el mismo historico persistido + +Ambas lineas siguen compartiendo el mismo SQLite local para el estado live y el +historico bruto, pero la capa de snapshots UI queda desacoplada como archivos +en disco para simplificar inspeccion, servicio y depuracion. diff --git a/docs/historical-rcon-backfill.md b/docs/historical-rcon-backfill.md new file mode 100644 index 0000000..d5b48c9 --- /dev/null +++ b/docs/historical-rcon-backfill.md @@ -0,0 +1,57 @@ +# Historical RCON AdminLog Backfill + +The RCON/AdminLog backfill is an explicit operator command. It does not run on +backend startup or on web requests. + +Run it through the advanced worker image: + +```powershell +docker compose run --rm rcon-historical-worker python -m app.rcon_historical_backfill --ensure-recent-matches 100 --servers comunidad-hispana-01,comunidad-hispana-02 --dry-run +``` + +Before a real manual backfill, stop the writer services to avoid waiting on the +shared writer lock: + +```powershell +docker compose --profile advanced stop historical-runner rcon-historical-worker +``` + +Restart them afterwards: + +```powershell +docker compose --profile advanced up -d historical-runner rcon-historical-worker +``` + +Examples: + +```powershell +docker compose run --rm rcon-historical-worker python -m app.rcon_historical_backfill --ensure-recent-matches 100 --servers comunidad-hispana-01,comunidad-hispana-02 +docker compose run --rm rcon-historical-worker python -m app.rcon_historical_backfill --ensure-current-month --servers comunidad-hispana-01,comunidad-hispana-02 +docker compose run --rm rcon-historical-worker python -m app.rcon_historical_backfill --ensure-leaderboard-windows --servers comunidad-hispana-01,comunidad-hispana-02 +docker compose run --rm rcon-historical-worker python -m app.rcon_historical_backfill --ensure-recent-matches 100 --servers comunidad-hispana-01,comunidad-hispana-02 --chunk-hours 6 --sleep-seconds 1 --max-days-back 45 --regenerate-snapshots +``` + +Direct module examples: + +```powershell +python -m app.rcon_historical_backfill --from 2026-05-01 --to now --servers comunidad-hispana-01,comunidad-hispana-02 +python -m app.rcon_historical_backfill --ensure-recent-matches 100 --servers comunidad-hispana-01,comunidad-hispana-02 +python -m app.rcon_historical_backfill --ensure-current-month --servers comunidad-hispana-01,comunidad-hispana-02 +python -m app.rcon_historical_backfill --ensure-leaderboard-windows --servers comunidad-hispana-01,comunidad-hispana-02 +``` + +Useful configuration: + +- `HLL_RCON_BACKFILL_CHUNK_HOURS`, default `6` +- `HLL_RCON_BACKFILL_SLEEP_SECONDS`, default `1` +- `HLL_RCON_BACKFILL_MAX_DAYS_BACK`, default `45` +- `HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES`, for normal prospective worker capture only + +The command only selects `comunidad-hispana-01` and `comunidad-hispana-02` by +default. `comunidad-hispana-03` is not included unless it is configured in +`HLL_BACKEND_RCON_TARGETS` and explicitly passed with `--servers`. + +Monthly RCON leaderboards use the previous calendar month on days 1 through 7. +From day 8 onward they use the current calendar month. Weekly RCON leaderboards +use the current week only when the current week has enough closed materialized +matches; otherwise they fall back to the previous week. diff --git a/docs/monthly-mvp-ranking-scoring-design.md b/docs/monthly-mvp-ranking-scoring-design.md new file mode 100644 index 0000000..41f4099 --- /dev/null +++ b/docs/monthly-mvp-ranking-scoring-design.md @@ -0,0 +1,197 @@ +# Monthly MVP Ranking Scoring Design + +## Validation Date + +- 2026-03-24 + +## Objective + +Definir una formula V1 precisa y auditable para un ranking mensual de mejores +jugadores usando solo metricas ya persistidas y suficientemente fiables en el +repositorio. + +## Evidence Base + +This proposal is based on: + +- `docs/monthly-player-ranking-data-audit.md` +- `docs/historical-domain-model.md` +- `docs/historical-data-quality-notes.md` +- `backend/app/historical_models.py` +- `backend/app/historical_storage.py` +- `backend/app/payloads.py` + +The design assumes the existing monthly window already used by the backend: + +- UTC calendar month +- closed matches only +- fallback to the previous closed month only when the current month has no + closed matches at all + +## V1 Meaning Of "Best Player Of The Month" + +V1 should not mean "highest raw kills only" and should not pretend to measure +full tactical impact that the project does not persist yet. + +For this project, "monthly MVP" in V1 means: + +- sustained offensive contribution across the month +- meaningful team contribution through support +- good efficiency without rewarding one or two short outlier matches +- enough participation to make the result credible + +This is therefore a balanced MVP model with a light offensive bias. + +## Metrics Included In V1 + +Included metrics: + +- total kills +- total support +- total time played +- KPM derived from monthly totals +- KDA derived from monthly totals +- optional teamkill penalty +- matches played as an eligibility guard + +Derived metrics must be recomputed from monthly totals, not from the average of +per-match ratios: + +- `kpm = total_kills / max(total_time_minutes, 1)` +- `kda = total_kills / max(total_deaths, 1)` + +## Metrics Explicitly Out Of Scope For V1 + +Do not include in V1: + +- combat +- offense +- defense +- matches over 100 kills +- win/loss context +- weapons profile +- kill streaks or life-span fields +- duels, `most_killed`, `death_by` +- garrisons, OPs or tactical events not confirmed as persisted + +Reason: + +- some are useful but would complicate the first release without improving + reliability enough +- others are not persisted today or are not confirmed with stable semantics + +## Eligibility Rules + +A player is eligible for the monthly MVP ranking only if all conditions hold: + +- played at least `6` closed matches in the selected month and scope +- accumulated at least `21600` seconds (`6` hours) of play time in that month +- has non-null persisted stats for kills, deaths, support and time + +These gates are intentionally dual: + +- match count blocks one-match outliers +- time played blocks short-session inflation + +## Scope Recommendation + +V1 should be computed in both scopes from the same formula: + +- per server +- global aggregate using `all-servers` + +Publication recommendation: + +- default visible ranking: per server +- secondary comparable view: global aggregate + +Why: + +- per-server ranking is easier to interpret and fairer for each community shard +- the repository already supports the logical aggregate `all-servers` +- using one formula for both scopes avoids redesign later + +## Normalized Component Scores + +For each month and scope, first aggregate one row per eligible player. + +Then calculate these normalized component scores on a `0..100` scale: + +- `kills_score = 100 * ln(1 + total_kills) / ln(1 + max_total_kills_eligible)` +- `support_score = 100 * ln(1 + total_support) / ln(1 + max_total_support_eligible)` +- `kpm_score = 100 * ln(1 + kpm) / ln(1 + max_kpm_eligible)` +- `kda_score = 100 * ln(1 + kda) / ln(1 + max_kda_eligible)` +- `participation_score = 100 * min(1, total_time_seconds / 28800)` + +Implementation notes: + +- `ln(1 + x)` dampens extreme leaders without hiding real advantage +- participation reaches full score at `8` hours +- all `max_*_eligible` references are calculated inside the same month and scope + +## V1 Scoring Formula + +Recommended V1 monthly MVP score: + +`mvp_score = 0.35 * kills_score + 0.20 * support_score + 0.20 * kpm_score + 0.15 * kda_score + 0.10 * participation_score - teamkill_penalty` + +Weight rationale: + +- `35%` kills: offensive impact should matter most in a first public ranking +- `20%` support: keeps the model closer to MVP than to a pure frag ranking +- `20%` KPM: rewards productive time, not only volume +- `15%` KDA: rewards cleaner performance but keeps it below kills volume +- `10%` participation: favors sustained monthly presence without turning the + ranking into a pure grind chart + +## Teamkill Penalty + +Use a small optional penalty in V1: + +- `teamkill_penalty = min(6, total_teamkills * 0.5)` + +Effect: + +- `1` teamkill subtracts `0.5` +- `4` teamkills subtract `2` +- penalty caps at `6` + +This keeps the penalty visible without letting it dominate the ranking. + +## Tie-Break Rules + +If two players have the same `mvp_score`, resolve ties in this order: + +1. higher `participation_score` +2. higher `kills_score` +3. higher `support_score` +4. lower `total_teamkills` +5. alphabetical `display_name` +6. stable player key as final deterministic fallback + +## Why This V1 Is Reasonable + +This design is defendable for a first release because it: + +- uses only metrics already persisted with strong coverage +- recomputes efficiency from totals instead of averaging noisy per-match ratios +- blocks absurd winners from tiny samples with explicit eligibility gates +- stays interpretable enough to explain in product copy +- can be implemented from current monthly aggregates without new ingestion or + schema work + +## V2 Expansion Path + +V2 can extend the same structure without redesigning the whole ranking: + +- add combat, offense and defense as extra weighted components +- add win/loss context only where team scores are present and validated +- review whether teamkill penalty should become rate-based instead of absolute +- later add tactical metrics only after deliberate persistence work + +The important constraint for V2 is to preserve the same shape: + +- explicit eligibility +- normalized component scores +- weighted sum +- deterministic tie-breaks diff --git a/docs/monthly-mvp-v2-scoring-design.md b/docs/monthly-mvp-v2-scoring-design.md new file mode 100644 index 0000000..fb1ef68 --- /dev/null +++ b/docs/monthly-mvp-v2-scoring-design.md @@ -0,0 +1,243 @@ +# Monthly MVP V2 Scoring Design + +## Validation Date + +- 2026-03-24 + +## Objective + +Definir una formula V2 precisa, explicable e implementable para el MVP mensual +usando la base V1 ya aprobada y solo las senales avanzadas V2 que hoy tienen +soporte real en la repo. + +## Evidence Base + +This proposal is based on: + +- `docs/monthly-mvp-ranking-scoring-design.md` +- `docs/monthly-player-ranking-data-audit.md` +- `docs/player-event-pipeline-v2-design.md` +- `backend/app/monthly_mvp.py` +- `backend/app/player_event_aggregates.py` +- `backend/app/historical_snapshots.py` +- `backend/app/payloads.py` + +## Design Position + +V2 should not replace the V1 logic with a radically different opaque model. + +The correct direction is: + +- keep V1 as the stable baseline +- preserve the same monthly UTC window and closed-match policy +- add a small set of advanced event-derived signals with limited weight +- avoid weapon-type or kill-type complexity until the source is richer + +## Meaning Of MVP In V2 + +V2 still means "best monthly player", not "best fragger only". + +Compared with V1, V2 should reward: + +- sustained offensive output +- team contribution +- efficiency over the month +- cleaner player-vs-player control in repeated encounters +- better discipline through a stricter teamkill penalty + +## Signals Included In V2 + +V2 keeps these V1 signals: + +- total kills +- total support +- KPM recomputed from monthly totals +- KDA recomputed from monthly totals +- participation based on monthly time played +- monthly teamkills as penalty input + +V2 adds these advanced signals: + +- `most_killed` +- `death_by` +- net duel summaries + +These signals are used only as modest scoring components, not as the core of +the ranking. + +## Signals Explicitly Excluded From V2 Formula + +Do not score these yet: + +- weapon-type weighting +- kill-category weighting +- weapon variety bonus +- `death_by_weapons` +- combat, offense and defense +- win/loss context + +Reason: + +- the current CRCON-derived V2 layer is partial and summary-based +- weapon and type semantics are not robust enough for a serious weighted score +- adding too many low-confidence knobs would make V2 harder to defend than V1 + +Weapon kills remain useful for product readouts and future analysis, but not as +a weighted scoring factor in this phase. + +## Eligibility Rules + +Player eligibility for V2 should remain identical to V1: + +- at least `6` closed matches in the selected month and scope +- at least `21600` seconds (`6` hours) played in the selected month and scope +- non-null monthly totals for kills, deaths, support and time + +Additional publication gate for the ranking itself: + +- publish V2 only when the selected month and scope have matching player-event + coverage for that same `month_key` + +This avoids ranking a month with V1 totals but missing V2 event coverage. + +## Derived Advanced Metrics + +For each eligible player-month, derive: + +- `most_killed_count` + - kills against the player most often killed by this player in the month +- `death_by_count` + - deaths suffered from the player that killed this player most often in the + month +- `rivalry_edge_raw = max(0, most_killed_count - death_by_count)` +- `duel_control_raw` + - sum of positive `net_duel_value` across the player's top `3` duel pairs in + the selected month and scope + +Then normalize: + +- `rivalry_edge_score = 100 * ln(1 + rivalry_edge_raw) / ln(1 + max_rivalry_edge_raw_eligible)` +- `duel_control_score = 100 * ln(1 + duel_control_raw) / ln(1 + max_duel_control_raw_eligible)` + +## Small-Sample Treatment + +Advanced event signals should be damped on low-volume months. + +Use: + +- `advanced_confidence = min(1, total_kills / 35)` + +Effect: + +- under `35` kills, advanced components contribute only partially +- at `35+` kills, the full advanced weight is available + +This keeps V2 from overreacting to tiny rivalry samples. + +## Normalized Core Component Scores + +V2 keeps the same V1 normalization style on a `0..100` scale: + +- `kills_score = 100 * ln(1 + total_kills) / ln(1 + max_total_kills_eligible)` +- `support_score = 100 * ln(1 + total_support) / ln(1 + max_total_support_eligible)` +- `kpm_score = 100 * ln(1 + kpm) / ln(1 + max_kpm_eligible)` +- `kda_score = 100 * ln(1 + kda) / ln(1 + max_kda_eligible)` +- `participation_score = 100 * min(1, total_time_seconds / 28800)` + +## V2 Teamkill Penalty + +V2 should be slightly stricter than V1 on discipline. + +Use: + +- `teamkill_penalty_v2 = min(8, total_teamkills * 0.75)` + +Effect: + +- `1` teamkill subtracts `0.75` +- `4` teamkills subtract `3` +- penalty caps at `8` + +## V2 Scoring Formula + +Recommended V2 monthly MVP score: + +`mvp_v2_score = 0.30 * kills_score + 0.18 * support_score + 0.18 * kpm_score + 0.12 * kda_score + 0.10 * participation_score + advanced_confidence * (0.07 * rivalry_edge_score + 0.05 * duel_control_score) - teamkill_penalty_v2` + +Weight rationale: + +- `30%` kills keeps offense as the main visible driver +- `18%` support preserves MVP rather than pure frag logic +- `18%` KPM rewards productive time +- `12%` KDA rewards cleaner performance without dominating the table +- `10%` participation keeps monthly presence relevant +- `7%` rivalry edge rewards players who repeatedly finish ahead in their + strongest recurring encounter +- `5%` duel control adds a second advanced signal but keeps it clearly bounded + +## Why Weapon Kills Are Not Weighted Yet + +The repository can already expose kills by weapon, but the current source layer: + +- is summary-based, not a full raw kill feed +- does not yet prove a stable weapon taxonomy for competitive weighting +- would invite fragile distinctions such as tank vs infantry vs artillery too + early + +Decision: + +- do not weight kills by weapon in V2 +- do not assign bonus or penalty by weapon type +- keep weapon-kill outputs as audit and UI-facing data only + +## Tie-Break Rules + +If two players have the same `mvp_v2_score`, resolve ties in this order: + +1. higher `advanced_confidence` +2. higher `participation_score` +3. higher `kills_score` +4. higher `rivalry_edge_score` +5. lower `total_teamkills` +6. alphabetical `display_name` +7. stable player key as final deterministic fallback + +## Coexistence With V1 + +V1 and V2 should coexist explicitly: + +- `V1` remains the stable default ranking +- `V2` is a separate ranking version with its own `ranking_version` +- both versions should use the same month and scope selectors +- V2 should never overwrite or reinterpret the V1 payload contract + +## Implementation Guidance For Next Task + +The backend task should compute V2 from: + +- the same monthly player totals already used by V1 +- direct player-event monthly aggregates derived from the raw ledger + +Required per-player V2 outputs: + +- `mvp_v2_score` +- `advanced_confidence` +- `rivalry_edge_raw` +- `duel_control_raw` +- `component_scores` +- `teamkill_penalty_v2` + +Recommended `ranking_version`: + +- `v2` + +## Final Recommendation + +The correct V2 for the current repository is an incremental evolution of V1: + +- keep the same explainable weighted-score structure +- add only `most_killed` / `death_by` / duel-derived pressure signals +- make discipline stricter +- refuse weapon-type weighting until the signal quality improves + +This yields a V2 that is materially richer than V1 without becoming speculative. diff --git a/docs/monthly-player-ranking-data-audit.md b/docs/monthly-player-ranking-data-audit.md new file mode 100644 index 0000000..b39b8d6 --- /dev/null +++ b/docs/monthly-player-ranking-data-audit.md @@ -0,0 +1,246 @@ +# Monthly Player Ranking Data Audit + +## Validation Date + +- 2026-03-24 + +## Scope + +Auditoria tecnica del estado real de datos para un futuro ranking mensual de +"mejores jugadores" usando: + +- codigo y esquema historico del backend +- persistencia local en `backend/data/hll_vietnam_dev.sqlite3` +- snapshots historicos ya generados en `backend/data/snapshots/` +- discovery ya documentada de la fuente CRCON/scoreboard + +No se implementa todavia ninguna formula de ranking, tabla nueva ni cambio de +UI. + +## Evidence Reviewed + +- `backend/app/historical_models.py` +- `backend/app/historical_storage.py` +- `backend/app/historical_ingestion.py` +- `backend/app/historical_snapshots.py` +- `backend/app/historical_snapshot_storage.py` +- `backend/app/payloads.py` +- `docs/historical-domain-model.md` +- `docs/historical-data-quality-notes.md` +- `docs/historical-crcon-source-discovery.md` +- `docs/historical-coverage-report.md` + +## Current Persisted State + +Local SQLite currently contains: + +- `historical_servers`: `3` +- `historical_matches`: `9638` +- `historical_players`: `163506` +- `historical_player_match_stats`: `1062244` +- `historical_ingestion_runs`: `32` + +Coverage visible in the local database today: + +- `comunidad-hispana-01`: `8602` matches, from `2024-05-17T20:48:40Z` to `2026-03-23T16:01:20Z` +- `comunidad-hispana-02`: `753` matches, from `2025-11-04T17:10:19Z` to `2026-03-23T18:58:06Z` +- `comunidad-hispana-03`: `283` matches, from `2026-01-14T22:34:18Z` to `2026-03-08T18:11:52Z` + +Important quality notes from the local dataset: + +- all `historical_player_match_stats` rows have populated values for kills, + deaths, teamkills, time, KPM, KDA, combat, offense, defense, support, level + and team side +- `85,270 / 163,506` players have SteamID; the rest currently depend on + `crcon-player:*` identity, so identity continuity is usable but not equally + strong for every player +- all persisted matches have start/end timestamps, map and game mode +- `7,961 / 9,638` persisted matches currently have both allied/axis score + +## What Is Persisted Today + +### Match level + +Persisted per match: + +- server +- external match id +- creation/start/end timestamps +- map name, pretty name, game mode, image +- allied score +- axis score + +Not persisted at match level: + +- raw full CRCON JSON payload +- derived win/loss per player +- any tactical event ledger + +### Player identity level + +Persisted per player: + +- stable player key +- display name +- SteamID when available +- source player id +- first seen / last seen + +### Player per match level + +Persisted per player-match row: + +- level +- team side +- kills +- deaths +- teamkills +- time seconds +- kills per minute +- deaths per minute +- kill/death ratio +- combat +- offense +- defense +- support + +## What Exists In CRCON Source But Is Not Persisted + +The documented CRCON detail payload already exposes fields that the project does +not currently store: + +- `kills_by_type` +- `kills_streak` +- `longest_life_secs` +- `shortest_life_secs` +- `most_killed` +- `death_by` +- `weapons` +- `death_by_weapons` + +These fields are visible in the source discovery, but the current upsert logic +only persists the smaller normalized subset listed above. + +## What Was Not Confirmed As Available + +The current repository evidence does not confirm any stable source fields for: + +- garrisons destroyed +- outposts destroyed +- direct duel history in a structured reusable form +- tactical actions such as node building, dismantling or commander abilities + +For direct encounters, the source does expose `most_killed` and `death_by`, but +that is not the same thing as a complete duel graph and is not stored today. + +## Availability And Reliability Matrix + +| Metric / signal | Exists in source | Persisted today | Reliability for ranking | Extra work | V1? | +| --- | --- | --- | --- | --- | --- | +| Kills | Yes | Yes | High | None | Yes | +| Deaths | Yes | Yes | High | None | Yes | +| Support | Yes | Yes | High | None | Yes | +| Combat | Yes | Yes | Medium-High | Query only | Maybe | +| Offense | Yes | Yes | Medium-High | Query only | Maybe | +| Defense | Yes | Yes | Medium-High | Query only | Maybe | +| Teamkills | Yes | Yes | High as penalty signal | Query only | Maybe | +| Match count | Yes | Derivable | High | Query only | Yes | +| Time played | Yes | Yes | High | Query only | Yes | +| KPM | Yes | Yes | Medium-High if computed from totals, lower if averaging raw per-match KPM | Query only | Yes | +| KDA / KD ratio | Yes | Yes | Medium-High if computed from totals, lower if averaging raw per-match KDA | Query only | Yes | +| 100+ kill matches | Derivable | Exposed in leaderboard | Medium | None | No | +| Win/loss context | Partially | Derivable from team side + scores when scores exist | Medium | Query and validation | Maybe | +| Weapons profile | Yes | No | Medium-Low for V1 | New persistence/modeling | No | +| Kill streak / life metrics | Yes | No | Medium-Low for V1 | New persistence/modeling | No | +| Direct encounters / duels | Partial only | No | Low today | New extraction plus modeling | No | +| Garrisons destroyed | Not confirmed | No | Unknown | Source validation first | No | +| OPs destroyed | Not confirmed | No | Unknown | Source validation first | No | +| Tactical impact composite | Partial proxies only | Partial | Medium after design work | Query/design | No for strict V1 | + +## Current Product Readiness + +The backend is already able to expose monthly leaderboard snapshots, but only +for these metrics: + +- `kills` +- `deaths` +- `support` +- `matches_over_100_kills` + +This means: + +- the project already supports a monthly ranking surface operationally +- the current ranking surface is narrower than the real data persisted in SQLite +- offense, defense, combat, KPM and KDA are available in the database but not + yet wired as first-class monthly leaderboard metrics + +## Recommendation For Ranking V1 + +A realistic V1 should use only metrics already persisted with strong coverage +and low modeling risk: + +- total kills +- total support +- KPM recomputed from `SUM(kills) / SUM(time_seconds)` +- KDA recomputed from `SUM(kills) / NULLIF(SUM(deaths), 0)` +- minimum participation gate based on matches played and/or minutes played +- optional small penalty for teamkills + +Why this is the safest V1: + +- no new ingestion is required +- all needed raw fields already exist locally +- the ranking can avoid inflated outliers by requiring minimum activity +- KPM and KDA become more defensible when derived from totals, not from average + of precomputed per-match ratios + +## Recommendation For Ranking V2 + +A stronger V2 can expand the model with already persisted but not yet surfaced +signals: + +- offense +- defense +- combat +- win/loss context derived from player side and match result when scores exist + +V2 may also evaluate source-only fields if a later task decides to persist them: + +- weapons-based detail +- kill streak and life-span signals +- partial rivalry/encounter signals from `most_killed` and `death_by` + +## Metrics Not Recommended For Early Use + +Not recommended for V1 and not yet defensible for a serious monthly ranking: + +- garrisons destroyed +- OPs destroyed +- duel ranking +- generic "impact in match" as a single opaque score + +Reason: + +- either the source availability is not confirmed +- or the source exists but the project does not yet persist enough structure to + make the metric auditable and stable + +## Final Conclusion + +The repository already has enough persisted historical data for a credible +monthly Top 3 V1 without touching ingestion: + +- kills +- support +- time played +- deaths +- teamkills +- offense +- defense +- combat + +The most realistic first release is a constrained monthly ranking based on +volume plus efficiency, using only persisted fields and explicit participation +thresholds. Tactical metrics such as garrisons, OPs and real duel graphs should +stay out of scope until the source is revalidated and the missing structures are +persisted deliberately. diff --git a/docs/player-event-pipeline-v2-design.md b/docs/player-event-pipeline-v2-design.md new file mode 100644 index 0000000..c5b63d0 --- /dev/null +++ b/docs/player-event-pipeline-v2-design.md @@ -0,0 +1,340 @@ +# Player Event Pipeline V2 Design + +## Validation Date + +- 2026-03-24 + +## Scope + +Diseno tecnico minimo de una futura canalizacion de eventos de jugador para +alimentar una V2 del ranking MVP mensual con metricas avanzadas. + +Fuera de alcance: + +- implementacion real del pipeline +- nuevas tablas o migraciones +- nuevos endpoints +- cambios de UI + +## Inputs Reviewed + +- `docs/rcon-data-capability-audit.md` +- `docs/crcon-advanced-metrics-origin-audit.md` +- `docs/monthly-mvp-ranking-scoring-design.md` +- `backend/README.md` +- `backend/app/historical_models.py` +- `backend/app/historical_storage.py` +- `backend/app/rcon_client.py` + +## Problem Statement + +La capa historica actual persiste bien metricas agregadas por jugador y partida: + +- kills +- deaths +- teamkills +- tiempo +- combat +- offense +- defense +- support + +Eso basta para un MVP V1. No basta para una V2 que quiera exponer o puntuar: + +- killer -> victim +- `most_killed` +- `death_by` +- kills por arma +- `kills_by_type` +- `death_by_weapons` +- distincion infantry / tank / artillery + +La auditoria previa deja claro que esas senales no salen del RCON live minimo +implementado hoy. + +## Design Goals + +La V2 necesita una capa nueva con estos objetivos: + +- capturar eventos finos fuera del request path HTTP +- persistir eventos crudos con identidad suficiente para reprocess y auditoria +- derivar agregados reproducibles por partida, jugador y mes +- convivir con el modelo historico actual sin romper V1 + +## Minimal Architecture + +La arquitectura minima propuesta tiene cuatro capas: + +1. Source adapter + +- un adaptador futuro conectado a eventos, logs o feed equivalente +- responsable solo de leer senales crudas y normalizarlas + +2. Event ingestion worker + +- proceso batch o loop dedicado, separado de `app.main` +- valida, deduplica y persiste eventos crudos +- nunca calcula ranking dentro del request HTTP + +3. Raw event storage + +- ledger append-only por evento +- base de auditoria y reproceso + +4. Derived aggregates + +- jobs posteriores que resumen por partida, jugador y ventana mensual +- capa consumible por un futuro MVP V2 + +## Proposed Flow + +Flujo minimo: + +1. El source adapter recibe un evento crudo del servidor o del origen elegido. +2. El worker normaliza el evento a un contrato comun. +3. El evento se persiste en un ledger crudo con claves de deduplicacion. +4. Un agregador por partida resume los eventos cerrados del match. +5. Un agregador mensual construye metricas V2 por jugador y servidor. +6. El ranking MVP V2 consume solo agregados ya cerrados y auditables. + +## Minimum Event Types + +El subconjunto minimo recomendado es: + +- `player_kill` +- `player_death` +- `player_teamkill` +- `player_weapon_usage` + +En la practica, `player_death` y `player_weapon_usage` pueden modelarse como +parte del mismo evento de kill si la fuente trae toda la informacion en un solo +registro. Aun asi, conceptualmente la V2 debe capturar estas piezas: + +- match id o match scope +- timestamp del evento +- server slug +- killer player key +- victim player key +- killer team +- victim team +- weapon id o nombre de arma +- kill type o damage type +- contexto opcional de vehiculo, artilleria o explosivo +- indicador de teamkill + +## Event Contract Proposal + +Contrato minimo recomendado para un evento normalizado: + +- `event_id` +- `event_type` +- `occurred_at` +- `server_slug` +- `external_match_id` +- `source_kind` +- `source_ref` +- `killer_player_key` +- `victim_player_key` +- `killer_team_side` +- `victim_team_side` +- `weapon_name` +- `weapon_category` +- `kill_category` +- `is_teamkill` +- `raw_event_ref` + +Campos opcionales pero utiles desde el inicio: + +- `killer_display_name` +- `victim_display_name` +- `vehicle_name` +- `explosive_name` +- `match_phase` + +## High-Level Persistence Model + +No se crean tablas en esta task, pero la persistencia minima deberia separar: + +### 1. Raw player event ledger + +Rol: + +- guardar cada evento de forma append-only +- permitir reprocess y auditoria + +Campos minimos: + +- event key estable +- server +- match +- timestamp +- tipo de evento +- actor y target +- arma o categoria +- flags de clasificacion + +### 2. Match event aggregates + +Rol: + +- resumir por partida ya cerrada +- evitar recalcular toda la historia cada vez + +Ejemplos de campos: + +- kills por jugador +- deaths por jugador +- teamkills por jugador +- kills por arma +- kills por categoria +- tabla de killer -> victim mas frecuente +- tabla de victim <- killer mas frecuente + +### 3. Monthly player advanced aggregates + +Rol: + +- dejar lista la capa consumible por el ranking V2 + +Ejemplos de campos: + +- total kills por arma +- total kills por categoria +- rival mas matado (`most_killed`) +- rival que mas le mata (`death_by`) +- teamkills mensuales +- pesos o subscores avanzados V2 + +## Relationship With Current Historical Model + +La propuesta no sustituye el modelo existente `historical_player_match_stats`. + +Relacion recomendada: + +- `historical_*` sigue guardando el resumen estable V1 por partida +- el nuevo ledger de eventos guarda granularidad que hoy no existe +- los agregados V2 se derivan del ledger y se pueden unir despues al dominio + mensual existente + +Esto evita dos errores: + +- inflar `historical_player_match_stats` con JSON opaco o columnas prematuras +- mezclar captura cruda y vistas derivadas en la misma tabla + +## How Advanced Metrics Would Be Derived + +### `most_killed` + +Derivacion: + +- agrupar eventos de kill por `killer_player_key` y `victim_player_key` +- contar ocurrencias +- elegir el victim con mayor conteo por jugador y ventana + +### `death_by` + +Derivacion: + +- agrupar eventos de kill por `victim_player_key` y `killer_player_key` +- contar ocurrencias +- elegir el killer con mayor conteo recibido por jugador y ventana + +### Kills por arma + +Derivacion: + +- agrupar kills por `killer_player_key` y `weapon_name` + +### `kills_by_type` + +Derivacion: + +- clasificar cada kill en una categoria normalizada +- agrupar por `killer_player_key` y `kill_category` + +Categorias minimas sugeridas: + +- `infantry` +- `vehicle` +- `artillery` +- `explosive` +- `unknown` + +### `death_by_weapons` + +Derivacion: + +- agrupar eventos por `victim_player_key` y `weapon_name` + +### Distincion infantry / tank / artillery + +Solo es viable si el origen del evento trae senales suficientes para clasificar: + +- arma +- vehiculo +- damage type +- o una taxonomia equivalente ya normalizada + +Si la fuente no trae esa precision, la categoria debe quedarse en `unknown` y +la V2 no debe inventar inferencias fragiles. + +## Recommended Processing Policy + +Politica minima recomendada: + +- ingesta continua o semi-continua fuera de HTTP +- deduplicacion por `event_id` o hash determinista +- agregacion solo sobre partidas cerradas +- recomputacion idempotente por partida y por ventana mensual +- capacidad de rehidratar agregados desde el ledger crudo + +## Integration Point With Monthly MVP V2 + +La V2 mensual deberia seguir la misma forma general de la V1: + +- elegibilidad explicita +- componentes normalizados +- pesos auditables +- tie-breaks deterministas + +La diferencia es la entrada: + +- V1 consume agregados simples ya persistidos por partida +- V2 consumiria un agregado mensual enriquecido derivado del ledger de eventos + +Componentes plausibles para una futura V2: + +- kills totales +- support total +- eficiencia base +- variedad o efectividad por arma +- penalizacion por teamkills +- subscore de encounters a partir de killer/victim +- categoria de impacto por tipo de kill si la fuente lo soporta + +## Minimal Rollout Path + +Secuencia minima recomendada para futuras tasks: + +1. Validar la fuente real de eventos/logs reutilizable. +2. Definir el contrato normalizado del evento. +3. Implementar ledger crudo con deduplicacion. +4. Implementar agregador por partida cerrada. +5. Implementar agregado mensual avanzado por jugador. +6. Disenar formula MVP V2 sobre esos agregados. +7. Exponer lectura API solo cuando los agregados sean estables. + +## Main Risks + +- la fuente real puede no exponer todas las senales necesarias +- clasificar kills por tipo puede requerir un mapa de armas adicional +- sin deduplicacion robusta, los agregados V2 serian poco fiables +- mezclar eventos abiertos con partidas no cerradas inflaria metricas + +## Final Recommendation + +La arquitectura minima correcta para una V2 no es ampliar el snapshot live ni +sobrecargar `historical_player_match_stats`. Es anadir una capa separada de +eventos crudos, con agregacion derivada por partida y por mes, para producir de +forma auditable metricas como `most_killed`, `death_by`, kills por arma y +clasificaciones avanzadas. diff --git a/docs/project-overview.md b/docs/project-overview.md new file mode 100644 index 0000000..5b2ee7e --- /dev/null +++ b/docs/project-overview.md @@ -0,0 +1,42 @@ +# Project Overview + +## Vision del proyecto + +HLL Vietnam busca convertirse en la base de una web de comunidad para centralizar la presencia digital de una comunidad hispana alrededor del juego, con una identidad visual sobria, tactica y coherente con el universo Vietnam. + +## Objetivo inicial + +Publicar una landing simple que permita presentar la comunidad, mostrar el trailer del proyecto y facilitar el acceso directo al servidor de Discord. + +## Alcance actual + +- Estructura inicial del repositorio. +- Landing estatica en HTML, CSS y JavaScript. +- Documentacion base para organizar el crecimiento del proyecto. +- Preparacion de carpetas para backend y orquestacion futura. +- Plataforma de tasks y orquestacion integrada para coordinar trabajo tecnico. + +## Stack actual + +- HTML +- CSS +- JavaScript +- Git para control de versiones + +## Stack futuro previsto + +- Backend principal en Python +- Integraciones de comunidad y automatizacion +- Posible ampliacion de paneles administrativos y servicios internos + +## Contrato inicial frontend backend + +El repositorio define un contrato API inicial en `docs/frontend-backend-contract.md` para alinear la futura comunicacion entre la landing y el backend Python. + +En esta fase solo existe `GET /health` como endpoint implementado. Las rutas de comunidad, trailer, Discord y servidores quedan documentadas como contrato previsto para futuras tasks sin cambiar todavia el comportamiento visible del frontend. + +## Evolucion prevista del frontend + +La landing debe seguir siendo funcional al abrirse directamente en navegador mientras los datos dinamicos se introducen de forma incremental. La estrategia de consumo prevista usa `fetch` y JavaScript simple cuando una task lo requiera, siempre conservando fallbacks estaticos mientras se valida cada endpoint. + +La planificacion detallada de prioridades de consumo, estados de carga, errores y placeholders queda en `docs/frontend-data-consumption-plan.md`. diff --git a/docs/rcon-data-capability-audit.md b/docs/rcon-data-capability-audit.md new file mode 100644 index 0000000..4b115d9 --- /dev/null +++ b/docs/rcon-data-capability-audit.md @@ -0,0 +1,191 @@ +# RCON Data Capability Audit + +## Validation Date + +- 2026-03-24 + +## Scope + +Auditoria tecnica del alcance real de RCON en esta repo, separando con claridad: + +- RCON directo implementado hoy en el backend +- historico CRCON / scoreboard publico +- metricas que solo serian posibles con captura propia de eventos o logs + +No se implementa ninguna tabla, ruta, scoring ni captura adicional. + +## Evidence Reviewed + +- `backend/app/rcon_client.py` +- `backend/app/providers/rcon_provider.py` +- `backend/app/data_sources.py` +- `backend/app/payloads.py` +- `backend/README.md` +- `docs/historical-crcon-source-discovery.md` +- `docs/monthly-player-ranking-data-audit.md` +- `docs/monthly-mvp-ranking-scoring-design.md` + +## Current RCON Surface In This Repository + +La implementacion RCON actual es minima y solo cubre estado live. + +Capacidades confirmadas en codigo hoy: + +- handshake `ServerConnect` +- autenticacion `Login` +- consulta `GetServerInformation` + +No hay evidencia en la repo de otros comandos RCON ya integrados para: + +- eventos de kill +- detalle por arma +- relaciones killer -> victim +- teamkills por evento +- destruccion de garrisons u OPs +- historico de partidas cerradas + +## What The Current Live Provider Exposes Today + +El proveedor `RconLiveDataSource` solo normaliza estos campos para `/api/servers`: + +- `external_server_id` +- `server_name` +- `status` +- `players` +- `max_players` +- `current_map` +- `region` +- `source_name` +- `snapshot_origin` +- `source_ref` + +Esto significa que RCON directo hoy ya alimenta de forma confirmada: + +- disponibilidad online del servidor +- nombre del servidor +- numero de jugadores actual +- capacidad maxima +- mapa actual +- metadata de procedencia del snapshot + +## Data That Is Not Exposed By Direct RCON Today + +Aunque la task pide revisar equipos, scoreboard actual y otros campos live, la +repo no confirma que el proveedor actual los este devolviendo hoy. + +No quedan expuestos en el snapshot live actual: + +- composicion por equipos +- scoreboard de jugadores en tiempo real +- kills por jugador en la partida en curso +- deaths por jugador en la partida en curso +- support/combat/offense/defense live +- teamkills live + +Importante: + +- esto no prueba que el protocolo HLL RCON no pueda ofrecer mas cosas +- solo prueba que la implementacion actual de esta repo no las consulta ni las + serializa + +## Historical Boundary + +La separacion entre live e historico queda clara en la repo: + +- `get_live_data_source()` puede resolver `rcon` +- `get_historical_data_source()` devuelve un placeholder `RconHistoricalDataSource` +- ese proveedor historico lanza `RuntimeError("Historical RCON provider is not implemented yet.")` + +Conclusion operativa: + +- RCON esta operativo hoy para estado live de `/api/servers` +- RCON no esta operativo hoy para ingesta historica +- el historico reutilizable del proyecto sigue viniendo de CRCON / scoreboard publico + +## Capability Matrix For Future MVP V2 Metrics + +| Metrica / senal | RCON directo hoy en esta repo | Requeriria eventos/logs + persistencia | Estado actual | +| --- | --- | --- | --- | +| Estado del servidor | Si | No | Disponible | +| Jugadores actuales totales | Si | No | Disponible | +| Capacidad maxima | Si | No | Disponible | +| Mapa actual | Si | No | Disponible | +| Equipos live | No confirmado | Posiblemente no, depende de ampliar cliente | No expuesto hoy | +| Scoreboard live por jugador | No confirmado | Posiblemente no, depende de ampliar cliente | No expuesto hoy | +| Kills por arma | No | Si | Requiere pipeline nuevo | +| Distincion artillery / tank / infantry | No | Si | Requiere pipeline nuevo | +| Killer -> victim | No | Si | Requiere pipeline nuevo | +| `most_killed` | No | Si | Requiere pipeline nuevo | +| `death_by` | No | Si | Requiere pipeline nuevo | +| Teamkills por evento | No | Si | Requiere pipeline nuevo | +| Teamkills agregados por partida/mes | No desde RCON actual | Si | Requiere pipeline nuevo | +| Garrisons destruidos | No confirmado | Si como minimo | No confirmado | +| OPs destruidos | No confirmado | Si como minimo | No confirmado | +| Otras metricas tacticas finas | No | Si | Requiere pipeline nuevo | +| Partidas cerradas historicas | No | Si | No disponible hoy via RCON | + +## What Can Feed An MVP V2 From RCON + +Subset viable usando solo RCON directo ya implementado: + +- ninguno de los componentes avanzados de scoring MVP +- solo datos de presencia live del servidor, utiles para panel operativo pero no + para ranking mensual + +Subset viable si se amplia solo el cliente RCON pero sin pipeline historico: + +- quiza mas detalle live si el protocolo ofrece comandos adicionales +- aun asi no bastaria para un ranking mensual auditable, porque faltaria + persistencia por evento o por partida cerrada + +Subset viable si se construye una linea nueva de eventos/logs RCON: + +- kills por arma +- killer/victim +- teamkills por evento +- clasificacion artillery/tank/infantry +- senales tacticas si el origen real las emite + +Condiciones minimas para que eso sirva a un MVP V2: + +- ampliar el cliente RCON con comandos o feeds adicionales reales +- capturar eventos de forma continua fuera del request path HTTP +- persistir historico propio por partida, jugador y evento +- definir agregados reproducibles para mes y servidor + +## Separation From CRCON / Public Scoreboard + +La repo ya confirma que ciertas metricas avanzadas existen en CRCON publico, +pero eso no debe confundirse con RCON directo. + +La evidencia actual de CRCON/scoreboard publico incluye campos como: + +- `kills_by_type` +- `most_killed` +- `death_by` +- `weapons` +- `death_by_weapons` + +Eso pertenece al historico JSON publico ya documentado y no a la superficie +RCON hoy implementada en `rcon_client.py`. + +## Practical Conclusion + +Para esta repo, la respuesta precisa hoy es: + +- RCON directo sirve para estado live de servidores +- RCON directo no sirve todavia para alimentar un MVP mensual V2 +- cualquier MVP V2 con armas, duelos, teamkills por evento o tacticas requiere + una canalizacion nueva de eventos/logs y persistencia historica propia +- garrisons y OPs siguen sin evidencia confirmada en la repo como metrica + disponible por RCON + +## Recommended Next Step + +Antes de disenar scoring V2 sobre RCON, la siguiente decision tecnica correcta +seria una task separada de discovery para definir: + +- si el origen RCON real del servidor expone mas comandos aparte de `GetServerInformation` +- si existe flujo de eventos reutilizable +- que granularidad y frecuencia tendria la persistencia de esos eventos +- que subset minimo merece convertirse en modelo historico propio diff --git a/docs/rcon-historical-ingestion-design.md b/docs/rcon-historical-ingestion-design.md new file mode 100644 index 0000000..9992c53 --- /dev/null +++ b/docs/rcon-historical-ingestion-design.md @@ -0,0 +1,271 @@ +# RCON Historical Ingestion Design + +## Validation Date + +- 2026-03-25 + +## Scope + +Definir si la repo puede soportar historico por RCON con la implementacion +actual y, si no puede hacerlo de forma retroactiva, dejar una arquitectura +minima y defendible para una primera captura prospectiva. + +Este documento se limita a la evidencia local de la repo. No asume comandos +RCON no integrados ni capacidades externas no demostradas aqui. + +## Evidence Reviewed + +- `backend/app/data_sources.py` +- `backend/app/providers/rcon_provider.py` +- `backend/app/rcon_client.py` +- `backend/app/historical_ingestion.py` +- `backend/app/historical_storage.py` +- `backend/app/player_event_worker.py` +- `backend/app/player_event_storage.py` +- `backend/README.md` +- `docs/rcon-data-capability-audit.md` +- `docs/crcon-advanced-metrics-origin-audit.md` + +## Current State In Code + +La separacion entre live e historico ya existe en la seleccion de proveedores: + +- `get_live_data_source()` puede resolver `rcon` +- `get_historical_data_source()` puede resolver `rcon`, pero hoy devuelve un + placeholder que falla + +La implementacion RCON real disponible en la repo es minima y esta concentrada +en `backend/app/rcon_client.py`. + +Comandos soportados hoy en codigo: + +- `ServerConnect` +- `Login` +- `GetServerInformation` + +No hay evidencia local de otros comandos ya integrados para: + +- scoreboards por jugador +- detalle de partida cerrada +- eventos kill por kill +- logs tacticos +- historico retroactivo de matches cerrados + +## Payload Available Today + +La salida efectiva que la repo consume desde RCON hoy es la normalizada por +`query_live_server_state()`: + +- `external_server_id` +- `server_name` +- `status` +- `players` +- `max_players` +- `current_map` +- `region` +- `source_name` +- `snapshot_origin` +- `source_ref` + +Inferencia basada en `rcon_client.py`: + +- el payload remoto de `GetServerInformation` contiene como minimo + `serverName`, `playerCount`, `maxPlayerCount` y `mapId` o `mapName` +- la repo no persiste hoy el payload crudo ni deriva entidades historicas de + match o jugador a partir de RCON + +## Operational Frequency Assessment + +Inferencia basada en la implementacion actual: + +- cada pasada por target abre una conexion TCP +- realiza handshake `ServerConnect` +- autentica con `Login` +- ejecuta una consulta `GetServerInformation` + +Con este alcance, una frecuencia inicial razonable para captura prospectiva es: + +- cada `60` a `300` segundos para operativa normal +- `30` segundos solo como validacion o monitoreo puntual + +No hay evidencia en la repo para defender un polling mas agresivo de forma +sostenida ni para asegurar que aportaria historico competitivo util. + +## Viability Decision + +Conclusion principal: + +- no viable hoy para historico real retroactivo de partidas cerradas con el + cliente actual +- viable solo para captura prospectiva + +Motivos: + +- el cliente actual solo consulta estado live puntual +- no existe base local para reconstruir partidas ya cerradas +- no existe feed raw de eventos ni logs persistidos +- `RconHistoricalDataSource` sigue siendo un placeholder y no puede sustituir a + `public-scoreboard` + +Conclusion secundaria: + +- una capa historica parcial por RCON si es defendible, pero solo si se define + como captura prospectiva de muestras live y no como backfill de matches ya + perdidos + +## Recommended Minimal Architecture + +### Storage + +Separar completamente la persistencia prospectiva RCON del historico actual +`historical_*`. + +Tablas minimas recomendadas: + +- `rcon_historical_targets` + - identidad estable del target configurado + - ultimo estado conocido de configuracion +- `rcon_historical_capture_runs` + - una fila por ejecucion del worker + - estado, inicio, fin, errores y target scope +- `rcon_historical_samples` + - una fila por muestra y target + - `captured_at` + - identidad de target + - payload normalizado + - payload crudo opcional de `GetServerInformation` + +Si se quiere checkpoint explicito desde la primera version: + +- `rcon_historical_checkpoints` + - `target_key` + - `last_successful_capture_at` + - `last_sample_at` + - `last_error` + +### Workers + +Worker dedicado fuera del request path HTTP: + +- `python -m app.rcon_historical_worker capture` +- `python -m app.rcon_historical_worker loop --interval 120` + +Responsabilidades: + +- cargar `HLL_BACKEND_RCON_TARGETS` +- consultar cada target con el cliente RCON actual +- persistir run tracking +- persistir muestras idempotentes por target y timestamp +- actualizar checkpoints + +### Checkpoints + +Como no existe backfill retroactivo real, el checkpoint no debe modelarse como +pagina o offset de archivo historico. Debe modelarse como tiempo de captura. + +Checkpoint minimo defendible: + +- ultimo `captured_at` exitoso por target +- ultimo error por target +- ultimo run exitoso global + +### Compatibility With `public-scoreboard` + +Politica recomendada: + +- `public-scoreboard` sigue siendo la fuente historica principal para: + - leaderboards semanales y mensuales + - MVP V1 y V2 + - recent matches cerrados + - player events derivados de la capa publica actual +- RCON prospectivo convive en una linea paralela para: + - cobertura temporal hacia delante + - disponibilidad del servidor + - actividad reciente + - trazabilidad de frescura por target + +## Recommended Degradation Policy + +Si en una fase posterior se habilita `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon`, +la degradacion minima correcta es esta: + +- solo exponer endpoints o bloques claramente soportados por la persistencia + prospectiva RCON +- no simular leaderboards completos cuando no existan +- devolver metadata de cobertura y frescura antes que rankings vacios + +Contratos defendibles en una primera lectura minima: + +- resumen de cobertura por servidor +- actividad reciente por servidor +- estado de frescura +- rango temporal disponible + +Contratos que deben seguir dependiendo de `public-scoreboard` hasta nueva +evidencia: + +- weekly leaderboards completos +- monthly leaderboards completos +- monthly MVP V1 +- monthly MVP V2 +- player profiles competitivos +- equivalencia completa con `historico.html` + +## Recommended Phases + +### Phase 1: Prospective Capture + +Objetivo: + +- empezar a guardar muestras live RCON hacia delante + +Incluye: + +- storage separado +- worker dedicado +- run tracking +- checkpoints temporales +- ejecucion manual y en loop + +No incluye: + +- backfill retroactivo +- paridad con `public-scoreboard` +- endpoints competitivos nuevos + +### Phase 2: Minimal Operational Read Model + +Objetivo: + +- leer la persistencia prospectiva RCON sin consultar RCON on-demand en HTTP + +Incluye: + +- resumen por servidor +- ultima muestra +- cobertura disponible +- actividad reciente + +### Phase 3: Competitive Metrics Only If Signal Improves + +Objetivo: + +- evaluar si aparecen comandos, eventos o logs suficientes para enriquecer la + capa historica RCON + +Solo deberia abrirse si existe evidencia real de: + +- eventos reutilizables +- scoreboards historificables +- granularidad por jugador o por encounter + +## Final Recommendation + +La decision tecnica correcta para esta repo es: + +- mantener `public-scoreboard` como fuente historica por defecto +- tratar RCON historico como una linea prospectiva separada +- no prometer reconstruccion retroactiva con el cliente actual +- abrir implementacion incremental en dos tasks: + - captura prospectiva persistida + - lectura minima sobre persistencia local diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..77277b7 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,35 @@ +# Roadmap + +## Fase 1: base del repo + +- Crear estructura inicial profesional. +- Definir documentacion base del proyecto. +- Publicar la primera landing estatica. + +## Fase 2: landing mejorada + +- Incorporar branding definitivo y recursos visuales. +- Anadir mas secciones informativas de comunidad. +- Mejorar experiencia responsive y contenido. + +## Fase 3: backend Python + +- Definir arquitectura del backend. +- Incorporar servicios base en Python. +- Preparar configuracion, entornos y despliegue inicial. + +## Fase 4: integracion de datos de Discord/servidores + +- Documentar el plan tecnico de datos para Discord y servidores antes de integrar fuentes reales. +- Empezar por placeholders o datos manuales controlados desde el backend Python. +- Incorporar integraciones limitadas y trazables solo despues de validar fuentes, limites y seguridad. +- Diferenciar de forma explicita los servidores actuales de Hell Let Loose frente al futuro contexto HLL Vietnam. +- Sustituir el bloque provisional de servidores actuales cuando existan datos mas cercanos al producto final. +- Definir snapshots de servidores como unidad base para historicos y estadisticas basicas antes de persistir datos reales. +- Separar por fases la ingesta, la normalizacion y la futura explotacion historica para no acoplar el frontend a fuentes externas. + +## Fase 5: panel/admin y automatizacion + +- Construir panel interno o administrativo. +- Anadir flujos de gestion y publicacion. +- Ampliar y madurar el sistema de tasks y orquestacion ya integrado en el repositorio. diff --git a/docs/scoreboard-correlation-debugging.md b/docs/scoreboard-correlation-debugging.md new file mode 100644 index 0000000..0fee09e --- /dev/null +++ b/docs/scoreboard-correlation-debugging.md @@ -0,0 +1,45 @@ +# Scoreboard Correlation Debugging + +Use backend commands to debug a missing public scoreboard button on an RCON +historical match. Normal frontend payloads and pages should stay free of +correlation diagnostics. + +## Sequence + +1. Refresh trusted public scoreboard candidates for the relevant server: + + ```powershell + docker compose exec backend python -m app.scoreboard_candidate_backfill --server comunidad-hispana-02 --from 2026-05-20T00:00:00Z --to 2026-05-21T23:59:59Z --max-pages 5 --page-size 100 + ``` + +2. Scan existing materialized RCON matches against those candidates: + + ```powershell + docker compose exec backend python -m app.rcon_scoreboard_relink --server comunidad-hispana-02 + ``` + +3. Inspect one match correlation: + + ```powershell + docker compose exec backend python -m app.scoreboard_correlation_diagnostics --server comunidad-hispana-02 --match comunidad-hispana-02:1779310451:1779315851:foywarfare + ``` + +4. Verify the detail endpoint used by the match page: + + ```powershell + Invoke-WebRequest 'http://localhost:8000/api/historical/matches/detail?server=comunidad-hispana-02&match=comunidad-hispana-02%3A1779310451%3A1779315851%3Afoywarfare' | Select-Object -ExpandProperty Content + ``` + +## Reading Output + +The diagnostic JSON includes the RCON match window, score, candidate search +window, safe top candidate summaries, the selected candidate when one is strong +enough, and `final_reason`. + +- `linked` means the detail read model can expose the trusted `match_url`. +- `no-safe-candidate` means candidate persistence or map/window matching needs + inspection. +- `low-confidence` means candidates exist but evidence is insufficient. +- `ambiguous-candidate` means two candidates tie and no public URL is selected. +- `unsafe-url` in a candidate summary means the raw candidate URL is not emitted + or selected. diff --git a/docs/stats-database-schema-foundation.md b/docs/stats-database-schema-foundation.md new file mode 100644 index 0000000..660305a --- /dev/null +++ b/docs/stats-database-schema-foundation.md @@ -0,0 +1,151 @@ +# Stats Database Schema Foundation + +## Objective + +Definir una base de almacenamiento simple y reutilizable para snapshots de +servidores y estadisticas iniciales, sin comprometer todavia una base de datos +productiva concreta. + +## Design Principles + +- naming generico reutilizable para HLL actual y futuro HLL Vietnam +- separacion entre identidad de servidor y observaciones historicas +- persistir primero solo lo necesario para reconstruir actividad basica +- dejar espacio para multiples fuentes sin acoplar el modelo a una integracion + unica + +## Proposed Core Entities + +### `game_sources` + +Proposito: +describir el contexto del juego o dominio de origen de los datos. + +Campos principales: + +- `id` +- `slug` +- `display_name` +- `provider_kind` +- `is_active` +- `created_at` +- `updated_at` + +Notas: + +- `slug` puede tomar valores como `current-hll` y en el futuro otros contextos + mas cercanos a HLL Vietnam. +- Esta entidad evita incrustar el juego en cada nombre de tabla. + +### `servers` + +Proposito: +mantener la identidad estable de cada servidor observado. + +Campos principales: + +- `id` +- `game_source_id` +- `external_server_id` nullable +- `server_name` +- `region` nullable +- `first_seen_at` +- `last_seen_at` +- `created_at` +- `updated_at` + +Claves y relaciones: + +- primary key en `id` +- foreign key a `game_sources.id` +- unique recomendado sobre `game_source_id` + `external_server_id` cuando el + origen entregue identificador externo fiable + +Notas: + +- `server_name` no debe usarse como clave unica porque puede cambiar. +- `last_seen_at` resume la ultima observacion conocida sin sustituir a los + snapshots historicos. + +### `server_snapshots` + +Proposito: +registrar cada captura puntual normalizada de un servidor. + +Campos principales: + +- `id` +- `server_id` +- `captured_at` +- `status` +- `players` +- `max_players` +- `current_map` nullable +- `source_name` +- `raw_payload_ref` nullable +- `created_at` + +Claves y relaciones: + +- primary key en `id` +- foreign key a `servers.id` +- index recomendado sobre `server_id` + `captured_at` + +Notas: + +- `status`, `players`, `max_players` y `current_map` son la base a persistir + desde la primera fase. +- `raw_payload_ref` queda como referencia opcional para trazabilidad futura si + el backend decide guardar artefactos crudos fuera de esta tabla. + +## Initial Statistics Layer + +No es necesario persistir metricas complejas desde el inicio. La primera capa +de estadisticas puede documentarse como derivada de `server_snapshots`. + +Vistas o agregaciones recomendadas para una siguiente fase: + +- ultima observacion por servidor +- pico de jugadores por servidor en una ventana temporal +- numero de snapshots online por servidor +- ultima vez visto online + +Si mas adelante aparecen necesidades de rendimiento o cuadros de mando +persistentes, podra anadirse una tabla de agregados sin cambiar la base del +modelo. + +## What To Persist First + +Persistir por snapshot: + +- `server_id` +- `captured_at` +- `status` +- `players` +- `max_players` +- `current_map` cuando exista +- `source_name` + +Puede derivarse despues: + +- tendencias +- medias por periodo +- picos historicos +- porcentaje de disponibilidad +- rankings + +## Technology Position + +El repositorio todavia no fija una tecnologia de persistencia productiva. La +base del esquema debe entenderse como modelo logico compatible con el backend en +Python y trasladable despues a la opcion de almacenamiento que se valide en una +task especifica. + +En esta fase no se anaden migraciones, ORM ni ficheros de base de datos. + +## Open Questions For Future Tasks + +- que fuente aportara un identificador externo suficientemente estable +- con que frecuencia debe capturarse un snapshot +- si conviene guardar payload crudo completo o solo referencias +- cuando merece la pena materializar agregados persistentes diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..6d13b20 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitignore +.venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..849b258 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /srv/frontend + +COPY . /srv/frontend + +EXPOSE 8080 + +CMD ["python", "-m", "http.server", "8080", "--bind", "0.0.0.0", "--directory", "/srv/frontend"] diff --git a/frontend/assets/css/hero-header-compact.css b/frontend/assets/css/hero-header-compact.css new file mode 100644 index 0000000..a4febdd --- /dev/null +++ b/frontend/assets/css/hero-header-compact.css @@ -0,0 +1,92 @@ +@media (min-width: 1121px) { + .hero:not(.historical-hero) .hero__content { + padding-block: 54px 60px; + padding-inline: clamp(64px, 5.5vw, 104px); + } + + .hero:not(.historical-hero) .hero__brand { + width: 100%; + max-width: 1420px; + margin: 0 auto; + display: grid; + grid-template-columns: minmax(400px, 470px) minmax(48px, 1fr) minmax(640px, 820px); + column-gap: clamp(28px, 3vw, 56px); + align-items: center; + } + + .hero:not(.historical-hero) .logo-frame { + grid-column: 1; + justify-self: start; + width: min(470px, 100%); + min-height: 360px; + padding: 30px 34px; + } + + .hero:not(.historical-hero) .logo-frame__image { + max-height: 340px; + } + + .hero:not(.historical-hero) .hero__copy { + grid-column: 3; + justify-self: end; + width: 100%; + max-width: 820px; + align-content: center; + justify-items: start; + text-align: left; + transform: none; + } + + .hero:not(.historical-hero) .hero__title { + max-width: none; + font-size: clamp(4.2rem, 5.2vw, 6.35rem); + line-height: 1.02; + } + + .hero:not(.historical-hero) .hero__title-accent { + display: inline; + } + + .hero:not(.historical-hero) .hero__text { + max-width: 68ch; + } +} + +@media (min-width: 1121px) and (max-width: 1380px) { + .hero:not(.historical-hero) .hero__content { + padding-inline: clamp(44px, 4vw, 64px); + } + + .hero:not(.historical-hero) .hero__brand { + grid-template-columns: minmax(320px, 390px) minmax(28px, 1fr) minmax(560px, 720px); + column-gap: clamp(20px, 2.5vw, 40px); + } + + .hero:not(.historical-hero) .logo-frame { + width: min(390px, 100%); + min-height: 316px; + } + + .hero:not(.historical-hero) .logo-frame__image { + max-height: 298px; + } + + .hero:not(.historical-hero) .hero__copy { + grid-column: 3; + justify-self: end; + width: 100%; + max-width: 720px; + transform: none; + } + + .hero:not(.historical-hero) .hero__title { + font-size: clamp(3.7rem, 5.35vw, 5.15rem); + } +} + +@media (max-width: 1120px) { + .hero:not(.historical-hero) .hero__copy { + grid-column: auto; + transform: none; + } +} \ No newline at end of file diff --git a/frontend/assets/css/historico-scoreboard-detail.css b/frontend/assets/css/historico-scoreboard-detail.css new file mode 100644 index 0000000..180074f --- /dev/null +++ b/frontend/assets/css/historico-scoreboard-detail.css @@ -0,0 +1,752 @@ +.historical-summary-grid:has(.historical-scoreboard-layout) { + display: block; +} + +.historical-panel__note:empty { + display: none; +} + +#match-detail-timeline-section[hidden] { + display: none !important; +} + +.historical-table--players tbody tr.historical-player-row--allies td:first-child { + box-shadow: inset 4px 0 0 rgba(104, 162, 214, 0.82); +} + +.historical-table--players tbody tr.historical-player-row--axis td:first-child { + box-shadow: inset 4px 0 0 rgba(190, 82, 64, 0.82); +} + +.historical-table--players tbody tr.historical-player-row--unknown td:first-child { + box-shadow: inset 4px 0 0 rgba(159, 168, 141, 0.5); +} + +.historical-table--players tbody tr.historical-player-row--allies { + background: linear-gradient(90deg, rgba(74, 126, 178, 0.14), transparent 42%); +} + +.historical-table--players tbody tr.historical-player-row--axis { + background: linear-gradient(90deg, rgba(156, 66, 49, 0.16), transparent 42%); +} + +.historical-table--players tbody tr.historical-player-row--unknown { + background: linear-gradient(90deg, rgba(159, 168, 141, 0.08), transparent 42%); +} + +.historical-table--players { + min-width: 760px; +} + +.historical-player-controls { + display: grid; + grid-template-columns: minmax(220px, 1fr) repeat(3, minmax(150px, 190px)); + gap: 10px; + align-items: end; +} + +.historical-player-controls[hidden] { + display: none; +} + +.historical-player-control { + display: grid; + gap: 6px; + min-width: 0; +} + +.historical-player-control span { + color: var(--muted); + font-size: 0.66rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-player-control input, +.historical-player-control select { + width: 100%; + min-height: 42px; + padding: 0 12px; + border: 1px solid rgba(159, 168, 141, 0.28); + border-radius: 8px; + background: rgba(10, 13, 10, 0.82); + color: var(--text); + font: inherit; +} + +.historical-player-control input::placeholder { + color: var(--muted); +} + +.historical-player-control input:focus-visible, +.historical-player-control select:focus-visible { + outline: 2px solid rgba(210, 182, 118, 0.72); + outline-offset: 1px; +} + +.historical-player-row { + outline: 0; +} + +.historical-player-row.is-inactive { + color: var(--muted); +} + +.historical-player-row.is-inactive .historical-player-row__details-button span:first-child { + color: var(--text-soft); + font-style: italic; +} + +.historical-player-row:hover td, +.historical-player-row:focus-within td, +.historical-player-row.is-expanded td { + background: rgba(210, 182, 118, 0.06); +} + +.historical-player-row__details-button:focus-visible { + outline: 2px solid rgba(210, 182, 118, 0.78); + outline-offset: -2px; +} + +.historical-player-row__details-button { + display: flex; + width: 100%; + min-width: 220px; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.historical-player-row__details-button span:first-child { + overflow: hidden; + min-width: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + +.historical-player-row__details-button span:last-child { + flex: 0 0 auto; + display: inline-flex; + width: 22px; + height: 22px; + align-items: center; + justify-content: center; + border: 1px solid rgba(210, 182, 118, 0.28); + border-radius: 999px; + color: var(--accent-strong); + font-size: 0.72rem; + font-weight: 900; + line-height: 1; + text-transform: uppercase; +} + +.historical-player-detail-row { + display: none; +} + +.historical-player-detail-row.is-open { + display: table-row; +} + +.historical-player-detail-row td { + padding: 0 12px 16px; + background: rgba(7, 9, 7, 0.76); + border-bottom-color: rgba(210, 182, 118, 0.16); +} + +.historical-player-stats-panel { + display: grid; + gap: 16px; + padding: 16px; + border: 1px solid rgba(210, 182, 118, 0.18); + border-radius: 16px; + background: + linear-gradient(180deg, rgba(19, 24, 17, 0.96), rgba(10, 13, 10, 0.98)), + rgba(10, 13, 10, 0.96); + box-shadow: 0 18px 34px rgba(0, 0, 0, 0.28); +} + +.historical-player-stats-panel__header { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(0, 2fr); + gap: 16px; + align-items: start; +} + +.historical-player-stats-panel__header p, +.historical-player-stats-panel__section p, +.historical-player-stats-panel__empty { + margin: 0; + color: var(--muted); +} + +.historical-player-stats-panel__header p { + margin-bottom: 5px; + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.historical-player-stats-panel__header h4, +.historical-player-stats-panel__section h5 { + margin: 0; + color: var(--text); +} + +.historical-player-stats-panel__header h4 { + font-size: 1.08rem; +} + +.historical-player-stats-panel__summary { + display: grid; + grid-template-columns: repeat(5, minmax(70px, 1fr)); + gap: 8px; +} + +.historical-player-stats-panel__summary article { + padding: 9px 10px; + border: 1px solid rgba(159, 168, 141, 0.14); + border-radius: 10px; + background: rgba(13, 17, 12, 0.68); +} + +.historical-player-stats-panel__summary span { + display: block; + margin-bottom: 4px; + color: var(--muted); + font-size: 0.64rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-player-stats-panel__summary strong { + color: var(--text); +} + +.historical-player-stats-panel__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.historical-player-stats-panel__section { + display: grid; + gap: 10px; + align-content: start; + padding: 13px; + border: 1px solid rgba(159, 168, 141, 0.14); + border-radius: 12px; + background: rgba(13, 17, 12, 0.52); +} + +.historical-player-stats-panel__section--wide { + grid-column: 1 / -1; +} + +.historical-player-stats-panel__profiles { + padding: 13px; +} + +.historical-player-profile-links { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.historical-player-profile-links a { + display: inline-flex; + min-height: 32px; + align-items: center; + padding: 0 11px; + border: 1px solid rgba(210, 182, 118, 0.34); + border-radius: 999px; + color: var(--accent-warm); + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.08em; + text-decoration: none; + text-transform: uppercase; +} + +.historical-player-profile-links a:hover, +.historical-player-profile-links a:focus-visible { + border-color: rgba(210, 182, 118, 0.62); + color: var(--text); +} + +.historical-player-stats-panel__section h5 { + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-player-stats-panel__section ol { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.historical-player-stats-panel__section li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: baseline; +} + +.historical-player-stats-panel__section li span { + overflow: hidden; + min-width: 0; + color: var(--text-soft); + text-overflow: ellipsis; + white-space: nowrap; +} + +.historical-player-stats-panel__section li strong { + color: var(--accent-strong); +} + +.historical-player-matchups { + display: grid; + gap: 0; + overflow-x: auto; +} + +.historical-player-matchups [role="row"] { + display: grid; + grid-template-columns: minmax(180px, 1fr) repeat(3, minmax(72px, auto)); + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid rgba(159, 168, 141, 0.1); +} + +.historical-player-matchups [role="row"]:last-child { + border-bottom: 0; +} + +.historical-player-matchups [role="columnheader"] { + color: var(--muted); + font-size: 0.66rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-player-matchups [role="cell"] { + color: var(--text-soft); +} + +.historical-player-matchups strong[role="cell"] { + color: var(--text); +} + +.historical-player-team-cell { + white-space: nowrap; +} + +.historical-player-team-badge { + display: inline-flex; + align-items: center; + min-width: 96px; + justify-content: center; + padding: 5px 10px; + border: 1px solid rgba(159, 168, 141, 0.24); + border-radius: 999px; + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.08em; + line-height: 1; + text-transform: uppercase; +} + +.historical-player-team-badge--allies { + border-color: rgba(118, 175, 229, 0.46); + background: rgba(61, 109, 163, 0.2); + color: #c8e1ff; +} + +.historical-player-team-badge--axis { + border-color: rgba(213, 105, 83, 0.48); + background: rgba(129, 45, 35, 0.22); + color: #f2beb2; +} + +.historical-player-team-badge--unknown { + border-color: rgba(159, 168, 141, 0.28); + background: rgba(159, 168, 141, 0.1); + color: var(--muted); +} + +@media (max-width: 760px) { + .historical-player-controls { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .historical-player-control--search { + grid-column: 1 / -1; + } + + .historical-player-stats-panel__header, + .historical-player-stats-panel__grid { + grid-template-columns: 1fr; + } + + .historical-player-stats-panel__summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .historical-player-matchups [role="row"] { + min-width: 520px; + } +} + +.historical-scoreboard-layout { + display: grid; + gap: 18px; + margin-bottom: 18px; +} + +.historical-scoreboard-layout__main { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(240px, 340px) minmax(0, 1fr); + align-items: center; + gap: 22px; + padding: 28px 30px; + border: 1px solid rgba(210, 182, 118, 0.2); + border-radius: 24px; + background: + radial-gradient(circle at 50% 10%, rgba(210, 182, 118, 0.1), transparent 44%), + linear-gradient(180deg, rgba(11, 14, 10, 0.72), rgba(11, 14, 10, 0.94)); + box-shadow: var(--shadow-soft); +} + +.historical-scoreboard-side { + display: grid; + grid-template-columns: 124px minmax(0, 1fr); + align-items: center; + gap: 18px; + min-height: 148px; +} + +.historical-scoreboard-side--axis { + grid-template-columns: minmax(0, 1fr) 124px; + text-align: right; +} + +.historical-scoreboard-side--axis .historical-scoreboard-side__emblem { + order: 2; +} + +.historical-scoreboard-side--axis .historical-scoreboard-side__text { + order: 1; +} + +.historical-scoreboard-side.is-emblem-missing, +.historical-scoreboard-side--axis.is-emblem-missing { + grid-template-columns: minmax(0, 1fr); +} + +.historical-scoreboard-side__emblem { + width: 124px; + height: 124px; + border-radius: 999px; + object-fit: contain; + padding: 10px; + border: 1px solid rgba(210, 182, 118, 0.26); + background: + radial-gradient(circle at center, rgba(210, 182, 118, 0.15), transparent 60%), + rgba(7, 9, 7, 0.72); +} + +.historical-scoreboard-side__text { + display: grid; + gap: 5px; +} + +.historical-scoreboard-side__text strong { + color: var(--text); + font-size: clamp(1.8rem, 4vw, 3.1rem); + line-height: 0.95; + text-transform: uppercase; +} + +.historical-scoreboard-side__text em { + color: var(--accent-strong); + font-style: normal; + font-size: 0.78rem; + font-weight: 900; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.historical-scoreboard-side.is-winner .historical-scoreboard-side__emblem { + border-color: rgba(210, 182, 118, 0.62); + box-shadow: 0 0 26px rgba(210, 182, 118, 0.14); +} + +.historical-scoreboard-center { + display: grid; + justify-items: center; + gap: 6px; + text-align: center; +} + +.historical-scoreboard-center__timer { + color: var(--text-soft); + font-size: 0.86rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-scoreboard-center__score { + color: var(--text); + font-size: clamp(4.6rem, 8.6vw, 7.6rem); + line-height: 0.9; + letter-spacing: 0.02em; +} + +.historical-scoreboard-center__map { + color: var(--text); + font-size: 1.05rem; + font-weight: 800; +} + +.historical-scoreboard-center__mode, +.historical-scoreboard-center__winner { + color: var(--text-soft); + font-size: 0.86rem; +} + +.historical-scoreboard-center__winner { + color: var(--accent-strong); + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-scoreboard-layout__meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 170px), 1fr)); + gap: 12px; +} + +.historical-scoreboard-layout__meta article { + padding: 13px 14px; + border: 1px solid rgba(159, 168, 141, 0.14); + border-radius: 16px; + background: rgba(13, 17, 12, 0.44); +} + +.historical-scoreboard-layout__meta span { + display: block; + margin-bottom: 7px; + color: var(--muted); + font-size: 0.72rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.historical-scoreboard-layout__meta strong { + display: block; + color: var(--text); + font-size: 1rem; + line-height: 1.35; +} + +@media (max-width: 920px) { + .historical-scoreboard-layout__main { + grid-template-columns: 1fr; + padding: 20px; + } + + .historical-scoreboard-center { + order: -1; + } + + .historical-scoreboard-side, + .historical-scoreboard-side--axis { + grid-template-columns: 96px minmax(0, 1fr); + text-align: left; + } + + .historical-scoreboard-side--axis .historical-scoreboard-side__emblem, + .historical-scoreboard-side--axis .historical-scoreboard-side__text { + order: initial; + } + + .historical-scoreboard-side__emblem { + width: 96px; + height: 96px; + } +} + + +.historical-player-control--team-toggle { + border: 0; + margin: 0; + padding: 0; +} + +.historical-player-control--team-toggle legend { + color: var(--muted); + display: block; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.14em; + margin-bottom: 0.45rem; + text-transform: uppercase; +} + +.historical-player-team-toggle { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.historical-player-team-toggle label { + cursor: pointer; +} + +.historical-player-team-toggle input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.historical-player-team-toggle span { + border: 1px solid rgba(210, 196, 130, 0.34); + border-radius: 999px; + color: var(--muted); + display: inline-flex; + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.08em; + padding: 0.55rem 0.8rem; + text-transform: uppercase; +} + +.historical-player-team-toggle input:checked + span { + background: rgba(210, 196, 130, 0.12); + border-color: rgba(210, 196, 130, 0.72); + color: var(--text); +} + +.historical-player-team-toggle input:focus-visible + span { + outline: 2px solid rgba(210, 196, 130, 0.9); + outline-offset: 2px; +} + +.historical-player-stats-panel__profiles code { + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 0.82rem; + word-break: break-all; +} + + +/* Player controls refinement: team selector above search/sort. */ +.historical-player-control--team-toggle { + border: 0; + grid-column: 1 / -1; + margin: 0; + order: -1; + padding: 0; +} + +.historical-player-control--team-toggle legend { + color: var(--muted); + display: block; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.14em; + margin-bottom: 0.45rem; + text-transform: uppercase; +} + +.historical-player-team-toggle { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.55rem; +} + +.historical-player-team-toggle label { + cursor: pointer; +} + +.historical-player-team-toggle input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.historical-player-team-toggle span { + border: 1px solid rgba(210, 196, 130, 0.34); + border-radius: 999px; + color: var(--muted); + display: inline-flex; + font-size: 0.75rem; + font-weight: 900; + letter-spacing: 0.08em; + padding: 0.58rem 0.9rem; + text-transform: uppercase; + transition: + background 160ms ease, + border-color 160ms ease, + color 160ms ease, + box-shadow 160ms ease; +} + +.historical-player-team-toggle__item--all input:checked + span { + background: rgba(210, 196, 130, 0.13); + border-color: rgba(210, 196, 130, 0.78); + color: var(--text); + box-shadow: 0 0 0 1px rgba(210, 196, 130, 0.18); +} + +.historical-player-team-toggle__item--allies span { + border-color: rgba(109, 171, 255, 0.42); + color: #b8d7ff; +} + +.historical-player-team-toggle__item--allies input:checked + span { + background: rgba(72, 135, 220, 0.24); + border-color: rgba(133, 190, 255, 0.9); + color: #e4f1ff; + box-shadow: 0 0 0 1px rgba(109, 171, 255, 0.22); +} + +.historical-player-team-toggle__item--axis span { + border-color: rgba(225, 113, 86, 0.48); + color: #ffb6a6; +} + +.historical-player-team-toggle__item--axis input:checked + span { + background: rgba(170, 72, 54, 0.28); + border-color: rgba(255, 143, 113, 0.92); + color: #ffe1d9; + box-shadow: 0 0 0 1px rgba(225, 113, 86, 0.24); +} + +.historical-player-team-toggle input:focus-visible + span { + outline: 2px solid rgba(210, 196, 130, 0.9); + outline-offset: 2px; +} + +.historical-player-stats-panel__profiles code { + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 0.82rem; + word-break: break-all; +} diff --git a/frontend/assets/css/historico.css b/frontend/assets/css/historico.css new file mode 100644 index 0000000..bb12464 --- /dev/null +++ b/frontend/assets/css/historico.css @@ -0,0 +1,1352 @@ +.historical-shell { + padding-bottom: 56px; +} + +.historical-hero { + min-height: auto; +} + +.historical-hero__content { + padding-top: 44px; + padding-bottom: 46px; +} + +.historical-hero__topline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 22px; +} + +.historical-hero__copy { + display: grid; + gap: 24px; + min-width: 0; +} + +.historical-hero__layout { + display: grid; + grid-template-columns: minmax(190px, 260px) minmax(0, 0.82fr) minmax(320px, 0.9fr); + align-items: center; + gap: 32px; +} + +.historical-hero__layout--registry { + grid-template-columns: minmax(220px, 330px) minmax(0, 1fr); + column-gap: clamp(72px, 7vw, 140px); +} + +.historical-hero__layout > * { + min-width: 0; +} + +.historical-logo-frame { + width: min(300px, 100%); + min-height: 220px; +} + +.historical-hero__title { + max-width: 12ch; +} + +.historical-hero__layout--registry .historical-hero__copy { + max-width: min(100%, 760px); +} + +.historical-hero__layout--registry .historical-hero__title { + max-width: 18ch; +} + +.historical-hero__text { + max-width: 60ch; + overflow-wrap: anywhere; +} + +/* Registry/historico hero: logo left, real flexible spacer, copy anchored right. */ +@media (min-width: 1121px) { + .historical-hero__layout--registry { + width: 100%; + max-width: 1420px; + margin: 0 auto; + display: grid; + grid-template-columns: minmax(400px, 470px) minmax(48px, 1fr) minmax(640px, 820px); + column-gap: clamp(28px, 3vw, 56px); + align-items: center; + } + + .historical-hero__layout--registry .historical-logo-frame { + grid-column: 1; + justify-self: start; + width: min(470px, 100%); + min-height: 360px; + padding: 30px 34px; + } + + .historical-hero__layout--registry .logo-frame__image { + max-height: 340px; + } + + .historical-hero__layout--registry .historical-hero__copy { + grid-column: 3; + justify-self: end; + width: 100%; + max-width: 820px; + align-content: center; + justify-items: start; + text-align: left; + } + + .historical-hero__layout--registry .historical-hero__title { + max-width: none; + font-size: clamp(4.2rem, 5.2vw, 6.35rem); + line-height: 1.02; + } + + .historical-hero__layout--registry .historical-hero__text { + max-width: 68ch; + } + + .historical-hero__layout--registry .historical-selector { + max-width: 760px; + } +} + +@media (min-width: 1121px) and (max-width: 1380px) { + .historical-hero__layout--registry { + grid-template-columns: minmax(320px, 390px) minmax(28px, 1fr) minmax(560px, 720px); + column-gap: clamp(20px, 2.5vw, 40px); + } + + .historical-hero__layout--registry .historical-logo-frame { + grid-column: 1; + width: min(390px, 100%); + min-height: 316px; + } + + .historical-hero__layout--registry .logo-frame__image { + max-height: 298px; + } + + .historical-hero__layout--registry .historical-hero__copy { + grid-column: 3; + width: 100%; + max-width: 720px; + justify-self: end; + } + + .historical-hero__layout--registry .historical-hero__title { + font-size: clamp(3.7rem, 5.35vw, 5.15rem); + } +} + +@media (max-width: 1120px) { + .historical-hero__layout--registry { + max-width: none; + } + + .historical-hero__layout--registry .historical-hero__copy, + .historical-hero__layout--registry .historical-hero__title, + .historical-hero__layout--registry .historical-hero__text { + width: auto; + max-width: none; + justify-self: stretch; + } +} + +.historical-map-hero { + position: relative; + overflow: hidden; + min-height: 220px; + margin: 0; + border: 1px solid rgba(210, 182, 118, 0.2); + border-radius: 18px; + background: + linear-gradient(180deg, rgba(19, 24, 16, 0.9), rgba(10, 13, 9, 0.72)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 18px 40px rgba(0, 0, 0, 0.24); +} + +.historical-map-hero[hidden] { + display: none; +} + +.historical-map-hero::before { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + border: 1px solid rgba(210, 182, 118, 0.12); + border-radius: inherit; + background: + linear-gradient(90deg, rgba(7, 9, 7, 0.24), transparent 34%), + linear-gradient(180deg, transparent 54%, rgba(7, 9, 7, 0.44)); + pointer-events: none; +} + +.historical-map-hero__image { + width: 100%; + height: 100%; + min-height: 220px; + object-fit: cover; +} + +.current-match-map-hero { + display: grid; + isolation: isolate; +} + +.current-match-map-hero > * { + grid-area: 1 / 1; +} + +.current-match-map-placeholder { + z-index: 0; + display: grid; + align-content: end; + gap: 6px; + min-height: 220px; + padding: 24px; + color: var(--text); + background: + linear-gradient(135deg, rgba(183, 201, 125, 0.12), transparent 44%), + repeating-linear-gradient( + -28deg, + rgba(210, 182, 118, 0.06) 0 1px, + transparent 1px 20px + ); +} + +.current-match-map-placeholder strong { + font-size: 1.16rem; + text-transform: uppercase; +} + +.current-match-map-placeholder span { + color: var(--text-soft); +} + +.current-match-scoreboard-message { + max-width: 12ch; + font-size: clamp(2rem, 4vw, 3.3rem); + line-height: 1; +} + +.historical-content { + gap: 22px; +} + +.historical-panel { + backdrop-filter: blur(4px); +} + +.historical-panel__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.historical-panel__note { + max-width: 58ch; + margin: 10px 0 0; + color: var(--muted); + line-height: 1.55; +} + +.historical-snapshot-meta { + margin: 8px 0 0; + color: var(--text-soft); + font-size: 0.86rem; + line-height: 1.5; +} + +.historical-selector { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.historical-selector__button { + min-height: 48px; + padding: 0 18px; + border: 1px solid rgba(183, 201, 125, 0.28); + border-radius: 999px; + background: linear-gradient(180deg, rgba(24, 30, 22, 0.94), rgba(11, 14, 10, 0.98)); + color: var(--text-soft); + font: inherit; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; + transition: + transform 160ms ease, + border-color 160ms ease, + background 160ms ease, + color 160ms ease; +} + +.historical-selector__button:hover, +.historical-selector__button:focus-visible, +.historical-selector__button.is-active { + transform: translateY(-1px); + border-color: rgba(210, 182, 118, 0.5); + background: linear-gradient(180deg, rgba(183, 201, 125, 0.18), rgba(89, 101, 58, 0.24)); + color: var(--text); +} + +.historical-tabs { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin: 0 0 16px; +} + +.historical-tabs--timeframe { + margin-bottom: 10px; +} + +.historical-tab { + min-height: 42px; + padding: 0 16px; + border: 1px solid rgba(159, 168, 141, 0.2); + border-radius: 999px; + background: rgba(13, 17, 12, 0.62); + color: var(--muted); + font: inherit; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + cursor: pointer; + transition: + transform 160ms ease, + border-color 160ms ease, + background 160ms ease, + color 160ms ease; +} + +.historical-tab:hover, +.historical-tab:focus-visible, +.historical-tab.is-active { + transform: translateY(-1px); + border-color: rgba(210, 182, 118, 0.46); + background: linear-gradient(180deg, rgba(183, 201, 125, 0.16), rgba(89, 101, 58, 0.18)); + color: var(--text); +} + +.historical-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); + gap: 14px; +} + +.historical-mvp-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); + gap: 14px; +} + +.historical-elo-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); + gap: 14px; +} + +.historical-stat-card, +.historical-match-card, +.historical-mvp-card, +.historical-elo-card { + position: relative; + overflow: hidden; + padding: 18px; + border: 1px solid rgba(159, 168, 141, 0.16); + border-radius: 18px; + background: + linear-gradient(180deg, rgba(28, 34, 25, 0.94), rgba(12, 15, 11, 0.98)); + box-shadow: var(--shadow-soft); +} + +.historical-stat-card p, +.historical-stat-card strong, +.historical-match-card p, +.historical-match-card strong, +.historical-mvp-card p, +.historical-mvp-card strong, +.historical-mvp-card span, +.historical-elo-card p, +.historical-elo-card strong, +.historical-elo-card span { + margin: 0; +} + +.historical-stat-card p, +.historical-match-meta__label { + margin-bottom: 6px; + color: var(--muted); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-stat-card strong { + font-size: 1.05rem; + line-height: 1.4; +} + +.historical-mvp-card { + display: grid; + gap: 16px; +} + +.historical-mvp-card--v2 { + border-color: rgba(187, 143, 72, 0.26); + background: + radial-gradient(circle at top right, rgba(187, 143, 72, 0.12), transparent 40%), + linear-gradient(180deg, rgba(31, 27, 18, 0.96), rgba(12, 15, 11, 0.98)); +} + +.historical-mvp-card--rank-1 { + border-color: rgba(210, 182, 118, 0.34); + background: + radial-gradient(circle at top right, rgba(210, 182, 118, 0.16), transparent 42%), + linear-gradient(180deg, rgba(36, 32, 20, 0.96), rgba(12, 15, 11, 0.98)); +} + +.historical-mvp-card__top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.historical-mvp-card__rank { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 42px; + min-height: 42px; + padding: 0 12px; + border: 1px solid rgba(210, 182, 118, 0.26); + border-radius: 999px; + color: var(--accent-warm); + font-size: 0.76rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-mvp-card__player { + font-size: 1.18rem; + line-height: 1.3; +} + +.historical-mvp-card__version { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border: 1px solid rgba(187, 143, 72, 0.28); + border-radius: 999px; + color: var(--accent-warm); + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-mvp-card__score-label { + margin-bottom: 6px; + color: var(--muted); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-mvp-card__score-value { + color: var(--accent-strong); + font-size: 1.8rem; + line-height: 1; +} + +.historical-mvp-card__meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.historical-mvp-card__meta span { + display: block; + margin-bottom: 6px; + color: var(--muted); + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-mvp-card__footer { + padding-top: 12px; + border-top: 1px solid rgba(159, 168, 141, 0.12); + color: var(--text-soft); + font-size: 0.88rem; + line-height: 1.5; +} + +.historical-mvp-card__signals { + display: grid; + gap: 10px; +} + +.historical-elo-card { + display: grid; + gap: 16px; + border-color: rgba(96, 150, 124, 0.24); + background: + radial-gradient(circle at top right, rgba(96, 150, 124, 0.12), transparent 40%), + linear-gradient(180deg, rgba(21, 32, 27, 0.96), rgba(12, 15, 11, 0.98)); +} + +.historical-elo-card__top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.historical-elo-card__rank { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 42px; + min-height: 42px; + padding: 0 12px; + border: 1px solid rgba(96, 150, 124, 0.28); + border-radius: 999px; + color: #a8d4bf; + font-size: 0.76rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-elo-card__accuracy { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border: 1px solid rgba(96, 150, 124, 0.24); + border-radius: 999px; + color: #a8d4bf; + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-elo-card__meta, +.historical-elo-card__scores { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.historical-elo-card__meta article, +.historical-elo-card__scores article { + padding: 10px 12px; + border: 1px solid rgba(96, 150, 124, 0.14); + border-radius: 14px; + background: rgba(13, 17, 12, 0.42); +} + +.historical-elo-card__meta span, +.historical-elo-card__scores span { + display: block; + margin-bottom: 6px; + color: var(--muted); + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-elo-card__summary { + color: var(--text-soft); + font-size: 0.9rem; + line-height: 1.5; +} + +.historical-elo-card__footer { + padding-top: 12px; + border-top: 1px solid rgba(159, 168, 141, 0.12); + color: var(--text-soft); + font-size: 0.88rem; + line-height: 1.5; +} + +.historical-mvp-card__signal-summary { + color: var(--text-soft); + font-size: 0.9rem; + line-height: 1.5; +} + +.historical-mvp-card__signal-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.historical-mvp-card__signal-grid article { + padding: 10px 12px; + border: 1px solid rgba(187, 143, 72, 0.14); + border-radius: 14px; + background: rgba(13, 17, 12, 0.42); +} + +.historical-mvp-card__signal-grid span { + display: block; + margin-bottom: 6px; + color: var(--muted); + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-state { + margin: 0 0 16px; + padding: 14px 16px; + border: 1px dashed rgba(159, 168, 141, 0.28); + border-radius: 14px; + color: var(--text-soft); + background: rgba(13, 17, 12, 0.52); +} + +.historical-state.is-error { + border-style: solid; + border-color: rgba(210, 182, 118, 0.28); + color: var(--accent-warm); +} + +.historical-state[hidden] { + display: none; +} + +.historical-table-shell { + overflow-x: auto; +} + +.historical-detail-section { + display: grid; + gap: 14px; + margin-top: 18px; +} + +.historical-detail-section__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.historical-detail-section__header h3 { + margin: 0; + font-size: 1.08rem; +} + +.historical-table { + width: 100%; + border-collapse: collapse; + min-width: 620px; +} + +.historical-table th, +.historical-table td { + padding: 14px 12px; + border-bottom: 1px solid rgba(159, 168, 141, 0.12); + text-align: left; +} + +.historical-table th { + color: var(--muted); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-table td { + color: var(--text); +} + +.historical-table tbody tr:last-child td { + border-bottom: 0; +} + +.historical-table--players { + min-width: 920px; +} + +.historical-table__position { + color: var(--accent-warm); + font-weight: 700; +} + +.historical-match-list { + display: grid; + gap: 14px; +} + +.historical-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-top: 16px; +} + +.historical-pagination[hidden] { + display: none; +} + +.historical-pagination__size, +.historical-pagination__nav { + display: flex; + align-items: center; + gap: 10px; +} + +.historical-pagination__size { + color: var(--text-soft); + font-size: 0.86rem; + font-weight: 700; +} + +.historical-pagination__size select { + min-height: 42px; + padding: 0 34px 0 12px; + border: 1px solid rgba(159, 168, 141, 0.2); + border-radius: 6px; + color: var(--text); + background: rgba(13, 17, 12, 0.72); + font: inherit; +} + +.historical-pagination__nav p { + margin: 0; + min-width: 110px; + color: var(--text-soft); + font-size: 0.86rem; + font-weight: 700; + text-align: center; +} + +.historical-pagination .historical-tab { + margin: 0; +} + +.historical-pagination .historical-tab:disabled { + transform: none; + border-color: rgba(159, 168, 141, 0.12); + color: rgba(169, 173, 154, 0.48); + cursor: default; +} + +.current-match-killfeed-screen { + position: relative; + width: 100%; + min-height: 180px; + max-height: 520px; + padding: 10px; + overflow: hidden; + border: 1px solid rgba(159, 168, 141, 0.2); + border-radius: 6px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 24%), + rgba(7, 9, 9, 0.94); + box-shadow: + inset 0 0 0 1px rgba(0, 0, 0, 0.44), + inset 0 18px 48px rgba(0, 0, 0, 0.24); +} + +.current-match-killfeed-screen::after { + position: absolute; + inset: 0; + z-index: 1; + border-radius: inherit; + background: repeating-linear-gradient( + 180deg, + rgba(255, 255, 255, 0.018) 0, + rgba(255, 255, 255, 0.018) 1px, + transparent 1px, + transparent 5px + ); + pointer-events: none; + content: ""; +} + +.current-match-killfeed { + position: relative; + z-index: 2; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + gap: 8px; + max-height: 500px; + overflow: hidden; +} + +.current-match-killfeed__column { + display: grid; + align-content: start; + gap: 6px; + min-width: 0; +} + +.current-match-killfeed__row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(86px, 112px) minmax(0, 1fr); + align-items: center; + gap: 8px; + min-height: 54px; + padding: 6px 9px; + overflow: hidden; + border: 1px solid rgba(159, 168, 141, 0.14); + border-left: 2px solid rgba(159, 168, 141, 0.24); + border-radius: 3px; + color: var(--text); + background: rgba(19, 22, 22, 0.78); + animation: current-match-killfeed-enter 180ms ease-out; +} + +.current-match-killfeed__column:last-child .current-match-killfeed__row:last-child { + border-color: rgba(210, 182, 118, 0.22); + background: + linear-gradient(90deg, rgba(210, 182, 118, 0.08), transparent 46%), + rgba(24, 25, 22, 0.92); +} + +.current-match-killfeed__row.is-teamkill { + border-left-color: rgba(210, 182, 118, 0.64); + background: + linear-gradient(90deg, rgba(210, 182, 118, 0.12), transparent 48%), + rgba(73, 49, 27, 0.32); +} + +.current-match-killfeed__player { + display: grid; + align-content: center; + gap: 3px; + min-width: 0; + font-size: 0.88rem; + line-height: 1.2; +} + +.current-match-killfeed__player-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.current-match-killfeed__player--killer { + color: var(--text); +} + +.current-match-killfeed__player--victim { + color: var(--text-soft); +} + +.current-match-killfeed__player-meta { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.current-match-killfeed__weapon { + display: grid; + grid-template-rows: 25px auto; + align-items: center; + justify-content: center; + gap: 1px; + min-width: 92px; + min-height: 38px; + padding: 2px 5px; + border: 1px solid rgba(159, 168, 141, 0.18); + border-radius: 3px; + color: var(--text); + background: rgba(8, 10, 11, 0.74); +} + +.current-match-killfeed__weapon-icon { + display: block; + width: min(100%, 104px); + height: 24px; + object-fit: contain; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.72)); +} + +.current-match-killfeed__weapon-fallback { + display: grid; + width: 24px; + height: 24px; + justify-self: center; + place-items: center; + border: 1px solid rgba(210, 182, 118, 0.3); + border-radius: 3px; + color: var(--accent-warm); + font-size: 0.82rem; + font-weight: 800; +} + +.current-match-killfeed__weapon-fallback[hidden] { + display: none; +} + +.current-match-killfeed__weapon em { + display: block; + max-width: 112px; + overflow: hidden; + color: var(--muted); + font-size: 0.58rem; + font-style: normal; + line-height: 1.1; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.current-match-killfeed__team-badge, +.current-match-killfeed__teamkill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 18px; + padding: 0 5px; + border-radius: 4px; + font-size: 0.62rem; + font-weight: 800; +} + +.current-match-killfeed__team-badge { + border: 1px solid rgba(159, 168, 141, 0.2); + color: var(--text-soft); + background: rgba(159, 168, 141, 0.08); +} + +.current-match-killfeed__team-badge--allies { + border-color: rgba(100, 139, 178, 0.34); + color: rgba(192, 215, 238, 0.96); + background: rgba(67, 101, 137, 0.28); +} + +.current-match-killfeed__team-badge--axis { + border-color: rgba(167, 109, 96, 0.36); + color: rgba(231, 198, 190, 0.96); + background: rgba(116, 68, 58, 0.3); +} + +.current-match-killfeed__team-badge--unknown { + border-color: rgba(159, 168, 141, 0.22); + color: var(--muted); + background: rgba(159, 168, 141, 0.1); +} + +.current-match-killfeed__teamkill { + border: 1px solid rgba(210, 182, 118, 0.36); + color: var(--accent-warm); +} + +.current-match-player-intro { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 10px 18px; + margin-top: 10px; +} + +.current-match-player-intro .historical-panel__note { + margin-top: 0; +} + +.current-match-player-count { + flex: 0 0 auto; + width: fit-content; + margin: 0; + padding: 7px 10px; + border: 1px solid rgba(159, 168, 141, 0.16); + border-radius: 6px; + color: var(--text-soft); + background: rgba(14, 17, 18, 0.7); + font-size: 0.8rem; + font-weight: 700; +} + +@keyframes current-match-killfeed-enter { + from { + opacity: 0; + transform: translateX(8px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +.historical-comparison-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); + gap: 14px; +} + +.historical-comparison-card { + display: grid; + gap: 14px; + padding: 18px; + border: 1px solid rgba(159, 168, 141, 0.16); + border-radius: 18px; + background: + radial-gradient(circle at top right, rgba(183, 201, 125, 0.1), transparent 42%), + linear-gradient(180deg, rgba(28, 34, 25, 0.94), rgba(12, 15, 11, 0.98)); + box-shadow: var(--shadow-soft); +} + +.historical-comparison-card__top, +.historical-comparison-card__scores { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.historical-comparison-card__eyebrow, +.historical-comparison-card__delta-label, +.historical-comparison-card__meta span { + margin: 0 0 6px; + color: var(--muted); + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-comparison-card__title { + margin: 0; + font-size: 1.08rem; + line-height: 1.35; +} + +.historical-comparison-card__delta-value { + color: var(--accent-strong); + font-size: 1.35rem; + line-height: 1; +} + +.historical-comparison-card__score-block strong { + display: block; + font-size: 1rem; +} + +.historical-comparison-card__meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.historical-comparison-card__meta article { + padding: 10px 12px; + border: 1px solid rgba(159, 168, 141, 0.14); + border-radius: 14px; + background: rgba(13, 17, 12, 0.42); +} + +.historical-comparison-card__summary { + margin: 0; + padding-top: 12px; + border-top: 1px solid rgba(159, 168, 141, 0.12); + color: var(--text-soft); + font-size: 0.9rem; + line-height: 1.5; +} + +.historical-match-card { + display: grid; + gap: 14px; +} + +.historical-match-card__top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.historical-match-card__title { + margin: 0; + font-size: 1.08rem; +} + +.historical-match-card__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} + +.historical-match-card__result { + padding: 0.45rem 0.75rem; + border: 1px solid rgba(183, 201, 125, 0.24); + border-radius: 999px; + color: var(--accent-strong); + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-match-card__link { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0 0.8rem; + border: 1px solid rgba(210, 182, 118, 0.34); + border-radius: 999px; + color: var(--accent-warm); + font-size: 0.76rem; + font-weight: 800; + letter-spacing: 0.08em; + text-decoration: none; + text-transform: uppercase; +} + +.historical-match-card__link:hover, +.historical-match-card__link:focus-visible { + border-color: rgba(210, 182, 118, 0.62); + color: var(--text); +} + +.historical-match-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); + gap: 12px; +} + +.historical-match-meta strong { + display: block; + line-height: 1.5; +} + +@media (max-width: 720px) { + .historical-hero__layout, + .historical-hero__topline, + .historical-panel__header, + .historical-detail-section__header, + .historical-match-card__top { + flex-direction: column; + align-items: flex-start; + } + + .historical-hero__layout { + display: grid; + grid-template-columns: 1fr; + } + + .historical-hero__copy, + .historical-hero__copy > div, + .historical-hero__text { + width: 100%; + max-width: 100%; + } + + .historical-hero__layout--registry .historical-hero__copy, + .historical-hero__layout--registry .historical-hero__title { + max-width: 100%; + } + + .historical-map-hero { + width: 100%; + min-height: 190px; + } + + .historical-map-hero__image { + min-height: 190px; + } + + .historical-selector { + flex-direction: column; + } + + .historical-tabs { + flex-direction: column; + } + + .historical-mvp-card__top { + flex-direction: column; + } + + .current-match-player-intro { + align-items: flex-start; + flex-direction: column; + } + + .historical-mvp-card__meta { + grid-template-columns: 1fr; + } + + .historical-mvp-card__signal-grid { + grid-template-columns: 1fr; + } + + .historical-comparison-card__top, + .historical-comparison-card__scores { + flex-direction: column; + } + + .historical-comparison-card__meta { + grid-template-columns: 1fr; + } + + .historical-match-card__actions { + justify-content: flex-start; + } + + .historical-pagination, + .historical-pagination__nav { + align-items: stretch; + flex-direction: column; + } + + .historical-pagination__size { + justify-content: space-between; + } + + .historical-tab { + width: 100%; + } + + .historical-selector__button { + width: 100%; + } + + .historical-table { + min-width: 540px; + } + + .current-match-killfeed { + grid-template-columns: 1fr; + max-height: 500px; + } + + .current-match-killfeed__row { + grid-template-columns: minmax(0, 1fr) minmax(86px, 116px) minmax(0, 1fr); + } +} + +@media (max-width: 480px) { + .current-match-killfeed__row { + grid-template-columns: minmax(0, 1fr) 82px minmax(0, 1fr); + gap: 5px; + padding-inline: 6px; + } + + .current-match-killfeed__weapon { + min-width: 82px; + padding-inline: 3px; + } + + .current-match-killfeed__player-name { + display: -webkit-box; + overflow: hidden; + font-size: 0.78rem; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + text-overflow: ellipsis; + white-space: normal; + } + + .current-match-killfeed__teamkill { + min-height: 16px; + font-size: 0.54rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .current-match-killfeed__row { + animation: none; + } +} + +.historical-match-card--clean { + gap: 12px; +} + +.historical-match-card__top--clean { + display: block; +} + +.historical-match-meta--clean { + grid-template-columns: + minmax(220px, 1.4fr) + minmax(130px, 0.75fr) + minmax(110px, 0.65fr) + minmax(110px, 0.65fr) + 340px; + align-items: end; +} + +.historical-match-meta--clean > article { + align-self: stretch; + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +.historical-match-card__actions-cell { + display: flex; + align-items: end; + justify-content: flex-end; + min-width: 340px; +} + +.historical-match-card__actions-cell .historical-match-card__actions { + gap: 10px; + align-items: center; + justify-content: flex-end; +} + +#recent-matches-list .historical-match-card__actions { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +#recent-matches-list .historical-match-card__result, +#recent-matches-list .historical-match-card__link { + flex: 0 0 auto; +} + +@media (max-width: 920px) { + .historical-match-meta--clean { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); + } + + .historical-match-card__actions-cell { + justify-content: flex-start; + min-width: 0; + } + + .historical-match-card__actions-cell .historical-match-card__actions { + justify-content: flex-start; + } + + #recent-matches-list .historical-match-card__actions { + flex-wrap: wrap; + justify-content: flex-start; + } +} + +/* Hide public scoreboard action only in the recent matches list. + The internal detail page can still show its own scoreboard button. */ +#recent-matches-list .historical-match-card__link--scoreboard { + display: none; +} \ No newline at end of file diff --git a/frontend/assets/css/styles.css b/frontend/assets/css/styles.css new file mode 100644 index 0000000..a3699f2 --- /dev/null +++ b/frontend/assets/css/styles.css @@ -0,0 +1,1066 @@ +:root { + --bg: #0f120d; + --bg-deep: #090b08; + --bg-elevated: rgba(27, 33, 24, 0.92); + --panel: rgba(21, 26, 19, 0.94); + --panel-soft: rgba(30, 36, 27, 0.7); + --border: rgba(159, 168, 141, 0.24); + --border-strong: rgba(183, 201, 125, 0.2); + --text: #e7e0cf; + --muted: #a9ad9a; + --text-soft: #c8ccb8; + --accent: #8ea062; + --accent-strong: #b7c97d; + --accent-warm: #d2b676; + --shadow: 0 28px 72px rgba(0, 0, 0, 0.42); + --shadow-soft: 0 18px 40px rgba(0, 0, 0, 0.24); + --page-shell-width: 1600px; + --page-shell-gutter: 32px; + --panel-content-width: 1120px; + --font-main: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; +} + +* { + box-sizing: border-box; +} + +html { + font-size: 16px; +} + +body { + margin: 0; + min-height: 100vh; + font-family: var(--font-main); + color: var(--text); + background-color: var(--bg); + background: + linear-gradient(rgba(9, 11, 8, 0.56), rgba(9, 11, 8, 0.97)), + radial-gradient(circle at 18% 12%, rgba(108, 124, 75, 0.2), transparent 28%), + radial-gradient(circle at top, rgba(84, 96, 59, 0.22), transparent 34%), + radial-gradient(circle at 85% 10%, rgba(210, 182, 118, 0.1), transparent 24%), + radial-gradient(circle at 78% 65%, rgba(54, 65, 42, 0.2), transparent 30%), + radial-gradient(circle at bottom, rgba(210, 182, 118, 0.08), transparent 28%), + linear-gradient(145deg, #171d15 0%, var(--bg-deep) 100%); +} + +img { + display: block; + max-width: 100%; +} + +a { + color: inherit; + text-decoration: none; +} + +.page-shell { + position: relative; + isolation: isolate; + width: 100%; + padding: 30px 20px 72px; +} + +.page-shell::before { + content: ""; + position: absolute; + inset: 0; + z-index: -2; + background: + radial-gradient(circle at 12% 18%, rgba(210, 182, 118, 0.07), transparent 18%), + radial-gradient(circle at 82% 9%, rgba(142, 160, 98, 0.14), transparent 24%), + linear-gradient(180deg, rgba(255, 255, 255, 0.012), transparent 24%); + pointer-events: none; +} + +.page-shell::after { + content: ""; + position: absolute; + inset: 18px 4% auto; + z-index: -1; + height: 220px; + border-radius: 999px; + background: radial-gradient(circle, rgba(0, 0, 0, 0.36), transparent 72%); + filter: blur(18px); + pointer-events: none; +} + +.hero { + position: relative; + overflow: hidden; + width: min(var(--page-shell-width), calc(100vw - (var(--page-shell-gutter) * 2))); + margin: 0 auto; + border: 1px solid var(--border); + border-radius: 24px; + background: + linear-gradient(180deg, rgba(26, 33, 21, 0.86), rgba(10, 13, 9, 0.98)), + radial-gradient(circle at top center, rgba(183, 201, 125, 0.08), transparent 32%), + radial-gradient(circle at 82% 22%, rgba(210, 182, 118, 0.08), transparent 24%), + repeating-linear-gradient( + 90deg, + rgba(255, 255, 255, 0.015) 0, + rgba(255, 255, 255, 0.015) 1px, + transparent 1px, + transparent 24px + ); + box-shadow: var(--shadow); +} + +.hero::before { + content: ""; + position: absolute; + inset: 18px; + border: 1px solid rgba(210, 182, 118, 0.1); + border-radius: 18px; + pointer-events: none; +} + +.hero::after { + content: ""; + position: absolute; + inset: auto 32px 0; + height: 1px; + background: linear-gradient( + 90deg, + transparent, + rgba(183, 201, 125, 0.2), + transparent + ); + pointer-events: none; +} + +.hero__overlay { + position: absolute; + inset: 0; + background: + radial-gradient(circle at top right, rgba(142, 160, 98, 0.18), transparent 28%), + radial-gradient(circle at left center, rgba(210, 182, 118, 0.1), transparent 26%), + radial-gradient(circle at 50% 115%, rgba(0, 0, 0, 0.42), transparent 34%), + linear-gradient(135deg, transparent 0%, rgba(0, 0, 0, 0.2) 100%), + linear-gradient(180deg, rgba(0, 0, 0, 0) 48%, rgba(0, 0, 0, 0.34) 100%); + pointer-events: none; +} + +.hero__content { + position: relative; + z-index: 1; + width: 100%; + max-width: none; + margin: 0 auto; + padding: 58px 52px 64px; +} + +.hero__brand { + position: relative; + display: grid; + grid-template-columns: minmax(240px, 340px) minmax(0, 1fr); + align-items: center; + gap: 36px; +} + +.hero__copy { + display: grid; + justify-items: start; + text-align: left; + gap: 18px; + max-width: 660px; +} + +.hero__content::before { + content: ""; + position: absolute; + inset: 28px 80px auto; + height: 140px; + border-radius: 999px; + background: radial-gradient( + circle, + rgba(210, 182, 118, 0.16) 0%, + rgba(142, 160, 98, 0.08) 42%, + transparent 78% + ); + filter: blur(14px); + pointer-events: none; +} + +.logo-frame { + width: min(340px, 100%); + min-height: 248px; + padding: 24px 28px; + display: grid; + place-items: center; + border: 1px dashed rgba(183, 201, 125, 0.45); + border-radius: 18px; + background: + linear-gradient(180deg, rgba(19, 24, 16, 0.9), rgba(10, 13, 9, 0.72)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 18px 40px rgba(0, 0, 0, 0.24); + position: relative; + isolation: isolate; +} + +.logo-frame::before { + content: ""; + position: absolute; + inset: 10px; + border: 1px solid rgba(210, 182, 118, 0.14); + border-radius: 12px; + pointer-events: none; +} + +.logo-frame::after { + content: ""; + position: absolute; + inset: auto 18% -24px; + height: 48px; + background: radial-gradient(circle, rgba(0, 0, 0, 0.42), transparent 72%); + filter: blur(8px); + z-index: -1; + pointer-events: none; +} + +.logo-frame__image { + width: auto; + height: auto; + max-width: 100%; + max-height: 246px; + object-fit: contain; + filter: + drop-shadow(0 10px 20px rgba(0, 0, 0, 0.28)) + drop-shadow(0 0 18px rgba(210, 182, 118, 0.08)); +} + +.eyebrow { + margin: 0; + padding: 0.35rem 0.75rem; + border: 1px solid rgba(183, 201, 125, 0.22); + border-radius: 999px; + background: rgba(142, 160, 98, 0.08); + font-size: 0.76rem; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--accent-strong); +} + +.eyebrow--section { + margin-bottom: 0.85rem; +} + +h1, +h2 { + margin: 0; + line-height: 1.1; +} + +h1 { + max-width: 10ch; + font-size: clamp(2.6rem, 5vw, 4.9rem); + letter-spacing: 0.02em; + text-shadow: 0 10px 28px rgba(0, 0, 0, 0.32); +} + +h2 { + font-size: clamp(1.5rem, 2.8vw, 2.2rem); +} + +.hero__title-accent { + display: block; + color: var(--accent-warm); +} + +.hero__text { + margin: 0; + max-width: 56ch; + color: var(--text-soft); + font-size: 1.03rem; + line-height: 1.75; +} + +.hero__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 14px 16px; + margin-top: 2px; +} + +.status-chip { + margin: 0; + padding: 0.5rem 0.9rem; + border: 1px solid rgba(159, 168, 141, 0.22); + border-radius: 999px; + background: linear-gradient(180deg, rgba(28, 34, 26, 0.82), rgba(14, 18, 13, 0.86)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 10px 24px rgba(0, 0, 0, 0.18); + font-size: 0.76rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); +} + +.status-chip--ok { + border-color: rgba(183, 201, 125, 0.34); + background: rgba(142, 160, 98, 0.12); + color: var(--accent-strong); +} + +.status-chip--fallback { + border-color: rgba(210, 182, 118, 0.24); + background: rgba(210, 182, 118, 0.08); + color: var(--accent-warm); +} + +.servers-loading, +.servers-empty { + display: grid; + justify-items: center; + gap: 14px; + width: 100%; + padding: 36px 24px; + border: 1px dashed rgba(183, 201, 125, 0.24); + border-radius: 18px; + background: linear-gradient(180deg, rgba(19, 24, 16, 0.72), rgba(10, 13, 9, 0.84)); + color: var(--text-soft); + text-align: center; +} + +.servers-loading__pulse { + width: 14px; + height: 14px; + border-radius: 999px; + background: var(--accent-warm); + box-shadow: 0 0 0 rgba(210, 182, 118, 0.34); + animation: servers-loading-pulse 1.6s ease-in-out infinite; +} + +@keyframes servers-loading-pulse { + 0% { + transform: scale(0.9); + box-shadow: 0 0 0 0 rgba(210, 182, 118, 0.26); + opacity: 0.78; + } + + 70% { + transform: scale(1); + box-shadow: 0 0 0 16px rgba(210, 182, 118, 0); + opacity: 1; + } + + 100% { + transform: scale(0.92); + box-shadow: 0 0 0 0 rgba(210, 182, 118, 0); + opacity: 0.82; + } +} + +.discord-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 52px; + min-width: 220px; + padding: 0 28px; + border: 1px solid rgba(183, 201, 125, 0.45); + border-radius: 999px; + background: linear-gradient(180deg, #8ea062 0%, #6e7f48 100%); + color: #11150f; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + transition: + transform 160ms ease, + box-shadow 160ms ease, + filter 160ms ease; + box-shadow: 0 12px 30px rgba(110, 127, 72, 0.35); +} + +.secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 52px; + min-width: 220px; + padding: 0 24px; + border: 1px solid rgba(210, 182, 118, 0.32); + border-radius: 999px; + background: linear-gradient(180deg, rgba(28, 34, 25, 0.9), rgba(12, 15, 11, 0.96)); + color: var(--text); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + transition: + transform 160ms ease, + border-color 160ms ease, + background 160ms ease; +} + +.secondary-button:hover, +.secondary-button:focus-visible { + transform: translateY(-1px); + border-color: rgba(210, 182, 118, 0.5); + background: linear-gradient(180deg, rgba(43, 50, 36, 0.94), rgba(16, 20, 14, 0.98)); +} + +.secondary-button--ghost { + min-width: 0; + min-height: 42px; + padding: 0 18px; + font-size: 0.76rem; +} + +.secondary-button--compact { + min-height: 44px; + min-width: 0; + padding: 0 18px; + font-size: 0.76rem; +} + +.discord-button:hover, +.discord-button:focus-visible { + transform: translateY(-1px); + filter: brightness(1.04); + box-shadow: 0 18px 36px rgba(110, 127, 72, 0.45); +} + +.content { + width: 100%; + margin-top: -28px; + position: relative; + z-index: 2; + display: grid; + gap: 30px; +} + +.panel { + position: relative; + width: min(var(--page-shell-width), calc(100vw - (var(--page-shell-gutter) * 2))); + margin: 0 auto; + padding: 34px 40px 38px; + border: 1px solid var(--border); + border-radius: 24px; + background: + linear-gradient(180deg, rgba(25, 31, 23, 0.96), rgba(12, 15, 11, 0.995)); + box-shadow: + 0 34px 82px rgba(0, 0, 0, 0.34), + inset 0 1px 0 rgba(255, 255, 255, 0.03); +} + +.panel::before { + content: ""; + position: absolute; + inset: 0 auto auto 32px; + width: 120px; + height: 1px; + background: linear-gradient(90deg, rgba(210, 182, 118, 0.7), transparent); +} + +.panel::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: + radial-gradient(circle at top center, rgba(183, 201, 125, 0.07), transparent 30%), + linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent 18%); + pointer-events: none; +} + +.panel__header { + margin-bottom: 22px; +} + +.panel__shell { + width: 100%; + margin: 0 auto; +} + +.panel__header--servers { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.panel__intro { + margin: 0 0 20px; + max-width: 70ch; + color: var(--text-soft); + line-height: 1.75; +} + +.panel__intro--tight { + margin-bottom: 0; + max-width: 58ch; +} + +.panel__actions { + margin-top: 18px; + margin-bottom: 20px; +} + +.panel__actions--compact { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.panel--video { + position: relative; + backdrop-filter: blur(4px); +} + +.video-wrapper { + position: relative; + width: 100%; + overflow: hidden; + padding: 12px; + border: 1px solid rgba(159, 168, 141, 0.2); + border-radius: 20px; + background: + linear-gradient(180deg, rgba(29, 35, 26, 0.95), rgba(10, 12, 9, 0.98)); + aspect-ratio: 16 / 9; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 24px 44px rgba(0, 0, 0, 0.3); +} + +.video-wrapper::before { + content: ""; + position: absolute; + inset: 0; + border-radius: 20px; + border: 1px solid rgba(210, 182, 118, 0.12); + pointer-events: none; +} + +.video-wrapper::after { + content: ""; + position: absolute; + inset: 12px auto auto 12px; + width: 132px; + height: 28px; + border-radius: 999px; + background: + linear-gradient(90deg, rgba(12, 16, 10, 0.88), rgba(12, 16, 10, 0.24)); + pointer-events: none; +} + +.video-wrapper iframe { + width: 100%; + height: 100%; + border: 0; + border-radius: 10px; +} + +.servers-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); + gap: 24px; +} + +.servers-grid--section { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); + align-items: stretch; +} + +.server-card { + position: relative; + overflow: hidden; + min-width: 0; + padding: 22px; + border: 1px solid rgba(159, 168, 141, 0.18); + border-radius: 22px; + background: + linear-gradient(180deg, rgba(28, 34, 25, 0.94), rgba(15, 18, 13, 0.98)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.03), + var(--shadow-soft); +} + +.server-card::before { + content: ""; + position: absolute; + inset: 0 0 auto; + height: 1px; + background: linear-gradient(90deg, rgba(210, 182, 118, 0.4), transparent 68%); + pointer-events: none; +} + +.server-card::after { + content: ""; + position: absolute; + inset: 0; + background: + radial-gradient(circle at top right, rgba(183, 201, 125, 0.06), transparent 28%); + pointer-events: none; +} + +.server-card--real { + border-color: rgba(183, 201, 125, 0.24); + background: + linear-gradient(180deg, rgba(32, 40, 28, 0.96), rgba(14, 18, 13, 0.98)); +} + +.server-card--reference { + border-color: rgba(210, 182, 118, 0.18); + background: + linear-gradient(180deg, rgba(31, 28, 22, 0.94), rgba(16, 14, 11, 0.98)); +} + +.server-card__top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.server-card__top--stats { + align-items: flex-start; + gap: 18px; +} + +.server-card__identity { + min-width: 0; + display: grid; + gap: 6px; +} + +.server-card__eyebrow { + margin: 0; + color: var(--accent-warm); + font-size: 0.68rem; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.server-card h3 { + margin: 0; + font-size: 1.08rem; + line-height: 1.4; + max-width: none; + overflow-wrap: anywhere; +} + +.server-card__status-column { + display: grid; + align-content: start; + justify-items: end; + gap: 10px; + min-width: 150px; +} + +.server-card__population { + margin: 0; + padding: 10px 12px; + max-width: 100%; + min-width: 120px; + border: 1px solid rgba(159, 168, 141, 0.18); + border-radius: 14px; + background: linear-gradient(180deg, rgba(12, 15, 10, 0.54), rgba(9, 11, 8, 0.34)); + color: var(--accent-strong); + font-size: 1rem; + font-weight: 700; + letter-spacing: 0.04em; + text-align: center; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); +} + +.server-card__actions { + margin: 0; + display: flex; + align-items: flex-end; + align-self: end; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.server-action-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: auto; + max-width: 100%; + min-height: 42px; + min-width: 136px; + padding: 0 16px; + border: 1px solid rgba(183, 201, 125, 0.38); + border-radius: 999px; + background: linear-gradient(180deg, rgba(183, 201, 125, 0.18), rgba(110, 127, 72, 0.24)); + color: var(--accent-strong); + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + cursor: pointer; + transition: + transform 160ms ease, + border-color 160ms ease, + background 160ms ease, + opacity 160ms ease; +} + +.server-action-link:hover, +.server-action-link:focus-visible { + transform: translateY(-1px); + border-color: rgba(210, 182, 118, 0.52); + background: linear-gradient(180deg, rgba(210, 182, 118, 0.2), rgba(142, 160, 98, 0.26)); +} + +.server-state { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 88px; + padding: 0.38rem 0.72rem; + border: 1px solid rgba(159, 168, 141, 0.22); + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); +} + +.server-state--online { + border-color: rgba(183, 201, 125, 0.34); + background: rgba(142, 160, 98, 0.12); + color: var(--accent-strong); +} + +.server-state--offline { + border-color: rgba(210, 182, 118, 0.24); + background: rgba(210, 182, 118, 0.08); + color: var(--accent-warm); +} + +.server-card__quickfacts { + margin-bottom: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +} + +.server-card__bottom { + display: grid; + grid-template-columns: minmax(0, 280px) minmax(272px, 1fr); + align-items: end; + justify-content: space-between; + gap: 16px; +} + +.server-card__quickfact { + min-width: 0; + padding: 12px 14px; + border: 1px solid rgba(159, 168, 141, 0.14); + border-radius: 14px; + background: linear-gradient(180deg, rgba(12, 15, 11, 0.34), rgba(8, 10, 7, 0.2)); +} + +.server-card__quickfact p, +.server-card__quickfact strong { + margin: 0; +} + +.server-card__quickfact p { + margin-bottom: 6px; + color: var(--muted); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.server-card__quickfact strong { + display: block; + color: var(--text); + font-size: 0.92rem; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.server-card__quickfact-value--map { + font-size: 0.98rem; + line-height: 1.5; + overflow-wrap: break-word; + word-break: normal; + hyphens: auto; +} + +.servers-empty { + margin: 0; + padding: 18px; + border: 1px dashed rgba(159, 168, 141, 0.24); + border-radius: 18px; + color: var(--muted); + text-align: center; +} + +.clans-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); + gap: 20px; +} + +.clan-card { + position: relative; + overflow: hidden; + display: grid; + gap: 18px; + padding: 22px; + border: 1px solid rgba(159, 168, 141, 0.18); + border-radius: 22px; + background: + linear-gradient(180deg, rgba(28, 34, 25, 0.94), rgba(15, 18, 13, 0.98)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.03), + var(--shadow-soft); +} + +.clan-card::before { + content: ""; + position: absolute; + inset: 0 0 auto; + height: 1px; + background: linear-gradient(90deg, rgba(210, 182, 118, 0.4), transparent 68%); + pointer-events: none; +} + +.clan-card__brand { + display: grid; + grid-template-columns: minmax(92px, 112px) minmax(0, 1fr); + align-items: center; + gap: 18px; +} + +.clan-card__logo { + min-height: 92px; + padding: 12px; + display: grid; + place-items: center; + border: 1px dashed rgba(183, 201, 125, 0.3); + border-radius: 18px; + background: linear-gradient(180deg, rgba(19, 24, 16, 0.82), rgba(10, 13, 9, 0.66)); +} + +.clan-card__logo--wide { + padding-inline: 8px; +} + +.clan-card__logo--shield { + padding-inline: 18px; +} + +.clan-card__logo img { + width: auto; + height: auto; + max-width: 100%; + max-height: 84px; + object-fit: contain; +} + +.clan-card__logo-placeholder { + width: 100%; + min-height: 84px; + display: grid; + place-items: center; + border: 1px dashed rgba(210, 182, 118, 0.22); + border-radius: 14px; + background: + linear-gradient(180deg, rgba(28, 32, 24, 0.88), rgba(12, 15, 11, 0.7)), + repeating-linear-gradient( + 135deg, + rgba(255, 255, 255, 0.02) 0, + rgba(255, 255, 255, 0.02) 10px, + transparent 10px, + transparent 20px + ); + color: var(--accent-warm); + font-size: 0.88rem; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.clan-card__copy { + display: grid; + gap: 8px; +} + +.clan-card__eyebrow { + margin: 0; + color: var(--accent-warm); + font-size: 0.68rem; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.clan-card__copy h3, +.clan-card__copy p { + margin: 0; +} + +.clan-card__copy h3 { + font-size: 1.08rem; + line-height: 1.4; +} + +.clan-card__copy p:last-child { + color: var(--text-soft); + line-height: 1.65; +} + +.clan-card__link { + width: fit-content; + min-width: 180px; +} + +.server-action-link--disabled { + border-color: rgba(159, 168, 141, 0.18); + background: linear-gradient(180deg, rgba(42, 46, 39, 0.5), rgba(19, 22, 17, 0.7)); + color: var(--muted); + cursor: default; + pointer-events: none; +} + +@media (max-width: 1120px) { + .hero__brand { + grid-template-columns: 1fr; + justify-items: center; + gap: 24px; + } + + .hero__copy { + justify-items: center; + text-align: center; + } + + .hero__actions { + justify-content: center; + } + + .servers-grid--section { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); + } +} + +@media (max-width: 760px) { + .servers-grid, + .servers-grid--section, + .clans-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .page-shell { + padding: 18px 14px 40px; + } + + .hero__content { + padding: 42px 18px 48px; + } + + .panel { + padding: 24px 16px; + } + + .hero__content::before { + inset: 24px 24px auto; + height: 110px; + } + + .hero__text { + font-size: 0.98rem; + } + + .logo-frame { + min-height: 180px; + padding: 16px 18px; + } + + .logo-frame__image { + max-height: 180px; + } + + h1 { + max-width: 11ch; + font-size: clamp(2.2rem, 10vw, 3.2rem); + } + + .hero__actions { + width: 100%; + justify-content: center; + } + + .discord-button { + width: 100%; + } + + .secondary-button { + width: 100%; + } + + .content { + margin-top: 18px; + gap: 18px; + } + + .panel__header--servers, + .server-card__top { + flex-direction: column; + align-items: flex-start; + } + + .server-state { + min-width: 0; + } + + .server-card__status-column { + width: 100%; + justify-items: start; + min-width: 0; + } + + .server-card__population { + min-width: 0; + } + + .server-card__actions { + justify-content: flex-start; + } + + .server-card__quickfacts { + grid-template-columns: 1fr; + } + + .server-card__bottom { + grid-template-columns: 1fr; + } + + .clan-card__brand { + grid-template-columns: 1fr; + justify-items: start; + } + + .clan-card__logo { + width: 100%; + } + + .clan-card__link { + width: 100%; + } + + .panel::before { + left: 16px; + } + + .video-wrapper { + padding: 6px; + } + + .servers-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/assets/img/.gitkeep b/frontend/assets/img/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/frontend/assets/img/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/assets/img/clans/250hispania-shield.png b/frontend/assets/img/clans/250hispania-shield.png new file mode 100644 index 0000000..a7adf08 Binary files /dev/null and b/frontend/assets/img/clans/250hispania-shield.png differ diff --git a/frontend/assets/img/clans/250hispania.png b/frontend/assets/img/clans/250hispania.png new file mode 100644 index 0000000..ff134fe Binary files /dev/null and b/frontend/assets/img/clans/250hispania.png differ diff --git a/frontend/assets/img/clans/7dv.png b/frontend/assets/img/clans/7dv.png new file mode 100644 index 0000000..9c4595d Binary files /dev/null and b/frontend/assets/img/clans/7dv.png differ diff --git a/frontend/assets/img/clans/bxb.png b/frontend/assets/img/clans/bxb.png new file mode 100644 index 0000000..9d2fd33 Binary files /dev/null and b/frontend/assets/img/clans/bxb.png differ diff --git a/frontend/assets/img/clans/h9h.png b/frontend/assets/img/clans/h9h.png new file mode 100644 index 0000000..2df20d9 Binary files /dev/null and b/frontend/assets/img/clans/h9h.png differ diff --git a/frontend/assets/img/clans/la129.png b/frontend/assets/img/clans/la129.png new file mode 100644 index 0000000..3569c0b Binary files /dev/null and b/frontend/assets/img/clans/la129.png differ diff --git a/frontend/assets/img/clans/lcm.png b/frontend/assets/img/clans/lcm.png new file mode 100644 index 0000000..2e77a97 Binary files /dev/null and b/frontend/assets/img/clans/lcm.png differ diff --git a/frontend/assets/img/factions/britain.webp b/frontend/assets/img/factions/britain.webp new file mode 100644 index 0000000..3271852 Binary files /dev/null and b/frontend/assets/img/factions/britain.webp differ diff --git a/frontend/assets/img/factions/germany.webp b/frontend/assets/img/factions/germany.webp new file mode 100644 index 0000000..ed29e5f Binary files /dev/null and b/frontend/assets/img/factions/germany.webp differ diff --git a/frontend/assets/img/factions/soviets.webp b/frontend/assets/img/factions/soviets.webp new file mode 100644 index 0000000..2ed1167 Binary files /dev/null and b/frontend/assets/img/factions/soviets.webp differ diff --git a/frontend/assets/img/factions/us.webp b/frontend/assets/img/factions/us.webp new file mode 100644 index 0000000..69ff852 Binary files /dev/null and b/frontend/assets/img/factions/us.webp differ diff --git a/frontend/assets/img/logo.png b/frontend/assets/img/logo.png new file mode 100644 index 0000000..18d2be4 Binary files /dev/null and b/frontend/assets/img/logo.png differ diff --git a/frontend/assets/img/maps/carentan-day.webp b/frontend/assets/img/maps/carentan-day.webp new file mode 100644 index 0000000..4e4d5b1 Binary files /dev/null and b/frontend/assets/img/maps/carentan-day.webp differ diff --git a/frontend/assets/img/maps/driel-day.webp b/frontend/assets/img/maps/driel-day.webp new file mode 100644 index 0000000..9d6ab2e Binary files /dev/null and b/frontend/assets/img/maps/driel-day.webp differ diff --git a/frontend/assets/img/maps/elalamein-day.webp b/frontend/assets/img/maps/elalamein-day.webp new file mode 100644 index 0000000..dd00ef0 Binary files /dev/null and b/frontend/assets/img/maps/elalamein-day.webp differ diff --git a/frontend/assets/img/maps/elsenbornridge-day.webp b/frontend/assets/img/maps/elsenbornridge-day.webp new file mode 100644 index 0000000..334a48f Binary files /dev/null and b/frontend/assets/img/maps/elsenbornridge-day.webp differ diff --git a/frontend/assets/img/maps/foy-day.webp b/frontend/assets/img/maps/foy-day.webp new file mode 100644 index 0000000..7eb7dfe Binary files /dev/null and b/frontend/assets/img/maps/foy-day.webp differ diff --git a/frontend/assets/img/maps/hill400-day.webp b/frontend/assets/img/maps/hill400-day.webp new file mode 100644 index 0000000..24ac56b Binary files /dev/null and b/frontend/assets/img/maps/hill400-day.webp differ diff --git a/frontend/assets/img/maps/hurtgenforest-day.webp b/frontend/assets/img/maps/hurtgenforest-day.webp new file mode 100644 index 0000000..11bccf7 Binary files /dev/null and b/frontend/assets/img/maps/hurtgenforest-day.webp differ diff --git a/frontend/assets/img/maps/kharkov-day.webp b/frontend/assets/img/maps/kharkov-day.webp new file mode 100644 index 0000000..c3a0c49 Binary files /dev/null and b/frontend/assets/img/maps/kharkov-day.webp differ diff --git a/frontend/assets/img/maps/kursk-day.webp b/frontend/assets/img/maps/kursk-day.webp new file mode 100644 index 0000000..5e00bac Binary files /dev/null and b/frontend/assets/img/maps/kursk-day.webp differ diff --git a/frontend/assets/img/maps/mortain-day.webp b/frontend/assets/img/maps/mortain-day.webp new file mode 100644 index 0000000..354a601 Binary files /dev/null and b/frontend/assets/img/maps/mortain-day.webp differ diff --git a/frontend/assets/img/maps/omahabeach-day.webp b/frontend/assets/img/maps/omahabeach-day.webp new file mode 100644 index 0000000..ece99ec Binary files /dev/null and b/frontend/assets/img/maps/omahabeach-day.webp differ diff --git a/frontend/assets/img/maps/purpleheartlane-rain.webp b/frontend/assets/img/maps/purpleheartlane-rain.webp new file mode 100644 index 0000000..b4e0585 Binary files /dev/null and b/frontend/assets/img/maps/purpleheartlane-rain.webp differ diff --git a/frontend/assets/img/maps/smolensk-day.webp b/frontend/assets/img/maps/smolensk-day.webp new file mode 100644 index 0000000..f8e815a Binary files /dev/null and b/frontend/assets/img/maps/smolensk-day.webp differ diff --git a/frontend/assets/img/maps/stmariedumont-day.webp b/frontend/assets/img/maps/stmariedumont-day.webp new file mode 100644 index 0000000..2daa86d Binary files /dev/null and b/frontend/assets/img/maps/stmariedumont-day.webp differ diff --git a/frontend/assets/img/maps/stmereeglise-day.webp b/frontend/assets/img/maps/stmereeglise-day.webp new file mode 100644 index 0000000..d2ca95b Binary files /dev/null and b/frontend/assets/img/maps/stmereeglise-day.webp differ diff --git a/frontend/assets/img/maps/tobruk-dawn.webp b/frontend/assets/img/maps/tobruk-dawn.webp new file mode 100644 index 0000000..f15404a Binary files /dev/null and b/frontend/assets/img/maps/tobruk-dawn.webp differ diff --git a/frontend/assets/img/maps/tobruk-day.webp b/frontend/assets/img/maps/tobruk-day.webp new file mode 100644 index 0000000..4ed1b16 Binary files /dev/null and b/frontend/assets/img/maps/tobruk-day.webp differ diff --git a/frontend/assets/img/maps/utahbeach-day.webp b/frontend/assets/img/maps/utahbeach-day.webp new file mode 100644 index 0000000..fcd4abd Binary files /dev/null and b/frontend/assets/img/maps/utahbeach-day.webp differ diff --git a/frontend/assets/img/weapons/bazooka.PNG b/frontend/assets/img/weapons/bazooka.PNG new file mode 100644 index 0000000..1931bb2 Binary files /dev/null and b/frontend/assets/img/weapons/bazooka.PNG differ diff --git a/frontend/assets/img/weapons/black/bazooka_black.svg b/frontend/assets/img/weapons/black/bazooka_black.svg new file mode 100644 index 0000000..d8711de --- /dev/null +++ b/frontend/assets/img/weapons/black/bazooka_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/bren_gun_black.svg b/frontend/assets/img/weapons/black/bren_gun_black.svg new file mode 100644 index 0000000..83f3cc0 --- /dev/null +++ b/frontend/assets/img/weapons/black/bren_gun_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/browing_m1919_black.svg b/frontend/assets/img/weapons/black/browing_m1919_black.svg new file mode 100644 index 0000000..3b70295 --- /dev/null +++ b/frontend/assets/img/weapons/black/browing_m1919_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/colt_1911_black.svg b/frontend/assets/img/weapons/black/colt_1911_black.svg new file mode 100644 index 0000000..78e032c --- /dev/null +++ b/frontend/assets/img/weapons/black/colt_1911_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/dp27_black.svg b/frontend/assets/img/weapons/black/dp27_black.svg new file mode 100644 index 0000000..b98d87b --- /dev/null +++ b/frontend/assets/img/weapons/black/dp27_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/flammenwefer41_black.svg b/frontend/assets/img/weapons/black/flammenwefer41_black.svg new file mode 100644 index 0000000..c797876 --- /dev/null +++ b/frontend/assets/img/weapons/black/flammenwefer41_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/gewehr_black.svg b/frontend/assets/img/weapons/black/gewehr_black.svg new file mode 100644 index 0000000..f49e4dd --- /dev/null +++ b/frontend/assets/img/weapons/black/gewehr_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/kar98k_black.svg b/frontend/assets/img/weapons/black/kar98k_black.svg new file mode 100644 index 0000000..daf387c --- /dev/null +++ b/frontend/assets/img/weapons/black/kar98k_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/kar98k_x8_black.svg b/frontend/assets/img/weapons/black/kar98k_x8_black.svg new file mode 100644 index 0000000..5becf00 --- /dev/null +++ b/frontend/assets/img/weapons/black/kar98k_x8_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/lee_enfield_n4_black.svg b/frontend/assets/img/weapons/black/lee_enfield_n4_black.svg new file mode 100644 index 0000000..805e28e --- /dev/null +++ b/frontend/assets/img/weapons/black/lee_enfield_n4_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/luger_p08_black.svg b/frontend/assets/img/weapons/black/luger_p08_black.svg new file mode 100644 index 0000000..274abdf --- /dev/null +++ b/frontend/assets/img/weapons/black/luger_p08_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/m1903_springfield_black.svg b/frontend/assets/img/weapons/black/m1903_springfield_black.svg new file mode 100644 index 0000000..69bdbc6 --- /dev/null +++ b/frontend/assets/img/weapons/black/m1903_springfield_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/m1_carabine_black.svg b/frontend/assets/img/weapons/black/m1_carabine_black.svg new file mode 100644 index 0000000..e8cb265 --- /dev/null +++ b/frontend/assets/img/weapons/black/m1_carabine_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/m1_garand_black.svg b/frontend/assets/img/weapons/black/m1_garand_black.svg new file mode 100644 index 0000000..198d463 --- /dev/null +++ b/frontend/assets/img/weapons/black/m1_garand_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/m2_flamethrower_black.svg b/frontend/assets/img/weapons/black/m2_flamethrower_black.svg new file mode 100644 index 0000000..13b196f --- /dev/null +++ b/frontend/assets/img/weapons/black/m2_flamethrower_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/m3_grease_gun_black.svg b/frontend/assets/img/weapons/black/m3_grease_gun_black.svg new file mode 100644 index 0000000..ed95d47 --- /dev/null +++ b/frontend/assets/img/weapons/black/m3_grease_gun_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/m97_black.svg b/frontend/assets/img/weapons/black/m97_black.svg new file mode 100644 index 0000000..36e1370 --- /dev/null +++ b/frontend/assets/img/weapons/black/m97_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/mg34_black.svg b/frontend/assets/img/weapons/black/mg34_black.svg new file mode 100644 index 0000000..73623ca --- /dev/null +++ b/frontend/assets/img/weapons/black/mg34_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/mg42_black.svg b/frontend/assets/img/weapons/black/mg42_black.svg new file mode 100644 index 0000000..572d28e --- /dev/null +++ b/frontend/assets/img/weapons/black/mg42_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/mosing_nagant_1891_black.svg b/frontend/assets/img/weapons/black/mosing_nagant_1891_black.svg new file mode 100644 index 0000000..9a43a30 --- /dev/null +++ b/frontend/assets/img/weapons/black/mosing_nagant_1891_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/mosing_nagant_9130_black.svg b/frontend/assets/img/weapons/black/mosing_nagant_9130_black.svg new file mode 100644 index 0000000..0f4cb80 --- /dev/null +++ b/frontend/assets/img/weapons/black/mosing_nagant_9130_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/mosing_nagant_m38_black.svg b/frontend/assets/img/weapons/black/mosing_nagant_m38_black.svg new file mode 100644 index 0000000..28752e1 --- /dev/null +++ b/frontend/assets/img/weapons/black/mosing_nagant_m38_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/mp40_black.svg b/frontend/assets/img/weapons/black/mp40_black.svg new file mode 100644 index 0000000..1dc44c5 --- /dev/null +++ b/frontend/assets/img/weapons/black/mp40_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/nagant_m1895_black.svg b/frontend/assets/img/weapons/black/nagant_m1895_black.svg new file mode 100644 index 0000000..90b609d --- /dev/null +++ b/frontend/assets/img/weapons/black/nagant_m1895_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/panzerchreck_black.svg b/frontend/assets/img/weapons/black/panzerchreck_black.svg new file mode 100644 index 0000000..76a0074 --- /dev/null +++ b/frontend/assets/img/weapons/black/panzerchreck_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/piat_black.svg b/frontend/assets/img/weapons/black/piat_black.svg new file mode 100644 index 0000000..cb2cd98 --- /dev/null +++ b/frontend/assets/img/weapons/black/piat_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/ppsh41_black.svg b/frontend/assets/img/weapons/black/ppsh41_black.svg new file mode 100644 index 0000000..b83dbd0 --- /dev/null +++ b/frontend/assets/img/weapons/black/ppsh41_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/ppsh_41w_drum_black.svg b/frontend/assets/img/weapons/black/ppsh_41w_drum_black.svg new file mode 100644 index 0000000..e3c4e30 --- /dev/null +++ b/frontend/assets/img/weapons/black/ppsh_41w_drum_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/ptrs41_black.svg b/frontend/assets/img/weapons/black/ptrs41_black.svg new file mode 100644 index 0000000..b6cf077 --- /dev/null +++ b/frontend/assets/img/weapons/black/ptrs41_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/scoped_mosin_nagant_9130_black.svg b/frontend/assets/img/weapons/black/scoped_mosin_nagant_9130_black.svg new file mode 100644 index 0000000..fbb9b02 --- /dev/null +++ b/frontend/assets/img/weapons/black/scoped_mosin_nagant_9130_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/scoped_svt40_black.svg b/frontend/assets/img/weapons/black/scoped_svt40_black.svg new file mode 100644 index 0000000..2a48ea9 --- /dev/null +++ b/frontend/assets/img/weapons/black/scoped_svt40_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/sten_mk_v_black.svg b/frontend/assets/img/weapons/black/sten_mk_v_black.svg new file mode 100644 index 0000000..0bf6fde --- /dev/null +++ b/frontend/assets/img/weapons/black/sten_mk_v_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/stg44_black.svg b/frontend/assets/img/weapons/black/stg44_black.svg new file mode 100644 index 0000000..acf00fc --- /dev/null +++ b/frontend/assets/img/weapons/black/stg44_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/svt40_black.svg b/frontend/assets/img/weapons/black/svt40_black.svg new file mode 100644 index 0000000..74feee4 --- /dev/null +++ b/frontend/assets/img/weapons/black/svt40_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/thompson_black.svg b/frontend/assets/img/weapons/black/thompson_black.svg new file mode 100644 index 0000000..c2082e2 --- /dev/null +++ b/frontend/assets/img/weapons/black/thompson_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/tokarev_tt33_black.svg b/frontend/assets/img/weapons/black/tokarev_tt33_black.svg new file mode 100644 index 0000000..39885cc --- /dev/null +++ b/frontend/assets/img/weapons/black/tokarev_tt33_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/walther_p38_black.svg b/frontend/assets/img/weapons/black/walther_p38_black.svg new file mode 100644 index 0000000..4c04457 --- /dev/null +++ b/frontend/assets/img/weapons/black/walther_p38_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/black/webley_revolver_black.svg b/frontend/assets/img/weapons/black/webley_revolver_black.svg new file mode 100644 index 0000000..3fd7b99 --- /dev/null +++ b/frontend/assets/img/weapons/black/webley_revolver_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/bren_gun.PNG b/frontend/assets/img/weapons/bren_gun.PNG new file mode 100644 index 0000000..2ad8652 Binary files /dev/null and b/frontend/assets/img/weapons/bren_gun.PNG differ diff --git a/frontend/assets/img/weapons/browing_m1919.PNG b/frontend/assets/img/weapons/browing_m1919.PNG new file mode 100644 index 0000000..b35bbe2 Binary files /dev/null and b/frontend/assets/img/weapons/browing_m1919.PNG differ diff --git a/frontend/assets/img/weapons/colt_1911.PNG b/frontend/assets/img/weapons/colt_1911.PNG new file mode 100644 index 0000000..a303b9f Binary files /dev/null and b/frontend/assets/img/weapons/colt_1911.PNG differ diff --git a/frontend/assets/img/weapons/dp27.PNG b/frontend/assets/img/weapons/dp27.PNG new file mode 100644 index 0000000..eba5e9a Binary files /dev/null and b/frontend/assets/img/weapons/dp27.PNG differ diff --git a/frontend/assets/img/weapons/flammenwefer41.PNG b/frontend/assets/img/weapons/flammenwefer41.PNG new file mode 100644 index 0000000..316316d Binary files /dev/null and b/frontend/assets/img/weapons/flammenwefer41.PNG differ diff --git a/frontend/assets/img/weapons/gewehr.PNG b/frontend/assets/img/weapons/gewehr.PNG new file mode 100644 index 0000000..d4a32ed Binary files /dev/null and b/frontend/assets/img/weapons/gewehr.PNG differ diff --git a/frontend/assets/img/weapons/kar98k.PNG b/frontend/assets/img/weapons/kar98k.PNG new file mode 100644 index 0000000..01e3b2b Binary files /dev/null and b/frontend/assets/img/weapons/kar98k.PNG differ diff --git a/frontend/assets/img/weapons/kar98k_x8.PNG b/frontend/assets/img/weapons/kar98k_x8.PNG new file mode 100644 index 0000000..a951486 Binary files /dev/null and b/frontend/assets/img/weapons/kar98k_x8.PNG differ diff --git a/frontend/assets/img/weapons/lee_enfield_n4.PNG b/frontend/assets/img/weapons/lee_enfield_n4.PNG new file mode 100644 index 0000000..78fa846 Binary files /dev/null and b/frontend/assets/img/weapons/lee_enfield_n4.PNG differ diff --git a/frontend/assets/img/weapons/luger_p08.PNG b/frontend/assets/img/weapons/luger_p08.PNG new file mode 100644 index 0000000..58da18f Binary files /dev/null and b/frontend/assets/img/weapons/luger_p08.PNG differ diff --git a/frontend/assets/img/weapons/m1903_springfield.PNG b/frontend/assets/img/weapons/m1903_springfield.PNG new file mode 100644 index 0000000..0d3bf0b Binary files /dev/null and b/frontend/assets/img/weapons/m1903_springfield.PNG differ diff --git a/frontend/assets/img/weapons/m1_carabine.PNG b/frontend/assets/img/weapons/m1_carabine.PNG new file mode 100644 index 0000000..99b9e53 Binary files /dev/null and b/frontend/assets/img/weapons/m1_carabine.PNG differ diff --git a/frontend/assets/img/weapons/m1_garand.PNG b/frontend/assets/img/weapons/m1_garand.PNG new file mode 100644 index 0000000..66ec70e Binary files /dev/null and b/frontend/assets/img/weapons/m1_garand.PNG differ diff --git a/frontend/assets/img/weapons/m2_flamethrower.PNG b/frontend/assets/img/weapons/m2_flamethrower.PNG new file mode 100644 index 0000000..3965dca Binary files /dev/null and b/frontend/assets/img/weapons/m2_flamethrower.PNG differ diff --git a/frontend/assets/img/weapons/m3_grease_gun.PNG b/frontend/assets/img/weapons/m3_grease_gun.PNG new file mode 100644 index 0000000..02bb622 Binary files /dev/null and b/frontend/assets/img/weapons/m3_grease_gun.PNG differ diff --git a/frontend/assets/img/weapons/m97.PNG b/frontend/assets/img/weapons/m97.PNG new file mode 100644 index 0000000..dbe3fdc Binary files /dev/null and b/frontend/assets/img/weapons/m97.PNG differ diff --git a/frontend/assets/img/weapons/mg34.PNG b/frontend/assets/img/weapons/mg34.PNG new file mode 100644 index 0000000..410e558 Binary files /dev/null and b/frontend/assets/img/weapons/mg34.PNG differ diff --git a/frontend/assets/img/weapons/mg42.PNG b/frontend/assets/img/weapons/mg42.PNG new file mode 100644 index 0000000..00376e9 Binary files /dev/null and b/frontend/assets/img/weapons/mg42.PNG differ diff --git a/frontend/assets/img/weapons/mosing_nagant_1891.PNG b/frontend/assets/img/weapons/mosing_nagant_1891.PNG new file mode 100644 index 0000000..1e21c98 Binary files /dev/null and b/frontend/assets/img/weapons/mosing_nagant_1891.PNG differ diff --git a/frontend/assets/img/weapons/mosing_nagant_9130.PNG b/frontend/assets/img/weapons/mosing_nagant_9130.PNG new file mode 100644 index 0000000..22771ec Binary files /dev/null and b/frontend/assets/img/weapons/mosing_nagant_9130.PNG differ diff --git a/frontend/assets/img/weapons/mosing_nagant_m38.PNG b/frontend/assets/img/weapons/mosing_nagant_m38.PNG new file mode 100644 index 0000000..4af4b9e Binary files /dev/null and b/frontend/assets/img/weapons/mosing_nagant_m38.PNG differ diff --git a/frontend/assets/img/weapons/mp40.PNG b/frontend/assets/img/weapons/mp40.PNG new file mode 100644 index 0000000..359cd8d Binary files /dev/null and b/frontend/assets/img/weapons/mp40.PNG differ diff --git a/frontend/assets/img/weapons/nagant_m1895.PNG b/frontend/assets/img/weapons/nagant_m1895.PNG new file mode 100644 index 0000000..62755d7 Binary files /dev/null and b/frontend/assets/img/weapons/nagant_m1895.PNG differ diff --git a/frontend/assets/img/weapons/panzerchreck.PNG b/frontend/assets/img/weapons/panzerchreck.PNG new file mode 100644 index 0000000..ec563e5 Binary files /dev/null and b/frontend/assets/img/weapons/panzerchreck.PNG differ diff --git a/frontend/assets/img/weapons/piat.PNG b/frontend/assets/img/weapons/piat.PNG new file mode 100644 index 0000000..534573e Binary files /dev/null and b/frontend/assets/img/weapons/piat.PNG differ diff --git a/frontend/assets/img/weapons/ppsh41.PNG b/frontend/assets/img/weapons/ppsh41.PNG new file mode 100644 index 0000000..174de04 Binary files /dev/null and b/frontend/assets/img/weapons/ppsh41.PNG differ diff --git a/frontend/assets/img/weapons/ppsh_41w_drum.PNG b/frontend/assets/img/weapons/ppsh_41w_drum.PNG new file mode 100644 index 0000000..23227fd Binary files /dev/null and b/frontend/assets/img/weapons/ppsh_41w_drum.PNG differ diff --git a/frontend/assets/img/weapons/ptrs41.PNG b/frontend/assets/img/weapons/ptrs41.PNG new file mode 100644 index 0000000..eb21493 Binary files /dev/null and b/frontend/assets/img/weapons/ptrs41.PNG differ diff --git a/frontend/assets/img/weapons/red/bazooka_red.svg b/frontend/assets/img/weapons/red/bazooka_red.svg new file mode 100644 index 0000000..0556eb2 --- /dev/null +++ b/frontend/assets/img/weapons/red/bazooka_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/bren_gun_red.svg b/frontend/assets/img/weapons/red/bren_gun_red.svg new file mode 100644 index 0000000..9379ee1 --- /dev/null +++ b/frontend/assets/img/weapons/red/bren_gun_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/browing_m1919_red.svg b/frontend/assets/img/weapons/red/browing_m1919_red.svg new file mode 100644 index 0000000..282f47b --- /dev/null +++ b/frontend/assets/img/weapons/red/browing_m1919_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/colt_1911_red.svg b/frontend/assets/img/weapons/red/colt_1911_red.svg new file mode 100644 index 0000000..00fd839 --- /dev/null +++ b/frontend/assets/img/weapons/red/colt_1911_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/dp27_red.svg b/frontend/assets/img/weapons/red/dp27_red.svg new file mode 100644 index 0000000..f7c254a --- /dev/null +++ b/frontend/assets/img/weapons/red/dp27_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/flammenwefer41_red.svg b/frontend/assets/img/weapons/red/flammenwefer41_red.svg new file mode 100644 index 0000000..ba31c70 --- /dev/null +++ b/frontend/assets/img/weapons/red/flammenwefer41_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/gewehr_red.svg b/frontend/assets/img/weapons/red/gewehr_red.svg new file mode 100644 index 0000000..68863e6 --- /dev/null +++ b/frontend/assets/img/weapons/red/gewehr_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/kar98k_red.svg b/frontend/assets/img/weapons/red/kar98k_red.svg new file mode 100644 index 0000000..71400d5 --- /dev/null +++ b/frontend/assets/img/weapons/red/kar98k_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/kar98k_x8_red.svg b/frontend/assets/img/weapons/red/kar98k_x8_red.svg new file mode 100644 index 0000000..9405c4c --- /dev/null +++ b/frontend/assets/img/weapons/red/kar98k_x8_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/lee_enfield_n4_red.svg b/frontend/assets/img/weapons/red/lee_enfield_n4_red.svg new file mode 100644 index 0000000..a20179a --- /dev/null +++ b/frontend/assets/img/weapons/red/lee_enfield_n4_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/luger_p08_red.svg b/frontend/assets/img/weapons/red/luger_p08_red.svg new file mode 100644 index 0000000..662f0c1 --- /dev/null +++ b/frontend/assets/img/weapons/red/luger_p08_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/m1903_springfield_red.svg b/frontend/assets/img/weapons/red/m1903_springfield_red.svg new file mode 100644 index 0000000..458cf4d --- /dev/null +++ b/frontend/assets/img/weapons/red/m1903_springfield_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/m1_carabine_red.svg b/frontend/assets/img/weapons/red/m1_carabine_red.svg new file mode 100644 index 0000000..c7eaed3 --- /dev/null +++ b/frontend/assets/img/weapons/red/m1_carabine_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/m1_garand_red.svg b/frontend/assets/img/weapons/red/m1_garand_red.svg new file mode 100644 index 0000000..cf46ede --- /dev/null +++ b/frontend/assets/img/weapons/red/m1_garand_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/m2_flamethrower_red.svg b/frontend/assets/img/weapons/red/m2_flamethrower_red.svg new file mode 100644 index 0000000..674e42c --- /dev/null +++ b/frontend/assets/img/weapons/red/m2_flamethrower_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/m3_grease_gun_red.svg b/frontend/assets/img/weapons/red/m3_grease_gun_red.svg new file mode 100644 index 0000000..5c49209 --- /dev/null +++ b/frontend/assets/img/weapons/red/m3_grease_gun_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/m97_red.svg b/frontend/assets/img/weapons/red/m97_red.svg new file mode 100644 index 0000000..bd375f5 --- /dev/null +++ b/frontend/assets/img/weapons/red/m97_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/mg34_red.svg b/frontend/assets/img/weapons/red/mg34_red.svg new file mode 100644 index 0000000..ac77a19 --- /dev/null +++ b/frontend/assets/img/weapons/red/mg34_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/mg42_red.svg b/frontend/assets/img/weapons/red/mg42_red.svg new file mode 100644 index 0000000..90c53e9 --- /dev/null +++ b/frontend/assets/img/weapons/red/mg42_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/mosing_nagant_1891_red.svg b/frontend/assets/img/weapons/red/mosing_nagant_1891_red.svg new file mode 100644 index 0000000..d318b8f --- /dev/null +++ b/frontend/assets/img/weapons/red/mosing_nagant_1891_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/mosing_nagant_9130_red.svg b/frontend/assets/img/weapons/red/mosing_nagant_9130_red.svg new file mode 100644 index 0000000..dacd7b2 --- /dev/null +++ b/frontend/assets/img/weapons/red/mosing_nagant_9130_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/mosing_nagant_m38_red.svg b/frontend/assets/img/weapons/red/mosing_nagant_m38_red.svg new file mode 100644 index 0000000..ae943c7 --- /dev/null +++ b/frontend/assets/img/weapons/red/mosing_nagant_m38_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/mp40_red.svg b/frontend/assets/img/weapons/red/mp40_red.svg new file mode 100644 index 0000000..91c2e9f --- /dev/null +++ b/frontend/assets/img/weapons/red/mp40_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/nagant_m1895_red.svg b/frontend/assets/img/weapons/red/nagant_m1895_red.svg new file mode 100644 index 0000000..afcbafd --- /dev/null +++ b/frontend/assets/img/weapons/red/nagant_m1895_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/panzerchreck_red.svg b/frontend/assets/img/weapons/red/panzerchreck_red.svg new file mode 100644 index 0000000..9680f07 --- /dev/null +++ b/frontend/assets/img/weapons/red/panzerchreck_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/piat_red.svg b/frontend/assets/img/weapons/red/piat_red.svg new file mode 100644 index 0000000..429fb6b --- /dev/null +++ b/frontend/assets/img/weapons/red/piat_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/ppsh41_red.svg b/frontend/assets/img/weapons/red/ppsh41_red.svg new file mode 100644 index 0000000..bcd6bcf --- /dev/null +++ b/frontend/assets/img/weapons/red/ppsh41_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/ppsh_41w_drum_red.svg b/frontend/assets/img/weapons/red/ppsh_41w_drum_red.svg new file mode 100644 index 0000000..88dbb6b --- /dev/null +++ b/frontend/assets/img/weapons/red/ppsh_41w_drum_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/ptrs41_red.svg b/frontend/assets/img/weapons/red/ptrs41_red.svg new file mode 100644 index 0000000..bede18f --- /dev/null +++ b/frontend/assets/img/weapons/red/ptrs41_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/scoped_mosin_nagant_9130_red.svg b/frontend/assets/img/weapons/red/scoped_mosin_nagant_9130_red.svg new file mode 100644 index 0000000..504fd22 --- /dev/null +++ b/frontend/assets/img/weapons/red/scoped_mosin_nagant_9130_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/scoped_svt40_red.svg b/frontend/assets/img/weapons/red/scoped_svt40_red.svg new file mode 100644 index 0000000..7f19ebb --- /dev/null +++ b/frontend/assets/img/weapons/red/scoped_svt40_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/sten_mk_v_red.svg b/frontend/assets/img/weapons/red/sten_mk_v_red.svg new file mode 100644 index 0000000..0d59100 --- /dev/null +++ b/frontend/assets/img/weapons/red/sten_mk_v_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/stg44_red.svg b/frontend/assets/img/weapons/red/stg44_red.svg new file mode 100644 index 0000000..e67a238 --- /dev/null +++ b/frontend/assets/img/weapons/red/stg44_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/svt40_red.svg b/frontend/assets/img/weapons/red/svt40_red.svg new file mode 100644 index 0000000..91e45fa --- /dev/null +++ b/frontend/assets/img/weapons/red/svt40_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/thompson_red.svg b/frontend/assets/img/weapons/red/thompson_red.svg new file mode 100644 index 0000000..5b82748 --- /dev/null +++ b/frontend/assets/img/weapons/red/thompson_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/tokarev_tt33_red.svg b/frontend/assets/img/weapons/red/tokarev_tt33_red.svg new file mode 100644 index 0000000..8ba75e2 --- /dev/null +++ b/frontend/assets/img/weapons/red/tokarev_tt33_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/walther_p38_red.svg b/frontend/assets/img/weapons/red/walther_p38_red.svg new file mode 100644 index 0000000..56cb2a7 --- /dev/null +++ b/frontend/assets/img/weapons/red/walther_p38_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/red/webley_revolver_red.svg b/frontend/assets/img/weapons/red/webley_revolver_red.svg new file mode 100644 index 0000000..94c783f --- /dev/null +++ b/frontend/assets/img/weapons/red/webley_revolver_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/scoped_mosin_nagant_9130.PNG b/frontend/assets/img/weapons/scoped_mosin_nagant_9130.PNG new file mode 100644 index 0000000..6f98634 Binary files /dev/null and b/frontend/assets/img/weapons/scoped_mosin_nagant_9130.PNG differ diff --git a/frontend/assets/img/weapons/scoped_svt40.PNG b/frontend/assets/img/weapons/scoped_svt40.PNG new file mode 100644 index 0000000..0e10b99 Binary files /dev/null and b/frontend/assets/img/weapons/scoped_svt40.PNG differ diff --git a/frontend/assets/img/weapons/sten_mk_v.PNG b/frontend/assets/img/weapons/sten_mk_v.PNG new file mode 100644 index 0000000..b6df7f2 Binary files /dev/null and b/frontend/assets/img/weapons/sten_mk_v.PNG differ diff --git a/frontend/assets/img/weapons/stg44.PNG b/frontend/assets/img/weapons/stg44.PNG new file mode 100644 index 0000000..95e5a81 Binary files /dev/null and b/frontend/assets/img/weapons/stg44.PNG differ diff --git a/frontend/assets/img/weapons/svt40.PNG b/frontend/assets/img/weapons/svt40.PNG new file mode 100644 index 0000000..7d18f2e Binary files /dev/null and b/frontend/assets/img/weapons/svt40.PNG differ diff --git a/frontend/assets/img/weapons/thompson.PNG b/frontend/assets/img/weapons/thompson.PNG new file mode 100644 index 0000000..f96f7dc Binary files /dev/null and b/frontend/assets/img/weapons/thompson.PNG differ diff --git a/frontend/assets/img/weapons/tokarev_tt33.PNG b/frontend/assets/img/weapons/tokarev_tt33.PNG new file mode 100644 index 0000000..b693240 Binary files /dev/null and b/frontend/assets/img/weapons/tokarev_tt33.PNG differ diff --git a/frontend/assets/img/weapons/walther_p38.PNG b/frontend/assets/img/weapons/walther_p38.PNG new file mode 100644 index 0000000..71ab2b2 Binary files /dev/null and b/frontend/assets/img/weapons/walther_p38.PNG differ diff --git a/frontend/assets/img/weapons/webley_revolver.PNG b/frontend/assets/img/weapons/webley_revolver.PNG new file mode 100644 index 0000000..4a5d7d9 Binary files /dev/null and b/frontend/assets/img/weapons/webley_revolver.PNG differ diff --git a/frontend/assets/img/weapons/white/bazooka_white.svg b/frontend/assets/img/weapons/white/bazooka_white.svg new file mode 100644 index 0000000..417e068 --- /dev/null +++ b/frontend/assets/img/weapons/white/bazooka_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/bren_gun_white.svg b/frontend/assets/img/weapons/white/bren_gun_white.svg new file mode 100644 index 0000000..feeb12c --- /dev/null +++ b/frontend/assets/img/weapons/white/bren_gun_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/browing_m1919_white.svg b/frontend/assets/img/weapons/white/browing_m1919_white.svg new file mode 100644 index 0000000..b6cee7c --- /dev/null +++ b/frontend/assets/img/weapons/white/browing_m1919_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/colt_1911_white.svg b/frontend/assets/img/weapons/white/colt_1911_white.svg new file mode 100644 index 0000000..612b7cd --- /dev/null +++ b/frontend/assets/img/weapons/white/colt_1911_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/dp27_white.svg b/frontend/assets/img/weapons/white/dp27_white.svg new file mode 100644 index 0000000..88d4284 --- /dev/null +++ b/frontend/assets/img/weapons/white/dp27_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/flammenwefer41_white.svg b/frontend/assets/img/weapons/white/flammenwefer41_white.svg new file mode 100644 index 0000000..75df07a --- /dev/null +++ b/frontend/assets/img/weapons/white/flammenwefer41_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/gewehr_white.svg b/frontend/assets/img/weapons/white/gewehr_white.svg new file mode 100644 index 0000000..ee556f6 --- /dev/null +++ b/frontend/assets/img/weapons/white/gewehr_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/kar98k_white.svg b/frontend/assets/img/weapons/white/kar98k_white.svg new file mode 100644 index 0000000..a88e7b3 --- /dev/null +++ b/frontend/assets/img/weapons/white/kar98k_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/kar98k_x8_white.svg b/frontend/assets/img/weapons/white/kar98k_x8_white.svg new file mode 100644 index 0000000..7e0b27d --- /dev/null +++ b/frontend/assets/img/weapons/white/kar98k_x8_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/lee_enfield_n4_white.svg b/frontend/assets/img/weapons/white/lee_enfield_n4_white.svg new file mode 100644 index 0000000..1f38f33 --- /dev/null +++ b/frontend/assets/img/weapons/white/lee_enfield_n4_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/luger_p08_white.svg b/frontend/assets/img/weapons/white/luger_p08_white.svg new file mode 100644 index 0000000..6c135a5 --- /dev/null +++ b/frontend/assets/img/weapons/white/luger_p08_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/m1903_springfield_white.svg b/frontend/assets/img/weapons/white/m1903_springfield_white.svg new file mode 100644 index 0000000..c0222e1 --- /dev/null +++ b/frontend/assets/img/weapons/white/m1903_springfield_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/m1_carabine_white.svg b/frontend/assets/img/weapons/white/m1_carabine_white.svg new file mode 100644 index 0000000..dcdc33e --- /dev/null +++ b/frontend/assets/img/weapons/white/m1_carabine_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/m1_garand_white.svg b/frontend/assets/img/weapons/white/m1_garand_white.svg new file mode 100644 index 0000000..5cf2b51 --- /dev/null +++ b/frontend/assets/img/weapons/white/m1_garand_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/m2_flamethrower_white.svg b/frontend/assets/img/weapons/white/m2_flamethrower_white.svg new file mode 100644 index 0000000..6a06490 --- /dev/null +++ b/frontend/assets/img/weapons/white/m2_flamethrower_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/m3_grease_gun_white.svg b/frontend/assets/img/weapons/white/m3_grease_gun_white.svg new file mode 100644 index 0000000..f945b66 --- /dev/null +++ b/frontend/assets/img/weapons/white/m3_grease_gun_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/m97_white.svg b/frontend/assets/img/weapons/white/m97_white.svg new file mode 100644 index 0000000..c82c3bb --- /dev/null +++ b/frontend/assets/img/weapons/white/m97_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/mg34_white.svg b/frontend/assets/img/weapons/white/mg34_white.svg new file mode 100644 index 0000000..0f1fa80 --- /dev/null +++ b/frontend/assets/img/weapons/white/mg34_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/mg42_white.svg b/frontend/assets/img/weapons/white/mg42_white.svg new file mode 100644 index 0000000..2a1d01c --- /dev/null +++ b/frontend/assets/img/weapons/white/mg42_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/mosing_nagant_1891_white.svg b/frontend/assets/img/weapons/white/mosing_nagant_1891_white.svg new file mode 100644 index 0000000..f918409 --- /dev/null +++ b/frontend/assets/img/weapons/white/mosing_nagant_1891_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/mosing_nagant_9130_white.svg b/frontend/assets/img/weapons/white/mosing_nagant_9130_white.svg new file mode 100644 index 0000000..bef6ec0 --- /dev/null +++ b/frontend/assets/img/weapons/white/mosing_nagant_9130_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/mosing_nagant_m38_white.svg b/frontend/assets/img/weapons/white/mosing_nagant_m38_white.svg new file mode 100644 index 0000000..f7fb0b7 --- /dev/null +++ b/frontend/assets/img/weapons/white/mosing_nagant_m38_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/mp40_white.svg b/frontend/assets/img/weapons/white/mp40_white.svg new file mode 100644 index 0000000..3474642 --- /dev/null +++ b/frontend/assets/img/weapons/white/mp40_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/nagant_m1895_white.svg b/frontend/assets/img/weapons/white/nagant_m1895_white.svg new file mode 100644 index 0000000..9624574 --- /dev/null +++ b/frontend/assets/img/weapons/white/nagant_m1895_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/panzerchreck_white.svg b/frontend/assets/img/weapons/white/panzerchreck_white.svg new file mode 100644 index 0000000..abd8964 --- /dev/null +++ b/frontend/assets/img/weapons/white/panzerchreck_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/piat_white.svg b/frontend/assets/img/weapons/white/piat_white.svg new file mode 100644 index 0000000..95ca97a --- /dev/null +++ b/frontend/assets/img/weapons/white/piat_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/ppsh41_white.svg b/frontend/assets/img/weapons/white/ppsh41_white.svg new file mode 100644 index 0000000..11bd2b3 --- /dev/null +++ b/frontend/assets/img/weapons/white/ppsh41_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/ppsh_41w_drum_white.svg b/frontend/assets/img/weapons/white/ppsh_41w_drum_white.svg new file mode 100644 index 0000000..7d5883c --- /dev/null +++ b/frontend/assets/img/weapons/white/ppsh_41w_drum_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/ptrs41_white.svg b/frontend/assets/img/weapons/white/ptrs41_white.svg new file mode 100644 index 0000000..f3a8d0b --- /dev/null +++ b/frontend/assets/img/weapons/white/ptrs41_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/scoped_mosin_nagant_9130_white.svg b/frontend/assets/img/weapons/white/scoped_mosin_nagant_9130_white.svg new file mode 100644 index 0000000..62a2f9e --- /dev/null +++ b/frontend/assets/img/weapons/white/scoped_mosin_nagant_9130_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/scoped_svt40_white.svg b/frontend/assets/img/weapons/white/scoped_svt40_white.svg new file mode 100644 index 0000000..f7a9fcf --- /dev/null +++ b/frontend/assets/img/weapons/white/scoped_svt40_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/sten_mk_v_white.svg b/frontend/assets/img/weapons/white/sten_mk_v_white.svg new file mode 100644 index 0000000..402f091 --- /dev/null +++ b/frontend/assets/img/weapons/white/sten_mk_v_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/stg44_white.svg b/frontend/assets/img/weapons/white/stg44_white.svg new file mode 100644 index 0000000..d7c4ed7 --- /dev/null +++ b/frontend/assets/img/weapons/white/stg44_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/svt40_white.svg b/frontend/assets/img/weapons/white/svt40_white.svg new file mode 100644 index 0000000..2161cc9 --- /dev/null +++ b/frontend/assets/img/weapons/white/svt40_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/thompson_white.svg b/frontend/assets/img/weapons/white/thompson_white.svg new file mode 100644 index 0000000..e6a1e64 --- /dev/null +++ b/frontend/assets/img/weapons/white/thompson_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/tokarev_tt33_white.svg b/frontend/assets/img/weapons/white/tokarev_tt33_white.svg new file mode 100644 index 0000000..9a68803 --- /dev/null +++ b/frontend/assets/img/weapons/white/tokarev_tt33_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/walther_p38_white.svg b/frontend/assets/img/weapons/white/walther_p38_white.svg new file mode 100644 index 0000000..881ffaa --- /dev/null +++ b/frontend/assets/img/weapons/white/walther_p38_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/img/weapons/white/webley_revolver_white.svg b/frontend/assets/img/weapons/white/webley_revolver_white.svg new file mode 100644 index 0000000..f48d20b --- /dev/null +++ b/frontend/assets/img/weapons/white/webley_revolver_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/js/config.js b/frontend/assets/js/config.js new file mode 100644 index 0000000..9c07fad --- /dev/null +++ b/frontend/assets/js/config.js @@ -0,0 +1,70 @@ +(function () { + "use strict"; + + const DEFAULT_DEV_BACKEND = "http://127.0.0.1:8000"; + + function isLocalHost(hostname) { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; + } + + function hasOwn(object, property) { + return Object.prototype.hasOwnProperty.call(object || {}, property); + } + + function resolveConfiguredBackendBaseUrl() { + const explicitConfig = window.HLL_FRONTEND_CONFIG || {}; + if (hasOwn(explicitConfig, "backendBaseUrl")) { + return String(explicitConfig.backendBaseUrl || ""); + } + + const body = document.body; + if (body && body.dataset && hasOwn(body.dataset, "backendBaseUrl")) { + const bodyValue = body.dataset.backendBaseUrl; + if (bodyValue === DEFAULT_DEV_BACKEND && !isLocalHost(window.location.hostname)) { + return ""; + } + return String(bodyValue || ""); + } + + return isLocalHost(window.location.hostname) ? DEFAULT_DEV_BACKEND : ""; + } + + function rewriteUrl(input) { + const configuredBaseUrl = resolveConfiguredBackendBaseUrl(); + if (typeof input !== "string") { + return input; + } + + if (configuredBaseUrl === "") { + if (input.startsWith(`${DEFAULT_DEV_BACKEND}/`)) { + return input.slice(DEFAULT_DEV_BACKEND.length); + } + return input; + } + + if (input.startsWith(`${DEFAULT_DEV_BACKEND}/`)) { + return `${configuredBaseUrl}${input.slice(DEFAULT_DEV_BACKEND.length)}`; + } + + return input; + } + + const nativeFetch = window.fetch.bind(window); + window.fetch = function hllConfiguredFetch(input, init) { + if (typeof input === "string") { + return nativeFetch(rewriteUrl(input), init); + } + if (input instanceof Request) { + const rewrittenUrl = rewriteUrl(input.url); + if (rewrittenUrl !== input.url) { + return nativeFetch(new Request(rewrittenUrl, input), init); + } + } + return nativeFetch(input, init); + }; + + window.HLL_FRONTEND_CONFIG = Object.freeze({ + ...window.HLL_FRONTEND_CONFIG, + backendBaseUrl: resolveConfiguredBackendBaseUrl(), + }); +})(); diff --git a/frontend/assets/js/historico-partida.js b/frontend/assets/js/historico-partida.js new file mode 100644 index 0000000..25ad79e --- /dev/null +++ b/frontend/assets/js/historico-partida.js @@ -0,0 +1,979 @@ +document.addEventListener("DOMContentLoaded", () => { + const backendBaseUrl = + document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000"; + const params = new URLSearchParams(window.location.search); + const serverSlug = params.get("server") || ""; + const matchId = params.get("match") || ""; + const nodes = { + title: document.getElementById("match-detail-title"), + summary: document.getElementById("match-detail-summary"), + note: document.getElementById("match-detail-note"), + state: document.getElementById("match-detail-state"), + grid: document.getElementById("match-detail-grid"), + actions: document.getElementById("match-detail-actions"), + playersSection: document.getElementById("match-detail-players-section"), + playersNote: document.getElementById("match-detail-players-note"), + playersState: document.getElementById("match-detail-players-state"), + playerControls: document.getElementById("match-detail-player-controls"), + playerSearch: document.getElementById("match-detail-player-search"), + playerTeamFilters: [...document.querySelectorAll('input[name="match-detail-player-team-filter"]')], + playerSort: document.getElementById("match-detail-player-sort"), + playerSortDirection: document.getElementById("match-detail-player-sort-direction"), + playersTableShell: document.getElementById("match-detail-players-table-shell"), + playersBody: document.getElementById("match-detail-players-body"), + timelineSection: document.getElementById("match-detail-timeline-section"), + timelineNote: document.getElementById("match-detail-timeline-note"), + timelineState: document.getElementById("match-detail-timeline-state"), + timelineGrid: document.getElementById("match-detail-timeline-grid"), + mapHero: document.getElementById("match-detail-map-hero"), + mapImage: document.getElementById("match-detail-map-image"), + }; + + if (!serverSlug || !matchId) { + nodes.title.textContent = "Partida no seleccionada"; + nodes.summary.textContent = "Vuelve al historico y abre una partida registrada."; + nodes.note.textContent = ""; + setState(nodes.state, "No hay una partida seleccionada.", true); + return; + } + + void loadMatchDetail({ backendBaseUrl, serverSlug, matchId, nodes }); +}); + +async function loadMatchDetail({ backendBaseUrl, serverSlug, matchId, nodes }) { + try { + const payload = await fetchJson( + `${backendBaseUrl}/api/historical/matches/detail?server=${encodeURIComponent( + serverSlug, + )}&match=${encodeURIComponent(matchId)}`, + ); + const data = payload?.data; + const item = data?.item; + if (!data?.found || !item) { + nodes.title.textContent = "Detalle no disponible"; + nodes.summary.textContent = + "La partida existe como enlace interno, pero todavia no hay detalle suficiente para mostrar."; + nodes.note.textContent = ""; + setState(nodes.state, "Detalle no disponible para esta partida."); + return; + } + + renderMatchDetail(item, nodes); + } catch (error) { + nodes.title.textContent = "Detalle no disponible"; + nodes.summary.textContent = "No se pudo conectar con el backend local."; + nodes.note.textContent = ""; + setState(nodes.state, "Error al cargar el detalle de la partida.", true); + } +} + +function renderMatchDetail(item, nodes) { + const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible"; + const serverName = item.server?.name || item.server?.slug || "Servidor no disponible"; + nodes.title.textContent = mapName; + nodes.summary.textContent = serverName; + nodes.note.textContent = ""; + renderMapHero(item, mapName, nodes); + nodes.grid.innerHTML = renderScoreboardDetail(item, { mapName, serverName }); + renderPlayerSection(item, nodes); + hideTimelineSection(nodes); + renderActions(item, nodes.actions); + nodes.state.hidden = true; + nodes.grid.hidden = false; +} + +function renderMapHero(item, mapName, nodes) { + if (!nodes.mapHero || !nodes.mapImage) { + return; + } + + const mapImagePath = resolveMapImagePath(item, mapName); + if (!mapImagePath) { + nodes.mapImage.removeAttribute("src"); + nodes.mapImage.alt = ""; + nodes.mapHero.hidden = true; + return; + } + + nodes.mapImage.src = mapImagePath; + nodes.mapImage.alt = mapName; + nodes.mapImage.onerror = () => { + nodes.mapImage.removeAttribute("src"); + nodes.mapHero.hidden = true; + }; + nodes.mapHero.hidden = false; +} + +function renderScoreboardDetail(item, { mapName, serverName }) { + const result = item.result || {}; + const alliedScore = Number.isFinite(Number(result.allied_score)) + ? formatNumber(result.allied_score) + : "-"; + const axisScore = Number.isFinite(Number(result.axis_score)) + ? formatNumber(result.axis_score) + : "-"; + const winner = String(item.winner || result.winner || "").toLowerCase(); + const isAlliedWinner = winner === "allies" || winner === "allied"; + const isAxisWinner = winner === "axis"; + const factions = resolveMatchFactions(item, mapName); + const metadata = [ + ["Servidor", serverName], + ["Mapa", mapName], + ["Modo", formatGameMode(item.game_mode || item.gamestate?.game_mode)], + ["Duracion", formatDuration(item.duration_seconds)], + ["Inicio", formatMatchTimestamp(item, "start")], + ]; + if (item.ended_at) { + metadata.push(["Fin", formatMatchTimestamp(item, "end")]); + } + + return ` +
+
+ ${renderScoreboardSide({ + sideClass: "historical-scoreboard-side--allied", + emblem: factions.allied.emblem, + sideLabel: "Aliados", + factionLabel: factions.allied.label, + isWinner: isAlliedWinner, + })} +
+ ${escapeHtml(formatDuration(item.duration_seconds))} + ${escapeHtml(alliedScore)} : ${escapeHtml(axisScore)} + ${escapeHtml(mapName)} + ${escapeHtml(formatGameMode(item.game_mode || item.gamestate?.game_mode))} + ${escapeHtml(formatWinner(winner))} +
+ ${renderScoreboardSide({ + sideClass: "historical-scoreboard-side--axis", + emblem: factions.axis.emblem, + sideLabel: "Eje", + factionLabel: factions.axis.label, + isWinner: isAxisWinner, + })} +
+
+ ${metadata.map(([label, value]) => renderCompactMeta(label, value)).join("")} +
+
+ `; +} + +function renderScoreboardSide({ sideClass, emblem, sideLabel, factionLabel, isWinner }) { + const fallbackLabel = factionLabel || sideLabel; + return ` +
+ ${escapeHtml(fallbackLabel)} +
+ ${escapeHtml(sideLabel)} + ${isWinner ? "Ganador" : ""} +
+
+ `; +} + +function renderCompactMeta(label, value) { + return ` +
+ ${escapeHtml(label)} + ${escapeHtml(value || "No disponible")} +
+ `; +} + +function hideTimelineSection(nodes) { + if (!nodes.timelineSection) { + return; + } + nodes.timelineSection.hidden = true; + nodes.timelineNote.textContent = ""; + nodes.timelineState.hidden = true; + nodes.timelineGrid.hidden = true; + nodes.timelineGrid.innerHTML = ""; +} + +function renderPlayerSection(item, nodes) { + const players = Array.isArray(item.players) ? item.players : []; + nodes.playersSection.hidden = false; + if (players.length === 0) { + nodes.playersNote.textContent = + "Esta partida no tiene estadisticas por jugador disponibles en el detalle interno."; + setState( + nodes.playersState, + "No hay filas de jugador registradas para esta partida.", + ); + nodes.playerControls.hidden = true; + nodes.playersTableShell.hidden = true; + nodes.playersBody.innerHTML = ""; + return; + } + + const state = { + search: "", + team: "all", + sort: "kills", + direction: "desc", + isDefaultSort: true, + }; + const renderRows = () => renderPlayerTable(item, players, state, nodes); + + nodes.playerSearch.value = ""; + nodes.playerTeamFilters.forEach((control) => { + control.checked = control.value === state.team; + }); + nodes.playerSort.value = state.sort; + nodes.playerSortDirection.value = state.direction; + bindPlayerTableControls(nodes, state, renderRows); + renderRows(); + nodes.playerControls.hidden = false; + nodes.playersTableShell.hidden = false; +} + +function bindPlayerTableControls(nodes, state, renderRows) { + nodes.playerControls.onsubmit = (event) => { + event.preventDefault(); + }; + nodes.playerSearch.oninput = () => { + closePlayerDetailRows(nodes.playersBody); + state.search = nodes.playerSearch.value; + renderRows(); + }; + nodes.playerTeamFilters.forEach((control) => { + control.onchange = () => { + closePlayerDetailRows(nodes.playersBody); + state.team = control.value; + renderRows(); + }; + }); + nodes.playerSort.onchange = () => { + state.sort = nodes.playerSort.value; + state.isDefaultSort = false; + renderRows(); + }; + nodes.playerSortDirection.onchange = () => { + state.direction = nodes.playerSortDirection.value; + state.isDefaultSort = false; + renderRows(); + }; +} + +function renderPlayerTable(item, players, state, nodes) { + const visiblePlayers = getVisiblePlayers(players, item, state); + nodes.playersNote.textContent = + visiblePlayers.length === players.length + ? `${formatNumber(players.length)} jugadores con estadisticas locales.` + : `${formatNumber(visiblePlayers.length)} de ${formatNumber(players.length)} jugadores visibles.`; + nodes.playersState.hidden = visiblePlayers.length > 0; + if (!visiblePlayers.length) { + nodes.playersState.textContent = "No hay jugadores que coincidan con los controles activos."; + } + nodes.playersBody.innerHTML = visiblePlayers + .map((entry, index) => renderPlayerRows(entry.player, item, index, entry.inactive)) + .join(""); + bindPlayerDetailRows(nodes.playersBody); +} + +function getVisiblePlayers(players, item, state) { + const normalizedSearch = normalizeLookupText(state.search); + return players + .map((player) => ({ + player, + inactive: isInactiveMatchPlayer(player), + team: getTeamSideDisplay(player.team || player.team_side), + })) + .filter((entry) => { + const matchesTeam = state.team === "all" || entry.team.key === state.team; + const matchesName = + !normalizedSearch || + normalizeLookupText(entry.player.player_name).includes(normalizedSearch); + return matchesTeam && matchesName; + }) + .sort((a, b) => comparePlayerEntries(a, b, item, state)); +} + +function comparePlayerEntries(a, b, item, state) { + if (state.isDefaultSort) { + return ( + compareInactivePriority(a, b) || + compareNumericStat(b.player.kills, a.player.kills) || + compareNumericStat(a.player.deaths, b.player.deaths) || + comparePlayerNames(a.player, b.player) + ); + } + + if (!["name", "team"].includes(state.sort)) { + const inactivePriority = compareInactivePriority(a, b); + if (inactivePriority) { + return inactivePriority; + } + } + + const direction = state.direction === "asc" ? 1 : -1; + const compared = comparePlayerSortValue(a, b, item, state.sort); + return compared * direction || comparePlayerNames(a.player, b.player); +} + +function comparePlayerSortValue(a, b, item, sort) { + if (sort === "name") { + return comparePlayerNames(a.player, b.player); + } + if (sort === "team") { + return compareText(a.team.label, b.team.label); + } + if (sort === "deaths" || sort === "teamkills" || sort === "kills") { + return compareNumericStat(a.player[sort], b.player[sort]); + } + if (sort === "kd") { + return compareNumericStat(getKdRatioValue(a.player), getKdRatioValue(b.player)); + } + return compareNumericStat( + getKpmValue(a.player.kills, item.duration_seconds), + getKpmValue(b.player.kills, item.duration_seconds), + ); +} + +function compareInactivePriority(a, b) { + return Number(a.inactive) - Number(b.inactive); +} + +function comparePlayerNames(a, b) { + return compareText(getPlayerName(a), getPlayerName(b)); +} + +function compareText(a, b) { + return String(a || "").localeCompare(String(b || ""), "es", { + sensitivity: "base", + }); +} + +function compareNumericStat(a, b) { + return toSortableNumber(a) - toSortableNumber(b); +} + +function renderPlayerRows(player, item, index, inactive = false) { + const team = getTeamSideDisplay(player.team || player.team_side); + const rowId = `match-player-row-${index}`; + const panelId = `match-player-panel-${index}`; + const playerName = getPlayerName(player); + const kpm = formatKpm(player.kills, item.duration_seconds); + return ` + + + + + + + ${escapeHtml(team.label)} + + + ${escapeHtml(formatOptionalNumber(player.kills))} + ${escapeHtml(formatOptionalNumber(player.deaths))} + ${escapeHtml(formatOptionalNumber(player.teamkills))} + ${escapeHtml(formatKdRatio(player))} + ${escapeHtml(kpm)} + + + + ${renderPlayerStatsPanel(player, item, { team, playerName, kpm })} + + + `; +} + +function bindPlayerDetailRows(playersBody) { + const playerRows = [...playersBody.querySelectorAll(".historical-player-row")]; + const collapseRow = (row) => { + const button = row.querySelector(".historical-player-row__details-button"); + const detailRow = row.nextElementSibling; + if (!button || !detailRow?.classList.contains("historical-player-detail-row")) { + return; + } + row.classList.remove("is-expanded"); + detailRow.classList.remove("is-open"); + button.setAttribute("aria-expanded", "false"); + }; + + playerRows.forEach((row) => { + const button = row.querySelector(".historical-player-row__details-button"); + const detailRow = row.nextElementSibling; + if (!button || !detailRow?.classList.contains("historical-player-detail-row")) { + return; + } + const setExpanded = (expanded) => { + if (expanded) { + playerRows.filter((candidate) => candidate !== row).forEach(collapseRow); + } + row.classList.toggle("is-expanded", expanded); + detailRow.classList.toggle("is-open", expanded); + button.setAttribute("aria-expanded", String(expanded)); + }; + const toggleExpanded = () => setExpanded(!detailRow.classList.contains("is-open")); + + button.addEventListener("click", () => { + toggleExpanded(); + }); + }); +} + +function closePlayerDetailRows(playersBody) { + [...playersBody.querySelectorAll(".historical-player-row")].forEach((row) => { + const button = row.querySelector(".historical-player-row__details-button"); + const detailRow = row.nextElementSibling; + if (!button || !detailRow?.classList.contains("historical-player-detail-row")) { + return; + } + row.classList.remove("is-expanded"); + detailRow.classList.remove("is-open"); + button.setAttribute("aria-expanded", "false"); + }); +} + +function getPlayerName(player) { + return player.player_name || player.name || "Jugador no identificado"; +} + +function isInactiveMatchPlayer(player) { + const team = getTeamSideDisplay(player.team || player.team_side); + return ( + team.key === "unknown" && + toSortableNumber(player.kills) === 0 && + toSortableNumber(player.deaths) === 0 && + toSortableNumber(player.teamkills) === 0 && + getKdRatioValue(player) === 0 && + !hasNamedCounts(player.top_weapons) && + !hasNamedCounts(player.most_killed) && + !hasNamedCounts(player.death_by) + ); +} + +function renderPlayerStatsPanel(player, item, context) { + const matchups = buildPlayerDirectMatchups(player); + const hasExpandedStats = + hasNamedCounts(player.top_weapons) || + hasNamedCounts(player.most_killed) || + hasNamedCounts(player.death_by) || + matchups.length > 0; + + return ` +
+
+
+

${escapeHtml(context.team.label)}

+

${escapeHtml(context.playerName)}

+
+
+ ${renderPlayerStatChip("Kills", formatOptionalNumber(player.kills))} + ${renderPlayerStatChip("Muertes", formatOptionalNumber(player.deaths))} + ${renderPlayerStatChip("TK", formatOptionalNumber(player.teamkills))} + ${renderPlayerStatChip("KD", formatKdRatio(player))} + ${renderPlayerStatChip("KPM", context.kpm)} +
+
+ ${renderExternalProfilesSection(player)} + ${ + hasExpandedStats + ? ` +
+ ${renderNamedCountSection("Armas", player.top_weapons)} + ${renderNamedCountSection("Mas abatido", player.most_killed)} + ${renderNamedCountSection("Muere por", player.death_by)} + ${renderDirectMatchupsSection(matchups)} +
+ ` + : `

Sin estadisticas ampliadas disponibles.

` + } +
+ `; +} + +function renderExternalProfilesSection(player) { + const links = [ + ["steam", "Steam"], + ["hellor", "Hellor"], + ["hll_records", "HLL Records"], + ["helo", "Helo"], + ] + .map(([key, label]) => [label, player.external_profile_links?.[key]]) + .filter(([, href]) => typeof href === "string" && href.trim()); + + return ` +
+
Perfiles externos
+ ${ + links.length + ? ` + + ` + : renderExternalProfilesUnavailable(player) + } +
+ `; +} + + +function renderExternalProfilesUnavailable(player) { + const platform = String(player.platform || "").toLowerCase(); + const epicId = typeof player.epic_id === "string" ? player.epic_id.trim() : ""; + + if (platform === "epic") { + return epicId + ? `

Jugador detectado como Epic. ID capturado: ${escapeHtml(epicId)}. Sin enlaces externos compatibles confirmados para este proveedor.

` + : "

Jugador detectado como Epic. Sin enlaces externos compatibles confirmados para este proveedor.

"; + } + + return "

Perfiles externos no disponibles.

"; +} + +function renderPlayerStatChip(label, value) { + return ` +
+ ${escapeHtml(label)} + ${escapeHtml(value)} +
+ `; +} + +function renderNamedCountSection(title, items) { + if (!hasNamedCounts(items)) { + return ` +
+
${escapeHtml(title)}
+

No disponible

+
+ `; + } + return ` +
+
${escapeHtml(title)}
+
    + ${items + .map((stat) => { + const name = stat.name || stat.label || "Sin nombre"; + const count = stat.count ?? stat.total ?? 0; + return `
  1. ${escapeHtml(name)}${escapeHtml(formatNumber(count))}
  2. `; + }) + .join("")} +
+
+ `; +} + +function renderDirectMatchupsSection(matchups) { + if (!matchups.length) { + return ` +
+
Duelo directo
+

No disponible

+
+ `; + } + return ` +
+
Duelo directo
+
+
+ Rival + Abatidos + Muertes + Balance +
+ ${matchups + .map( + (matchup) => ` +
+ ${escapeHtml(matchup.name)} + ${escapeHtml(formatNumber(matchup.kills))} + ${escapeHtml(formatNumber(matchup.deaths))} + ${escapeHtml(formatSignedNumber(matchup.balance))} +
+ `, + ) + .join("")} +
+
+ `; +} + +function buildPlayerDirectMatchups(player) { + const byName = new Map(); + const addStats = (items, key) => { + if (!Array.isArray(items)) { + return; + } + items.forEach((item) => { + const name = item.name || item.label; + if (!name) { + return; + } + const normalizedName = String(name); + const current = byName.get(normalizedName) || { + name: normalizedName, + kills: 0, + deaths: 0, + }; + current[key] += Number(item.count ?? item.total ?? 0) || 0; + byName.set(normalizedName, current); + }); + }; + + addStats(player.most_killed, "kills"); + addStats(player.death_by, "deaths"); + return [...byName.values()] + .map((matchup) => ({ + ...matchup, + balance: matchup.kills - matchup.deaths, + involvement: matchup.kills + matchup.deaths, + })) + .sort((a, b) => b.involvement - a.involvement || a.name.localeCompare(b.name, "es")) + .slice(0, 8); +} + +function renderActions(item, actionsNode) { + const matchUrl = normalizeSafePublicScoreboardMatchUrl(item.match_url); + if (!matchUrl) { + actionsNode.innerHTML = ""; + actionsNode.hidden = true; + return; + } + actionsNode.innerHTML = ` + + Ver en Scoreboard + + `; + actionsNode.hidden = false; +} + +function resolveMatchFactions(item, mapName) { + const normalizedMap = normalizeLookupText( + `${item.map?.name || ""} ${item.map?.pretty_name || ""} ${mapName || ""}`, + ); + + if (/(kursk|stalingrad|kharkov)/.test(normalizedMap)) { + return { + allied: { + label: "Sovieticos", + emblem: "./assets/img/factions/soviets.webp", + }, + axis: { + label: "Eje", + emblem: "./assets/img/factions/germany.webp", + }, + }; + } + + if (/(driel|elalamein|el alamein|tobruk)/.test(normalizedMap)) { + return { + allied: { + label: "Britanicos", + emblem: "./assets/img/factions/britain.webp", + }, + axis: { + label: normalizedMap.includes("tobruk") || normalizedMap.includes("elalamein") + ? "Afrika Korps" + : "Eje", + emblem: "./assets/img/factions/germany.webp", + }, + }; + } + + return { + allied: { + label: "USA", + emblem: "./assets/img/factions/us.webp", + }, + axis: { + label: "Eje", + emblem: "./assets/img/factions/germany.webp", + }, + }; +} + +function resolveMapImagePath(item, mapName) { + const normalizedMap = normalizeLookupText( + `${item.map?.name || ""} ${item.map?.pretty_name || ""} ${mapName || ""}`, + ).replaceAll(" ", ""); + const mapAssetByKey = { + carentan: "carentan-day.webp", + driel: "driel-day.webp", + elalamein: "elalamein-day.webp", + elsenbornridge: "elsenbornridge-day.webp", + foy: "foy-day.webp", + hill400: "hill400-day.webp", + hurtgenforest: "hurtgenforest-day.webp", + kharkov: "kharkov-day.webp", + kursk: "kursk-day.webp", + mortain: "mortain-day.webp", + omahabeach: "omahabeach-day.webp", + purpleheartlane: "purpleheartlane-rain.webp", + smolensk: "smolensk-day.webp", + stmariedumont: "stmariedumont-day.webp", + stmereeglise: "stmereeglise-day.webp", + tobrukdawn: "tobruk-dawn.webp", + tobruk: "tobruk-day.webp", + utahbeach: "utahbeach-day.webp", + }; + const matchedKey = Object.keys(mapAssetByKey).find((key) => + normalizedMap.includes(key), + ); + return matchedKey ? `./assets/img/maps/${mapAssetByKey[matchedKey]}` : ""; +} + +function normalizeLookupText(value) { + return String(value || "") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function formatTeamSide(value) { + return getTeamSideDisplay(value).label; +} + +function getTeamSideDisplay(value) { + const normalized = String(value || "") + .trim() + .toLowerCase(); + if (normalized === "allies" || normalized === "allied" || normalized === "aliados") { + return { key: "allies", label: "Aliados" }; + } + if (normalized === "axis" || normalized === "eje") { + return { key: "axis", label: "Eje" }; + } + return { key: "unknown", label: "No disponible" }; +} + +function formatGameMode(value) { + if (!value) { + return "Modo no disponible"; + } + const normalized = String(value).replaceAll("_", " ").replaceAll("-", " "); + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +} + +function formatDuration(value) { + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds <= 0) { + return "Duracion no disponible"; + } + const minutes = Math.round(seconds / 60); + if (minutes < 60) { + return `${formatNumber(minutes)} min`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${formatNumber(hours)} h ${formatNumber(remainingMinutes)} min`; +} + +function formatWinner(value) { + const normalized = String(value || "").toLowerCase(); + if (normalized === "allies" || normalized === "allied") { + return "Ganador: Aliados"; + } + if (normalized === "axis") { + return "Ganador: Eje"; + } + if (normalized === "draw") { + return "Empate"; + } + return "Resultado no disponible"; +} + +function formatOptionalNumber(value) { + return value === null || value === undefined ? "No disponible" : formatNumber(value); +} + +function formatKdRatio(player) { + if ( + !Number.isFinite(Number(player.kd_ratio)) && + (!Number.isFinite(Number(player.kills)) || !Number.isFinite(Number(player.deaths))) + ) { + return "No disponible"; + } + return formatDecimal(getKdRatioValue(player), 2); +} + +function getKdRatioValue(player) { + if (Number.isFinite(Number(player.kd_ratio))) { + return Number(player.kd_ratio); + } + const kills = Number(player.kills); + const deaths = Number(player.deaths); + if (!Number.isFinite(kills) || !Number.isFinite(deaths)) { + return 0; + } + return deaths > 0 ? kills / deaths : kills; +} + +function formatKpm(kills, durationSeconds) { + return formatDecimal(getKpmValue(kills, durationSeconds), 2); +} + +function getKpmValue(kills, durationSeconds) { + const parsedKills = Number(kills); + const parsedDurationSeconds = Number(durationSeconds); + if ( + !Number.isFinite(parsedKills) || + !Number.isFinite(parsedDurationSeconds) || + parsedDurationSeconds <= 0 + ) { + return 0; + } + return parsedKills / (parsedDurationSeconds / 60); +} + +function formatNamedCounts(items) { + if (!Array.isArray(items) || items.length === 0) { + return "No disponible"; + } + return items + .slice(0, 3) + .map((item) => { + const name = item.name || item.label || "Sin nombre"; + const count = item.count ?? item.total ?? 0; + return `${name} (${formatNumber(count)})`; + }) + .join(" / "); +} + +function hasNamedCounts(items) { + return Array.isArray(items) && items.length > 0; +} + +function formatNumber(value) { + const parsedValue = Number(value); + if (!Number.isFinite(parsedValue)) { + return "0"; + } + return new Intl.NumberFormat("es-ES").format(parsedValue); +} + +function toSortableNumber(value) { + const parsedValue = Number(value); + return Number.isFinite(parsedValue) ? parsedValue : 0; +} + +function formatSignedNumber(value) { + const parsedValue = Number(value); + if (!Number.isFinite(parsedValue) || parsedValue === 0) { + return "0"; + } + return `${parsedValue > 0 ? "+" : ""}${formatNumber(parsedValue)}`; +} + +function formatDecimal(value, fractionDigits = 1) { + const parsedValue = Number(value); + if (!Number.isFinite(parsedValue)) { + return "0"; + } + return new Intl.NumberFormat("es-ES", { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }).format(parsedValue); +} + +function formatTimestamp(timestamp) { + if (!timestamp) { + return "Fecha no disponible"; + } + + const value = new Date(timestamp); + if (Number.isNaN(value.getTime())) { + return "Fecha no disponible"; + } + + return new Intl.DateTimeFormat("es-ES", { + dateStyle: "short", + timeStyle: "short", + }).format(value); +} + +function formatMatchTimestamp(item, kind) { + const timestamp = kind === "start" ? item.started_at : item.ended_at; + if (timestamp) { + return formatTimestamp(timestamp); + } + return "No disponible"; +} + +function normalizeSafePublicScoreboardMatchUrl(value) { + if (typeof value !== "string" || !value.trim()) { + return ""; + } + try { + const url = new URL(value.trim()); + const allowedOrigins = new Set([ + "https://scoreboard.comunidadhll.es", + "https://scoreboard.comunidadhll.es:5443", + ]); + const isAllowedPath = url.pathname === "/games" || url.pathname.startsWith("/games/"); + return allowedOrigins.has(url.origin) && isAllowedPath ? url.href : ""; + } catch (error) { + return ""; + } +} + +async function fetchJson(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Request failed with ${response.status}`); + } + return response.json(); +} + +function setState(node, message, isError = false) { + node.textContent = message; + node.hidden = false; + node.classList.toggle("is-error", isError); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/frontend/assets/js/historico-recent-live.js b/frontend/assets/js/historico-recent-live.js new file mode 100644 index 0000000..2e0acbc --- /dev/null +++ b/frontend/assets/js/historico-recent-live.js @@ -0,0 +1,363 @@ +(() => { + const RECENT_MATCHES_ENDPOINT = "/api/historical/recent-matches"; + const REFRESH_DELAYS_MS = [150, 1000, 3000, 6000]; + const RECENT_MATCHES_LIMIT = 100; + const DEFAULT_RECENT_MATCHES_PAGE_SIZE = 10; + const RECENT_MATCHES_PAGE_SIZES = Object.freeze([10, 25, 50, 100]); + const LIVE_PAGINATION_ID = "recent-matches-live-pagination"; + const LEGACY_PAGINATION_ID = "recent-matches-pagination"; + + const recentMatchesState = { + items: [], + serverSlug: "all-servers", + page: 1, + pageSize: DEFAULT_RECENT_MATCHES_PAGE_SIZE, + activeRequestId: 0, + rendering: false, + observerReady: false, + }; + + document.addEventListener("DOMContentLoaded", () => { + ensureDynamicPaginationControls(); + setupRecentMatchesOwnershipObserver(); + + REFRESH_DELAYS_MS.forEach((delay) => { + window.setTimeout(() => { + void refreshDynamicRecentMatches(); + }, delay); + }); + + document.querySelectorAll("[data-server-slug]").forEach((button) => { + button.addEventListener("click", () => { + REFRESH_DELAYS_MS.forEach((delay) => { + window.setTimeout(() => { + void refreshDynamicRecentMatches(button.dataset.serverSlug); + }, delay); + }); + }); + }); + }); + + async function refreshDynamicRecentMatches(forcedServerSlug) { + const listNode = document.getElementById("recent-matches-list"); + const stateNode = document.getElementById("recent-matches-state"); + const metaNode = document.getElementById("recent-matches-snapshot-meta"); + const noteNode = document.getElementById("recent-matches-note"); + + if (!listNode || !stateNode || !metaNode) return; + + const backendBaseUrl = document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000"; + const serverSlug = normalizeDynamicServerSlug(forcedServerSlug || readServerFromUrl()); + const shouldResetPage = serverSlug !== recentMatchesState.serverSlug; + const requestId = recentMatchesState.activeRequestId + 1; + recentMatchesState.activeRequestId = requestId; + recentMatchesState.serverSlug = serverSlug; + + try { + const response = await fetch(`${backendBaseUrl}${RECENT_MATCHES_ENDPOINT}?server=${encodeURIComponent(serverSlug)}&limit=${RECENT_MATCHES_LIMIT}`, { cache: "no-store" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const payload = await response.json(); + if (requestId !== recentMatchesState.activeRequestId || serverSlug !== recentMatchesState.serverSlug) { + return; + } + const data = payload?.data || {}; + const items = Array.isArray(data.items) ? data.items : []; + + recentMatchesState.items = items; + if (shouldResetPage) { + recentMatchesState.page = 1; + } + + if (!items.length) { + recentMatchesState.page = 1; + setDynamicState(stateNode, "No hay partidas recientes disponibles para este alcance."); + renderOwnedList(listNode, ""); + metaNode.textContent = "Datos recientes sin partidas disponibles."; + renderDynamicPagination(); + return; + } + + stateNode.hidden = true; + if (noteNode) noteNode.textContent = "Lista dinámica de partidas registradas."; + metaNode.textContent = buildDynamicRecentMeta(items); + renderDynamicRecentMatchesPage(); + } catch (error) { + if (requestId !== recentMatchesState.activeRequestId || serverSlug !== recentMatchesState.serverSlug) { + return; + } + recentMatchesState.items = []; + recentMatchesState.page = 1; + setDynamicState(stateNode, "No se pudieron cargar las partidas recientes dinámicas.", true); + metaNode.textContent = "Error al leer las partidas recientes dinámicas."; + renderDynamicPagination(); + } + } + + function renderDynamicRecentMatchesPage() { + const listNode = document.getElementById("recent-matches-list"); + const stateNode = document.getElementById("recent-matches-state"); + if (!listNode || !stateNode) return; + + const totalItems = recentMatchesState.items.length; + const totalPages = getDynamicTotalPages(); + recentMatchesState.page = clampDynamicPage(recentMatchesState.page, totalPages); + + if (!totalItems) { + renderOwnedList(listNode, ""); + setDynamicState(stateNode, "No hay partidas recientes disponibles para este alcance."); + renderDynamicPagination(); + return; + } + + const startIndex = (recentMatchesState.page - 1) * recentMatchesState.pageSize; + const pageItems = recentMatchesState.items.slice(startIndex, startIndex + recentMatchesState.pageSize); + renderOwnedList(listNode, pageItems.map((item) => renderDynamicRecentMatchCard(item)).join("")); + stateNode.hidden = true; + renderDynamicPagination(); + } + + function renderOwnedList(listNode, html) { + recentMatchesState.rendering = true; + listNode.innerHTML = html; + window.queueMicrotask(() => { + recentMatchesState.rendering = false; + }); + } + + function setupRecentMatchesOwnershipObserver() { + const listNode = document.getElementById("recent-matches-list"); + if (!listNode || recentMatchesState.observerReady || typeof MutationObserver === "undefined") return; + + recentMatchesState.observerReady = true; + const observer = new MutationObserver(() => { + if (recentMatchesState.rendering || !recentMatchesState.items.length) return; + window.setTimeout(() => { + if (!recentMatchesState.rendering && recentMatchesState.items.length) { + renderDynamicRecentMatchesPage(); + } + }, 0); + }); + observer.observe(listNode, { childList: true }); + } + + function ensureDynamicPaginationControls() { + const listNode = document.getElementById("recent-matches-list"); + if (!listNode || document.getElementById(LIVE_PAGINATION_ID)) return; + + const paginationNode = document.createElement("div"); + paginationNode.className = "historical-pagination"; + paginationNode.id = LIVE_PAGINATION_ID; + paginationNode.hidden = true; + + const sizeLabel = document.createElement("label"); + sizeLabel.className = "historical-pagination__size"; + sizeLabel.append("Mostrar "); + + const pageSizeSelect = document.createElement("select"); + pageSizeSelect.id = "recent-matches-live-page-size"; + pageSizeSelect.setAttribute("aria-label", "Partidas por pagina"); + RECENT_MATCHES_PAGE_SIZES.forEach((size) => { + const option = document.createElement("option"); + option.value = String(size); + option.textContent = String(size); + option.selected = size === DEFAULT_RECENT_MATCHES_PAGE_SIZE; + pageSizeSelect.append(option); + }); + sizeLabel.append(pageSizeSelect); + + const navNode = document.createElement("div"); + navNode.className = "historical-pagination__nav"; + navNode.setAttribute("aria-label", "Paginacion de partidas recientes"); + + const prevButton = document.createElement("button"); + prevButton.className = "historical-tab"; + prevButton.type = "button"; + prevButton.id = "recent-matches-live-prev"; + prevButton.textContent = "Anterior"; + + const pageLabel = document.createElement("p"); + pageLabel.id = "recent-matches-live-page-label"; + pageLabel.textContent = "Pagina 1 de 1"; + + const nextButton = document.createElement("button"); + nextButton.className = "historical-tab"; + nextButton.type = "button"; + nextButton.id = "recent-matches-live-next"; + nextButton.textContent = "Siguiente"; + + navNode.append(prevButton, pageLabel, nextButton); + paginationNode.append(sizeLabel, navNode); + listNode.insertAdjacentElement("afterend", paginationNode); + + pageSizeSelect.addEventListener("change", () => { + const nextPageSize = Number(pageSizeSelect.value); + recentMatchesState.pageSize = RECENT_MATCHES_PAGE_SIZES.includes(nextPageSize) ? nextPageSize : DEFAULT_RECENT_MATCHES_PAGE_SIZE; + recentMatchesState.page = 1; + renderDynamicRecentMatchesPage(); + }); + prevButton.addEventListener("click", () => { + recentMatchesState.page -= 1; + renderDynamicRecentMatchesPage(); + }); + nextButton.addEventListener("click", () => { + recentMatchesState.page += 1; + renderDynamicRecentMatchesPage(); + }); + } + + function hideLegacyPagination() { + const legacyPagination = document.getElementById(LEGACY_PAGINATION_ID); + if (legacyPagination) { + legacyPagination.hidden = true; + } + } + + function renderDynamicPagination() { + ensureDynamicPaginationControls(); + hideLegacyPagination(); + + const paginationNode = document.getElementById(LIVE_PAGINATION_ID); + const pageSizeSelect = document.getElementById("recent-matches-live-page-size"); + const prevButton = document.getElementById("recent-matches-live-prev"); + const nextButton = document.getElementById("recent-matches-live-next"); + const pageLabel = document.getElementById("recent-matches-live-page-label"); + if (!paginationNode || !pageSizeSelect || !prevButton || !nextButton || !pageLabel) return; + + const totalItems = recentMatchesState.items.length; + const totalPages = getDynamicTotalPages(); + recentMatchesState.page = clampDynamicPage(recentMatchesState.page, totalPages); + paginationNode.hidden = totalItems <= recentMatchesState.pageSize; + pageSizeSelect.value = String(recentMatchesState.pageSize); + prevButton.disabled = recentMatchesState.page <= 1; + nextButton.disabled = recentMatchesState.page >= totalPages; + pageLabel.textContent = `Pagina ${recentMatchesState.page} de ${totalPages}`; + } + + function getDynamicTotalPages() { + return Math.max(1, Math.ceil(recentMatchesState.items.length / recentMatchesState.pageSize)); + } + + function clampDynamicPage(page, totalPages) { + const numericPage = Number(page); + if (!Number.isFinite(numericPage)) return 1; + return Math.min(Math.max(1, Math.trunc(numericPage)), totalPages); + } + + function renderDynamicRecentMatchCard(item) { + const mapName = item?.map?.pretty_name || item?.map?.name || "Mapa no disponible"; + const serverName = item?.server?.name || "Servidor no disponible"; + const closedAt = item?.closed_at || item?.ended_at || item?.started_at; + const detailUrl = buildDynamicInternalMatchDetailUrl(item); + const actionLinks = [`${escapeDynamicHtml(formatDynamicResultLabel(item?.result))}`, detailUrl ? `Ver detalles` : ""].join(""); + + return ` +
+
+

${escapeDynamicHtml(mapName)}

+
+ +
+
+

Servidor

+ ${escapeDynamicHtml(serverName)} +
+ +
+

Cierre

+ ${escapeDynamicHtml(formatDynamicTimestamp(closedAt))} +
+ +
+

Jugadores

+ ${escapeDynamicHtml(formatDynamicNumber(item?.player_count))} +
+ +
+

Marcador

+ ${escapeDynamicHtml(formatDynamicScore(item?.result))} +
+ +
+
+ ${actionLinks} +
+
+
+
+ `; + } + + function readServerFromUrl() { + return new URLSearchParams(window.location.search).get("server") || "all-servers"; + } + + function normalizeDynamicServerSlug(value) { + const normalized = String(value || "").trim(); + if (["comunidad-hispana-01", "comunidad-hispana-02", "all-servers"].includes(normalized)) return normalized; + return "all-servers"; + } + + function buildDynamicRecentMeta(items) { + const newest = items[0]?.closed_at || items[0]?.ended_at || items[0]?.started_at; + return newest ? `Actualizado: ${formatDynamicTimestamp(newest)}` : "Actualizado recientemente"; + } + + function setDynamicState(node, message, isError = false) { + node.textContent = message; + node.hidden = false; + node.classList.toggle("is-error", Boolean(isError)); + } + + function formatDynamicTimestamp(value) { + if (!value) return "Fecha no disponible"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return new Intl.DateTimeFormat("es-ES", { day: "numeric", month: "numeric", year: "2-digit", hour: "2-digit", minute: "2-digit" }).format(date); + } + + function formatDynamicNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? new Intl.NumberFormat("es-ES").format(number) : "0"; + } + + function formatDynamicScore(result) { + const allied = result?.allied_score; + const axis = result?.axis_score; + if (Number.isFinite(Number(allied)) && Number.isFinite(Number(axis))) return `${allied} - ${axis}`; + return "- - -"; + } + + function formatDynamicResultLabel(result) { + const winner = String(result?.winner || "").toLowerCase(); + if (winner === "allies" || winner === "allied") return "Victoria aliada"; + if (winner === "axis") return "Victoria axis"; + return "Empate"; + } + + function buildDynamicInternalMatchDetailUrl(item) { + const serverSlug = item?.server?.slug; + const matchId = item?.internal_detail_match_id || item?.match_id; + if (!serverSlug || matchId === undefined || matchId === null) return ""; + return `./historico-partida.html?server=${encodeURIComponent(String(serverSlug))}&match=${encodeURIComponent(String(matchId))}`; + } + + function normalizeDynamicExternalMatchUrl(value) { + if (typeof value !== "string" || !value.trim()) return ""; + try { + const url = new URL(value.trim()); + return ["http:", "https:"].includes(url.protocol) ? url.href : ""; + } catch (error) { + return ""; + } + } + + function escapeDynamicHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } +})(); \ No newline at end of file diff --git a/frontend/assets/js/historico.js b/frontend/assets/js/historico.js new file mode 100644 index 0000000..0d90568 --- /dev/null +++ b/frontend/assets/js/historico.js @@ -0,0 +1,1939 @@ +const HISTORICAL_SERVERS = Object.freeze([ + { + slug: "comunidad-hispana-01", + label: "Comunidad Hispana #01", + }, + { + slug: "comunidad-hispana-02", + label: "Comunidad Hispana #02", + }, + { + slug: "all-servers", + label: "Todos", + }, +]); +const HISTORICAL_SERVER_SLUGS = Object.freeze( + HISTORICAL_SERVERS.map((server) => server.slug), +); +const DEFAULT_HISTORICAL_SERVER = "all-servers"; +const SNAPSHOT_CACHE_TTL_MS = 120000; +const STALE_SNAPSHOT_CACHE_TTL_MS = 30000; +const NEGATIVE_SNAPSHOT_CACHE_TTL_MS = 15000; +const RECENT_MATCHES_LIMIT = 100; +const DEFAULT_RECENT_MATCHES_PAGE_SIZE = 10; +const RECENT_MATCHES_PAGE_SIZES = Object.freeze([10, 25, 50, 100]); +let activeServerSlug = DEFAULT_HISTORICAL_SERVER; +let activeLeaderboardMetric; +let activeLeaderboardTimeframe; +let activeServerRequestId = 0; +let activeLeaderboardRequestId = 0; +let recentMatchesPagination; +const LEADERBOARD_TIMEFRAMES = Object.freeze([ + { + key: "weekly", + label: "Semanal", + shortLabel: "semanal", + }, + { + key: "monthly", + label: "Mensual", + shortLabel: "mensual", + }, +]); +const LEADERBOARD_METRICS = Object.freeze([ + { + key: "kills", + title: "Top kills", + valueHeading: "Kills", + emptyMessage: "Sin datos historicos suficientes para mostrar este ranking de kills.", + }, + { + key: "deaths", + title: "Top muertes", + valueHeading: "Muertes", + emptyMessage: "Sin datos historicos suficientes para mostrar este ranking de muertes.", + }, + { + key: "matches_over_100_kills", + title: "Top partidas con 100+ kills", + valueHeading: "Partidas 100+", + emptyMessage: "Ningun jugador ha registrado partidas de 100+ kills en esta ventana.", + }, + { + key: "support", + title: "Top puntos de soporte", + valueHeading: "Soporte", + emptyMessage: "Sin datos historicos suficientes para mostrar este ranking de soporte.", + }, +]); +const DEFAULT_LEADERBOARD_METRIC = LEADERBOARD_METRICS[0].key; +const DEFAULT_LEADERBOARD_TIMEFRAME = LEADERBOARD_TIMEFRAMES[0].key; +activeLeaderboardMetric = DEFAULT_LEADERBOARD_METRIC; +activeLeaderboardTimeframe = DEFAULT_LEADERBOARD_TIMEFRAME; + +document.addEventListener("DOMContentLoaded", () => { + const backendBaseUrl = + document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000"; + const selectorButtons = Array.from( + document.querySelectorAll("[data-server-slug]"), + ); + const leaderboardTimeframeButtons = Array.from( + document.querySelectorAll("[data-leaderboard-timeframe]"), + ); + const leaderboardTabButtons = Array.from( + document.querySelectorAll("[data-leaderboard-metric]"), + ); + const summaryNode = document.getElementById("historical-summary"); + const rangeNode = document.getElementById("historical-range"); + const summaryNoteNode = document.getElementById("historical-summary-note"); + const summarySnapshotMetaNode = document.getElementById( + "historical-summary-snapshot-meta", + ); + const weeklyTitleNode = document.getElementById("weekly-ranking-title"); + const weeklyStateNode = document.getElementById("weekly-leaderboard-state"); + const weeklyTableNode = document.getElementById("weekly-leaderboard-table"); + const weeklyBodyNode = document.getElementById("weekly-leaderboard-body"); + const weeklyValueHeadingNode = document.getElementById("weekly-leaderboard-value-heading"); + const weeklyWindowNoteNode = document.getElementById("weekly-window-note"); + const weeklySnapshotMetaNode = document.getElementById( + "weekly-leaderboard-snapshot-meta", + ); + const recentStateNode = document.getElementById("recent-matches-state"); + const recentListNode = document.getElementById("recent-matches-list"); + const recentNoteNode = document.getElementById("recent-matches-note"); + const recentSnapshotMetaNode = document.getElementById( + "recent-matches-snapshot-meta", + ); + recentMatchesPagination = initializeRecentMatchesPagination(recentListNode); + + const params = new URLSearchParams(window.location.search); + activeServerSlug = normalizeServerSlug(params.get("server")); + activeLeaderboardMetric = normalizeLeaderboardMetric(params.get("metric")); + activeLeaderboardTimeframe = normalizeLeaderboardTimeframe( + params.get("timeframe"), + ); + + const summaryCache = new Map(); + const recentMatchesCache = new Map(); + const leaderboardCache = new Map(); + const pendingRequestCache = new Map(); + + const getSummarySnapshot = (serverSlug) => + getCachedJson( + summaryCache, + pendingRequestCache, + buildSummarySnapshotKey(serverSlug), + `${backendBaseUrl}/api/historical/snapshots/server-summary?server=${encodeURIComponent(serverSlug)}`, + ); + + const getRecentMatchesSnapshot = (serverSlug) => + getCachedJson( + recentMatchesCache, + pendingRequestCache, + buildRecentMatchesSnapshotKey(serverSlug), + `${backendBaseUrl}/api/historical/snapshots/recent-matches?server=${encodeURIComponent(serverSlug)}&limit=${RECENT_MATCHES_LIMIT}`, + ); + + const getLeaderboardSnapshot = (serverSlug, timeframeKey, metricKey) => + getCachedJson( + leaderboardCache, + pendingRequestCache, + buildLeaderboardSnapshotKey(serverSlug, timeframeKey, metricKey), + `${backendBaseUrl}/api/historical/snapshots/leaderboard?server=${encodeURIComponent(serverSlug)}&timeframe=${encodeURIComponent(timeframeKey)}&metric=${encodeURIComponent(metricKey)}&limit=10`, + ); + + const refreshServerContent = async () => { + const requestId = activeServerRequestId + 1; + const leaderboardRequestId = activeLeaderboardRequestId + 1; + activeServerRequestId = requestId; + activeLeaderboardRequestId = leaderboardRequestId; + const activeMetricConfig = getLeaderboardMetricConfig(activeLeaderboardMetric); + const activeTimeframeConfig = getLeaderboardTimeframeConfig( + activeLeaderboardTimeframe, + ); + const activeServerLabel = getHistoricalServerLabel(activeServerSlug); + + syncActiveButtons(selectorButtons, activeServerSlug); + syncLeaderboardTimeframes( + leaderboardTimeframeButtons, + activeLeaderboardTimeframe, + ); + syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric); + weeklyTitleNode.textContent = buildLeaderboardTitle( + activeMetricConfig, + activeServerSlug, + activeLeaderboardTimeframe, + ); + weeklyValueHeadingNode.textContent = activeMetricConfig.valueHeading; + setRangeBadge(rangeNode, "Cargando rango temporal", false); + summaryNoteNode.textContent = `La vista esta leyendo datos precalculados del historico local para ${activeServerLabel}.`; + setSnapshotMeta(summarySnapshotMetaNode, "Cargando datos de resumen..."); + renderSummaryLoading(summaryNode); + weeklyWindowNoteNode.textContent = "Cargando datos del ranking activo..."; + setSnapshotMeta( + weeklySnapshotMetaNode, + `Preparando datos ${activeTimeframeConfig.shortLabel}...`, + ); + resetRecentMatchesPagination(); + recentListNode.innerHTML = ""; + recentNoteNode.textContent = buildRecentMatchesNote(activeServerSlug); + setState(recentStateNode, "Cargando partidas recientes..."); + setSnapshotMeta(recentSnapshotMetaNode, "Cargando datos de partidas..."); + + const cachedSummaryPayload = readCachedPayload( + summaryCache, + buildSummarySnapshotKey(activeServerSlug), + ); + if (cachedSummaryPayload) { + hydrateSummary( + { status: "fulfilled", value: cachedSummaryPayload }, + summaryNode, + rangeNode, + summaryNoteNode, + summarySnapshotMetaNode, + ); + } + + const cachedLeaderboardPayload = readCachedPayload( + leaderboardCache, + buildLeaderboardSnapshotKey( + activeServerSlug, + activeLeaderboardTimeframe, + activeLeaderboardMetric, + ), + ); + if (cachedLeaderboardPayload) { + hydrateWeeklyLeaderboard( + { status: "fulfilled", value: cachedLeaderboardPayload }, + weeklyStateNode, + weeklyTableNode, + weeklyBodyNode, + weeklyTitleNode, + weeklyValueHeadingNode, + weeklyWindowNoteNode, + weeklySnapshotMetaNode, + activeMetricConfig, + activeLeaderboardTimeframe, + ); + } else { + setState( + weeklyStateNode, + `Cargando ranking ${activeTimeframeConfig.shortLabel}...`, + ); + weeklyTableNode.hidden = true; + } + + const cachedRecentMatchesPayload = readCachedPayload( + recentMatchesCache, + buildRecentMatchesSnapshotKey(activeServerSlug), + ); + if (cachedRecentMatchesPayload) { + hydrateRecentMatches( + { status: "fulfilled", value: cachedRecentMatchesPayload }, + recentStateNode, + recentListNode, + recentSnapshotMetaNode, + ); + } + + const targetServerSlug = activeServerSlug; + const targetTimeframe = activeLeaderboardTimeframe; + const targetMetric = activeLeaderboardMetric; + void settlePromise(getSummarySnapshot(targetServerSlug)).then((summaryResult) => { + if ( + !isActiveServerRequest( + requestId, + targetServerSlug, + targetTimeframe, + targetMetric, + ) + ) { + return; + } + + hydrateSummary( + summaryResult, + summaryNode, + rangeNode, + summaryNoteNode, + summarySnapshotMetaNode, + ); + }); + + void settlePromise(getRecentMatchesSnapshot(targetServerSlug)).then((recentMatchesResult) => { + if ( + !isActiveServerRequest( + requestId, + targetServerSlug, + targetTimeframe, + targetMetric, + ) + ) { + return; + } + + hydrateRecentMatches( + recentMatchesResult, + recentStateNode, + recentListNode, + recentSnapshotMetaNode, + ); + }); + + void settlePromise( + getLeaderboardSnapshot(targetServerSlug, targetTimeframe, targetMetric), + ).then((leaderboardResult) => { + if ( + !isActiveLeaderboardRequest( + requestId, + leaderboardRequestId, + targetServerSlug, + targetTimeframe, + targetMetric, + ) + ) { + return; + } + + hydrateWeeklyLeaderboard( + leaderboardResult, + weeklyStateNode, + weeklyTableNode, + weeklyBodyNode, + weeklyTitleNode, + weeklyValueHeadingNode, + weeklyWindowNoteNode, + weeklySnapshotMetaNode, + activeMetricConfig, + targetTimeframe, + ); + }); + + }; + + const refreshLeaderboardContent = async () => { + const requestId = activeLeaderboardRequestId + 1; + activeLeaderboardRequestId = requestId; + const metricConfig = getLeaderboardMetricConfig(activeLeaderboardMetric); + const timeframeConfig = getLeaderboardTimeframeConfig( + activeLeaderboardTimeframe, + ); + const targetServerSlug = activeServerSlug; + const targetTimeframe = activeLeaderboardTimeframe; + const targetMetric = activeLeaderboardMetric; + + syncLeaderboardTimeframes( + leaderboardTimeframeButtons, + activeLeaderboardTimeframe, + ); + syncLeaderboardTabs(leaderboardTabButtons, activeLeaderboardMetric); + weeklyTitleNode.textContent = buildLeaderboardTitle( + metricConfig, + activeServerSlug, + activeLeaderboardTimeframe, + ); + weeklyValueHeadingNode.textContent = metricConfig.valueHeading; + + const cachedPayload = readCachedPayload( + leaderboardCache, + buildLeaderboardSnapshotKey( + targetServerSlug, + targetTimeframe, + targetMetric, + ), + ); + if (cachedPayload) { + hydrateWeeklyLeaderboard( + { status: "fulfilled", value: cachedPayload }, + weeklyStateNode, + weeklyTableNode, + weeklyBodyNode, + weeklyTitleNode, + weeklyValueHeadingNode, + weeklyWindowNoteNode, + weeklySnapshotMetaNode, + metricConfig, + targetTimeframe, + ); + return; + } + + weeklyWindowNoteNode.textContent = "Cargando datos del ranking activo..."; + setSnapshotMeta( + weeklySnapshotMetaNode, + `Cargando datos ${timeframeConfig.shortLabel}...`, + ); + setState( + weeklyStateNode, + `Cargando ranking ${timeframeConfig.shortLabel}...`, + ); + weeklyTableNode.hidden = true; + + const leaderboardResult = await settlePromise( + getLeaderboardSnapshot(targetServerSlug, targetTimeframe, targetMetric), + ); + + if ( + requestId !== activeLeaderboardRequestId || + targetServerSlug !== activeServerSlug || + targetTimeframe !== activeLeaderboardTimeframe || + targetMetric !== activeLeaderboardMetric + ) { + return; + } + + hydrateWeeklyLeaderboard( + leaderboardResult, + weeklyStateNode, + weeklyTableNode, + weeklyBodyNode, + weeklyTitleNode, + weeklyValueHeadingNode, + weeklyWindowNoteNode, + weeklySnapshotMetaNode, + metricConfig, + targetTimeframe, + ); + }; + + selectorButtons.forEach((button) => { + button.addEventListener("click", () => { + const nextServerSlug = normalizeServerSlug(button.dataset.serverSlug); + if (nextServerSlug === activeServerSlug) { + return; + } + + activeServerSlug = nextServerSlug; + params.set("server", activeServerSlug); + params.set("timeframe", activeLeaderboardTimeframe); + params.set("metric", activeLeaderboardMetric); + window.history.replaceState({}, "", `?${params.toString()}`); + void refreshServerContent(); + }); + }); + + leaderboardTimeframeButtons.forEach((button) => { + button.addEventListener("click", () => { + const nextTimeframe = normalizeLeaderboardTimeframe( + button.dataset.leaderboardTimeframe, + ); + if (nextTimeframe === activeLeaderboardTimeframe) { + return; + } + + activeLeaderboardTimeframe = nextTimeframe; + params.set("server", activeServerSlug); + params.set("timeframe", activeLeaderboardTimeframe); + params.set("metric", activeLeaderboardMetric); + window.history.replaceState({}, "", `?${params.toString()}`); + void refreshLeaderboardContent(); + }); + }); + + leaderboardTabButtons.forEach((button) => { + button.addEventListener("click", () => { + const nextMetric = normalizeLeaderboardMetric(button.dataset.leaderboardMetric); + if (nextMetric === activeLeaderboardMetric) { + return; + } + + activeLeaderboardMetric = nextMetric; + params.set("server", activeServerSlug); + params.set("timeframe", activeLeaderboardTimeframe); + params.set("metric", activeLeaderboardMetric); + window.history.replaceState({}, "", `?${params.toString()}`); + void refreshLeaderboardContent(); + }); + }); + + void refreshServerContent(); +}); + +function isActiveServerRequest(requestId, serverSlug, timeframeKey, metricKey) { + return ( + requestId === activeServerRequestId && + serverSlug === activeServerSlug && + timeframeKey === activeLeaderboardTimeframe && + metricKey === activeLeaderboardMetric + ); +} + +function isActiveLeaderboardRequest( + serverRequestId, + leaderboardRequestId, + serverSlug, + timeframeKey, + metricKey, +) { + return ( + isActiveServerRequest(serverRequestId, serverSlug, timeframeKey, metricKey) && + leaderboardRequestId === activeLeaderboardRequestId + ); +} + +function hydrateSummary(result, summaryNode, rangeNode, noteNode, snapshotMetaNode) { + const emptyState = getHistoricalEmptyState(activeServerSlug); + if (result.status !== "fulfilled") { + renderSummaryError(summaryNode); + setRangeBadge(rangeNode, "Resumen no disponible", false); + noteNode.textContent = + "No se pudo leer el resumen precalculado para el alcance seleccionado."; + setSnapshotMeta(snapshotMetaNode, "Error al leer los datos de resumen."); + return; + } + + const payload = result.value?.data; + const summary = payload?.item; + const hasHistoricalData = + Number(summary?.imported_matches_count ?? summary?.matches_count ?? 0) > 0; + if (!payload?.found || !summary || !hasHistoricalData) { + renderSummaryEmpty(summaryNode, emptyState.summaryMessage); + setRangeBadge(rangeNode, emptyState.rangeLabel, false); + noteNode.textContent = emptyState.summaryNote; + setSnapshotMeta( + snapshotMetaNode, + payload?.generated_at + ? buildSnapshotMetaText(payload, "Resumen pendiente de generacion.") + : "Resumen pendiente de generacion.", + ); + return; + } + + const coverage = summary.coverage || {}; + const timeRange = summary.time_range || {}; + const rangeLabel = buildCoverageBadgeLabel(coverage, { + start: payload?.source_range_start || timeRange.start, + end: payload?.source_range_end || timeRange.end, + }, summary.server?.slug); + setRangeBadge( + rangeNode, + rangeLabel || "Cobertura historica disponible", + coverage.status === "week-plus" && !payload?.is_stale, + ); + noteNode.textContent = buildSummaryNote( + "snapshot-precomputed", + 7, + coverage, + summary.server?.slug, + ); + setSnapshotMeta( + snapshotMetaNode, + buildSnapshotMetaText(payload, "Resumen sin fecha de actualizacion."), + ); + summaryNode.innerHTML = [ + renderSummaryCard("Servidor", summary.server?.name || "Servidor no disponible"), + renderSummaryCard( + "Partidas registradas", + formatNumber(summary.imported_matches_count ?? summary.matches_count), + ), + renderSummaryCard("Jugadores unicos", formatNumber(summary.unique_players)), + renderSummaryCard( + "Cobertura historica", + buildCoveragePeriodLabel(coverage, timeRange, summary.server?.slug), + ), + renderSummaryCard("Inicio de registro", formatTimestamp(coverage.first_match_at)), + renderSummaryCard("Ultimo cierre", formatTimestamp(coverage.last_match_at)), + renderSummaryCard( + "Mapas frecuentes", + formatTopMaps(summary.top_maps), + ), + ].join(""); +} + +function hydrateWeeklyLeaderboard( + result, + stateNode, + tableNode, + bodyNode, + titleNode, + valueHeadingNode, + noteNode, + snapshotMetaNode, + metricConfig, + timeframeKey, +) { + const targetServerSlug = result.value?.data?.server_slug || activeServerSlug; + const resolvedTimeframeKey = result.value?.data?.timeframe || timeframeKey; + valueHeadingNode.textContent = metricConfig.valueHeading; + if (result.status !== "fulfilled") { + titleNode.textContent = buildLeaderboardTitle( + metricConfig, + targetServerSlug, + resolvedTimeframeKey, + ); + noteNode.textContent = + "No se pudo leer los datos precalculados para esta metrica."; + setSnapshotMeta(snapshotMetaNode, "Error al leer los datos del ranking."); + setState( + stateNode, + `No se pudo cargar el ranking ${getLeaderboardTimeframeConfig(resolvedTimeframeKey).shortLabel}.`, + true, + ); + tableNode.hidden = true; + return; + } + + const payload = result.value?.data; + titleNode.textContent = buildLeaderboardTitle( + metricConfig, + payload?.server_slug, + payload?.timeframe || resolvedTimeframeKey, + ); + noteNode.textContent = buildWeeklyWindowNote(payload); + setSnapshotMeta( + snapshotMetaNode, + buildSnapshotMetaText(payload, "Ranking pendiente de generacion."), + ); + if (!payload?.found) { + setState( + stateNode, + buildLeaderboardEmptyMessage( + metricConfig, + targetServerSlug, + payload?.timeframe || resolvedTimeframeKey, + ), + ); + tableNode.hidden = true; + return; + } + + const items = payload?.items; + if (!Array.isArray(items) || items.length === 0) { + setState( + stateNode, + buildLeaderboardEmptyMessage( + metricConfig, + targetServerSlug, + payload?.timeframe || resolvedTimeframeKey, + ), + ); + tableNode.hidden = true; + return; + } + + bodyNode.innerHTML = items + .map( + (item) => ` + + #${escapeHtml(item.ranking_position)} + ${escapeHtml(item.player?.name || "Jugador no identificado")} + ${escapeHtml(formatNumber(item.metric_value))} + ${escapeHtml(formatNumber(item.matches_considered))} + + `, + ) + .join(""); + stateNode.hidden = true; + tableNode.hidden = false; +} + +function hydrateRecentMatches(result, stateNode, listNode, snapshotMetaNode) { + const emptyState = getHistoricalEmptyState(activeServerSlug); + if (result.status !== "fulfilled") { + setState(stateNode, "No se pudieron cargar las partidas recientes.", true); + setSnapshotMeta(snapshotMetaNode, "Error al leer los datos de partidas."); + return; + } + + const payload = result.value?.data; + setSnapshotMeta( + snapshotMetaNode, + buildSnapshotMetaText(payload, "Partidas pendientes de generacion."), + ); + if (!payload?.found) { + resetRecentMatchesPagination(); + listNode.innerHTML = ""; + setState(stateNode, emptyState.recentMessage); + return; + } + + const items = payload?.items; + if (!Array.isArray(items) || items.length === 0) { + resetRecentMatchesPagination(); + listNode.innerHTML = ""; + setState(stateNode, emptyState.recentMessage); + return; + } + + setRecentMatchesPaginationItems(items.slice(0, RECENT_MATCHES_LIMIT), listNode); + stateNode.hidden = true; +} + +function initializeRecentMatchesPagination(listNode) { + if (!listNode) { + return null; + } + + listNode.insertAdjacentHTML( + "afterend", + ` + + `, + ); + const pagination = { + items: [], + page: 1, + pageSize: DEFAULT_RECENT_MATCHES_PAGE_SIZE, + root: document.getElementById("recent-matches-pagination"), + pageSizeSelect: document.getElementById("recent-matches-page-size"), + previousButton: document.getElementById("recent-matches-page-prev"), + nextButton: document.getElementById("recent-matches-page-next"), + status: document.getElementById("recent-matches-page-status"), + }; + pagination.previousButton?.addEventListener("click", () => { + if (pagination.page <= 1) { + return; + } + pagination.page -= 1; + renderRecentMatchesPage(listNode); + }); + pagination.nextButton?.addEventListener("click", () => { + if (pagination.page >= getRecentMatchesPageCount(pagination)) { + return; + } + pagination.page += 1; + renderRecentMatchesPage(listNode); + }); + pagination.pageSizeSelect?.addEventListener("change", () => { + pagination.pageSize = normalizeRecentMatchesPageSize( + pagination.pageSizeSelect.value, + ); + pagination.page = 1; + renderRecentMatchesPage(listNode); + }); + return pagination; +} + +function resetRecentMatchesPagination() { + if (!recentMatchesPagination) { + return; + } + + recentMatchesPagination.items = []; + recentMatchesPagination.page = 1; + recentMatchesPagination.pageSize = DEFAULT_RECENT_MATCHES_PAGE_SIZE; + if (recentMatchesPagination.pageSizeSelect) { + recentMatchesPagination.pageSizeSelect.value = String( + DEFAULT_RECENT_MATCHES_PAGE_SIZE, + ); + } + if (recentMatchesPagination.root) { + recentMatchesPagination.root.hidden = true; + } +} + +function setRecentMatchesPaginationItems(items, listNode) { + if (!recentMatchesPagination) { + listNode.innerHTML = items.map((item) => renderRecentMatchCard(item)).join(""); + return; + } + + recentMatchesPagination.items = items; + recentMatchesPagination.page = 1; + renderRecentMatchesPage(listNode); +} + +function renderRecentMatchesPage(listNode) { + const pagination = recentMatchesPagination; + if (!pagination) { + return; + } + + const pageCount = getRecentMatchesPageCount(pagination); + pagination.page = Math.min(Math.max(1, pagination.page), pageCount); + const pageStart = (pagination.page - 1) * pagination.pageSize; + const visibleItems = pagination.items.slice(pageStart, pageStart + pagination.pageSize); + listNode.innerHTML = visibleItems.map((item) => renderRecentMatchCard(item)).join(""); + if (pagination.status) { + pagination.status.textContent = `Pagina ${pagination.page} de ${pageCount}`; + } + if (pagination.previousButton) { + pagination.previousButton.disabled = pagination.page <= 1; + } + if (pagination.nextButton) { + pagination.nextButton.disabled = pagination.page >= pageCount; + } + if (pagination.root) { + pagination.root.hidden = pagination.items.length <= DEFAULT_RECENT_MATCHES_PAGE_SIZE; + } +} + +function getRecentMatchesPageCount(pagination) { + return Math.max(1, Math.ceil(pagination.items.length / pagination.pageSize)); +} + +function normalizeRecentMatchesPageSize(rawValue) { + const pageSize = Number(rawValue); + return RECENT_MATCHES_PAGE_SIZES.includes(pageSize) + ? pageSize + : DEFAULT_RECENT_MATCHES_PAGE_SIZE; +} + +function hydrateMonthlyMvp( + result, + stateNode, + listNode, + titleNode, + noteNode, + snapshotMetaNode, +) { + if (result.status !== "fulfilled") { + titleNode.textContent = `Top 3 MVP mensual V1 - ${getHistoricalServerLabel(activeServerSlug)}`; + noteNode.textContent = "No se pudo leer el registro mensual del MVP V1."; + setSnapshotMeta(snapshotMetaNode, "Error al leer el registro del MVP mensual V1."); + setState(stateNode, "No se pudo cargar el Top 3 MVP mensual V1.", true); + listNode.innerHTML = ""; + return; + } + + const payload = result.value?.data; + titleNode.textContent = `Top 3 MVP mensual V1 - ${getHistoricalServerLabel( + payload?.server_slug || activeServerSlug, + )}`; + noteNode.textContent = buildMonthlyMvpNote(payload); + setSnapshotMeta( + snapshotMetaNode, + buildSnapshotMetaText(payload, "Registro del MVP mensual V1 pendiente de generacion."), + ); + + if (!payload?.found) { + setState( + stateNode, + "Todavia no hay un Top 3 MVP mensual V1 listo para el alcance activo.", + ); + listNode.innerHTML = ""; + return; + } + + const items = payload?.items; + if (!Array.isArray(items) || items.length === 0) { + setState( + stateNode, + "No hay jugadores elegibles para el MVP mensual en el periodo activo.", + ); + listNode.innerHTML = ""; + return; + } + + listNode.innerHTML = items.map((item) => renderMonthlyMvpCard(item, payload)).join(""); + stateNode.hidden = true; +} + +function hydrateMonthlyMvpV2( + result, + stateNode, + listNode, + titleNode, + noteNode, + snapshotMetaNode, +) { + if (result.status !== "fulfilled") { + titleNode.textContent = `Top 3 MVP mensual V2 - ${getHistoricalServerLabel(activeServerSlug)}`; + noteNode.textContent = "No se pudo leer el registro mensual del MVP V2."; + setSnapshotMeta(snapshotMetaNode, "Error al leer el registro del MVP mensual V2."); + setState(stateNode, "No se pudo cargar el Top 3 MVP mensual V2.", true); + listNode.innerHTML = ""; + return; + } + + const payload = result.value?.data; + titleNode.textContent = `Top 3 MVP mensual V2 - ${getHistoricalServerLabel( + payload?.server_slug || activeServerSlug, + )}`; + noteNode.textContent = buildMonthlyMvpV2Note(payload); + setSnapshotMeta( + snapshotMetaNode, + buildSnapshotMetaText(payload, "Registro del MVP mensual V2 pendiente de generacion."), + ); + + if (!payload?.found) { + setState( + stateNode, + "Todavia no hay un Top 3 MVP mensual V2 listo para el alcance activo.", + ); + listNode.innerHTML = ""; + return; + } + + const items = payload?.items; + if (!Array.isArray(items) || items.length === 0) { + setState( + stateNode, + "No hay jugadores elegibles para el MVP mensual V2 en el periodo activo.", + ); + listNode.innerHTML = ""; + return; + } + + listNode.innerHTML = items.map((item) => renderMonthlyMvpV2Card(item, payload)).join(""); + stateNode.hidden = true; +} + +function hydrateMvpComparison( + monthlyMvpResult, + monthlyMvpV2Result, + stateNode, + listNode, + noteNode, +) { + if (!monthlyMvpResult || !monthlyMvpV2Result) { + setState(stateNode, "Preparando comparativa V1 vs V2..."); + return; + } + + if ( + monthlyMvpResult.status !== "fulfilled" || + monthlyMvpV2Result.status !== "fulfilled" + ) { + noteNode.textContent = "No se pudo completar la lectura conjunta de V1 y V2."; + setState(stateNode, "No se pudo cargar la comparativa V1 vs V2.", true); + listNode.innerHTML = ""; + return; + } + + const v1Payload = monthlyMvpResult.value?.data; + const v2Payload = monthlyMvpV2Result.value?.data; + const v1Items = Array.isArray(v1Payload?.items) ? v1Payload.items : []; + const v2Items = Array.isArray(v2Payload?.items) ? v2Payload.items : []; + + if (!v1Payload?.found || !v2Payload?.found || !v1Items.length || !v2Items.length) { + noteNode.textContent = + "La comparativa se activara cuando existan rankings V1 y V2 listos para el mismo alcance."; + setState(stateNode, "Todavia no hay una comparativa V1 vs V2 lista para este alcance."); + listNode.innerHTML = ""; + return; + } + + const comparisonItems = buildMvpComparisonItems(v1Items, v2Items); + if (!comparisonItems.length) { + noteNode.textContent = + "No se encontraron jugadores coincidentes o relevantes para comparar entre V1 y V2."; + setState(stateNode, "Sin diferencias comparables entre V1 y V2 para el alcance activo."); + listNode.innerHTML = ""; + return; + } + + noteNode.textContent = buildMvpComparisonNote(v1Payload, v2Payload, comparisonItems.length); + listNode.innerHTML = comparisonItems + .map((item) => renderMvpComparisonCard(item)) + .join(""); + stateNode.hidden = true; +} + +function renderRecentMatchCard(item) { + const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible"; + const detailUrl = buildInternalMatchDetailUrl(item); + const actionLinks = [ + `${escapeHtml(formatMatchResult(item.result))}`, + detailUrl + ? ` + + Ver detalles + + ` + : "", + ].join(""); + return ` +
+
+

${escapeHtml(mapName)}

+
+ +
+
+

Servidor

+ ${escapeHtml(item.server?.name || "Servidor no disponible")} +
+
+

Cierre

+ ${escapeHtml(formatTimestamp(item.closed_at))} +
+
+

Jugadores

+ ${escapeHtml(formatNumber(item.player_count))} +
+
+

Marcador

+ ${escapeHtml(formatScore(item.result))} +
+
+
+ ${actionLinks} +
+
+
+
+ `; +} + +function normalizeExternalMatchUrl(value) { + if (typeof value !== "string" || !value.trim()) { + return ""; + } + try { + const url = new URL(value.trim()); + return ["http:", "https:"].includes(url.protocol) ? url.href : ""; + } catch (error) { + return ""; + } +} + +function buildInternalMatchDetailUrl(item) { + const serverSlug = item?.server?.slug; + const matchId = item?.internal_detail_match_id || item?.match_id; + if (typeof serverSlug !== "string" || !serverSlug.trim()) { + return ""; + } + if (typeof matchId !== "string" && typeof matchId !== "number") { + return ""; + } + const normalizedMatchId = String(matchId).trim(); + if (!normalizedMatchId) { + return ""; + } + return `./historico-partida.html?server=${encodeURIComponent( + serverSlug.trim(), + )}&match=${encodeURIComponent(normalizedMatchId)}`; +} + +function renderSummaryLoading(summaryNode) { + summaryNode.innerHTML = renderSummaryCard("Estado", "Cargando datos historicos"); +} + +function renderSummaryError(summaryNode) { + summaryNode.innerHTML = renderSummaryCard("Estado", "Error al cargar el resumen"); +} + +function renderSummaryEmpty(summaryNode, message = "Sin datos historicos suficientes") { + summaryNode.innerHTML = renderSummaryCard("Estado", message); +} + +function renderSummaryCard(label, value) { + return ` +
+

${escapeHtml(label)}

+ ${escapeHtml(value)} +
+ `; +} + +function renderMonthlyMvpCard(item, payload) { + const scoreValue = Number(item?.mvp_score); + return ` +
+
+
+ #${escapeHtml(item?.ranking_position || "-")} +
+
+

Puntuacion MVP

+ ${escapeHtml( + Number.isFinite(scoreValue) ? scoreValue.toFixed(1) : "0.0", + )} +
+
+
+ ${escapeHtml( + item?.player?.name || "Jugador no identificado", + )} +
+
+
+ Kills + ${escapeHtml(formatNumber(item?.totals?.kills))} +
+
+ Soporte + ${escapeHtml(formatNumber(item?.totals?.support))} +
+
+ KPM + ${escapeHtml(formatDecimal(item?.derived?.kpm, 2))} +
+
+ KDA + ${escapeHtml(formatDecimal(item?.derived?.kda, 2))} +
+
+ +
+ `; +} + +function renderMonthlyMvpV2Card(item, payload) { + const scoreValue = Number(item?.mvp_v2_score); + return ` +
+
+
+ #${escapeHtml(item?.ranking_position || "-")} +
+
+

Puntuacion MVP V2

+ ${escapeHtml( + Number.isFinite(scoreValue) ? scoreValue.toFixed(1) : "0.0", + )} +
+
+
+ V2 avanzado +
+
+ ${escapeHtml( + item?.player?.name || "Jugador no identificado", + )} +
+
+

${escapeHtml( + buildMonthlyMvpV2SignalSummary(item), + )}

+
+
+ Penalty TK + ${escapeHtml(formatDecimal(item?.teamkill_penalty_v2, 2))} +
+
+ Confidence + ${escapeHtml(formatPercent(item?.advanced_confidence))} +
+
+ Most killed + ${escapeHtml(formatNumber(item?.advanced?.most_killed_count))} +
+
+ Duel control + ${escapeHtml(formatNumber(item?.advanced?.duel_control_raw))} +
+
+
+ +
+ `; +} + +function renderMvpComparisonCard(item) { + return ` +
+
+
+

Jugador comparado

+

${escapeHtml(item.playerName)}

+
+
+

Delta puesto

+ ${escapeHtml(item.positionDeltaLabel)} +
+
+
+
+

Score V1

+ ${escapeHtml(item.v1ScoreLabel)} +
+
+

Score V2

+ ${escapeHtml(item.v2ScoreLabel)} +
+
+
+
+ Posicion V1 + ${escapeHtml(item.v1PositionLabel)} +
+
+ Posicion V2 + ${escapeHtml(item.v2PositionLabel)} +
+
+ Delta score + ${escapeHtml(item.scoreDeltaLabel)} +
+
+ Penalty TK + ${escapeHtml(item.teamkillPenaltyLabel)} +
+
+

${escapeHtml(item.summary)}

+
+ `; +} + +function hydrateEloMmr(result, stateNode, listNode, noteNode, metaNode) { + if (result?.status !== "fulfilled") { + setState(stateNode, "No se pudo cargar el leaderboard Elo/MMR.", true); + listNode.innerHTML = ""; + noteNode.textContent = + "El sistema Elo/MMR sigue disponible solo cuando existe rebuild persistido."; + setSnapshotMeta(metaNode, "Error al cargar metadata de Elo/MMR."); + return; + } + + const payload = result.value?.data; + const items = Array.isArray(payload?.items) ? payload.items : []; + if (!payload?.found || !items.length) { + setState( + stateNode, + "Todavia no hay leaderboard Elo/MMR mensual listo para este alcance.", + ); + listNode.innerHTML = ""; + noteNode.textContent = + "El bloque aparece cuando existe un rebuild persistido con jugadores elegibles."; + setSnapshotMeta( + metaNode, + buildEloMmrMeta(payload), + ); + return; + } + + stateNode.hidden = true; + noteNode.textContent = buildEloMmrNote(payload); + setSnapshotMeta(metaNode, buildEloMmrMeta(payload)); + listNode.innerHTML = items.map((item) => renderEloMmrCard(item, payload)).join(""); +} + +function renderEloMmrCard(item, payload) { + return ` +
+
+
+ #${escapeHtml(item?.ranking_position || "-")} +
+
+ ${escapeHtml(formatAccuracyMode(item?.accuracy_mode))} +
+
+
+ ${escapeHtml( + item?.player?.name || "Jugador no identificado", + )} +
+
+
+ Score mensual + ${escapeHtml(formatDecimal(item?.monthly_rank_score, 2))} +
+
+ MMR persistente + ${escapeHtml(formatDecimal(item?.persistent_rating?.mmr, 1))} +
+
+
+
+ MMR gain + ${escapeHtml(formatSignedDecimal(item?.persistent_rating?.mmr_gain, 1))} +
+
+ Elegibilidad + ${escapeHtml(item?.eligible ? "Elegible" : "Parcial")} +
+
+ Partidas validas + ${escapeHtml(formatNumber(item?.valid_matches))} +
+
+ Confidence + ${escapeHtml(formatDecimal(item?.components?.confidence, 1))} +
+
+

${escapeHtml(buildEloMmrSummary(item))}

+ +
+ `; +} + +function setState(node, message, isError = false) { + node.textContent = message; + node.hidden = false; + node.classList.toggle("is-error", isError); +} + +function setRangeBadge(node, label, isFresh) { + node.textContent = label; + node.classList.toggle("status-chip--ok", isFresh); + node.classList.toggle("status-chip--fallback", !isFresh); +} + +function setSnapshotMeta(node, message) { + node.textContent = message; +} + +function syncActiveButtons(buttons, activeServerSlug) { + buttons.forEach((button) => { + button.classList.toggle( + "is-active", + button.dataset.serverSlug === activeServerSlug, + ); + }); +} + +function syncLeaderboardTabs(buttons, activeMetric) { + buttons.forEach((button) => { + const isActive = button.dataset.leaderboardMetric === activeMetric; + button.classList.toggle("is-active", isActive); + button.setAttribute("aria-selected", String(isActive)); + }); +} + +function syncLeaderboardTimeframes(buttons, activeTimeframe) { + buttons.forEach((button) => { + const isActive = button.dataset.leaderboardTimeframe === activeTimeframe; + button.classList.toggle("is-active", isActive); + button.setAttribute("aria-selected", String(isActive)); + }); +} + +function normalizeServerSlug(rawValue) { + const normalized = typeof rawValue === "string" ? rawValue.trim() : ""; + if (HISTORICAL_SERVER_SLUGS.includes(normalized)) { + return normalized; + } + + return DEFAULT_HISTORICAL_SERVER; +} + +function getHistoricalServerLabel(serverSlug) { + return ( + HISTORICAL_SERVERS.find((server) => server.slug === serverSlug)?.label || + HISTORICAL_SERVERS[0].label + ); +} + +function normalizeLeaderboardMetric(rawValue) { + const normalized = typeof rawValue === "string" ? rawValue.trim() : ""; + if (LEADERBOARD_METRICS.some((metric) => metric.key === normalized)) { + return normalized; + } + + return DEFAULT_LEADERBOARD_METRIC; +} + +function normalizeLeaderboardTimeframe(rawValue) { + const normalized = typeof rawValue === "string" ? rawValue.trim() : ""; + if (LEADERBOARD_TIMEFRAMES.some((timeframe) => timeframe.key === normalized)) { + return normalized; + } + + return DEFAULT_LEADERBOARD_TIMEFRAME; +} + +function getLeaderboardMetricConfig(metricKey) { + return ( + LEADERBOARD_METRICS.find((metric) => metric.key === metricKey) || + LEADERBOARD_METRICS[0] + ); +} + +function getLeaderboardTimeframeConfig(timeframeKey) { + return ( + LEADERBOARD_TIMEFRAMES.find((timeframe) => timeframe.key === timeframeKey) || + LEADERBOARD_TIMEFRAMES[0] + ); +} + +function buildSummarySnapshotKey(serverSlug) { + return `summary:${serverSlug}`; +} + +function buildRecentMatchesSnapshotKey(serverSlug) { + return `recent:${serverSlug}`; +} + +function buildLeaderboardSnapshotKey(serverSlug, timeframeKey, metricKey) { + return `leaderboard:${serverSlug}:${timeframeKey}:${metricKey}`; +} + +function buildMonthlyMvpSnapshotKey(serverSlug) { + return `monthly-mvp:${serverSlug}`; +} + +function buildMonthlyMvpV2SnapshotKey(serverSlug) { + return `monthly-mvp-v2:${serverSlug}`; +} + +function buildEloMmrSnapshotKey(serverSlug) { + return `elo-mmr:${serverSlug}`; +} + +function buildRangeLabel(start, end) { + if (!start && !end) { + return ""; + } + + return `${formatTimestamp(start)} a ${formatTimestamp(end)}`; +} + +function buildCoverageBadgeLabel(coverage, timeRange, serverSlug) { + const rangeStart = coverage?.first_match_at || timeRange?.start; + const rangeEnd = coverage?.last_match_at || timeRange?.end; + if (!rangeStart && !rangeEnd) { + return "Sin cobertura registrada"; + } + if (coverage?.status === "under-week") { + return "Cobertura inicial"; + } + if (coverage?.status === "week-plus") { + return "Cobertura historica"; + } + return "Periodo registrado"; +} + +function buildCoveragePeriodLabel(coverage, timeRange, serverSlug) { + const start = coverage?.first_match_at || timeRange?.start; + const end = coverage?.last_match_at || timeRange?.end; + if (start && end) { + return `Desde ${formatDateOnly(start)} hasta ${formatDateOnly(end)}`; + } + if (start) { + return `Desde ${formatDateOnly(start)}`; + } + if (end) { + return `Hasta ${formatDateOnly(end)}`; + } + return "Sin cobertura registrada"; +} + +function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage, serverSlug) { + const basisLabel = + summaryBasis === "snapshot-precomputed" + ? "el historico local" + : "el historico persistido disponible"; + const status = coverage?.status; + void weeklyWindowDays; + void serverSlug; + if (status === "under-week") { + return `Este bloque resume ${basisLabel}. La cobertura registrada todavia es inicial y puede crecer en los proximos dias.`; + } + if (serverSlug === "all-servers") { + return `Resumen de los servidores desde ${basisLabel}, combinado solo con los servidores actuales de la comunidad.`; + } + return `Resumen servido desde ${basisLabel}.`; +} + +function buildWeeklyWindowNote(payload) { + if (!payload?.found) { + const timeframeLabel = getLeaderboardTimeframeConfig( + payload?.timeframe || activeLeaderboardTimeframe, + ).shortLabel; + return `No existen datos en ${timeframeLabel} suficientes para esta metrica en el rango activo.`; + } + + const start = formatTimestamp(payload?.window_start); + const end = formatTimestamp(payload?.window_end); + const windowLabel = + payload?.window_label || + (payload?.timeframe === "monthly" ? "Mes activo" : "Semana activa"); + if (payload?.uses_fallback) { + return `${windowLabel}: ${start} a ${end}.`; + } + return `${windowLabel}: ${start} a ${end}.`; +} + +function buildLeaderboardTitle(metricConfig, serverSlug, timeframeKey) { + const timeframeLabel = getLeaderboardTimeframeConfig(timeframeKey).label; + return `${metricConfig.title} ${timeframeLabel} - ${getHistoricalServerLabel(serverSlug)}`; +} + +function buildRecentMatchesNote(serverSlug) { + if (serverSlug === "all-servers") { + return "Lista de cierres ya registrados para los servidores con historico disponible."; + } + return `Lista de cierres ya registrados para ${getHistoricalServerLabel(serverSlug)}.`; +} + +function buildMonthlyMvpNote(payload) { + if (!payload?.found) { + return "El Top 3 mensual V1 aparecera cuando exista un registro MVP listo para este alcance."; + } + const periodLabel = + payload?.window_label && payload?.month_key + ? `${payload.window_label} (${formatMonthKey(payload.month_key)})` + : formatMonthKey(payload?.month_key); + const eligiblePlayers = formatNumber(payload?.eligible_players_count); + return `${periodLabel || "Periodo mensual activo"}. ${eligiblePlayers} jugadores cumplen los umbrales base de la version V1.`; +} + +function buildMonthlyMvpFooter(item, payload) { + const hoursPlayed = Number(item?.totals?.time_seconds) / 3600; + const monthLabel = formatMonthKey(payload?.month_key); + return `${monthLabel || "Mes activo"} · ${formatNumber( + item?.matches_considered, + )} partidas · ${formatDecimal(hoursPlayed, 1)} h jugadas`; +} + +function buildMonthlyMvpV2Note(payload) { + if (!payload?.found) { + return "El Top 3 mensual V2 aparecera cuando exista un registro alineado con la cobertura de eventos."; + } + const periodLabel = + payload?.window_label && payload?.month_key + ? `${payload.window_label} (${formatMonthKey(payload.month_key)})` + : formatMonthKey(payload?.month_key); + const eligiblePlayers = formatNumber(payload?.eligible_players_count); + const eventCount = formatNumber(payload?.event_coverage?.event_count); + return `${periodLabel || "Periodo mensual activo"}. ${eligiblePlayers} jugadores elegibles y ${eventCount} eventos V2 cubiertos para este alcance.`; +} + +function buildMonthlyMvpV2SignalSummary(item) { + const rivalryEdge = formatNumber(item?.advanced?.rivalry_edge_raw); + const deathBy = formatNumber(item?.advanced?.death_by_count); + return `Ventaja de rivalidad ${rivalryEdge} y ${deathBy} muertes frente a su rival mas repetido.`; +} + +function buildMonthlyMvpV2Footer(item, payload) { + const hoursPlayed = Number(item?.totals?.time_seconds) / 3600; + const monthLabel = formatMonthKey(payload?.month_key); + return `${monthLabel || "Mes activo"} · ${formatNumber( + item?.matches_considered, + )} partidas · ${formatDecimal(hoursPlayed, 1)} h jugadas`; +} + +function buildEloMmrNote(payload) { + const monthLabel = formatMonthKey(payload?.month_key); + const exactRatio = Number(payload?.capabilities_summary?.exact_ratio || 0); + const approximateRatio = Number(payload?.capabilities_summary?.approximate_ratio || 0); + return `${monthLabel || "Mes activo"}. Rating persistente + score mensual con ${formatPercent(exactRatio)} de senal exacta y ${formatPercent(approximateRatio)} de senal aproximada en este corte.`; +} + +function buildEloMmrMeta(payload) { + const sourceLabel = payload?.selected_source || payload?.source || "origen no disponible"; + const fallbackLabel = payload?.fallback_used + ? `fallback ${payload?.fallback_reason || "activo"}` + : "sin fallback"; + return `Generado ${formatTimestamp(payload?.generated_at)} · fuente ${sourceLabel} · ${fallbackLabel}`; +} + +function buildEloMmrSummary(item) { + return `AvgMatchScore ${formatDecimal(item?.components?.avg_match_score, 1)}, actividad ${formatDecimal(item?.components?.activity, 1)} y strength-of-schedule ${formatDecimal(item?.components?.strength_of_schedule, 1)}.`; +} + +function buildEloMmrFooter(item, payload) { + const monthLabel = formatMonthKey(payload?.month_key); + const hoursPlayed = Number(item?.total_time_seconds) / 3600; + return `${monthLabel || "Mes activo"} · ${formatNumber(item?.valid_matches)} validas / ${formatNumber(item?.total_matches)} totales · ${formatDecimal(hoursPlayed, 1)} h`; +} + +function buildMvpComparisonItems(v1Items, v2Items) { + const v1TopItems = v1Items.slice(0, 3); + const v2TopItems = v2Items.slice(0, 3); + const v1Index = new Map( + v1Items.map((item) => [item?.player?.stable_player_key, item]), + ); + const v2Index = new Map( + v2Items.map((item) => [item?.player?.stable_player_key, item]), + ); + const comparisonKeys = []; + + [...v1TopItems, ...v2TopItems].forEach((item) => { + const stableKey = item?.player?.stable_player_key; + if (!stableKey || comparisonKeys.includes(stableKey)) { + return; + } + comparisonKeys.push(stableKey); + }); + + return comparisonKeys.map((stableKey) => { + const v1Item = v1Index.get(stableKey); + const v2Item = v2Index.get(stableKey); + const v1Position = Number(v1Item?.ranking_position); + const v2Position = Number(v2Item?.ranking_position); + const v1Score = Number(v1Item?.mvp_score); + const v2Score = Number(v2Item?.mvp_v2_score); + const scoreDelta = Number.isFinite(v1Score) && Number.isFinite(v2Score) + ? v2Score - v1Score + : null; + return { + playerName: + v2Item?.player?.name || + v1Item?.player?.name || + "Jugador no identificado", + v1PositionLabel: Number.isFinite(v1Position) ? `#${v1Position}` : "Fuera del Top V1", + v2PositionLabel: Number.isFinite(v2Position) ? `#${v2Position}` : "Fuera del Top V2", + positionDeltaLabel: buildPositionDeltaLabel(v1Position, v2Position), + v1ScoreLabel: Number.isFinite(v1Score) ? formatDecimal(v1Score, 1) : "Sin entrada", + v2ScoreLabel: Number.isFinite(v2Score) ? formatDecimal(v2Score, 1) : "Sin entrada", + scoreDeltaLabel: buildScoreDeltaLabel(scoreDelta), + teamkillPenaltyLabel: buildTeamkillPenaltyComparisonLabel(v1Item, v2Item), + summary: buildMvpComparisonSummary(v1Item, v2Item, scoreDelta), + }; + }); +} + +function buildMvpComparisonNote(v1Payload, v2Payload, itemCount) { + const monthLabel = formatMonthKey(v2Payload?.month_key || v1Payload?.month_key); + return `${monthLabel || "Periodo mensual activo"}. Comparativa ligera de ${formatNumber(itemCount)} jugadores visibles entre el Top V1 y el Top V2 para validar cambios antes de converger rankings.`; +} + +function buildPositionDeltaLabel(v1Position, v2Position) { + if (Number.isFinite(v1Position) && Number.isFinite(v2Position)) { + const delta = v1Position - v2Position; + if (delta > 0) { + return `Sube ${delta}`; + } + if (delta < 0) { + return `Baja ${Math.abs(delta)}`; + } + return "Sin cambio"; + } + if (Number.isFinite(v2Position)) { + return "Entra en V2"; + } + if (Number.isFinite(v1Position)) { + return "Sale en V2"; + } + return "Sin cruce"; +} + +function buildScoreDeltaLabel(scoreDelta) { + if (!Number.isFinite(scoreDelta)) { + return "Sin cruce"; + } + const prefix = scoreDelta > 0 ? "+" : ""; + return `${prefix}${formatDecimal(scoreDelta, 1)}`; +} + +function buildTeamkillPenaltyComparisonLabel(v1Item, v2Item) { + const v1Penalty = Number(v1Item?.teamkill_penalty); + const v2Penalty = Number(v2Item?.teamkill_penalty_v2); + if (!Number.isFinite(v1Penalty) && !Number.isFinite(v2Penalty)) { + return "Sin dato"; + } + return `${formatDecimal(v1Penalty, 1)} -> ${formatDecimal(v2Penalty, 1)}`; +} + +function buildMvpComparisonSummary(v1Item, v2Item, scoreDelta) { + const deltaLabel = Number.isFinite(scoreDelta) + ? `${scoreDelta > 0 ? "mejora" : scoreDelta < 0 ? "cae" : "mantiene"} ${buildScoreDeltaLabel(scoreDelta)}` + : "no tiene cruce completo"; + const mostKilled = formatNumber(v2Item?.advanced?.most_killed_count); + const duelControl = formatNumber(v2Item?.advanced?.duel_control_raw); + return `En V2 ${deltaLabel}. Senales avanzadas visibles: most killed ${mostKilled} y duel control ${duelControl}.`; +} + +function buildSnapshotMetaText(payload, missingMessage) { + if (!payload?.generated_at) { + return missingMessage; + } + + const parts = [ + payload.is_stale + ? `Actualizado: ${formatTimestamp(payload.generated_at)}` + : `Actualizado: ${formatTimestamp(payload.generated_at)}`, + ]; + const sourceRangeLabel = buildRangeLabel( + payload?.source_range_start, + payload?.source_range_end, + ); + if (sourceRangeLabel) { + parts.push(`Cobertura: ${sourceRangeLabel}`); + } + return parts.join(" | "); +} + +function formatTopMaps(topMaps) { + if (!Array.isArray(topMaps) || topMaps.length === 0) { + return "Sin mapas frecuentes"; + } + + return topMaps + .map((item) => `${item.map_name} (${formatNumber(item.matches_count)})`) + .join(" / "); +} + +function formatDateOnly(timestamp) { + if (!timestamp) { + return "Fecha no disponible"; + } + + const value = new Date(timestamp); + if (Number.isNaN(value.getTime())) { + return "Fecha no disponible"; + } + + return new Intl.DateTimeFormat("es-ES", { + dateStyle: "medium", + }).format(value); +} + +function formatMatchResult(result) { + const winner = result?.winner; + if (winner === "allies" || winner === "allied") { + return "Victoria Aliada"; + } + if (winner === "axis") { + return "Victoria Axis"; + } + if (winner === "draw") { + return "Empate"; + } + return "Resultado parcial"; +} + +function formatScore(result) { + if (!hasMatchScore(result)) { + return "Resultado no disponible"; + } + const alliedScore = Number(result.allied_score); + const axisScore = Number(result.axis_score); + return `${alliedScore} - ${axisScore}`; +} + +function hasMatchScore(result) { + return ( + Number.isFinite(Number(result?.allied_score)) && + Number.isFinite(Number(result?.axis_score)) + ); +} + +function formatRecentMatchStatus(item) { + if (hasMatchScore(item?.result)) { + const sourceLabel = formatResultSource(item?.result_source || item?.source_basis); + return sourceLabel ? `Resultado confirmado (${sourceLabel})` : "Resultado confirmado"; + } + if (item?.capture_basis === "rcon-competitive-window") { + return "En curso"; + } + if (item?.result_source || item?.source_basis || item?.capture_basis) { + return formatResultSource(item.result_source || item.source_basis || item.capture_basis); + } + return "Resultado no disponible"; +} + +function formatResultSource(value) { + if (value === "admin-log-match-ended") { + return "cierre RCON"; + } + if (value === "rcon-session") { + return "sesion RCON"; + } + if (value === "rcon-materialized-admin-log") { + return "registro RCON"; + } + if (value === "public-scoreboard-match") { + return "scoreboard externo"; + } + if (value === "rcon-competitive-window") { + return "ventana RCON"; + } + return value ? String(value).replaceAll("-", " ") : ""; +} + +function formatNumber(value) { + const parsedValue = Number(value); + if (!Number.isFinite(parsedValue)) { + return "0"; + } + + return new Intl.NumberFormat("es-ES").format(parsedValue); +} + +function formatDecimal(value, fractionDigits = 1) { + const parsedValue = Number(value); + if (!Number.isFinite(parsedValue)) { + return "0"; + } + + return new Intl.NumberFormat("es-ES", { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }).format(parsedValue); +} + +function formatPercent(value) { + const parsedValue = Number(value); + if (!Number.isFinite(parsedValue)) { + return "0 %"; + } + + return `${new Intl.NumberFormat("es-ES", { + maximumFractionDigits: 0, + }).format(parsedValue * 100)} %`; +} + +function formatSignedDecimal(value, fractionDigits = 1) { + const parsedValue = Number(value); + if (!Number.isFinite(parsedValue)) { + return "0"; + } + const prefix = parsedValue > 0 ? "+" : ""; + return `${prefix}${formatDecimal(parsedValue, fractionDigits)}`; +} + +function formatAccuracyMode(mode) { + if (mode === "exact") { + return "Exacto"; + } + if (mode === "approximate") { + return "Aproximado"; + } + if (mode === "partial") { + return "Parcial"; + } + return "Mixto"; +} + +function formatMonthKey(monthKey) { + if (!monthKey) { + return ""; + } + + const value = new Date(`${monthKey}-01T00:00:00Z`); + if (Number.isNaN(value.getTime())) { + return monthKey; + } + + return new Intl.DateTimeFormat("es-ES", { + month: "long", + year: "numeric", + timeZone: "UTC", + }).format(value); +} + +function formatTimestamp(timestamp) { + if (!timestamp) { + return "Fecha no disponible"; + } + + const value = new Date(timestamp); + if (Number.isNaN(value.getTime())) { + return "Fecha no disponible"; + } + + return new Intl.DateTimeFormat("es-ES", { + dateStyle: "short", + timeStyle: "short", + }).format(value); +} + +async function getCachedJson(cache, pendingCache, key, url) { + const cachedPayload = readCachedPayload(cache, key); + if (cachedPayload) { + return cachedPayload; + } + if (pendingCache.has(key)) { + return pendingCache.get(key); + } + + const request = fetchJson(url) + .then((payload) => { + writeCachedPayload(cache, key, payload); + pendingCache.delete(key); + return payload; + }) + .catch((error) => { + pendingCache.delete(key); + throw error; + }); + pendingCache.set(key, request); + return request; +} + +function readCachedPayload(cache, key) { + const entry = cache.get(key); + if (!entry) { + return null; + } + + if (entry.expiresAt <= Date.now()) { + cache.delete(key); + return null; + } + + return entry.payload; +} + +function writeCachedPayload(cache, key, payload) { + cache.set(key, { + payload, + expiresAt: Date.now() + resolveSnapshotCacheTtl(payload), + }); +} + +function resolveSnapshotCacheTtl(payload) { + const data = payload?.data; + if (!data) { + return NEGATIVE_SNAPSHOT_CACHE_TTL_MS; + } + + if (data.snapshot_status === "missing" || data.found === false) { + return NEGATIVE_SNAPSHOT_CACHE_TTL_MS; + } + + if (data.is_stale) { + return STALE_SNAPSHOT_CACHE_TTL_MS; + } + + return SNAPSHOT_CACHE_TTL_MS; +} + +async function settlePromise(promise) { + try { + const value = await promise; + return { status: "fulfilled", value }; + } catch (reason) { + return { status: "rejected", reason }; + } +} + +async function fetchJson(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Request failed with ${response.status}`); + } + + return response.json(); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function buildLeaderboardEmptyMessage(metricConfig, serverSlug, timeframeKey) { + void serverSlug; + const timeframeLabel = getLeaderboardTimeframeConfig(timeframeKey).shortLabel; + return metricConfig.emptyMessage.replace("esta ventana", `esta ventana ${timeframeLabel}`); +} + +function getHistoricalEmptyState(serverSlug) { + void serverSlug; + + return { + rangeLabel: "Sin cobertura registrada", + summaryMessage: "Sin datos historicos suficientes", + summaryNote: + "Todavia no existe un resumen listo para el alcance seleccionado.", + recentMessage: "Todavia no hay partidas recientes disponibles.", + }; +} diff --git a/frontend/assets/js/main.js b/frontend/assets/js/main.js new file mode 100644 index 0000000..48598c2 --- /dev/null +++ b/frontend/assets/js/main.js @@ -0,0 +1,611 @@ +// Progressive enhancement for local frontend-backend checks. +const DEFAULT_SERVER_POLL_INTERVAL_MS = 300 * 1000; +const TRUSTED_SERVER_ACTIONS = Object.freeze({ + "comunidad-hispana-01": Object.freeze({ + publicScoreboardUrl: "https://scoreboard.comunidadhll.es", + historicalUrl: "./historico.html?server=comunidad-hispana-01", + currentMatchUrl: "./partida-actual.html?server=comunidad-hispana-01", + }), + "comunidad-hispana-02": Object.freeze({ + publicScoreboardUrl: "https://scoreboard.comunidadhll.es:5443", + historicalUrl: "./historico.html?server=comunidad-hispana-02", + currentMatchUrl: "./partida-actual.html?server=comunidad-hispana-02", + }), +}); +const COMMUNITY_CLANS = Object.freeze([ + { + name: "LCM", + badge: "Clan CH", + description: + "Clan activo de la comunidad, con acceso directo a su discord.", + logoSrc: "./assets/img/clans/lcm.png", + logoAlt: "Logo de LCM", + logoClassName: "", + discordUrl: "https://discord.gg/9F9S353QZv", + discordLabel: "Abrir Discord", + }, + { + name: "La 129", + badge: "Clan CH", + description: + "Clan activo de la comunidad.", + logoSrc: "./assets/img/clans/la129.png", + logoAlt: "Logo de La 129", + logoClassName: "clan-card__logo--wide", + discordUrl: "", + discordLabel: "Proximamente", + }, + { + name: "250 Hispania", + badge: "Clan CH", + description: + "Clan activo de la comunidad, con acceso directo a su discord.", + logoSrc: "./assets/img/clans/250hispania-shield.png", + logoAlt: "Escudo de 250 Hispania", + logoClassName: "clan-card__logo--shield", + discordUrl: "https://discord.gg/3E62Yb6Aw3", + discordLabel: "Abrir Discord", + }, + { + name: "H9H", + badge: "Clan CH", + description: + "Clan activo de la comunidad, con acceso directo a su discord.", + logoSrc: "./assets/img/clans/h9h.png", + logoAlt: "", + logoClassName: "", + discordUrl: "https://discord.gg/tYnXK7MQjB", + discordLabel: "Abrir Discord", + placeholderLabel: "H9H", + }, + { + name: "BxB", + badge: "Clan CH", + description: + "Clan activo de la comunidad.", + logoSrc: "./assets/img/clans/bxb.png", + logoAlt: "Logo de BxB", + logoClassName: "", + discordUrl: "", + discordLabel: "Proximamente", + }, + { + name: "7dv", + badge: "Clan CH", + description: + "Clan activo de la comunidad, con acceso directo a su discord.", + logoSrc: "./assets/img/clans/7dv.png", + logoAlt: "Logo de 7dv", + logoClassName: "", + discordUrl: "https://discord.gg/3sxNQZwrg6", + discordLabel: "Abrir Discord", + }, +]); + +document.addEventListener("DOMContentLoaded", () => { + console.info("HLL Vietnam frontend ready"); + + const backendBaseUrl = + document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000"; + const serverPollIntervalMs = getServerPollIntervalMs( + document.body.dataset.serverRefreshMs, + ); + const statusNode = document.getElementById("backend-status"); + const trailerFrame = document.getElementById("trailer-frame"); + const trailerTitle = document.getElementById("trailer-title"); + const serversTitle = document.getElementById("servers-title"); + const serversList = document.getElementById("servers-list"); + const serversBadge = document.getElementById("servers-badge"); + const communityClansList = document.getElementById("community-clans-list"); + + updateBackendStatus(statusNode, "Backend comprobando", "status-chip--idle"); + setServersDataState(serversBadge, { timestampLabel: "" }); + renderServersLoadingState(serversList); + hydrateCommunityClans(communityClansList); + + let serverRefreshInFlight = false; + const refreshServers = async () => { + if (serverRefreshInFlight) { + return; + } + + serverRefreshInFlight = true; + try { + await hydrateServers( + backendBaseUrl, + serversTitle, + serversList, + serversBadge, + ); + } finally { + serverRefreshInFlight = false; + } + }; + + Promise.allSettled([ + fetchHealth(backendBaseUrl, statusNode), + hydrateTrailer(backendBaseUrl, trailerFrame, trailerTitle), + refreshServers(), + ]).catch((error) => { + console.warn("Progressive enhancement failed", error); + }); + + if (serverPollIntervalMs > 0) { + window.setInterval(() => { + void refreshServers(); + }, serverPollIntervalMs); + } +}); + +async function fetchHealth(backendBaseUrl, statusNode) { + try { + const response = await fetch(`${backendBaseUrl}/health`); + if (!response.ok) { + throw new Error(`Health request failed with ${response.status}`); + } + + const payload = await response.json(); + if (payload.status === "ok") { + updateBackendStatus(statusNode, "Backend operativo", "status-chip--ok"); + return; + } + + throw new Error("Unexpected health payload"); + } catch (error) { + console.warn("Backend health check unavailable", error); + updateBackendStatus( + statusNode, + "Modo estatico activo", + "status-chip--fallback", + ); + } +} + +async function hydrateTrailer(backendBaseUrl, trailerFrame, trailerTitle) { + if (!trailerFrame || !trailerTitle) { + return; + } + + try { + const response = await fetch(`${backendBaseUrl}/api/trailer`); + if (!response.ok) { + throw new Error(`Trailer request failed with ${response.status}`); + } + + const payload = await response.json(); + const trailer = payload.data; + if (!trailer || !trailer.video_url || !trailer.title) { + throw new Error("Trailer payload incomplete"); + } + + trailerFrame.src = trailer.video_url; + trailerFrame.title = trailer.title; + trailerTitle.textContent = trailer.title; + } catch (error) { + console.warn("Trailer placeholder remains static", error); + } +} + +async function hydrateServers( + backendBaseUrl, + serversTitle, + serversList, + serversBadge, +) { + if (!serversTitle || !serversList || !serversBadge) { + return; + } + + try { + const payload = await fetchJson(`${backendBaseUrl}/api/servers`); + const serversData = payload.data; + if (!serversData || !Array.isArray(serversData.items)) { + throw new Error("Servers payload incomplete"); + } + + serversTitle.textContent = + serversData.title || "Estado actual de servidores"; + setServersDataState(serversBadge, deriveSnapshotState(serversData)); + + if (serversData.items.length === 0) { + serversList.innerHTML = + '

Informacion de servidores disponible mas adelante.

'; + return; + } + + const visibleItems = selectPrimaryServerItems(serversData.items); + serversList.innerHTML = renderServerSections(visibleItems); + } catch (error) { + console.warn("Servers panel failed to hydrate with live data", error); + serversList.innerHTML = + '

No se pudo cargar el estado real de servidores en este momento.

'; + setServersDataState(serversBadge, { + label: "Actualizacion no disponible", + isFresh: false, + }); + } +} + +function renderServersLoadingState(serversList) { + if (!serversList) { + return; + } + serversList.innerHTML = ` +
+ +

Cargando estado real de servidores...

+
+ `; +} + +function updateBackendStatus(statusNode, label, stateClass) { + if (!statusNode) { + return; + } + + statusNode.textContent = label; + statusNode.classList.remove("status-chip--ok", "status-chip--fallback"); + if (stateClass) { + statusNode.classList.add(stateClass); + } +} + +function setServersDataState(badgeNode, state) { + if (!badgeNode) { + return; + } + + const hasLabel = typeof state.label === "string" && state.label; + badgeNode.textContent = hasLabel + ? state.label + : "Actualizado no disponible"; + badgeNode.classList.toggle("status-chip--ok", Boolean(hasLabel && state.isFresh)); + badgeNode.classList.toggle( + "status-chip--fallback", + !hasLabel || !state.isFresh, + ); +} + +function renderServerStatsCard(server) { + const serverName = server.server_name || "Servidor sin nombre"; + const statusLabel = formatServerStatus(server.status); + const stateClass = + server.status === "online" ? "server-state--online" : "server-state--offline"; + const isRealSnapshot = isRealLiveSnapshot(server); + const currentMap = server.current_map || "Sin mapa disponible"; + const region = normalizeServerRegion(server.region); + const players = Number.isFinite(server.players) ? server.players : 0; + const maxPlayers = Number.isFinite(server.max_players) ? server.max_players : 0; + const actionMarkup = renderServerAction(server); + const cardVariantClass = isRealSnapshot ? "server-card--real" : "server-card--reference"; + const eyebrowLabel = isRealSnapshot ? "Servidor de comunidad" : "Referencia actual"; + const quickFactItems = [ + { label: "Mapa", value: currentMap, valueClassName: "server-card__quickfact-value--map" }, + ]; + if (region) { + quickFactItems.push({ label: "Region", value: region }); + } + const quickFacts = renderQuickFacts(quickFactItems); + + return ` +
+
+
+

${escapeHtml(eyebrowLabel)}

+

${escapeHtml(serverName)}

+
+
+ ${escapeHtml(statusLabel)} +

${escapeHtml(`${players} / ${maxPlayers}`)}

+
+
+
+ ${quickFacts} + ${actionMarkup} +
+
+ `; +} + +function renderServerSections(latestItems) { + return latestItems.map((server) => renderServerStatsCard(server)).join(""); +} + +function normalizeServerRegion(value) { + if (typeof value !== "string") { + return ""; + } + const trimmedValue = value.trim(); + if (!trimmedValue) { + return ""; + } + const normalizedValue = trimmedValue + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase(); + const placeholderValues = new Set([ + "region pendiente", + "region pending", + "pending", + "unknown", + "desconocida", + "no disponible", + "por confirmar", + "n/a", + ]); + return placeholderValues.has(normalizedValue) ? "" : trimmedValue; +} + +function renderServerAction(server) { + const actions = getTrustedServerActions(server); + if (!actions) { + return ""; + } + + return ` + + `; +} + +function hydrateCommunityClans(listNode) { + if (!listNode) { + return; + } + + listNode.innerHTML = shuffleItems(COMMUNITY_CLANS) + .map((clan) => renderCommunityClanCard(clan)) + .join(""); +} + +function renderCommunityClanCard(clan) { + const logoMarkup = renderClanLogo(clan); + const discordMarkup = renderClanDiscordLink(clan); + + return ` +
+
+ ${logoMarkup} +
+

${escapeHtml(clan.badge)}

+

${escapeHtml(clan.name)}

+

${escapeHtml(clan.description)}

+
+
+ ${discordMarkup} +
+ `; +} + +function renderClanLogo(clan) { + const logoClassNames = ["clan-card__logo"]; + if (clan.logoClassName) { + logoClassNames.push(clan.logoClassName); + } + + if (clan.logoSrc) { + return ` +
+ ${escapeHtml(clan.logoAlt)} +
+ `; + } + + return ` +
+
+ ${escapeHtml(clan.placeholderLabel || clan.name)} +
+
+ `; +} + +function renderClanDiscordLink(clan) { + if (!clan.discordUrl) { + return ` + + ${escapeHtml(clan.discordLabel)} + + `; + } + + return ` + + ${escapeHtml(clan.discordLabel)} + + `; +} + +function renderQuickFacts(items) { + return ` +
+ ${items + .map( + (item) => ` +
+

${escapeHtml(item.label)}

+ ${escapeHtml(item.value)} +
+ `, + ) + .join("")} +
+ `; +} + +function getTrustedServerActions(server) { + const trustedActionKey = resolveTrustedServerActionKey(server); + return TRUSTED_SERVER_ACTIONS[trustedActionKey] || null; +} + +function resolveTrustedServerActionKey(server) { + if (!server) { + return ""; + } + + const externalServerId = getTrimmedServerValue(server.external_server_id); + if (TRUSTED_SERVER_ACTIONS[externalServerId]) { + return externalServerId; + } + + const trustedSlugFields = [ + server.server_slug, + server.target_key, + server.slug, + server.community_slug, + ]; + const trustedSlug = trustedSlugFields + .map(getTrimmedServerValue) + .find((value) => TRUSTED_SERVER_ACTIONS[value]); + if (trustedSlug) { + return trustedSlug; + } + + const serverNames = [server.server_name, server.name].map(getTrimmedServerValue); + if ( + serverNames.some( + (name) => name.startsWith("#01") || name.includes("Comunidad Hispana #01"), + ) + ) { + return "comunidad-hispana-01"; + } + if ( + serverNames.some( + (name) => name.startsWith("#02") || name.includes("Comunidad Hispana #02"), + ) + ) { + return "comunidad-hispana-02"; + } + + const serverReference = [ + getTrimmedServerValue(server.source_ref), + externalServerId, + ].join(" "); + if (serverReference.includes("152.114.195.174") || serverReference.includes(":7779")) { + return "comunidad-hispana-01"; + } + if (serverReference.includes("152.114.195.150") || serverReference.includes(":7879")) { + return "comunidad-hispana-02"; + } + + return ""; +} + +function getTrimmedServerValue(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function selectPrimaryServerItems(items) { + if (!Array.isArray(items)) { + return []; + } + + const realItems = items.filter(isRealLiveSnapshot); + return realItems.length > 0 ? realItems : items; +} + +function isRealLiveSnapshot(item) { + return item?.snapshot_origin === "real-a2s" || item?.snapshot_origin === "real-rcon"; +} + +function deriveSnapshotState(serversData) { + const timestampLabel = serversData?.last_snapshot_at + ? formatTimestamp(serversData.last_snapshot_at) + : ""; + if (!timestampLabel) { + return { + label: "", + isFresh: false, + }; + } + + const isFresh = serversData?.is_stale !== true; + return { + label: isFresh + ? `Actualizado ${timestampLabel}` + : `Ultimo snapshot ${timestampLabel}`, + isFresh, + }; +} + +function formatServerStatus(status) { + if (status === "online") { + return "Online"; + } + + if (status === "offline") { + return "Offline"; + } + + return "Estado pendiente"; +} + +function formatTimestamp(timestamp) { + const value = new Date(timestamp); + if (Number.isNaN(value.getTime())) { + return "Fecha no disponible"; + } + + return new Intl.DateTimeFormat("es-ES", { + dateStyle: "short", + timeStyle: "short", + }).format(value); +} + +function getServerPollIntervalMs(rawValue) { + const parsedValue = Number(rawValue); + if (!Number.isFinite(parsedValue) || parsedValue <= 0) { + return DEFAULT_SERVER_POLL_INTERVAL_MS; + } + + return parsedValue; +} + +async function fetchJson(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Request failed with ${response.status}`); + } + + return response.json(); +} + +function shuffleItems(items) { + const shuffledItems = [...items]; + for (let currentIndex = shuffledItems.length - 1; currentIndex > 0; currentIndex -= 1) { + const randomIndex = Math.floor(Math.random() * (currentIndex + 1)); + [shuffledItems[currentIndex], shuffledItems[randomIndex]] = [ + shuffledItems[randomIndex], + shuffledItems[currentIndex], + ]; + } + + return shuffledItems; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/frontend/assets/js/partida-actual.js b/frontend/assets/js/partida-actual.js new file mode 100644 index 0000000..26afd80 --- /dev/null +++ b/frontend/assets/js/partida-actual.js @@ -0,0 +1,915 @@ +const CURRENT_MATCH_POLL_INTERVAL_MS = 30 * 1000; +const CURRENT_MATCH_KILL_FEED_POLL_INTERVAL_MS = 1500; +const CURRENT_MATCH_PLAYER_STATS_POLL_INTERVAL_MS = 3000; +const CURRENT_MATCH_SERVERS = Object.freeze({ + "comunidad-hispana-01": "Comunidad Hispana #01", + "comunidad-hispana-02": "Comunidad Hispana #02", +}); +const CURRENT_MATCH_SCOREBOARDS = Object.freeze({ + "comunidad-hispana-01": "https://scoreboard.comunidadhll.es", + "comunidad-hispana-02": "https://scoreboard.comunidadhll.es:5443", +}); +const CURRENT_MATCH_KILL_FEED_LIMIT = 18; +const CURRENT_MATCH_WHITE_WEAPON_ICON_PATH = "./assets/img/weapons/white/"; +const CURRENT_MATCH_WHITE_WEAPON_ICON_FILES = Object.freeze([ + "bazooka_white.svg", + "bren_gun_white.svg", + "browing_m1919_white.svg", + "colt_1911_white.svg", + "dp27_white.svg", + "flammenwefer41_white.svg", + "gewehr_white.svg", + "kar98k_white.svg", + "kar98k_x8_white.svg", + "lee_enfield_n4_white.svg", + "luger_p08_white.svg", + "m1903_springfield_white.svg", + "m1_carabine_white.svg", + "m1_garand_white.svg", + "m2_flamethrower_white.svg", + "m3_grease_gun_white.svg", + "m97_white.svg", + "mg34_white.svg", + "mg42_white.svg", + "mosing_nagant_1891_white.svg", + "mosing_nagant_9130_white.svg", + "mosing_nagant_m38_white.svg", + "mp40_white.svg", + "nagant_m1895_white.svg", + "panzerchreck_white.svg", + "piat_white.svg", + "ppsh41_white.svg", + "ppsh_41w_drum_white.svg", + "ptrs41_white.svg", + "scoped_mosin_nagant_9130_white.svg", + "scoped_svt40_white.svg", + "sten_mk_v_white.svg", + "stg44_white.svg", + "svt40_white.svg", + "thompson_white.svg", + "tokarev_tt33_white.svg", + "walther_p38_white.svg", + "webley_revolver_white.svg", +]); +const CURRENT_MATCH_WEAPONS = Object.freeze({ + bazooka: currentMatchWeapon("Bazooka", "bazooka_white.svg"), + "m1 bazooka": currentMatchWeapon("M1 Bazooka", "bazooka_white.svg"), + "us bazooka": currentMatchWeapon("M1 Bazooka", "bazooka_white.svg"), + bren: currentMatchWeapon("Bren Gun", "bren_gun_white.svg"), + "bren gun": currentMatchWeapon("Bren Gun", "bren_gun_white.svg"), + m1919: currentMatchWeapon("Browning M1919", "browing_m1919_white.svg"), + "m1919 browning": currentMatchWeapon("Browning M1919", "browing_m1919_white.svg"), + "browning m1919": currentMatchWeapon("Browning M1919", "browing_m1919_white.svg"), + browning: currentMatchWeapon("Browning M1919", "browing_m1919_white.svg"), + "us tank machine gun": currentMatchWeapon("Browning M1919", "browing_m1919_white.svg"), + "us coaxial mg": currentMatchWeapon("Browning M1919", "browing_m1919_white.svg"), + "us vehicle mg": currentMatchWeapon("Browning M1919", "browing_m1919_white.svg"), + "coaxial m1919": currentMatchWeapon("Browning M1919", "browing_m1919_white.svg"), + "m1919 coaxial": currentMatchWeapon("Browning M1919", "browing_m1919_white.svg"), + colt: currentMatchWeapon("Colt 1911", "colt_1911_white.svg"), + "colt 1911": currentMatchWeapon("Colt 1911", "colt_1911_white.svg"), + "colt m1911": currentMatchWeapon("Colt 1911", "colt_1911_white.svg"), + m1911: currentMatchWeapon("Colt 1911", "colt_1911_white.svg"), + "m1911 pistol": currentMatchWeapon("Colt 1911", "colt_1911_white.svg"), + dp27: currentMatchWeapon("DP-27", "dp27_white.svg"), + "dp 27": currentMatchWeapon("DP-27", "dp27_white.svg"), + "dp 27 lmg": currentMatchWeapon("DP-27", "dp27_white.svg"), + "flammenwerfer 41": currentMatchWeapon("Flammenwerfer 41", "flammenwefer41_white.svg"), + flammenwerfer: currentMatchWeapon("Flammenwerfer 41", "flammenwefer41_white.svg"), + flammenwefer41: currentMatchWeapon("Flammenwerfer 41", "flammenwefer41_white.svg"), + "german flamethrower": currentMatchWeapon("Flammenwerfer 41", "flammenwefer41_white.svg"), + "gewehr 43": currentMatchWeapon("Gewehr 43", "gewehr_white.svg"), + gewehr43: currentMatchWeapon("Gewehr 43", "gewehr_white.svg"), + g43: currentMatchWeapon("Gewehr 43", "gewehr_white.svg"), + kar98k: currentMatchWeapon("Kar98k", "kar98k_white.svg"), + "kar 98k": currentMatchWeapon("Kar98k", "kar98k_white.svg"), + kar98: currentMatchWeapon("Kar98k", "kar98k_white.svg"), + k98: currentMatchWeapon("Kar98k", "kar98k_white.svg"), + "k98k": currentMatchWeapon("Kar98k", "kar98k_white.svg"), + "scoped kar98k": currentMatchWeapon("Scoped Kar98k", "kar98k_x8_white.svg"), + "kar98k x8": currentMatchWeapon("Scoped Kar98k", "kar98k_x8_white.svg"), + "kar 98k x8": currentMatchWeapon("Scoped Kar98k", "kar98k_x8_white.svg"), + "german sniper kar98k": currentMatchWeapon("Scoped Kar98k", "kar98k_x8_white.svg"), + "sniper kar98k": currentMatchWeapon("Scoped Kar98k", "kar98k_x8_white.svg"), + "lee enfield no 4": currentMatchWeapon("Lee-Enfield No.4", "lee_enfield_n4_white.svg"), + "lee enfield no4": currentMatchWeapon("Lee-Enfield No.4", "lee_enfield_n4_white.svg"), + "lee enfield": currentMatchWeapon("Lee-Enfield No.4", "lee_enfield_n4_white.svg"), + enfield: currentMatchWeapon("Lee-Enfield No.4", "lee_enfield_n4_white.svg"), + luger: currentMatchWeapon("Luger P08", "luger_p08_white.svg"), + p08: currentMatchWeapon("Luger P08", "luger_p08_white.svg"), + "luger p08": currentMatchWeapon("Luger P08", "luger_p08_white.svg"), + "m1903 springfield": currentMatchWeapon("M1903 Springfield", "m1903_springfield_white.svg"), + springfield: currentMatchWeapon("M1903 Springfield", "m1903_springfield_white.svg"), + "us sniper springfield": currentMatchWeapon("M1903 Springfield", "m1903_springfield_white.svg"), + "scoped springfield": currentMatchWeapon("M1903 Springfield", "m1903_springfield_white.svg"), + "m1 carbine": currentMatchWeapon("M1 Carbine", "m1_carabine_white.svg"), + "m1 carabine": currentMatchWeapon("M1 Carbine", "m1_carabine_white.svg"), + "m1 garand": currentMatchWeapon("M1 Garand", "m1_garand_white.svg"), + garand: currentMatchWeapon("M1 Garand", "m1_garand_white.svg"), + "m2 flamethrower": currentMatchWeapon("M2 Flamethrower", "m2_flamethrower_white.svg"), + "us flamethrower": currentMatchWeapon("M2 Flamethrower", "m2_flamethrower_white.svg"), + "m3 grease gun": currentMatchWeapon("M3 Grease Gun", "m3_grease_gun_white.svg"), + "grease gun": currentMatchWeapon("M3 Grease Gun", "m3_grease_gun_white.svg"), + m97: currentMatchWeapon("Winchester M97", "m97_white.svg"), + "winchester m97": currentMatchWeapon("Winchester M97", "m97_white.svg"), + "trench gun": currentMatchWeapon("Winchester M97", "m97_white.svg"), + shotgun: currentMatchWeapon("Winchester M97", "m97_white.svg"), + mg34: currentMatchWeapon("MG34", "mg34_white.svg"), + "mg 34": currentMatchWeapon("MG34", "mg34_white.svg"), + "german tank machine gun": currentMatchWeapon("MG34", "mg34_white.svg"), + "german coaxial mg": currentMatchWeapon("MG34", "mg34_white.svg"), + "german vehicle mg": currentMatchWeapon("MG34", "mg34_white.svg"), + "coaxial mg34": currentMatchWeapon("MG34", "mg34_white.svg"), + "mg34 coaxial": currentMatchWeapon("MG34", "mg34_white.svg"), + mg42: currentMatchWeapon("MG42", "mg42_white.svg"), + "mg 42": currentMatchWeapon("MG42", "mg42_white.svg"), + "mosin nagant 1891": currentMatchWeapon("Mosin Nagant 1891", "mosing_nagant_1891_white.svg"), + "mosin 1891": currentMatchWeapon("Mosin Nagant 1891", "mosing_nagant_1891_white.svg"), + "mosin nagant 91 30": currentMatchWeapon("Mosin Nagant 91/30", "mosing_nagant_9130_white.svg"), + "mosin 9130": currentMatchWeapon("Mosin Nagant 91/30", "mosing_nagant_9130_white.svg"), + "mosin nagant 9130": currentMatchWeapon("Mosin Nagant 91/30", "mosing_nagant_9130_white.svg"), + "mosin nagant m38": currentMatchWeapon("Mosin Nagant M38", "mosing_nagant_m38_white.svg"), + "mosin m38": currentMatchWeapon("Mosin Nagant M38", "mosing_nagant_m38_white.svg"), + m38: currentMatchWeapon("Mosin Nagant M38", "mosing_nagant_m38_white.svg"), + mp40: currentMatchWeapon("MP40", "mp40_white.svg"), + "mp 40": currentMatchWeapon("MP40", "mp40_white.svg"), + "nagant m1895": currentMatchWeapon("Nagant M1895", "nagant_m1895_white.svg"), + "nagant revolver": currentMatchWeapon("Nagant M1895", "nagant_m1895_white.svg"), + panzerschreck: currentMatchWeapon("Panzerschreck", "panzerchreck_white.svg"), + panzerchreck: currentMatchWeapon("Panzerschreck", "panzerchreck_white.svg"), + raketenpanzerbuchse: currentMatchWeapon("Panzerschreck", "panzerchreck_white.svg"), + piat: currentMatchWeapon("PIAT", "piat_white.svg"), + "ppsh 41": currentMatchWeapon("PPSh-41", "ppsh41_white.svg"), + ppsh41: currentMatchWeapon("PPSh-41", "ppsh41_white.svg"), + ppsh: currentMatchWeapon("PPSh-41", "ppsh41_white.svg"), + "ppsh 41 drum": currentMatchWeapon("PPSh-41 Drum", "ppsh_41w_drum_white.svg"), + "ppsh 41 w drum": currentMatchWeapon("PPSh-41 Drum", "ppsh_41w_drum_white.svg"), + "ppsh drum": currentMatchWeapon("PPSh-41 Drum", "ppsh_41w_drum_white.svg"), + "ppsh41 drum": currentMatchWeapon("PPSh-41 Drum", "ppsh_41w_drum_white.svg"), + "ptrs 41": currentMatchWeapon("PTRS-41", "ptrs41_white.svg"), + ptrs41: currentMatchWeapon("PTRS-41", "ptrs41_white.svg"), + ptrs: currentMatchWeapon("PTRS-41", "ptrs41_white.svg"), + "scoped mosin nagant 91 30": currentMatchWeapon("Scoped Mosin Nagant 91/30", "scoped_mosin_nagant_9130_white.svg"), + "scoped mosin nagant 9130": currentMatchWeapon("Scoped Mosin Nagant 91/30", "scoped_mosin_nagant_9130_white.svg"), + "soviet sniper mosin": currentMatchWeapon("Scoped Mosin Nagant 91/30", "scoped_mosin_nagant_9130_white.svg"), + "sniper mosin": currentMatchWeapon("Scoped Mosin Nagant 91/30", "scoped_mosin_nagant_9130_white.svg"), + "scoped svt 40": currentMatchWeapon("Scoped SVT-40", "scoped_svt40_white.svg"), + "scoped svt40": currentMatchWeapon("Scoped SVT-40", "scoped_svt40_white.svg"), + "svt40 scoped": currentMatchWeapon("Scoped SVT-40", "scoped_svt40_white.svg"), + "sten mk v": currentMatchWeapon("Sten Mk V", "sten_mk_v_white.svg"), + sten: currentMatchWeapon("Sten Mk V", "sten_mk_v_white.svg"), + stg44: currentMatchWeapon("StG 44", "stg44_white.svg"), + "stg 44": currentMatchWeapon("StG 44", "stg44_white.svg"), + "sturmgewehr 44": currentMatchWeapon("StG 44", "stg44_white.svg"), + "svt 40": currentMatchWeapon("SVT-40", "svt40_white.svg"), + svt40: currentMatchWeapon("SVT-40", "svt40_white.svg"), + "m1a1 thompson": currentMatchWeapon("M1A1 Thompson", "thompson_white.svg"), + m1a1: currentMatchWeapon("M1A1 Thompson", "thompson_white.svg"), + "m1928 thompson": currentMatchWeapon("M1928 Thompson", "thompson_white.svg"), + thompson: currentMatchWeapon("Thompson", "thompson_white.svg"), + "tokarev tt 33": currentMatchWeapon("Tokarev TT-33", "tokarev_tt33_white.svg"), + "tokarev tt33": currentMatchWeapon("Tokarev TT-33", "tokarev_tt33_white.svg"), + tt33: currentMatchWeapon("Tokarev TT-33", "tokarev_tt33_white.svg"), + "walther p38": currentMatchWeapon("Walther P38", "walther_p38_white.svg"), + p38: currentMatchWeapon("Walther P38", "walther_p38_white.svg"), + webley: currentMatchWeapon("Webley Revolver", "webley_revolver_white.svg"), + "webley revolver": currentMatchWeapon("Webley Revolver", "webley_revolver_white.svg"), + unknown: { label: "Arma desconocida", icon: "" }, +}); + +validateCurrentMatchWeaponMapping(); + +function currentMatchWeapon(label, fileName) { + return { + label, + icon: `${CURRENT_MATCH_WHITE_WEAPON_ICON_PATH}${fileName}`, + }; +} + +function validateCurrentMatchWeaponMapping() { + const expectedIcons = new Set(CURRENT_MATCH_WHITE_WEAPON_ICON_FILES); + const mappedIcons = new Set(); + const invalidIcons = []; + Object.entries(CURRENT_MATCH_WEAPONS).forEach(([alias, weapon]) => { + if (!weapon.icon) { + return; + } + if (!weapon.icon.startsWith(CURRENT_MATCH_WHITE_WEAPON_ICON_PATH)) { + invalidIcons.push(`${alias}: ${weapon.icon}`); + return; + } + const fileName = weapon.icon.slice(CURRENT_MATCH_WHITE_WEAPON_ICON_PATH.length); + mappedIcons.add(fileName); + if (!expectedIcons.has(fileName)) { + invalidIcons.push(`${alias}: ${weapon.icon}`); + } + }); + const unmappedIcons = [...expectedIcons].filter((fileName) => !mappedIcons.has(fileName)); + if (unmappedIcons.length > 0 || invalidIcons.length > 0) { + console.warn("Current match weapon icon mapping needs review.", { + unmappedIcons, + invalidIcons, + }); + } +} + +document.addEventListener("DOMContentLoaded", () => { + const params = new URLSearchParams(window.location.search); + const serverSlug = params.get("server") || ""; + const nodes = { + title: document.getElementById("current-match-title"), + summary: document.getElementById("current-match-summary"), + history: document.getElementById("current-match-history"), + scoreboard: document.getElementById("current-match-scoreboard"), + note: document.getElementById("current-match-note"), + state: document.getElementById("current-match-state"), + grid: document.getElementById("current-match-grid"), + feedTitle: document.getElementById("current-match-feed-title"), + playersTitle: document.getElementById("current-match-players-title"), + mapHero: document.getElementById("current-match-map-hero"), + mapImage: document.getElementById("current-match-map-image"), + mapPlaceholder: document.getElementById("current-match-map-placeholder"), + }; + const backendBaseUrl = + document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000"; + + if (!CURRENT_MATCH_SERVERS[serverSlug]) { + renderUnsupportedServer(nodes); + return; + } + + nodes.history.href = `./historico.html?server=${encodeURIComponent(serverSlug)}`; + const killFeedState = initializeKillFeed(nodes); + const playerStatsState = initializePlayerStats(nodes); + let currentMatchRefreshInFlight = false; + const refreshCurrentMatch = async () => { + if (currentMatchRefreshInFlight) { + return; + } + currentMatchRefreshInFlight = true; + try { + await loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }); + } finally { + currentMatchRefreshInFlight = false; + } + }; + + let killFeedRefreshInFlight = false; + const refreshKillFeed = async () => { + if (killFeedRefreshInFlight) { + return; + } + killFeedRefreshInFlight = true; + try { + await loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }); + } finally { + killFeedRefreshInFlight = false; + } + }; + + let playerStatsRefreshInFlight = false; + const refreshPlayerStats = async () => { + if (playerStatsRefreshInFlight) { + return; + } + playerStatsRefreshInFlight = true; + try { + await loadPlayerStats({ backendBaseUrl, serverSlug, nodes, playerStatsState }); + } finally { + playerStatsRefreshInFlight = false; + } + }; + + void refreshCurrentMatch(); + void refreshKillFeed(); + void refreshPlayerStats(); + window.setInterval(() => { + void refreshCurrentMatch(); + }, CURRENT_MATCH_POLL_INTERVAL_MS); + window.setInterval(() => { + void refreshKillFeed(); + }, CURRENT_MATCH_KILL_FEED_POLL_INTERVAL_MS); + window.setInterval(() => { + void refreshPlayerStats(); + }, CURRENT_MATCH_PLAYER_STATS_POLL_INTERVAL_MS); +}); + +async function loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }) { + try { + const payload = await fetchJson( + `${backendBaseUrl}/api/current-match?server=${encodeURIComponent(serverSlug)}`, + ); + renderCurrentMatch(payload?.data || {}, nodes); + } catch (error) { + nodes.note.textContent = "Se conserva el ultimo estado visible si estaba disponible."; + setState(nodes.state, "No se pudo actualizar la partida actual.", true); + } +} + +async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }) { + try { + const cursor = killFeedState.latestEventId + ? `&since_event_id=${encodeURIComponent(killFeedState.latestEventId)}` + : ""; + const payload = await fetchJson( + `${backendBaseUrl}/api/current-match/kills?server=${encodeURIComponent(serverSlug)}&limit=${CURRENT_MATCH_KILL_FEED_LIMIT}${cursor}`, + ); + renderKillFeed(payload?.data || {}, nodes, killFeedState); + } catch (error) { + setState(nodes.feedState, "No se pudo actualizar el feed de combate.", true); + } +} + +async function loadPlayerStats({ backendBaseUrl, serverSlug, nodes, playerStatsState }) { + try { + const payload = await fetchJson( + `${backendBaseUrl}/api/current-match/players?server=${encodeURIComponent(serverSlug)}`, + ); + renderPlayerStats(payload?.data || {}, nodes, playerStatsState); + } catch (error) { + setState( + nodes.playerStatsState, + "Todavía no hay estadísticas fiables de jugadores para esta partida.", + true, + ); + } +} + +function renderCurrentMatch(data, nodes) { + const rawServerName = data.server_name || data.server_slug || "Servidor no disponible"; + const serverName = formatServerDisplayName(data, rawServerName); + const mapName = data.map_pretty_name || data.map || "Mapa no disponible"; + const scoreboardUrl = resolveTrustedScoreboardUrl(data); + nodes.title.textContent = mapName; + nodes.summary.textContent = serverName; + nodes.note.textContent = data.found + ? "Lectura en vivo recibida. El feed de bajas se actualiza en tiempo casi real." + : "Todavia no hay snapshot live disponible para este servidor."; + nodes.scoreboard.href = scoreboardUrl || "./index.html"; + nodes.scoreboard.hidden = !scoreboardUrl; + renderMapHero(data, mapName, nodes); + nodes.grid.innerHTML = renderLiveScoreboard(data, { mapName, serverName }); + nodes.state.hidden = true; + nodes.grid.hidden = false; +} + +function renderUnsupportedServer(nodes) { + nodes.title.textContent = "Servidor no soportado"; + nodes.summary.textContent = + "Abre esta vista desde una tarjeta activa de Comunidad Hispana."; + nodes.note.textContent = ""; + nodes.scoreboard.hidden = true; + nodes.grid.hidden = true; + renderMapHero({}, "Mapa no disponible", nodes); + setState(nodes.state, "No se puede consultar la partida solicitada.", true); +} + +function initializeKillFeed(nodes) { + const feedShell = nodes.feedTitle?.closest(".panel__shell"); + if (feedShell) { + feedShell.insertAdjacentHTML( + "beforeend", + ` +

+ Cargando feed de combate... +

+
+
+
+ `, + ); + } + nodes.feedState = document.getElementById("current-match-feed-state"); + nodes.feedList = document.getElementById("current-match-feed-list"); + return { + byId: new Map(), + latestEventId: "", + visibleSignature: "", + }; +} + +function initializePlayerStats(nodes) { + const shell = nodes.playersTitle?.closest(".panel__shell"); + if (shell) { + shell.insertAdjacentHTML( + "beforeend", + ` +

+ Cargando estadisticas en vivo... +

+ + `, + ); + } + nodes.playerStatsState = document.getElementById("current-match-player-stats-state"); + nodes.playerCount = document.getElementById("current-match-player-count"); + nodes.playerStatsShell = document.getElementById("current-match-player-stats-shell"); + return { + visibleSignature: "", + }; +} + +function renderKillFeed(data, nodes, state) { + const incoming = Array.isArray(data.items) ? data.items : []; + if (data.scope === "no-current-match-events") { + state.byId.clear(); + state.latestEventId = ""; + } + incoming.forEach((event) => { + if (event?.event_id) { + state.byId.set(event.event_id, event); + } + }); + const events = [...state.byId.values()] + .sort(compareKillFeedEvents) + .slice(-CURRENT_MATCH_KILL_FEED_LIMIT); + state.byId = new Map(events.map((event) => [event.event_id, event])); + state.latestEventId = events[events.length - 1]?.event_id || state.latestEventId; + if (events.length === 0) { + nodes.feedList.innerHTML = ""; + state.visibleSignature = ""; + setState(nodes.feedState, "Todavía no se han detectado bajas en esta partida."); + return; + } + const visualEvents = events; + const visibleSignature = visualEvents.map((event) => event.event_id).join("|"); + if (visibleSignature !== state.visibleSignature) { + nodes.feedList.innerHTML = renderKillFeedColumns(visualEvents); + state.visibleSignature = visibleSignature; + } + nodes.feedState.textContent = formatKillFeedCoverage(data.scope); + nodes.feedState.classList.remove("historical-state--error"); +} + +function compareKillFeedEvents(left, right) { + const leftTime = Number(left.server_time); + const rightTime = Number(right.server_time); + if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) { + return leftTime - rightTime; + } + return ( + String(left.event_timestamp || "").localeCompare(String(right.event_timestamp || "")) || + String(left.event_id || "").localeCompare(String(right.event_id || "")) + ); +} + +function renderKillFeedColumns(events) { + const splitIndex = Math.ceil(events.length / 2); + return [events.slice(0, splitIndex), events.slice(splitIndex)] + .map( + (columnEvents) => ` +
+ ${columnEvents.map(renderKillFeedRow).join("")} +
+ `, + ) + .join(""); +} + +function renderKillFeedRow(event) { + const weapon = resolveKillFeedWeapon(event.weapon); + const killerTeam = getKillFeedTeamDisplay(event.killer_team); + const victimTeam = getKillFeedTeamDisplay(event.victim_team); + const teamkillBadge = event.is_teamkill + ? 'TK' + : ""; + return ` +
+ + + ${escapeHtml(event.killer_name || "Jugador no disponible")} + + + ${renderKillFeedTeamBadge(killerTeam)} + ${teamkillBadge} + + + + ${renderKillFeedWeaponIcon(weapon)} + ${escapeHtml(weapon.label)} + + + + ${escapeHtml(event.victim_name || "Objetivo no disponible")} + + ${renderKillFeedTeamBadge(victimTeam)} + +
+ `; +} + +function getKillFeedTeamDisplay(value) { + const team = getPlayerTeamDisplay(value); + return team.key === "unknown" ? { key: "unknown", label: "N/D" } : team; +} + +function renderKillFeedTeamBadge(team) { + return ` + + ${escapeHtml(team.label)} + + `; +} + +function resolveKillFeedWeapon(value) { + const key = normalizeLookupText(value); + return CURRENT_MATCH_WEAPONS[key] || { + label: String(value || CURRENT_MATCH_WEAPONS.unknown.label), + icon: CURRENT_MATCH_WEAPONS.unknown.icon, + }; +} + +function renderKillFeedWeaponIcon(weapon) { + if (!weapon.icon) { + return ''; + } + return ` + + + `; +} + +function renderPlayerStats(data, nodes, state) { + const items = Array.isArray(data.items) ? sortPlayerStats(data.items) : []; + renderDetectedPlayerCount(items.length, nodes); + if (items.length === 0) { + state.visibleSignature = ""; + nodes.playerStatsShell.innerHTML = ""; + nodes.playerStatsShell.hidden = true; + setState( + nodes.playerStatsState, + "Todavía no hay estadísticas fiables de jugadores para esta partida.", + ); + return; + } + const signature = items + .map((item) => + [ + item.player_name, + item.team, + item.kills, + item.deaths, + item.teamkills, + item.deaths_by_teamkill, + item.favorite_weapon, + item.last_seen_at, + ].join(":"), + ) + .join("|"); + if (signature !== state.visibleSignature) { + nodes.playerStatsShell.innerHTML = renderPlayerStatsTable(items); + state.visibleSignature = signature; + } + nodes.playerStatsShell.hidden = false; + setState(nodes.playerStatsState, "Estadisticas derivadas de los eventos recientes."); +} + +function renderDetectedPlayerCount(count, nodes) { + if (nodes.playerCount) { + nodes.playerCount.textContent = `Jugadores detectados: ${count}`; + } +} + +function sortPlayerStats(items) { + return [...items].sort( + (left, right) => + toStatNumber(right.kills) - toStatNumber(left.kills) || + toStatNumber(left.deaths) - toStatNumber(right.deaths) || + String(left.player_name || "").localeCompare(String(right.player_name || ""), "es", { + sensitivity: "base", + }), + ); +} + +function renderPlayerStatsTable(items) { + return ` + + + + + + + + + + + + + + ${items.map(renderPlayerStatsRow).join("")} + +
JugadorEquipoBajasMuertesTKMuertes TKArma frecuente
+ `; +} + +function renderPlayerStatsRow(item) { + const team = getPlayerTeamDisplay(item.team); + return ` + + ${escapeHtml(item.player_name || "Jugador no disponible")} + + + ${escapeHtml(team.label)} + + + ${escapeHtml(formatStatNumber(item.kills))} + ${escapeHtml(formatStatNumber(item.deaths))} + ${escapeHtml(formatStatNumber(item.teamkills))} + ${escapeHtml(formatStatNumber(item.deaths_by_teamkill))} + ${escapeHtml(item.favorite_weapon || "No disponible")} + + `; +} + +function getPlayerTeamDisplay(value) { + const normalized = String(value || "").trim().toLowerCase(); + if (normalized === "allies" || normalized === "allied" || normalized === "aliados") { + return { key: "allies", label: "Aliados" }; + } + if (normalized === "axis" || normalized === "eje") { + return { key: "axis", label: "Eje" }; + } + return { key: "unknown", label: "No disponible" }; +} + +function toStatNumber(value) { + return Number.isFinite(Number(value)) ? Number(value) : 0; +} + +function formatStatNumber(value) { + return Number.isFinite(Number(value)) ? String(Number(value)) : "0"; +} + +function renderCompactMeta(label, value) { + return ` +
+ ${escapeHtml(label)} + ${escapeHtml(value)} +
+ `; +} + +function formatStatus(value) { + if (value === "online") { + return "Online"; + } + if (value === "offline") { + return "Offline"; + } + return "No disponible"; +} + +function formatPlayers(players, maxPlayers) { + if (!isNumericValue(players) || !isNumericValue(maxPlayers)) { + return "No disponible"; + } + return `${Number(players)} / ${Number(maxPlayers)}`; +} + +function formatTimestamp(value) { + if (!value) { + return "No disponible"; + } + const timestamp = new Date(value); + if (Number.isNaN(timestamp.getTime())) { + return "No disponible"; + } + return new Intl.DateTimeFormat("es-ES", { + dateStyle: "short", + timeStyle: "short", + }).format(timestamp); +} + +function renderLiveScoreboard(data, { mapName, serverName }) { + const scoreKnown = hasKnownScore(data); + const scoreMarkup = scoreKnown + ? `${Number(data.allied_score)} : ${Number(data.axis_score)}` + : "Marcador no disponible"; + const scoreClass = scoreKnown ? "" : " current-match-scoreboard-message"; + const metadata = [ + ["Servidor", serverName], + ["Mapa", mapName], + ["Modo", formatGameMode(data.game_mode)], + ]; + if (data.started_at) { + metadata.push(["Inicio", formatTimestamp(data.started_at)]); + } + const remainingTime = Number(data.remaining_match_time_seconds); + if (Number.isFinite(remainingTime) && remainingTime > 0) { + metadata.push(["Tiempo restante", formatDuration(remainingTime)]); + } + const matchTime = Number(data.match_time_seconds); + if (Number.isFinite(matchTime) && matchTime > 0) { + metadata.push(["Tiempo de partida", formatDuration(matchTime)]); + } + metadata.push(["Jugadores", formatPlayerCount(data)]); + metadata.push(["Actualizado", formatTimestamp(data.captured_at || data.updated_at)]); + + return ` +
+
+ ${renderLiveSide("historical-scoreboard-side--allied", "Aliados", "./assets/img/factions/us.webp")} +
+ ${escapeHtml(formatStatus(data.status))} + ${escapeHtml(scoreMarkup)} + ${escapeHtml(mapName)} + ${escapeHtml(formatGameMode(data.game_mode))} +
+ ${renderLiveSide("historical-scoreboard-side--axis", "Eje", "./assets/img/factions/germany.webp")} +
+
+ ${metadata.map(([label, value]) => renderCompactMeta(label, value)).join("")} +
+
+ `; +} + +function renderLiveSide(sideClass, label, emblem) { + return ` +
+ ${escapeHtml(label)} +
+ ${escapeHtml(label)} +
+
+ `; +} + +function renderMapHero(data, mapName, nodes) { + if (!nodes.mapImage || !nodes.mapPlaceholder) { + return; + } + const mapImagePath = resolveMapImagePath(data, mapName); + nodes.mapPlaceholder.hidden = Boolean(mapImagePath); + nodes.mapImage.hidden = !mapImagePath; + if (!mapImagePath) { + nodes.mapImage.removeAttribute("src"); + nodes.mapImage.alt = ""; + return; + } + nodes.mapImage.src = mapImagePath; + nodes.mapImage.alt = mapName; + nodes.mapImage.onerror = () => { + nodes.mapImage.removeAttribute("src"); + nodes.mapImage.hidden = true; + nodes.mapPlaceholder.hidden = false; + }; +} + +function resolveMapImagePath(data, mapName) { + const normalizedMap = normalizeLookupText( + `${data.map_id || ""} ${data.map || ""} ${data.map_pretty_name || ""} ${mapName || ""}`, + ).replaceAll(" ", ""); + const mapAssetByKey = { + carentan: "carentan-day.webp", + driel: "driel-day.webp", + elalamein: "elalamein-day.webp", + elsenbornridge: "elsenbornridge-day.webp", + foy: "foy-day.webp", + hill400: "hill400-day.webp", + hurtgenforest: "hurtgenforest-day.webp", + kharkov: "kharkov-day.webp", + kursk: "kursk-day.webp", + mortain: "mortain-day.webp", + omahabeach: "omahabeach-day.webp", + purpleheartlane: "purpleheartlane-rain.webp", + smolensk: "smolensk-day.webp", + stmariedumont: "stmariedumont-day.webp", + stmereeglise: "stmereeglise-day.webp", + tobrukdawn: "tobruk-dawn.webp", + tobruk: "tobruk-day.webp", + utahbeach: "utahbeach-day.webp", + }; + const matchedKey = Object.keys(mapAssetByKey).find((key) => + normalizedMap.includes(key), + ); + return matchedKey ? `./assets/img/maps/${mapAssetByKey[matchedKey]}` : ""; +} + +function resolveTrustedScoreboardUrl(data) { + const trustedUrl = CURRENT_MATCH_SCOREBOARDS[data.server_slug]; + return data.public_scoreboard_url === trustedUrl ? trustedUrl : ""; +} + +function formatServerDisplayName(data, fallbackName) { + const trustedName = CURRENT_MATCH_SERVERS[data.server_slug]; + if (trustedName) { + return trustedName; + } + + const normalized = String(fallbackName || "").trim(); + const serverNumber = normalized.match(/^#0?([1-9])\b/); + if (serverNumber) { + return `Comunidad Hispana #${serverNumber[1].padStart(2, "0")}`; + } + + return normalized || "Servidor no disponible"; +} + +function hasKnownScore(data) { + return isNumericValue(data.allied_score) && isNumericValue(data.axis_score); +} + +function formatPlayerCount(data) { + if (!isReliablePlayerCount(data.player_count_quality)) { + return "No verificado"; + } + return formatPlayers(data.players, data.max_players); +} + +function isReliablePlayerCount(quality) { + return quality === "reliable" || quality === "a2s-query"; +} + +function isNumericValue(value) { + return value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value)); +} + +function formatGameMode(value) { + if (!value) { + return "No disponible"; + } + const normalized = String(value).replaceAll("_", " ").replaceAll("-", " "); + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +} + +function formatDuration(value) { + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds <= 0) { + return "No disponible"; + } + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return hours > 0 ? `${hours} h ${remainingMinutes} min` : `${minutes} min`; +} + +function formatKillFeedCoverage(scope) { + if (scope === "open-admin-log-match-window") { + return "Bajas detectadas en la partida actual."; + } + if (scope === "recent-admin-log-window") { + return "Cobertura parcial desde AdminLog reciente."; + } + if (scope === "no-current-match-events") { + return "Todavía no se han detectado bajas en esta partida."; + } + return "Todavía no se han detectado bajas en esta partida."; +} + +function normalizeLookupText(value) { + return String(value || "") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function setState(node, message, isError = false) { + node.textContent = message; + node.hidden = false; + node.classList.toggle("historical-state--error", isError); +} + +async function fetchJson(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Request failed with ${response.status}`); + } + return response.json(); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/frontend/historico-partida.html b/frontend/historico-partida.html new file mode 100644 index 0000000..bd2dc32 --- /dev/null +++ b/frontend/historico-partida.html @@ -0,0 +1,167 @@ + + + + + + + Detalle de partida - HLL Vietnam + + + + + +
+
+
+
+
+ + VOLVER HISTORICO + +

Detalle interno

+
+
+
+ Logo oficial de la comunidad HLL Vietnam +
+
+
+

+ Partida registrada +

+

+ Cargando datos disponibles del historico local. +

+
+
+ +
+
+
+ +
+
+
+
+
+

Partida historica

+

Marcador final

+

+ Leyendo el detalle interno de la partida. +

+
+
+

+ Cargando detalle... +

+ + + +
+
+
+
+ + + + + diff --git a/frontend/historico.html b/frontend/historico.html new file mode 100644 index 0000000..74127d2 --- /dev/null +++ b/frontend/historico.html @@ -0,0 +1,229 @@ + + + + + + + Historico - HLL Vietnam + + + + +
+
+
+
+
+ + VOLVER INICIO + +

Historico propio

+
+
+
+ Logo oficial de la comunidad HLL Vietnam +
+
+
+

+ Registro de la + Comunidad Hispana +

+

+ Consulta los registros, el ranking semanal y las partidas recientes + de nuestros servidores. +

+
+
+ + + +
+
+
+
+
+ +
+ + +
+
+
+
+

Rankings historicos

+

Ranking del alcance activo

+

+ Cargando rango del ranking... +

+

+ Cargando datos del ranking... +

+
+
+
+ + +
+
+ + + + +
+

+ Cargando ranking semanal... +

+
+ + + + + + + + + + + +
+
+
+ +
+
+
+
+

Partidas recientes

+

Ultimas partidas registradas

+

+ Lista de partidas ya registradas. +

+

+ Cargando datos de partidas... +

+
+
+

+ Cargando partidas recientes... +

+
+
+
+
+
+ + + + + + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3b79278 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,128 @@ + + + + + + + Comunidad Hispana - HLL + + + + +
+
+
+
+
+
+ Logo oficial de la comunidad HLL +
+
+

+ Comunidad Hispana + HLL +

+

+ Centro de reunion para escuadras, eventos y acceso directo al + Discord oficial de la comunidad. +

+ +
+
+
+
+ +
+
+
+
+

Trailer

+

Primer vistazo a HLL Vietnam

+
+
+ +
+
+
+ +
+
+
+
+

Servidores públicos

+

Estado actual de servidores

+
+

+ Actualizado no disponible +

+
+

+ El panel muestra el ultimo estado disponible consultado de los servidores. +

+ +
+
+ +

Cargando estado real de servidores...

+
+
+
+
+ +
+
+
+

Clanes de la comunidad

+

Grupo de la comunidad

+
+

+ Clanes activos de la escena hispana con presencia confirmada. +

+
+
+
+
+
+ + + + + + diff --git a/frontend/partida-actual.html b/frontend/partida-actual.html new file mode 100644 index 0000000..591d729 --- /dev/null +++ b/frontend/partida-actual.html @@ -0,0 +1,139 @@ + + + + + + + Partida actual - HLL Vietnam + + + + + +
+
+
+
+
+ + VOLVER INICIO + +

Partida en vivo

+
+
+
+ Logo oficial de la comunidad HLL Vietnam +
+
+
+

+ Partida actual +

+

+ Cargando estado del servidor. +

+
+ +
+
+ +
+ Mapa en vivo + Esperando imagen disponible. +
+
+
+
+
+ +
+
+
+
+
+

Estado en vivo

+

Marcador en curso

+

+ Leyendo el ultimo snapshot disponible. +

+
+
+

+ Cargando partida actual... +

+ +
+
+ +
+
+
+
+

Combate

+

Feed de combate

+

+ Leyendo eventos recientes para esta partida. +

+
+
+
+
+ +
+
+
+
+

Jugadores

+

Estadisticas en vivo

+
+

+ Las estadisticas en vivo apareceran cuando haya datos suficientes. +

+

+ Jugadores detectados: 0 +

+
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/install-ai-platform.ps1 b/install-ai-platform.ps1 new file mode 100644 index 0000000..2e6e26d --- /dev/null +++ b/install-ai-platform.ps1 @@ -0,0 +1,8 @@ +param( + [string]$RepositoryRoot = (Get-Location).Path +) + +Write-Host "HLL Vietnam AI platform is already integrated in this repository." +Write-Host "This script is kept as a platform utility placeholder inherited from the template approach." +Write-Host "Repository root: $RepositoryRoot" +Write-Host "If platform files drift, compare them against the project task workflow before replacing anything." diff --git a/scripts/codex-runner.ps1 b/scripts/codex-runner.ps1 new file mode 100644 index 0000000..0041cf3 --- /dev/null +++ b/scripts/codex-runner.ps1 @@ -0,0 +1,112 @@ +param( + [int]$PollIntervalSeconds = 30 +) + +$configPath = "ai-platform.json" + +function Get-PlatformConfig { + param( + [string]$Path + ) + + if (-not (Test-Path $Path)) { + return $null + } + + try { + return Get-Content -Raw $Path | ConvertFrom-Json + } + catch { + Write-Host "Unable to read $Path. Falling back to default worker paths." + return $null + } +} + +$platformConfig = Get-PlatformConfig -Path $configPath +$projectName = if ($platformConfig.project.name) { $platformConfig.project.name } else { "HLL Vietnam" } +$taskPaths = $platformConfig.workflow.task_paths +$runnerConfig = $platformConfig.runner + +$pendingTasksPath = if ($taskPaths.pending) { $taskPaths.pending } else { "ai/tasks/pending" } +$lockFile = if ($runnerConfig.lock_file) { $runnerConfig.lock_file } else { "ai/worker.lock" } +$metricsFile = if ($runnerConfig.metrics_file) { $runnerConfig.metrics_file } else { "ai/system-metrics.md" } +$integrationTestsScript = if ($runnerConfig.integration_tests_script) { $runnerConfig.integration_tests_script } else { "scripts/run-integration-tests.ps1" } +$codexPrompt = if ($runnerConfig.codex_prompt) { $runnerConfig.codex_prompt } else { "Follow AGENTS.md, read the platform context in ai/, and process the pending tasks without acting outside task scope." } + +Write-Host "$projectName Codex worker started..." + +function Test-WorkerProcessActive { + param( + [string]$LockFilePath + ) + + if (-not (Test-Path $LockFilePath)) { + return $false + } + + $lockContent = Get-Content $LockFilePath -ErrorAction SilentlyContinue | Select-Object -First 1 + $workerPid = 0 + + if ([int]::TryParse(($lockContent -as [string]), [ref]$workerPid) -and $workerPid -gt 0) { + try { + Get-Process -Id $workerPid -ErrorAction Stop | Out-Null + return $true + } + catch { + return $false + } + } + + return $false +} + +while ($true) { + if (Test-Path $lockFile) { + if (Test-WorkerProcessActive -LockFilePath $lockFile) { + Write-Host "Worker already running. Waiting..." + Start-Sleep -Seconds $PollIntervalSeconds + continue + } + + Write-Host "Stale worker lock detected. Removing it." + Remove-Item $lockFile -Force -ErrorAction SilentlyContinue + } + + $pendingTasks = Get-ChildItem $pendingTasksPath -Filter *.md -ErrorAction SilentlyContinue + + if (-not $pendingTasks) { + Write-Host "No pending tasks found." + Start-Sleep -Seconds $PollIntervalSeconds + continue + } + + if (-not (Get-Command codex -ErrorAction SilentlyContinue)) { + Write-Host "Codex CLI not found. Install and authenticate Codex before running the worker." + exit 1 + } + + Set-Content -Path $lockFile -Value "$PID" -NoNewline + + try { + $startTime = Get-Date + codex $codexPrompt + $codexExitCode = $LASTEXITCODE + $duration = [math]::Round(((Get-Date) - $startTime).TotalSeconds, 2) + + if ($codexExitCode -eq 0) { + Add-Content $metricsFile "$(Get-Date -Format s) | worker-cycle | $duration sec | success | codex-runner" + + if (Test-Path $integrationTestsScript) { + powershell -ExecutionPolicy Bypass -File $integrationTestsScript + } + } + else { + Add-Content $metricsFile "$(Get-Date -Format s) | worker-cycle | $duration sec | failed($codexExitCode) | codex-runner" + } + } + finally { + Remove-Item $lockFile -Force -ErrorAction SilentlyContinue + } + + Start-Sleep -Seconds $PollIntervalSeconds +} diff --git a/scripts/run-historical-ui-regression-tests.ps1 b/scripts/run-historical-ui-regression-tests.ps1 new file mode 100644 index 0000000..291409f --- /dev/null +++ b/scripts/run-historical-ui-regression-tests.ps1 @@ -0,0 +1,95 @@ +$ErrorActionPreference = "Stop" + +Write-Host "Historical UI regression validation" + +function Assert-Contains { + param( + [string] $Content, + [string] $Pattern, + [string] $Message + ) + + if ($Content -notmatch [regex]::Escape($Pattern)) { + throw $Message + } +} + +function Assert-NotContains { + param( + [string] $Content, + [string] $Pattern, + [string] $Message + ) + + if ($Content -match [regex]::Escape($Pattern)) { + throw $Message + } +} + +function Get-VisibleText { + param([string] $Html) + + return ($Html -replace "", " " ` + -replace "", " " ` + -replace "<[^>]+>", " ") +} + +$historicoHtml = Get-Content -Raw "frontend/historico.html" +$historicoPartidaHtml = Get-Content -Raw "frontend/historico-partida.html" +$historicoJs = Get-Content -Raw "frontend/assets/js/historico.js" +$historicoPartidaJs = Get-Content -Raw "frontend/assets/js/historico-partida.js" +$visibleHistoricalText = "$(Get-VisibleText $historicoHtml) $(Get-VisibleText $historicoPartidaHtml)" + +Assert-NotContains $historicoHtml 'data-server-slug="comunidad-hispana-03"' ` + "Comunidad Hispana #03 selector was reintroduced." +Assert-NotContains $historicoHtml "MVP mensual V1" ` + "MVP mensual V1 block was reintroduced in visible historical HTML." +Assert-NotContains $historicoHtml "MVP mensual V2" ` + "MVP mensual V2 block was reintroduced in visible historical HTML." +Assert-NotContains $historicoHtml "Comparativa V1 vs V2" ` + "Comparativa V1 vs V2 block was reintroduced in visible historical HTML." +Assert-NotContains $historicoHtml "Elo/MMR" ` + "Elo/MMR public block was reintroduced in visible historical HTML." +Assert-NotContains $visibleHistoricalText "snapshot" ` + "Public snapshot wording was reintroduced in visible historical text." + +Assert-NotContains $historicoJs "Ver partida" ` + "Recent match cards must not expose the external scoreboard action label." +Assert-Contains $historicoJs "Ver detalles" ` + "Recent match cards no longer include the internal detail fallback label." +Assert-NotContains $historicoJs "item.match_url || item.source_url" ` + "Recent match cards must not trust legacy source_url fallback." +Assert-Contains $historicoPartidaJs "Ver en Scoreboard" ` + "Match detail page no longer includes the external scoreboard action label." +Assert-Contains $historicoPartidaJs 'rel="noopener noreferrer"' ` + "External scoreboard links must keep rel noopener noreferrer." +Assert-Contains $historicoPartidaJs 'target="_blank"' ` + "External scoreboard links must open in a new tab." + +$backendCheck = @' +import sys +sys.path.insert(0, "backend") + +from app.config import get_historical_data_source_kind +from app.routes import resolve_get_payload + +status, payload = resolve_get_payload("/health") +if status is None or payload.get("status") != "ok": + raise SystemExit("/health did not resolve to an ok payload.") +if payload.get("historical_data_source") != "rcon": + raise SystemExit("/health no longer reports RCON-first historical source.") +if get_historical_data_source_kind() != "rcon": + raise SystemExit("Configured historical source is no longer rcon.") + +detail_status, detail_payload = resolve_get_payload( + "/api/historical/matches/detail?server=comunidad-hispana-01&match=regression-check" +) +if detail_status is None or detail_payload.get("status") != "ok": + raise SystemExit("Match detail endpoint did not resolve successfully.") +if detail_payload.get("data", {}).get("context") != "historical-match-detail": + raise SystemExit("Match detail endpoint context changed unexpectedly.") +'@ + +$backendCheck | python - + +Write-Host "Historical UI regression validation passed." diff --git a/scripts/run-integration-tests.ps1 b/scripts/run-integration-tests.ps1 new file mode 100644 index 0000000..7fab467 --- /dev/null +++ b/scripts/run-integration-tests.ps1 @@ -0,0 +1,63 @@ +$ErrorActionPreference = "Stop" + +Write-Host "HLL Vietnam platform validation" + +$configPath = "ai-platform.json" +if (-not (Test-Path $configPath)) { + throw "Missing $configPath" +} + +$config = Get-Content -Raw $configPath | ConvertFrom-Json +$taskPaths = $config.workflow.task_paths + +$requiredTaskPaths = @( + $taskPaths.pending, + $taskPaths.in_progress, + $taskPaths.review, + $taskPaths.blocked, + $taskPaths.obsolete, + $taskPaths.done +) + +foreach ($path in $requiredTaskPaths) { + if (-not $path -or -not (Test-Path $path)) { + throw "Missing task lifecycle path: $path" + } +} + +$gitignore = Get-Content -Raw ".gitignore" +$requiredIgnoreRules = @( + "backend/runtime/", + "ai/reports/*.md", + "!ai/reports/.gitkeep" +) + +foreach ($rule in $requiredIgnoreRules) { + if ($gitignore -notmatch [regex]::Escape($rule)) { + throw "Missing .gitignore rule: $rule" + } +} + +if (-not (Test-Path "ai/reports/.gitkeep")) { + throw "Missing ai/reports/.gitkeep" +} + +$backendImportCheck = @' +import sys +sys.path.insert(0, "backend") +import app.main +from app.routes import resolve_get_payload + +status, payload = resolve_get_payload("/health") +if status is None or payload.get("status") != "ok": + raise SystemExit("Backend health route did not resolve to an ok payload.") +'@ + +$backendImportCheck | python - + +powershell -ExecutionPolicy Bypass -File scripts/run-historical-ui-regression-tests.ps1 + +Write-Host "No product integration tests are configured for this platform-only scope." +Write-Host "Backend startup import check passed." +Write-Host "Platform validation passed." +exit 0 diff --git a/scripts/run-rcon-data-pipeline-tests.ps1 b/scripts/run-rcon-data-pipeline-tests.ps1 new file mode 100644 index 0000000..b770bfc --- /dev/null +++ b/scripts/run-rcon-data-pipeline-tests.ps1 @@ -0,0 +1,123 @@ +$ErrorActionPreference = "Stop" + +Write-Host "HLL Vietnam RCON data pipeline validation" + +function Invoke-Step { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][scriptblock]$Command + ) + + Write-Host "" + Write-Host "== $Name ==" + & $Command +} + +function Test-PythonModule { + param([Parameter(Mandatory = $true)][string]$ModuleName) + + $check = "import importlib.util; raise SystemExit(0 if importlib.util.find_spec('$ModuleName') else 1)" + python -c $check *> $null + return $LASTEXITCODE -eq 0 +} + +Invoke-Step "Compile backend application modules" { + python -m compileall backend/app +} + +$previousPythonPath = $env:PYTHONPATH +$env:PYTHONPATH = if ($previousPythonPath) { "backend;$previousPythonPath" } else { "backend" } + +try { + if (Test-PythonModule "pytest") { + Invoke-Step "Run RCON parser, storage and materialization tests with pytest" { + python -m pytest ` + backend/tests/test_rcon_admin_log_parser.py ` + backend/tests/test_rcon_admin_log_storage.py ` + backend/tests/test_rcon_materialization_pipeline.py ` + backend/tests/test_scoreboard_match_links.py + } + } + else { + Write-Host "" + Write-Host "pytest is not installed; running offline fallback checks for RCON parser/storage and unittest suites." + + Invoke-Step "Run RCON parser and storage fallback checks" { + $fallbackChecks = @' +from pathlib import Path +import tempfile +from backend.tests import test_rcon_admin_log_parser as parser_tests +from backend.tests import test_rcon_admin_log_storage as storage_tests + +parser_tests.test_parse_match_start() +parser_tests.test_parse_match_end() +parser_tests.test_parse_kill() +parser_tests.test_parse_team_switch() +parser_tests.test_parse_connected() +parser_tests.test_parse_disconnected() +parser_tests.test_parse_chat() +parser_tests.test_parse_kick() +parser_tests.test_parse_message_profile() +parser_tests.test_parse_player_profile_snapshot_spanish_sections() +parser_tests.test_non_profile_message_does_not_parse_as_profile_snapshot() + +with tempfile.TemporaryDirectory() as tmp: + storage_tests.test_initialize_rcon_admin_log_storage_creates_event_table(Path(tmp)) +with tempfile.TemporaryDirectory() as tmp: + storage_tests.test_persist_rcon_admin_log_entries_inserts_then_reports_duplicates(Path(tmp)) +with tempfile.TemporaryDirectory() as tmp: + storage_tests.test_profile_message_snapshots_are_materialized_and_deduped(Path(tmp)) +with tempfile.TemporaryDirectory() as tmp: + storage_tests.test_non_profile_messages_do_not_create_profile_snapshots(Path(tmp)) +with tempfile.TemporaryDirectory() as tmp: + storage_tests.test_canonical_message_dedupes_changing_relative_prefixes(Path(tmp)) +with tempfile.TemporaryDirectory() as tmp: + storage_tests.test_list_rcon_admin_log_event_counts_groups_by_target_and_event_type(Path(tmp)) + +print("RCON parser and storage fallback checks passed.") +'@ + $fallbackChecks | python - + } + + Invoke-Step "Run RCON materialization unittest suite" { + python -m unittest backend.tests.test_rcon_materialization_pipeline + } + + Invoke-Step "Run RCON scoreboard link unittest suite" { + python -m unittest backend.tests.test_scoreboard_match_links + } + } +} +finally { + $env:PYTHONPATH = $previousPythonPath +} + +Invoke-Step "Optional Docker backend smoke check" { + if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + Write-Host "Skipping Docker smoke check: docker command is not available." + return + } + + docker compose ps --services --filter "status=running" *> $null + if ($LASTEXITCODE -ne 0) { + Write-Host "Skipping Docker smoke check: docker compose is not available or no compose project is active." + return + } + + $runningServices = docker compose ps --services --filter "status=running" + if ($runningServices -notcontains "backend") { + Write-Host "Skipping backend endpoint smoke check: backend service is not running." + return + } + + $health = Invoke-WebRequest "http://localhost:8000/health" -UseBasicParsing + if ($health.StatusCode -lt 200 -or $health.StatusCode -ge 300) { + throw "Backend health smoke check failed with status $($health.StatusCode)." + } + Write-Host "Backend health smoke check passed." +} + +Write-Host "" +Write-Host "Skipping real RCON checks: this validation is designed to run without RCON credentials." +Write-Host "RCON data pipeline validation passed." +exit 0