sistema A2S

This commit is contained in:
devRaGonSa
2026-03-20 14:38:57 +01:00
parent a82a90a1b4
commit 87c1f4e8c3
75 changed files with 7625 additions and 137 deletions

View File

@@ -1,5 +1,389 @@
# Backend
Esta carpeta queda preparada para la futura implementación del backend principal en Python.
Esta carpeta contiene el bootstrap minimo del futuro backend principal en Python para HLL Vietnam.
En esta fase inicial no se incluye lógica funcional ni servicios activos. La intención es reservar una estructura clara para incorporar la aplicación, dependencias y configuración sin reorganizar el repositorio más adelante.
## 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
|-- 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`
Valor por defecto de `HLL_BACKEND_ALLOWED_ORIGINS`:
- `null`
- `http://127.0.0.1:5500`
- `http://127.0.0.1:8080`
- `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/servers` devuelve en esta fase un payload controlado con servidores
actuales de Hell Let Loose usados como referencia provisional. La respuesta
incluye `title`, `context`, `source` e `items`, y no representa todavia datos
reales de HLL Vietnam ni una integracion externa validada.
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.
## 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.
- `config.py` centraliza host, puerto y allowlist minima de origenes locales.
- `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`
Por defecto el archivo se crea en:
```text
backend/data/hll_vietnam_dev.sqlite3
```
Variable opcional:
- `HLL_BACKEND_STORAGE_PATH`
- `HLL_BACKEND_A2S_TARGETS`
La base logica sigue documentada en
`docs/stats-database-schema-foundation.md`. Esta implementacion no introduce
ORM, migraciones ni una decision de almacenamiento productivo.
## 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 `300` 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
```
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`.
## 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:8080`
- `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.
## 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`.

View File

@@ -1 +1,59 @@
"""Base package for the future HLL Vietnam Python backend."""
"""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",
]

176
backend/app/a2s_client.py Normal file
View File

@@ -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("<B", payload, offset)[0]
if __name__ == "__main__":
main()

269
backend/app/collector.py Normal file
View File

@@ -0,0 +1,269 @@
"""Minimal collector bootstrap for provisional server snapshots."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Callable, Mapping, Sequence
from .a2s_client import DEFAULT_A2S_TIMEOUT, query_server_info
from .normalizers import normalize_a2s_server_info, normalize_server_record
from .server_targets import A2SServerTarget, load_a2s_targets
from .snapshots import build_snapshot_batch, utc_now
from .storage import persist_snapshot_batch
RawSourceFetcher = Callable[[], Sequence[Mapping[str, object]]]
TargetProbe = Callable[[A2SServerTarget, float], Mapping[str, object]]
CONTROLLED_RAW_SERVER_SOURCE: tuple[dict[str, object], ...] = (
{
"external_server_id": "hll-esp-tactical-rotation",
"server_name": "HLL ESP Tactical Rotation",
"status": "online",
"players": 74,
"max_players": 100,
"current_map": "Sainte-Marie-du-Mont",
"region": "EU",
},
{
"external_server_id": "hll-latam-night-offensive",
"server_name": "HLL LATAM Night Offensive",
"status": "online",
"players": 51,
"max_players": 100,
"current_map": "Carentan",
"region": "LATAM",
},
{
"external_server_id": "hll-community-reserve",
"server_name": "HLL Community Reserve",
"status": "offline",
"players": 0,
"max_players": 100,
"current_map": None,
"region": "EU",
},
)
def fetch_controlled_server_source() -> 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()

77
backend/app/config.py Normal file
View File

@@ -0,0 +1,77 @@
"""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_ALLOWED_ORIGINS = (
"null",
"http://127.0.0.1:5500",
"http://127.0.0.1:8080",
"http://localhost:5500",
"http://localhost:8080",
)
DEFAULT_A2S_TARGETS_ENV_VAR = "HLL_BACKEND_A2S_TARGETS"
DEFAULT_A2S_SOURCE_NAME = "community-hispana-a2s"
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_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_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

73
backend/app/main.py Normal file
View File

@@ -0,0 +1,73 @@
"""Minimal HTTP entrypoint for the HLL Vietnam backend bootstrap."""
from __future__ import annotations
import json
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from .config import get_allowed_origins, get_bind_address
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
status, payload = resolve_get_payload(self.path)
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).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 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()

View File

@@ -0,0 +1,89 @@
"""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
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": _string_or_none(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": 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_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

180
backend/app/payloads.py Normal file
View File

@@ -0,0 +1,180 @@
"""Placeholder payload builders for the HLL Vietnam backend."""
from __future__ import annotations
from .server_targets import load_a2s_targets
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",
}
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 a controlled placeholder for current Hell Let Loose servers."""
return {
"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",
},
{
"server_name": "HLL LATAM Night Offensive",
"status": "online",
"players": 51,
"max_players": 100,
"current_map": "Carentan",
"region": "LATAM",
},
{
"server_name": "HLL Community Reserve",
"status": "offline",
"players": 0,
"max_players": 100,
"current_map": None,
"region": "EU",
},
],
},
}
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_error_payload(message: str) -> dict[str, str]:
"""Return the shared error payload shape used by the backend bootstrap."""
return {
"status": "error",
"message": message,
}
def _enrich_server_items(items: list[dict[str, object]]) -> list[dict[str, object]]:
target_index = {
target.external_server_id: target
for target in load_a2s_targets()
if target.external_server_id
}
enriched_items: list[dict[str, object]] = []
for item in items:
enriched_items.append(_enrich_server_item(item, target_index))
return enriched_items
def _enrich_server_item(
item: dict[str, object],
target_index: dict[str, object],
) -> dict[str, object]:
enriched = dict(item)
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 != "real-a2s":
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

70
backend/app/routes.py Normal file
View File

@@ -0,0 +1,70 @@
"""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 .payloads import (
build_community_payload,
build_discord_payload,
build_error_payload,
build_health_payload,
build_server_detail_history_payload,
build_server_history_payload,
build_server_latest_payload,
build_servers_payload,
build_trailer_payload,
)
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)
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

100
backend/app/scheduler.py Normal file
View File

@@ -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.",
)
parser.add_argument(
"--interval",
type=int,
default=get_refresh_interval_seconds(),
help="Seconds to wait between persisted refresh runs.",
)
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()

View File

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

54
backend/app/snapshots.py Normal file
View File

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

514
backend/app/storage.py Normal file
View File

@@ -0,0 +1,514 @@
"""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
DEFAULT_GAME_SOURCE = {
"slug": "current-hll",
"display_name": "Current Hell Let Loose",
"provider_kind": "development",
}
SUMMARY_SNAPSHOT_LIMIT = 6
def initialize_storage(*, db_path: Path | None = None) -> Path:
"""Create the local database file and minimal schema when missing."""
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 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."""
resolved_path = initialize_storage(db_path=db_path)
source_definition = dict(DEFAULT_GAME_SOURCE)
if game_source is not None:
source_definition.update(game_source)
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."""
resolved_path = initialize_storage(db_path=db_path)
with _connect(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."""
resolved_path = initialize_storage(db_path=db_path)
with _connect(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."""
resolved_path = initialize_storage(db_path=db_path)
server_filter, server_value = _build_server_filter(server_id)
with _connect(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:
connection = sqlite3.connect(db_path)
connection.row_factory = sqlite3.Row
return connection
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

Binary file not shown.

View File

@@ -1 +1 @@
# Dependencias del backend Python se definiran en fases posteriores.
# El bootstrap actual usa solo la libreria estandar de Python.