Promote rcon historical model to primary

This commit is contained in:
devRaGonSa
2026-03-26 07:18:56 +01:00
parent cdcd4523b4
commit 50bfadf471
14 changed files with 964 additions and 228 deletions

View File

@@ -0,0 +1,54 @@
# TASK-102-rcon-historical-competitive-read-model-primary
## Goal
Construir una capa histórica competitiva primaria basada en persistencia RCON, o en una materialización derivada de ella, suficiente para soportar el producto histórico sin depender de public-scoreboard como writer principal.
## Context
Hoy la repo ya tiene:
- captura histórica prospectiva RCON
- read model histórico RCON mínimo
- histórico clásico por public-scoreboard
- player-events V2
- Elo/MMR mensual
Pero el writer path histórico competitivo sigue dependiendo del archivo clásico importado por scoreboard.
## Steps
1. Auditar:
- `backend/app/rcon_historical_storage.py`
- `backend/app/rcon_historical_read_model.py`
- `backend/app/historical_storage.py`
- `backend/app/player_event_storage.py`
- `backend/app/elo_mmr_storage.py`
- `backend/app/payloads.py`
2. Definir un modelo primario histórico competitivo RCON-backed:
- directo sobre persistencia RCON, o
- mediante tablas/materializaciones derivadas generadas desde RCON
3. Ese modelo debe cubrir como prioridad:
- recent activity / recent matches
- historical server summary
- métricas mínimas competitivas reutilizables por MVP/Elo
4. Si hacen falta tablas/materialized snapshots nuevas, crearlas.
5. Dejar capabilities explícitas por dominio:
- exact
- approximate
- partial
- unavailable
6. Documentar claramente qué parte del histórico ya puede dejar de depender del import clásico.
7. Mantener scoreboard como fallback solo para lo que todavía no esté cubierto.
## Constraints
- No romper la persistencia RCON ya existente.
- No eliminar el histórico clásico todavía.
- No inventar granularidad que la captura RCON no tenga.
- No degradar el request path HTTP innecesariamente.
## Validation
- Existe una capa histórica competitiva primaria RCON-backed real.
- Al menos summary/recent activity dejan de depender del pipeline clásico como principal.
- Las capabilities quedan visibles y honestas.
- La repo queda consistente.
## Expected Files
- archivos backend nuevos o modificados bajo `backend/app/` para read model/materialización histórica RCON
- `docs/elo-mmr-monthly-ranking-design.md` si hace falta
- `backend/README.md`

View File

@@ -0,0 +1,51 @@
# TASK-103-rcon-first-historical-aggregates-and-fallback-boundary
## Goal
Mover los agregados históricos de producto a una frontera RCON-first real, dejando scoreboard solo para las piezas que todavía no puedan calcularse desde el modelo histórico competitivo RCON-backed.
## Context
Una vez exista el modelo primario histórico competitivo RCON-backed, hace falta conectar realmente los endpoints y payloads de producto para que usen esa capa como primaria.
## Steps
1. Auditar:
- `backend/app/payloads.py`
- `backend/app/routes.py`
- `backend/app/historical_snapshots.py`
- `backend/app/historical_snapshot_storage.py`
- `backend/app/elo_mmr_engine.py`
2. Reorientar como RCON-first real, al menos donde la nueva capa ya lo permita:
- historical server summary
- recent matches
- Elo/MMR mensual
- y cualquier agregado competitivo mínimo ya soportado
3. Mantener fallback a public-scoreboard solo cuando:
- la capability sea partial/unavailable
- la cobertura RCON no alcance
- el cálculo falle
4. Hacer visible la frontera exacta de fallback:
- qué endpoints ya son realmente RCON-first
- cuáles siguen cayendo a scoreboard
- por qué
5. Ajustar snapshots/materializaciones si hace falta para no depender del request path directo.
6. Alinear README/runbook con esta nueva frontera funcional.
## Constraints
- No afirmar que MVP V1/V2 completos ya sean 100% RCON-backed si no lo son.
- No romper endpoints existentes.
- No ocultar el fallback real.
- Mantener latencia razonable.
## Validation
- Los endpoints históricos soportados ya usan RCON-backed como primario real.
- El fallback a scoreboard queda reducido y explícito.
- Elo/MMR mensual consume primariamente el modelo RCON-backed donde ya sea posible.
- La repo queda consistente.
## Expected Files
- `backend/app/payloads.py`
- `backend/app/routes.py`
- `backend/app/historical_snapshots.py`
- `backend/app/historical_snapshot_storage.py`
- `backend/app/elo_mmr_engine.py`
- `backend/README.md`
- otros archivos backend si hace falta

View File

@@ -0,0 +1,45 @@
# TASK-104-landing-loading-state-and-stale-lock-hardening
## Goal
Corregir dos problemas de producto/operación ya detectados:
1. la landing muestra primero datos fake estáticos antes de hidratar
2. los stale locks entre contenedores Docker siguen bloqueando pasadas manuales aunque los workers ya estén parados
## Context
Se ha confirmado que:
- la homepage renderiza cards estáticas fake y luego las sustituye al hidratar
- eso genera un flash de datos falsos
- además, el lock compartido puede quedar huérfano entre contenedores y requiere borrado manual
## Steps
1. Para la landing:
- auditar `frontend/index.html`, `frontend/assets/js/main.js`, `frontend/assets/css/styles.css`
- aplicar la opción A:
- no mostrar cards fake iniciales
- dejar contenedor vacío o skeleton/loading state
- renderizar solo datos reales al hidratar
- degradar limpio si falla la API
2. Para el stale lock:
- auditar `backend/app/writer_lock.py`
- mejorar la detección/recuperación de locks huérfanos entre contenedores Docker
- no depender únicamente del hostname si eso bloquea recuperación válida
- mantener seguridad para no liberar locks activos por error
3. Documentar el comportamiento actualizado en README/runbook.
## Constraints
- No rehacer la landing completa.
- No romper el single-writer lock.
- No volver al comportamiento sin coordinación.
- No introducir riesgo de liberar locks válidos sin comprobación suficiente.
## Validation
- La landing ya no muestra datos fake antes de hidratar.
- Los locks huérfanos entre contenedores se recuperan mejor o quedan claramente resueltos.
- La repo queda consistente.
## Expected Files
- `frontend/index.html`
- `frontend/assets/js/main.js`
- `frontend/assets/css/styles.css`
- `backend/app/writer_lock.py`
- `backend/README.md`

View File

@@ -1195,6 +1195,12 @@ El servicio `historical-runner` usa el mismo volumen persistente `./backend/data
y ejecuta `python -m app.historical_runner --hourly` como bucle operativo y ejecuta `python -m app.historical_runner --hourly` como bucle operativo
dedicado, sin mezclar el scheduler con el proceso HTTP principal. dedicado, sin mezclar el scheduler con el proceso HTTP principal.
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 ## Coordinacion single-writer para automatizaciones y CLI
Todos los procesos writer-oriented que comparten el mismo SQLite usan ahora un Todos los procesos writer-oriented que comparten el mismo SQLite usan ahora un
@@ -1224,6 +1230,10 @@ Comportamiento:
- `started_at` - `started_at`
- host - host
- pid - 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` - la coordinacion principal es este single-writer lock; WAL y `busy_timeout`
quedan como endurecimiento complementario, no como solucion unica quedan como endurecimiento complementario, no como solucion unica
@@ -1434,12 +1444,18 @@ Metadata observable en payloads historicos:
Estado real a fecha de esta fase: Estado real a fecha de esta fase:
- el read model historico RCON soporta cobertura y actividad reciente - 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
- `historical_ingestion` intenta primero una captura writer-oriented por RCON y - `historical_ingestion` intenta primero una captura writer-oriented por RCON y
deja esa tentativa visible en su salida deja esa tentativa visible en su salida
- rankings competitivos, MVP y Elo/MMR siguen necesitando fallback a - leaderboards semanales/mensuales, MVP V1/V2 y player-events siguen teniendo
`public-scoreboard` porque el read model RCON actual no expone aun detalle fallback a `public-scoreboard` mientras RCON no disponga de señal competitiva
historico competitivo suficiente por jugador con paridad suficiente
- Elo/MMR pasa a consumir primero contexto competitivo RCON-backed para
cobertura y calidad de match cuando existe, pero sigue necesitando
`public-scoreboard` como suplemento para estadisticas por jugador
## Elo/MMR Monthly Ranking ## Elo/MMR Monthly Ranking
@@ -1467,6 +1483,17 @@ Politica de exactitud:
- `approximate`: role bucket, objective index, strength of schedule - `approximate`: role bucket, objective index, strength of schedule
- `not_available`: leadership y tacticas finas no persistidas - `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: La especificacion detallada y el mapa de capabilities quedan en:
- `docs/elo-mmr-monthly-ranking-design.md` - `docs/elo-mmr-monthly-ranking-design.md`

View File

@@ -174,7 +174,7 @@ class RconFirstLiveDataSource:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class RconHistoricalDataSource: class RconHistoricalDataSource:
"""Minimal persisted historical read model over prospective RCON capture.""" """Persisted RCON-backed historical read model over captured competitive windows."""
source_kind: str = SOURCE_KIND_RCON source_kind: str = SOURCE_KIND_RCON
@@ -200,7 +200,7 @@ class RconHistoricalDataSource:
) )
def list_server_summaries(self, *, server_key: str | None = None) -> list[dict[str, object]]: def list_server_summaries(self, *, server_key: str | None = None) -> list[dict[str, object]]:
"""Return coverage and freshness from persisted prospective RCON samples.""" """Return coverage and freshness from persisted RCON-backed competitive history."""
return list_rcon_historical_server_summaries(server_key=server_key) return list_rcon_historical_server_summaries(server_key=server_key)
def list_recent_activity( def list_recent_activity(
@@ -209,7 +209,7 @@ class RconHistoricalDataSource:
server_key: str | None = None, server_key: str | None = None,
limit: int = 20, limit: int = 20,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""Return recent persisted RCON activity without on-demand network calls.""" """Return recent RCON-backed competitive history without on-demand network calls."""
return list_rcon_historical_recent_activity(server_key=server_key, limit=limit) return list_rcon_historical_recent_activity(server_key=server_key, limit=limit)
def describe_capabilities(self) -> dict[str, object]: def describe_capabilities(self) -> dict[str, object]:
@@ -238,7 +238,7 @@ def get_live_data_source() -> LiveDataSource:
def get_rcon_historical_read_model() -> RconHistoricalDataSource | None: def get_rcon_historical_read_model() -> RconHistoricalDataSource | None:
"""Return the minimal persisted RCON historical read model when selected.""" """Return the persisted RCON-backed historical read model when selected."""
if get_historical_data_source_kind() != SOURCE_KIND_RCON: if get_historical_data_source_kind() != SOURCE_KIND_RCON:
return None return None
return RconHistoricalDataSource() return RconHistoricalDataSource()
@@ -258,8 +258,8 @@ def describe_historical_runtime_policy() -> dict[str, object]:
"primary_source": SOURCE_KIND_RCON, "primary_source": SOURCE_KIND_RCON,
"fallback_source": SOURCE_KIND_PUBLIC_SCOREBOARD, "fallback_source": SOURCE_KIND_PUBLIC_SCOREBOARD,
"summary": ( "summary": (
"Historical runtime attempts the persisted RCON read model first and falls " "Historical runtime attempts the persisted RCON-backed competitive model first "
"back to public-scoreboard when the requested operation is unsupported, has " "and falls back to public-scoreboard when the requested operation is unsupported, has "
"no coverage yet, or the primary path fails." "no coverage yet, or the primary path fails."
), ),
} }

View File

@@ -15,6 +15,7 @@ from .data_sources import (
SOURCE_KIND_RCON, SOURCE_KIND_RCON,
build_source_attempt, build_source_attempt,
build_source_policy, build_source_policy,
get_rcon_historical_read_model,
) )
from .elo_mmr_models import ( from .elo_mmr_models import (
CAPABILITY_APPROXIMATE, CAPABILITY_APPROXIMATE,
@@ -39,6 +40,7 @@ from .elo_mmr_storage import (
replace_elo_mmr_state, replace_elo_mmr_state,
) )
from .historical_storage import ALL_SERVERS_SLUG, initialize_historical_storage 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 .sqlite_utils import connect_sqlite_readonly
from .writer_lock import backend_writer_lock, build_writer_lock_holder from .writer_lock import backend_writer_lock, build_writer_lock_holder
@@ -68,6 +70,7 @@ def rebuild_elo_mmr_models(*, db_path=None) -> dict[str, object]:
resolved_path = initialize_historical_storage(db_path=db_path) resolved_path = initialize_historical_storage(db_path=db_path)
initialize_elo_mmr_storage(db_path=resolved_path) initialize_elo_mmr_storage(db_path=resolved_path)
historical_source_policy = _build_historical_source_policy_for_elo() 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) match_rows = _load_closed_match_rows(db_path=resolved_path)
grouped_matches = _group_match_rows(match_rows) grouped_matches = _group_match_rows(match_rows)
@@ -85,6 +88,15 @@ def rebuild_elo_mmr_models(*, db_path=None) -> dict[str, object]:
match_group=match_group, match_group=match_group,
scope_key=scope_key, scope_key=scope_key,
ratings_by_scope=ratings_by_scope[scope_key], ratings_by_scope=ratings_by_scope[scope_key],
rcon_match_context=(
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"),
)
if rcon_read_model is not None
else None
),
) )
) )
@@ -274,11 +286,18 @@ def _score_match_for_scope(
match_group: dict[str, object], match_group: dict[str, object],
scope_key: str, scope_key: str,
ratings_by_scope: dict[str, dict[str, object]], ratings_by_scope: dict[str, dict[str, object]],
rcon_match_context: dict[str, object] | None = None,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
players = list(match_group["players"]) players = list(match_group["players"])
duration_seconds, duration_mode = _resolve_match_duration(match_group, players) duration_seconds, duration_mode = _resolve_match_duration(
match_group,
players,
rcon_match_context=rcon_match_context,
)
quality_factor = _build_quality_factor( quality_factor = _build_quality_factor(
player_count=len(players), 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, duration_seconds=duration_seconds,
has_score=match_group.get("allied_score") is not None and match_group.get("axis_score") is not None, has_score=match_group.get("allied_score") is not None and match_group.get("axis_score") is not None,
) )
@@ -354,6 +373,14 @@ def _score_match_for_scope(
signals.append(build_signal("quality_duration", CAPABILITY_EXACT, "Duration computed from match timestamps.")) signals.append(build_signal("quality_duration", CAPABILITY_EXACT, "Duration computed from match timestamps."))
else: else:
signals.append(build_signal("quality_duration", CAPABILITY_APPROXIMATE, "Duration approximated from the maximum persisted player time.")) 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]) weights = ROLE_WEIGHTS.get(role_bucket, ROLE_WEIGHTS[ROLE_BUCKET_GENERALIST])
impact_score = round( impact_score = round(
@@ -541,17 +568,34 @@ def _build_historical_source_policy_for_elo() -> dict[str, object]:
) )
return build_source_policy( return build_source_policy(
primary_source=SOURCE_KIND_RCON, primary_source=SOURCE_KIND_RCON,
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD, selected_source="hybrid-rcon-competitive-plus-public-scoreboard",
fallback_used=True, fallback_used=True,
fallback_reason="rcon-historical-read-model-does-not-support-elo-mmr-competitive-calculations-yet", fallback_reason="rcon-competitive-context-primary-but-player-stats-still-require-public-scoreboard-supplement",
source_attempts=[ source_attempts=[
build_source_attempt(source=SOURCE_KIND_RCON, role="primary", status="unsupported", reason="rcon-historical-read-model-does-not-support-elo-mmr-competitive-calculations-yet"), build_source_attempt(
build_source_attempt(source=SOURCE_KIND_PUBLIC_SCOREBOARD, role="fallback", status="success"), 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]]) -> tuple[int, str]: 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")) started_at = _parse_optional_timestamp(match_group.get("started_at"))
ended_at = _parse_optional_timestamp(match_group.get("ended_at")) ended_at = _parse_optional_timestamp(match_group.get("ended_at"))
if started_at and ended_at and ended_at >= started_at: if started_at and ended_at and ended_at >= started_at:

View File

@@ -6,6 +6,8 @@ import sqlite3
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from .config import get_historical_data_source_kind
from .data_sources import SOURCE_KIND_RCON, get_rcon_historical_read_model
from .historical_storage import ( from .historical_storage import (
ALL_SERVERS_SLUG, ALL_SERVERS_SLUG,
list_historical_server_summaries, list_historical_server_summaries,
@@ -364,6 +366,14 @@ def _build_server_summary_snapshot(
*, *,
db_path: Path | None = None, db_path: Path | None = None,
) -> dict[str, object]: ) -> 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_items = list_historical_server_summaries(server_slug=server_key, db_path=db_path)
summary_item = summary_items[0] if summary_items else {} summary_item = summary_items[0] if summary_items else {}
time_range = summary_item.get("time_range") if isinstance(summary_item, dict) else {} time_range = summary_item.get("time_range") if isinstance(summary_item, dict) else {}
@@ -457,6 +467,14 @@ def _build_recent_matches_snapshot(
limit: int, limit: int,
db_path: Path | None = None, db_path: Path | None = None,
) -> dict[str, object]: ) -> 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( items = list_recent_historical_matches(
limit=limit, limit=limit,
server_slug=server_key, server_slug=server_key,

View File

@@ -373,12 +373,12 @@ def build_recent_historical_matches_payload(
return { return {
"status": "ok", "status": "ok",
"data": { "data": {
"title": "Actividad reciente capturada por RCON", "title": "Actividad competitiva reciente capturada por RCON",
"context": "historical-recent-matches", "context": "historical-recent-matches",
"source": "rcon-historical-read-model", "source": "rcon-historical-competitive-read-model",
"historical_data_source": "rcon", "historical_data_source": "rcon",
"supported": True, "supported": True,
"coverage_basis": "prospective-rcon-samples", "coverage_basis": "rcon-competitive-windows",
"limit": limit, "limit": limit,
"server_slug": server_slug, "server_slug": server_slug,
**build_source_policy( **build_source_policy(
@@ -520,8 +520,23 @@ def build_historical_server_summary_snapshot_payload(
"source": "historical-precomputed-snapshots", "source": "historical-precomputed-snapshots",
"server_slug": server_slug, "server_slug": server_slug,
"found": snapshot is not None and isinstance(item, dict), "found": snapshot is not None and isinstance(item, dict),
**_resolve_historical_fallback_policy( **(
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", fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet",
)
), ),
**_build_historical_snapshot_metadata(snapshot), **_build_historical_snapshot_metadata(snapshot),
"item": item if isinstance(item, dict) else None, "item": item if isinstance(item, dict) else None,
@@ -661,8 +676,23 @@ def build_recent_historical_matches_snapshot_payload(
**_build_historical_snapshot_metadata(snapshot), **_build_historical_snapshot_metadata(snapshot),
"snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None, "snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None,
"limit": limit, "limit": limit,
**_resolve_historical_fallback_policy( **(
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", fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet",
)
), ),
"items": sliced_items, "items": sliced_items,
}, },
@@ -856,9 +886,9 @@ def build_historical_server_summary_payload(
else "Cobertura historica minima RCON agregada" else "Cobertura historica minima RCON agregada"
), ),
"context": "historical-server-summary", "context": "historical-server-summary",
"source": "rcon-historical-read-model", "source": "rcon-historical-competitive-read-model",
"historical_data_source": "rcon", "historical_data_source": "rcon",
"summary_basis": "prospective-rcon-samples", "summary_basis": "rcon-competitive-windows",
"server_slug": server_slug, "server_slug": server_slug,
"supported": True, "supported": True,
**build_source_policy( **build_source_policy(
@@ -957,6 +987,7 @@ def build_elo_mmr_player_payload(
) -> dict[str, object]: ) -> dict[str, object]:
"""Return one Elo/MMR player profile.""" """Return one Elo/MMR player profile."""
profile = get_elo_mmr_player_payload(player_id=player_id, server_id=server_id) 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")
return { return {
"status": "ok", "status": "ok",
"data": { "data": {
@@ -966,10 +997,10 @@ def build_elo_mmr_player_payload(
"player_id": player_id, "player_id": player_id,
"server_slug": server_id, "server_slug": server_id,
"found": profile is not None, "found": profile is not None,
**_resolve_historical_fallback_policy( **(source_policy or _resolve_historical_fallback_policy(
operation="elo-mmr-player", operation="elo-mmr-player",
fallback_reason="rcon-historical-read-model-does-not-support-elo-mmr-competitive-calculations-yet", fallback_reason="elo-mmr-player-source-policy-missing",
), )),
"profile": profile, "profile": profile,
}, },
} }

View File

@@ -7,8 +7,9 @@ from datetime import datetime, timezone
from .historical_storage import ALL_SERVERS_SLUG from .historical_storage import ALL_SERVERS_SLUG
from .normalizers import normalize_map_name from .normalizers import normalize_map_name
from .rcon_historical_storage import ( from .rcon_historical_storage import (
list_rcon_historical_target_statuses, find_rcon_historical_competitive_window,
list_recent_rcon_historical_samples, list_rcon_historical_competitive_summary_rows,
list_rcon_historical_competitive_windows,
) )
@@ -16,8 +17,8 @@ def list_rcon_historical_server_summaries(
*, *,
server_key: str | None = None, server_key: str | None = None,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""Return per-target coverage and freshness from prospective RCON storage.""" """Return per-target coverage and freshness from RCON-backed competitive storage."""
items = list_rcon_historical_target_statuses() items = list_rcon_historical_competitive_summary_rows()
if server_key and server_key != ALL_SERVERS_SLUG: if server_key and server_key != ALL_SERVERS_SLUG:
normalized = server_key.strip() normalized = server_key.strip()
items = [ items = [
@@ -37,14 +38,37 @@ def list_rcon_historical_recent_activity(
server_key: str | None = None, server_key: str | None = None,
limit: int = 20, limit: int = 20,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""Return recent persisted RCON activity samples for one or all targets.""" """Return recent RCON-backed competitive windows for one or all targets."""
normalized_server_key = None if server_key == ALL_SERVERS_SLUG else server_key normalized_server_key = None if server_key == ALL_SERVERS_SLUG else server_key
items = list_recent_rcon_historical_samples(target_key=normalized_server_key, limit=limit) items = list_rcon_historical_competitive_windows(target_key=normalized_server_key, limit=limit)
return [ return [
{ {
**item, "server": {
"current_map": normalize_map_name(item.get("current_map")), "slug": item["target_key"],
"minutes_since_capture": _minutes_since_timestamp(item.get("captured_at")), "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"],
"map": {
"name": item.get("map_name"),
"pretty_name": normalize_map_name(item.get("map_pretty_name") or item.get("map_name")),
},
"result": {
"allied_score": None,
"axis_score": None,
"winner": None,
},
"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",
"capabilities": item.get("capabilities"),
"minutes_since_capture": _minutes_since_timestamp(item.get("last_seen_at")),
} }
for item in items for item in items
] ]
@@ -53,7 +77,7 @@ def list_rcon_historical_recent_activity(
def describe_rcon_historical_read_model() -> dict[str, object]: def describe_rcon_historical_read_model() -> dict[str, object]:
"""Describe what the minimal RCON historical read model currently supports.""" """Describe what the minimal RCON historical read model currently supports."""
return { return {
"source": "rcon-historical-read-model", "source": "rcon-historical-competitive-read-model",
"supported_endpoints": [ "supported_endpoints": [
"/api/historical/server-summary", "/api/historical/server-summary",
"/api/historical/recent-matches", "/api/historical/recent-matches",
@@ -70,27 +94,42 @@ def describe_rcon_historical_read_model() -> dict[str, object]:
"/api/historical/player-profile", "/api/historical/player-profile",
"/api/historical/snapshots/*", "/api/historical/snapshots/*",
], ],
"capabilities": [ "capabilities": {
"coverage by configured RCON target", "server_summary": "exact",
"recent persisted live activity", "recent_matches": "approximate",
"freshness and last successful capture metadata", "competitive_quality": "partial",
], "player_stats": "unavailable",
},
"limitations": [ "limitations": [
"No retroactive backfill of closed matches.", "No retroactive backfill of closed matches.",
"No weekly or monthly competitive leaderboards.", "No weekly or monthly competitive leaderboards.",
"No MVP or player-event parity with public-scoreboard.", "No MVP or player-event parity with public-scoreboard.",
"No precomputed historical snapshots for the RCON read model yet.", "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 _build_server_summary(item: dict[str, object]) -> dict[str, object]: def _build_server_summary(item: dict[str, object]) -> dict[str, object]:
sample_count = int(item.get("sample_count") or 0) sample_count = int(item.get("sample_count") or 0)
first_last_points = list_rcon_historical_recent_activity( first_last_points = list_rcon_historical_recent_activity(
server_key=str(item["target_key"]), server_key=str(item["target_key"]),
limit=1, limit=1,
) )
last_sample_at = item.get("last_sample_at") last_sample_at = item.get("last_seen_at")
latest_activity = first_last_points[0] if first_last_points else None latest_activity = first_last_points[0] if first_last_points else None
return { return {
@@ -101,31 +140,32 @@ def _build_server_summary(item: dict[str, object]) -> dict[str, object]:
"region": item["region"], "region": item["region"],
}, },
"coverage": { "coverage": {
"basis": "prospective-rcon-samples", "basis": "rcon-competitive-windows",
"status": "available" if sample_count > 0 else "empty", "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, "sample_count": sample_count,
"first_sample_at": item.get("first_sample_at"), "first_sample_at": item.get("first_seen_at"),
"last_sample_at": last_sample_at, "last_sample_at": last_sample_at,
"coverage_hours": _calculate_coverage_hours(item.get("first_sample_at"), last_sample_at), "coverage_hours": _calculate_coverage_hours(item.get("first_seen_at"), last_sample_at),
}, },
"freshness": { "freshness": {
"last_successful_capture_at": item.get("last_successful_capture_at"), "last_successful_capture_at": item.get("last_successful_capture_at"),
"minutes_since_last_capture": _minutes_since_timestamp(last_sample_at), "minutes_since_last_capture": _minutes_since_timestamp(last_sample_at),
"last_run_id": item.get("last_run_id"),
"last_run_status": item.get("last_run_status"), "last_run_status": item.get("last_run_status"),
"last_error": item.get("last_error"), "last_error": item.get("last_error"),
"last_error_at": item.get("last_error_at"), "last_error_at": item.get("last_error_at"),
}, },
"activity": { "activity": {
"latest_players": latest_activity.get("players") if latest_activity else None, "latest_players": latest_activity.get("player_count") if latest_activity else None,
"latest_max_players": latest_activity.get("max_players") if latest_activity else None, "latest_peak_players": latest_activity.get("peak_players") if latest_activity else None,
"latest_map": latest_activity.get("current_map") if latest_activity else None, "latest_map": latest_activity.get("map", {}).get("pretty_name") if latest_activity else None,
"latest_status": latest_activity.get("status") if latest_activity else None, "latest_status": "captured" if latest_activity else None,
}, },
"time_range": { "time_range": {
"start": None, "start": item.get("first_seen_at"),
"end": last_sample_at, "end": last_sample_at,
}, },
"capabilities": describe_rcon_historical_read_model()["capabilities"],
} }
@@ -145,7 +185,7 @@ def _build_all_servers_summary(items: list[dict[str, object]]) -> dict[str, obje
"region": None, "region": None,
}, },
"coverage": { "coverage": {
"basis": "prospective-rcon-samples-aggregate", "basis": "rcon-competitive-windows-aggregate",
"status": "available" if total_samples > 0 else "empty", "status": "available" if total_samples > 0 else "empty",
"sample_count": total_samples, "sample_count": total_samples,
"first_sample_at": None, "first_sample_at": None,
@@ -155,7 +195,6 @@ def _build_all_servers_summary(items: list[dict[str, object]]) -> dict[str, obje
"freshness": { "freshness": {
"last_successful_capture_at": last_capture_at, "last_successful_capture_at": last_capture_at,
"minutes_since_last_capture": _minutes_since_timestamp(last_capture_at), "minutes_since_last_capture": _minutes_since_timestamp(last_capture_at),
"last_run_id": None,
"last_run_status": None, "last_run_status": None,
"last_error": None, "last_error": None,
"last_error_at": None, "last_error_at": None,
@@ -171,6 +210,7 @@ def _build_all_servers_summary(items: list[dict[str, object]]) -> dict[str, obje
"end": last_capture_at, "end": last_capture_at,
}, },
"server_count": len(items), "server_count": len(items),
"capabilities": describe_rcon_historical_read_model()["capabilities"],
} }

View File

@@ -9,7 +9,14 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from .config import get_storage_path from .config import get_storage_path
from .sqlite_utils import connect_sqlite_writer from .normalizers import normalize_map_name
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: def initialize_rcon_historical_storage(*, db_path: Path | None = None) -> Path:
@@ -84,6 +91,32 @@ def initialize_rcon_historical_storage(*, db_path: Path | None = None) -> Path:
CREATE INDEX IF NOT EXISTS idx_rcon_historical_samples_target_time CREATE INDEX IF NOT EXISTS idx_rcon_historical_samples_target_time
ON rcon_historical_samples(target_id, captured_at DESC); 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);
""" """
) )
@@ -199,6 +232,13 @@ def persist_rcon_historical_sample(
run_id=run_id, run_id=run_id,
captured_at=captured_at, captured_at=captured_at,
) )
if inserted:
_upsert_competitive_window(
connection,
target_id=target_id,
captured_at=captured_at,
normalized_payload=normalized_payload,
)
return { return {
"samples_inserted": inserted, "samples_inserted": inserted,
"duplicate_samples": 0 if inserted else 1, "duplicate_samples": 0 if inserted else 1,
@@ -241,8 +281,9 @@ def list_rcon_historical_target_statuses(
db_path: Path | None = None, db_path: Path | None = None,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""Return per-target coverage and freshness for prospective RCON capture.""" """Return per-target coverage and freshness for prospective RCON capture."""
resolved_path = initialize_rcon_historical_storage(db_path=db_path) resolved_path = _resolve_db_path(db_path)
with _connect(resolved_path) as connection: try:
with _connect_readonly(resolved_path) as connection:
rows = connection.execute( rows = connection.execute(
""" """
SELECT SELECT
@@ -280,6 +321,8 @@ def list_rcon_historical_target_statuses(
ORDER BY targets.display_name ASC, targets.target_key ASC ORDER BY targets.display_name ASC, targets.target_key ASC
""" """
).fetchall() ).fetchall()
except sqlite3.OperationalError:
return []
return [ return [
{ {
"target_key": row["target_key"], "target_key": row["target_key"],
@@ -309,14 +352,15 @@ def list_recent_rcon_historical_samples(
db_path: Path | None = None, db_path: Path | None = None,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""Return recent prospective RCON samples for one or all configured targets.""" """Return recent prospective RCON samples for one or all configured targets."""
resolved_path = initialize_rcon_historical_storage(db_path=db_path) resolved_path = _resolve_db_path(db_path)
where_clause = "" where_clause = ""
params: list[object] = [limit] params: list[object] = [limit]
if target_key: if target_key:
where_clause = "WHERE targets.target_key = ? OR targets.external_server_id = ?" where_clause = "WHERE targets.target_key = ? OR targets.external_server_id = ?"
params = [target_key, target_key, limit] params = [target_key, target_key, limit]
with _connect(resolved_path) as connection: try:
with _connect_readonly(resolved_path) as connection:
rows = connection.execute( rows = connection.execute(
f""" f"""
SELECT SELECT
@@ -338,6 +382,8 @@ def list_recent_rcon_historical_samples(
""", """,
params, params,
).fetchall() ).fetchall()
except sqlite3.OperationalError:
return []
return [ return [
{ {
"target_key": row["target_key"], "target_key": row["target_key"],
@@ -354,10 +400,235 @@ def list_recent_rcon_historical_samples(
] ]
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."""
resolved_path = _resolve_db_path(db_path)
where_clause = ""
params: list[object] = [limit]
if target_key:
where_clause = "WHERE targets.target_key = ? OR targets.external_server_id = ?"
params = [target_key, target_key, 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
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"]),
}
)
return items
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."""
resolved_path = _resolve_db_path(db_path)
where_clause = ""
params: list[object] = []
if target_key:
where_clause = "WHERE targets.target_key = ? OR targets.external_server_id = ?"
params = [target_key, target_key]
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 not ended_at:
return None
resolved_path = _resolve_db_path(db_path)
normalized_map_name = normalize_map_name(map_name)
try:
with _connect_readonly(resolved_path) as connection:
candidates = connection.execute(
"""
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
FROM rcon_historical_competitive_windows AS windows
INNER JOIN rcon_historical_targets AS targets
ON targets.id = windows.target_id
WHERE (targets.target_key = ? OR targets.external_server_id = ?)
ORDER BY windows.last_seen_at DESC
LIMIT 12
""",
(server_key, server_key),
).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 _connect(db_path: Path) -> sqlite3.Connection: def _connect(db_path: Path) -> sqlite3.Connection:
return connect_sqlite_writer(db_path) 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 _upsert_target(connection: sqlite3.Connection, *, target: Mapping[str, object]) -> int: def _upsert_target(connection: sqlite3.Connection, *, target: Mapping[str, object]) -> int:
target_key = str(target.get("target_key") or "").strip() target_key = str(target.get("target_key") or "").strip()
if not target_key: if not target_key:
@@ -447,5 +718,158 @@ def _upsert_checkpoint_success(
) )
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,
"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: def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")

View File

@@ -25,6 +25,7 @@ class BackendWriterLockTimeoutError(RuntimeError):
_ACTIVE_LOCK_DEPTH_BY_PATH: dict[Path, int] = {} _ACTIVE_LOCK_DEPTH_BY_PATH: dict[Path, int] = {}
_ACTIVE_LOCK_TOKEN_BY_PATH: dict[Path, str] = {} _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: def resolve_backend_writer_lock_path(*, storage_path: Path | None = None) -> Path:
@@ -167,15 +168,27 @@ def _read_lock_metadata(lock_path: Path) -> dict[str, object] | None:
def _can_clear_stale_lock(existing_metadata: dict[str, object] | None) -> bool: def _can_clear_stale_lock(existing_metadata: dict[str, object] | None) -> bool:
if not existing_metadata: if not existing_metadata:
return False return False
if str(existing_metadata.get("hostname") or "") != socket.gethostname():
return False
try: try:
holder_pid = int(existing_metadata.get("pid")) holder_pid = int(existing_metadata.get("pid"))
except (TypeError, ValueError): except (TypeError, ValueError):
return False return False
if holder_pid <= 0: if holder_pid <= 0:
return False return False
return not _is_process_alive(holder_pid)
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: def _is_process_alive(pid: int) -> bool:
@@ -219,5 +232,24 @@ def _build_lock_timeout_message(
) )
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: def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")

View File

@@ -311,6 +311,49 @@ h2 {
color: var(--accent-warm); 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 { .discord-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -93,6 +93,7 @@ document.addEventListener("DOMContentLoaded", () => {
updateBackendStatus(statusNode, "Backend comprobando", "status-chip--idle"); updateBackendStatus(statusNode, "Backend comprobando", "status-chip--idle");
setServersDataState(serversBadge, { timestampLabel: "" }); setServersDataState(serversBadge, { timestampLabel: "" });
renderServersLoadingState(serversList);
hydrateCommunityClans(communityClansList); hydrateCommunityClans(communityClansList);
let serverRefreshInFlight = false; let serverRefreshInFlight = false;
@@ -208,10 +209,28 @@ async function hydrateServers(
const visibleItems = selectPrimaryServerItems(serversData.items); const visibleItems = selectPrimaryServerItems(serversData.items);
serversList.innerHTML = renderServerSections(visibleItems); serversList.innerHTML = renderServerSections(visibleItems);
} catch (error) { } catch (error) {
console.warn("Servers panel remains on static fallback", error); console.warn("Servers panel failed to hydrate with live data", error);
serversList.innerHTML =
'<p class="servers-empty">No se pudo cargar el estado real de servidores en este momento.</p>';
setServersDataState(serversBadge, {
label: "Actualizacion no disponible",
isFresh: false,
});
} }
} }
function renderServersLoadingState(serversList) {
if (!serversList) {
return;
}
serversList.innerHTML = `
<div class="servers-loading">
<span class="servers-loading__pulse"></span>
<p>Cargando estado real de servidores...</p>
</div>
`;
}
function updateBackendStatus(statusNode, label, stateClass) { function updateBackendStatus(statusNode, label, stateClass) {
if (!statusNode) { if (!statusNode) {
return; return;

View File

@@ -96,103 +96,11 @@
Abrir zona historico Abrir zona historico
</a> </a>
</div> </div>
<div class="servers-grid" id="servers-list"> <div class="servers-grid" id="servers-list" aria-live="polite">
<article class="server-card server-card--stats server-card--real"> <div class="servers-loading" id="servers-loading-state">
<div class="server-card__top server-card__top--stats"> <span class="servers-loading__pulse"></span>
<div class="server-card__identity"> <p>Cargando estado real de servidores...</p>
<p class="server-card__eyebrow">Servidor de comunidad</p>
<h3>Comunidad Hispana #01</h3>
</div> </div>
<div class="server-card__status-column">
<span class="server-state server-state--online">Online</span>
<p class="server-card__population">74 / 100</p>
<div class="server-card__actions">
<a
class="server-action-link"
href="https://scoreboard.comunidadhll.es/games"
target="_blank"
rel="noreferrer"
>
Historico
</a>
</div>
</div>
</div>
<div class="server-card__quickfacts">
<article class="server-card__quickfact">
<p>Mapa</p>
<strong>St. Marie Du Mont</strong>
</article>
<article class="server-card__quickfact">
<p>Region</p>
<strong>ES</strong>
</article>
</div>
</article>
<article class="server-card server-card--stats server-card--real">
<div class="server-card__top server-card__top--stats">
<div class="server-card__identity">
<p class="server-card__eyebrow">Servidor de comunidad</p>
<h3>Comunidad Hispana #02</h3>
</div>
<div class="server-card__status-column">
<span class="server-state server-state--offline">Offline</span>
<p class="server-card__population">0 / 100</p>
<div class="server-card__actions">
<a
class="server-action-link"
href="https://scoreboard.comunidadhll.es:5443/games"
target="_blank"
rel="noreferrer"
>
Historico
</a>
</div>
</div>
</div>
<div class="server-card__quickfacts">
<article class="server-card__quickfact">
<p>Mapa</p>
<strong>Sin mapa disponible</strong>
</article>
<article class="server-card__quickfact">
<p>Region</p>
<strong>ES</strong>
</article>
</div>
</article>
<article class="server-card server-card--stats server-card--real">
<div class="server-card__top server-card__top--stats">
<div class="server-card__identity">
<p class="server-card__eyebrow">Servidor de comunidad</p>
<h3>Comunidad Hispana #03</h3>
</div>
<div class="server-card__status-column">
<span class="server-state server-state--offline">Offline</span>
<p class="server-card__population">0 / 100</p>
<div class="server-card__actions">
<a
class="server-action-link"
href="https://scoreboard.comunidadhll.es:3443/games"
target="_blank"
rel="noreferrer"
>
Historico
</a>
</div>
</div>
</div>
<div class="server-card__quickfacts">
<article class="server-card__quickfact">
<p>Mapa</p>
<strong>Sin mapa disponible</strong>
</article>
<article class="server-card__quickfact">
<p>Region</p>
<strong>ES</strong>
</article>
</div>
</article>
</div> </div>
</div> </div>
</section> </section>