diff --git a/ai/tasks/done/TASK-093-rcon-first-source-selection-and-fallback-policy.md b/ai/tasks/done/TASK-093-rcon-first-source-selection-and-fallback-policy.md new file mode 100644 index 0000000..db78936 --- /dev/null +++ b/ai/tasks/done/TASK-093-rcon-first-source-selection-and-fallback-policy.md @@ -0,0 +1,60 @@ +# TASK-093-rcon-first-source-selection-and-fallback-policy + +## Goal +Convertir la aplicación a una política RCON-first real para extracción de datos, manteniendo fallback automático a los métodos antiguos solo cuando RCON falle. + +## Context +La repo ya tiene piezas RCON para live y captura prospectiva, pero todavía no está orientada a RCON-first por defecto. +Ahora mismo el sistema sigue arrancando con defaults antiguos y la política funcional real no coincide con el objetivo del producto. + +Queremos: +- live/state de servidores -> RCON primero, A2S solo como fallback +- histórico/recopilación -> RCON primero, CRCON/public-scoreboard solo como fallback +- selección de fuente transparente, consistente y observable + +## Steps +1. Auditar la selección actual de fuentes en: + - `backend/app/data_sources.py` + - `backend/app/payloads.py` + - `backend/app/collector.py` + - providers live e históricos actuales + - `backend/app/rcon_historical_read_model.py` +2. Introducir una política explícita de “source arbitration” o equivalente: + - RCON como fuente primaria + - fallback a A2S para live si RCON falla + - fallback a public-scoreboard/CRCON para histórico si RCON falla o no puede servir la operación concreta +3. Definir criterios claros de fallback: + - error de red / timeout / auth / target no disponible + - falta de cobertura o capacidad para una operación histórica concreta +4. Hacer que la respuesta backend deje trazabilidad clara: + - fuente primaria intentada + - fuente finalmente usada + - si hubo fallback + - motivo del fallback +5. Ajustar defaults/config para que el comportamiento por defecto del proyecto sea coherente con RCON-first. +6. Actualizar README con una sección clara: + - política de prioridad de fuentes + - casos de fallback + - qué capacidades históricas siguen siendo parciales en RCON y cuándo entra CRCON/public-scoreboard + +## Constraints +- No romper compatibilidad con los métodos antiguos. +- No eliminar A2S ni public-scoreboard. +- No tocar frontend salvo que haga falta exponer metadata mínima ya existente. +- Mantener el comportamiento observable y fácil de depurar. + +## Validation +- El backend intenta RCON primero para live. +- Si RCON live falla, el backend cae a A2S de forma controlada. +- El backend intenta RCON primero para histórico. +- Si RCON histórico falla o no soporta una operación concreta, el backend cae a public-scoreboard/CRCON. +- Las respuestas reflejan qué fuente se usó realmente. +- README queda alineado con la política RCON-first. + +## Expected Files +- `backend/app/data_sources.py` +- `backend/app/payloads.py` +- `backend/app/collector.py` +- providers/fuentes necesarias bajo `backend/app/` +- `backend/README.md` +- `backend/app/config.py` si hace falta para defaults explícitos diff --git a/ai/tasks/done/TASK-094-rcon-first-historical-orchestration-and-safe-fallback.md b/ai/tasks/done/TASK-094-rcon-first-historical-orchestration-and-safe-fallback.md new file mode 100644 index 0000000..dc3dde0 --- /dev/null +++ b/ai/tasks/done/TASK-094-rcon-first-historical-orchestration-and-safe-fallback.md @@ -0,0 +1,67 @@ +# TASK-094-rcon-first-historical-orchestration-and-safe-fallback + +## Goal +Reorientar la orquestación histórica para que RCON sea la vía principal de recopilación cuando esté disponible, dejando el flujo CRCON/public-scoreboard como fallback seguro y no como camino primario permanente. + +## Context +Actualmente: +- existe `historical_runner` +- existe `rcon_historical_worker` +- existe locking single-writer +Pero la orquestación no representa todavía una estrategia RCON-first coherente. +Queremos una orquestación donde: +- la recopilación prospectiva por RCON sea la prioridad +- el refresh histórico clásico solo entre cuando RCON falle o no cubra la operación +- no haya starvation del lock por loops demasiado agresivos +- las automatizaciones sean operables en Docker sin bloquear trabajo manual innecesariamente + +## Steps +1. Auditar: + - `backend/app/historical_runner.py` + - `backend/app/historical_ingestion.py` + - `backend/app/rcon_historical_worker.py` + - `backend/app/writer_lock.py` + - `docker-compose.yml` +2. Definir una estrategia clara: + - RCON historical capture como flujo primario + - historical_ingestion clásico como fallback + - snapshots/rebuilds alineados con esa política +3. Evitar que el worker RCON monopolice el writer lock: + - revisar intervalos por defecto + - revisar duración del trabajo por loop + - revisar si conviene separar captura frecuente y rebuild menos frecuente +4. Asegurar que las pasadas manuales sean razonables: + - si hay automatización activa, que el operador tenga mensajes claros + - reducir riesgo de starvation o lock ocupado permanente +5. Mejorar el manejo de stale locks entre contenedores Docker si es necesario: + - no depender solo del hostname si eso da falsos locks persistentes + - mantener seguridad y evitar liberar locks válidos por error +6. Ajustar `docker-compose.yml` para que el stack quede alineado con el nuevo comportamiento por defecto. +7. Actualizar README/runbook: + - qué proceso captura RCON primero + - cuándo entra el fallback histórico clásico + - cómo lanzar pasadas manuales + - cómo interpretar locks ocupados + +## Constraints +- No eliminar el locking compartido. +- No volver al comportamiento sin coordinación de writers. +- No dejar CRCON/public-scoreboard como camino principal encubierto. +- No romper la captura prospectiva RCON ya existente. + +## Validation +- La automatización prioriza RCON para recopilación histórica. +- El flujo clásico entra solo como fallback. +- El lock compartido sigue funcionando. +- La operativa Docker no queda en starvation constante. +- Las pasadas manuales tienen comportamiento claro y documentado. +- README/runbook queda actualizado. + +## Expected Files +- `backend/app/historical_runner.py` +- `backend/app/historical_ingestion.py` +- `backend/app/rcon_historical_worker.py` +- `backend/app/writer_lock.py` +- `docker-compose.yml` +- `backend/README.md` +- otros archivos backend si la orquestación lo requiere diff --git a/ai/tasks/done/TASK-095-restore-community-scores-link-and-source-aware-ui.md b/ai/tasks/done/TASK-095-restore-community-scores-link-and-source-aware-ui.md new file mode 100644 index 0000000..a61149b --- /dev/null +++ b/ai/tasks/done/TASK-095-restore-community-scores-link-and-source-aware-ui.md @@ -0,0 +1,40 @@ +# TASK-095-restore-community-scores-link-and-source-aware-ui + +## Goal +Restaurar correctamente el enlace/botón hacia los scores/histórico de comunidad y asegurar que la UI no pierda acciones útiles por depender de mapas hardcodeados incompletos. + +## Context +Se ha detectado que el botón/enlace hacia los scores/histórico de comunidad ha desaparecido o se degrada en algunos casos. +La lógica actual depende de un mapa hardcodeado incompleto y no cubre bien todos los servidores o casos reales. + +## Steps +1. Auditar: + - `frontend/index.html` + - `frontend/assets/js/main.js` + - `frontend/assets/css/styles.css` + - si hace falta, el payload backend consumido por `/api/servers` +2. Restaurar el CTA de scores/histórico para todos los servidores relevantes. +3. Evitar depender exclusivamente de un mapa hardcodeado incompleto cuando haya información backend utilizable. +4. Mantener una experiencia estable: + - si existe URL conocida -> mostrar enlace + - si no existe -> degradar de forma clara, no desaparecer silenciosamente +5. Mantener el diseño coherente con la landing actual. +6. No mezclar esta task con refactors amplios no relacionados. + +## Constraints +- No rehacer toda la homepage. +- No tocar la UI histórico V1/V2 salvo necesidad estricta. +- No inventar enlaces falsos. +- Si una URL no está disponible, la UI debe indicarlo de forma limpia. + +## Validation +- El botón/enlace a scores/histórico vuelve a mostrarse correctamente. +- No desaparece por un mapa hardcodeado incompleto. +- La landing sigue renderizando bien. +- La repo queda consistente. + +## Expected Files +- `frontend/index.html` +- `frontend/assets/js/main.js` +- `frontend/assets/css/styles.css` +- opcionalmente backend si se necesita exponer mejor la URL o metadata correspondiente diff --git a/backend/README.md b/backend/README.md index 959cc5f..f0dd4df 100644 --- a/backend/README.md +++ b/backend/README.md @@ -224,8 +224,9 @@ normaliza espacios y barras finales para mantener la comparacion con el header `GET /api/servers` trata el ultimo snapshot persistido como cache local y lo reutiliza solo si sigue dentro del objetivo de `120` segundos. Si ese snapshot -esta vencido, el endpoint intenta una consulta A2S real inmediata contra los 2 -servidores configurados antes de responder. +esta vencido, el endpoint intenta primero una consulta RCON real inmediata +contra los targets configurados. Solo si RCON falla o no devuelve snapshots +utilizables, cae a A2S de forma controlada antes de responder. La respuesta incluye metadata de frescura pensada para frontend: @@ -238,11 +239,17 @@ La respuesta incluye metadata de frescura pensada para frontend: - `source` - `refresh_attempted` - `refresh_status` +- `primary_source` +- `selected_source` +- `fallback_used` +- `fallback_reason` +- `source_attempts` Si la consulta real falla, `/api/servers` devuelve el ultimo snapshot valido disponible marcado como stale. Si no existe ningun snapshot valido, responde `items: []` en lugar de reintroducir servidores de respaldo ajenos a la -comunidad. +comunidad. Cada respuesta deja tambien trazabilidad de arbitraje de fuente para +que sea visible si se sirvio RCON directo o si hubo fallback a A2S. Los endpoints historicos leen la persistencia local SQLite creada por el colector. Si todavia no hay snapshots guardados, responden `status: "ok"` con @@ -262,16 +269,16 @@ Variables nuevas: Valores soportados en esta fase: - live: - - `a2s` como modo actual de desarrollo - - `rcon` como modo productivo para estado live via acceso directo al servidor + - `rcon` como camino primario recomendado + - `a2s` como fallback legacy o override explicito - historico: - - `public-scoreboard` como modo actual de desarrollo - - `rcon` seleccionado pero todavia sin ingesta historica operativa en esta repo + - `rcon` como camino primario recomendado para captura y lectura minima + - `public-scoreboard` como fallback legacy o override explicito Defaults actuales: -- `HLL_BACKEND_LIVE_DATA_SOURCE=a2s` -- `HLL_BACKEND_HISTORICAL_DATA_SOURCE=public-scoreboard` +- `HLL_BACKEND_LIVE_DATA_SOURCE=rcon` +- `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon` La seleccion efectiva se resuelve en `app/data_sources.py` y en adapters dedicados dentro de `app/providers/`: @@ -287,16 +294,27 @@ dedicados dentro de `app/providers/`: Proveedores operativos en esta fase: -- live `a2s` - live `rcon` +- live `a2s` +- historico `rcon` solo para read model minimo y captura prospectiva - historico `public-scoreboard` +Politica funcional actual: + +- live: + - RCON primero + - A2S solo si RCON falla o no devuelve snapshots utilizables +- historico/recopilacion: + - RCON primero + - `public-scoreboard` solo si RCON no soporta aun esa operacion concreta o + falla la captura primaria + Limitacion actual de `rcon`: - el backend puede usar `rcon` para `/api/servers` -- la ingesta historica por `historical_ingestion.py` sigue requiriendo +- la ingesta historica competitiva por `historical_ingestion.py` sigue cayendo a `public-scoreboard`, porque la repo todavia no incluye una canalizacion - persistente de eventos o logs RCON para reconstruir partidas cerradas + retroactiva RCON capaz de reconstruir partidas cerradas con paridad completa Estado real de "historico por RCON" en esta repo: @@ -349,13 +367,13 @@ $env:HLL_BACKEND_RCON_TARGETS='[ Runbook operativo minimo: -- desarrollo: +- modo recomendado por defecto: + - `HLL_BACKEND_LIVE_DATA_SOURCE=rcon` + - `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon` + - definir `HLL_BACKEND_RCON_TARGETS` fuera de la repo +- override legacy completo: - `HLL_BACKEND_LIVE_DATA_SOURCE=a2s` - `HLL_BACKEND_HISTORICAL_DATA_SOURCE=public-scoreboard` -- produccion live con RCON: - - `HLL_BACKEND_LIVE_DATA_SOURCE=rcon` - - `HLL_BACKEND_HISTORICAL_DATA_SOURCE=public-scoreboard` - - definir `HLL_BACKEND_RCON_TARGETS` fuera de la repo Verificacion minima del proveedor activo: @@ -418,15 +436,15 @@ La persistencia queda separada del historico `historical_*` actual y usa: Lectura historica minima cuando `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon`: -- endpoints soportados hoy: +- endpoints soportados hoy directamente por RCON persistido: - `GET /api/historical/server-summary` - `GET /api/historical/recent-matches` - lo que devuelven: - cobertura por target RCON configurado - frescura del ultimo capture exitoso - actividad reciente persistida -- endpoints que siguen dependiendo de `public-scoreboard` para el contrato - completo: +- endpoints que hoy caen automaticamente a `public-scoreboard` para mantener el + contrato completo: - `GET /api/historical/weekly-top-kills` - `GET /api/historical/weekly-leaderboard` - `GET /api/historical/leaderboard` @@ -437,8 +455,10 @@ Lectura historica minima cuando `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon`: - `GET /api/historical/snapshots/*` Cuando esos endpoints se consultan con `historical_data_source=rcon`, el backend -devuelve un payload coherente con `supported: false` y deja claro que esa parte -del contrato todavia requiere `public-scoreboard`. +intenta primero RCON para la operacion soportada y, si no hay cobertura o la +capacidad aun no existe, cae automaticamente a `public-scoreboard`. La +respuesta deja trazabilidad con `primary_source`, `selected_source`, +`fallback_used`, `fallback_reason` y `source_attempts`. ## Criterio de estructura @@ -1065,11 +1085,15 @@ Los reintentos de cada request JSON pueden ajustarse sin tocar codigo con: - `HLL_HISTORICAL_CRCON_REQUEST_RETRIES` - `HLL_HISTORICAL_CRCON_RETRY_DELAY_SECONDS` -El runner `python -m app.historical_runner` deja ese refresh incremental listo -para ejecucion local repetida sin depender de infraestructura externa y -mantiene calientes los snapshots historicos mas visibles tras cada refresh -correcto. Por defecto: +El runner `python -m app.historical_runner` deja ahora una orquestacion +RCON-first lista para ejecucion local repetida sin depender de infraestructura +externa y mantiene calientes los snapshots historicos mas visibles cuando el +fallback clasico entra de forma controlada. Por defecto: +- intenta primero una captura prospectiva RCON en cada ciclo +- solo lanza el refresh historico clasico cuando RCON falla, cuando se pide un + scope manual que sigue requiriendo cobertura competitiva, o en la cadencia + periodica de fallback para mantener rankings y snapshots clasicos - refresca cada `900` segundos - prewarmea en cada ciclo: - `server-summary` para `comunidad-hispana-01`, `comunidad-hispana-02`, `comunidad-hispana-03` y `all-servers` @@ -1109,8 +1133,10 @@ Sin `--server`, ese runner refresca: - `comunidad-hispana-02` - `comunidad-hispana-03` -Despues de cada refresh correcto, recompone snapshots para los servidores -afectados y vuelve a alinear el agregado `all-servers`. +Despues de cada fallback clasico correcto, recompone snapshots para los +servidores afectados y vuelve a alinear el agregado `all-servers`. Si el ciclo +RCON primario fue suficiente y no hizo falta el fallback clasico, el runner +deja constancia explicita de ese motivo en su salida JSON. Para regenerar snapshots de forma puntual dentro del contenedor sin dejar un bucle permanente, la validacion operativa minima es: diff --git a/backend/app/config.py b/backend/app/config.py index d2de8f6..66838b6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -10,8 +10,8 @@ DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 8000 DEFAULT_STORAGE_FILENAME = "hll_vietnam_dev.sqlite3" DEFAULT_REFRESH_INTERVAL_SECONDS = 300 -DEFAULT_LIVE_DATA_SOURCE = "a2s" -DEFAULT_HISTORICAL_DATA_SOURCE = "public-scoreboard" +DEFAULT_LIVE_DATA_SOURCE = "rcon" +DEFAULT_HISTORICAL_DATA_SOURCE = "rcon" DEFAULT_RCON_TIMEOUT_SECONDS = 10.0 DEFAULT_HISTORICAL_CRCON_PAGE_SIZE = 50 DEFAULT_HISTORICAL_CRCON_TIMEOUT_SECONDS = 15.0 @@ -30,7 +30,7 @@ DEFAULT_PLAYER_EVENT_REFRESH_INTERVAL_SECONDS = 1800 DEFAULT_PLAYER_EVENT_REFRESH_OVERLAP_HOURS = 12 DEFAULT_PLAYER_EVENT_REFRESH_MAX_RETRIES = 2 DEFAULT_PLAYER_EVENT_REFRESH_RETRY_DELAY_SECONDS = 30 -DEFAULT_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS = 120 +DEFAULT_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS = 600 DEFAULT_RCON_HISTORICAL_CAPTURE_MAX_RETRIES = 2 DEFAULT_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS = 15 DEFAULT_SQLITE_WRITER_TIMEOUT_SECONDS = 30.0 diff --git a/backend/app/data_sources.py b/backend/app/data_sources.py index 237f2d0..396b3c9 100644 --- a/backend/app/data_sources.py +++ b/backend/app/data_sources.py @@ -18,6 +18,7 @@ from .server_targets import A2SServerTarget, load_a2s_targets LIVE_SOURCE_A2S = "a2s" +SOURCE_KIND_PUBLIC_SCOREBOARD = "public-scoreboard" SOURCE_KIND_RCON = "rcon" @@ -50,7 +51,7 @@ class LiveDataSource(Protocol): def collect_snapshots(self, *, persist: bool) -> dict[str, object]: """Collect one live snapshot batch.""" - def build_target_index(self) -> dict[str | None, A2SServerTarget]: + def build_target_index(self) -> dict[str | None, object]: """Return optional server connection metadata keyed by external id.""" @@ -75,6 +76,102 @@ class A2SLiveDataSource: } +@dataclass(frozen=True, slots=True) +class RconFirstLiveDataSource: + """Live source arbitration with RCON as primary and A2S as controlled fallback.""" + + primary_source: RconLiveDataSource = RconLiveDataSource() + fallback_source: A2SLiveDataSource = A2SLiveDataSource() + source_kind: str = SOURCE_KIND_RCON + + def collect_snapshots(self, *, persist: bool) -> dict[str, object]: + attempts: list[dict[str, object]] = [] + fallback_reason: str | None = None + + try: + primary_payload = self.primary_source.collect_snapshots(persist=persist) + except Exception as error: # noqa: BLE001 - source arbitration keeps fallback controlled + attempts.append( + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="error", + reason="rcon-live-request-failed", + message=str(error), + ) + ) + fallback_reason = "rcon-live-request-failed" + else: + primary_success_count = int(primary_payload.get("success_count") or 0) + primary_snapshots = list(primary_payload.get("snapshots") or []) + if primary_success_count > 0 and primary_snapshots: + attempts.append( + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="success", + ) + ) + return attach_source_policy( + primary_payload, + build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source=SOURCE_KIND_RCON, + source_attempts=attempts, + ), + ) + + attempts.append( + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="empty", + reason="rcon-live-returned-no-usable-snapshots", + message=f"success_count={primary_success_count}", + ) + ) + fallback_reason = "rcon-live-returned-no-usable-snapshots" + + try: + fallback_payload = self.fallback_source.collect_snapshots(persist=persist) + except Exception as error: # noqa: BLE001 - keep combined failure explicit + attempts.append( + build_source_attempt( + source=LIVE_SOURCE_A2S, + role="fallback", + status="error", + reason="a2s-live-fallback-failed", + message=str(error), + ) + ) + raise RuntimeError( + "RCON-first live collection failed and A2S fallback also failed." + ) from error + + attempts.append( + build_source_attempt( + source=LIVE_SOURCE_A2S, + role="fallback", + status="success", + ) + ) + return attach_source_policy( + fallback_payload, + build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source=LIVE_SOURCE_A2S, + fallback_used=True, + fallback_reason=fallback_reason, + source_attempts=attempts, + ), + ) + + def build_target_index(self) -> dict[str | None, object]: + target_index = dict(self.fallback_source.build_target_index()) + target_index.update(self.primary_source.build_target_index()) + return target_index + + @dataclass(frozen=True, slots=True) class RconHistoricalDataSource: """Minimal persisted historical read model over prospective RCON capture.""" @@ -123,7 +220,7 @@ class RconHistoricalDataSource: def get_historical_data_source() -> HistoricalDataSource: """Select the historical provider configured for the current environment.""" source_kind = get_historical_data_source_kind() - if source_kind == "public-scoreboard": + if source_kind == SOURCE_KIND_PUBLIC_SCOREBOARD: return PublicScoreboardHistoricalDataSource() if source_kind == SOURCE_KIND_RCON: return RconHistoricalDataSource() @@ -136,5 +233,104 @@ def get_live_data_source() -> LiveDataSource: if source_kind == LIVE_SOURCE_A2S: return A2SLiveDataSource() if source_kind == SOURCE_KIND_RCON: - return RconLiveDataSource() + return RconFirstLiveDataSource() raise ValueError(f"Unsupported live data source: {source_kind}") + + +def get_rcon_historical_read_model() -> RconHistoricalDataSource | None: + """Return the minimal persisted RCON historical read model when selected.""" + if get_historical_data_source_kind() != SOURCE_KIND_RCON: + return None + return RconHistoricalDataSource() + + +def resolve_historical_ingestion_data_source() -> tuple[HistoricalDataSource, dict[str, object]]: + """Resolve the writer-oriented historical provider with safe fallback semantics.""" + configured_kind = get_historical_data_source_kind() + if configured_kind == SOURCE_KIND_PUBLIC_SCOREBOARD: + return ( + PublicScoreboardHistoricalDataSource(), + build_source_policy( + primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD, + selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD, + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_PUBLIC_SCOREBOARD, + role="primary", + status="success", + ) + ], + ), + ) + + if configured_kind == SOURCE_KIND_RCON: + return ( + PublicScoreboardHistoricalDataSource(), + build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD, + fallback_used=True, + fallback_reason="rcon-historical-ingestion-not-supported-yet", + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="unsupported", + reason="rcon-historical-ingestion-not-supported-yet", + ), + build_source_attempt( + source=SOURCE_KIND_PUBLIC_SCOREBOARD, + role="fallback", + status="success", + ), + ], + ), + ) + + raise ValueError(f"Unsupported historical data source: {configured_kind}") + + +def build_source_attempt( + *, + source: str, + role: str, + status: str, + reason: str | None = None, + message: str | None = None, +) -> dict[str, object]: + """Build one normalized trace entry for source arbitration.""" + return { + "source": source, + "role": role, + "status": status, + "reason": reason, + "message": message, + } + + +def build_source_policy( + *, + primary_source: str, + selected_source: str, + fallback_used: bool = False, + fallback_reason: str | None = None, + source_attempts: list[dict[str, object]] | None = None, +) -> dict[str, object]: + """Build one small source-policy block for API responses and worker output.""" + return { + "primary_source": primary_source, + "selected_source": selected_source, + "fallback_used": fallback_used, + "fallback_reason": fallback_reason, + "source_attempts": list(source_attempts or []), + } + + +def attach_source_policy( + payload: dict[str, object], + source_policy: dict[str, object], +) -> dict[str, object]: + """Attach normalized source-policy metadata to an existing payload.""" + enriched = dict(payload) + enriched.update(source_policy) + return enriched diff --git a/backend/app/historical_ingestion.py b/backend/app/historical_ingestion.py index 2cbecfe..071f0ba 100644 --- a/backend/app/historical_ingestion.py +++ b/backend/app/historical_ingestion.py @@ -12,7 +12,7 @@ from .config import ( get_historical_crcon_page_size, get_historical_refresh_overlap_hours, ) -from .data_sources import HistoricalDataSource, get_historical_data_source +from .data_sources import HistoricalDataSource, resolve_historical_ingestion_data_source from .historical_snapshots import generate_and_persist_historical_snapshots from .historical_storage import ( finalize_backfill_progress, @@ -119,7 +119,7 @@ def _run_ingestion( ) -> dict[str, object]: initialize_historical_storage() stats = IngestionStats() - data_source = get_historical_data_source() + data_source, source_policy = resolve_historical_ingestion_data_source() selected_servers = _select_servers(server_slug) processed_servers: list[dict[str, object]] = [] active_runs: dict[str, int] = {} @@ -219,6 +219,7 @@ def _run_ingestion( "status": "ok", "mode": mode, "source_provider": data_source.source_kind, + "source_policy": source_policy, "page_size": page_size or get_historical_crcon_page_size(), "start_page": start_page, "detail_workers": detail_workers or get_historical_crcon_detail_workers(), diff --git a/backend/app/historical_runner.py b/backend/app/historical_runner.py index b56943c..739a4f9 100644 --- a/backend/app/historical_runner.py +++ b/backend/app/historical_runner.py @@ -13,12 +13,14 @@ from .config import ( get_historical_refresh_interval_seconds, get_historical_refresh_max_retries, get_historical_refresh_retry_delay_seconds, + get_historical_data_source_kind, ) from .historical_ingestion import run_incremental_refresh from .historical_snapshots import ( generate_and_persist_historical_snapshots, generate_and_persist_priority_historical_snapshots, ) +from .rcon_historical_worker import run_rcon_historical_capture from .writer_lock import backend_writer_lock, build_writer_lock_holder HOURLY_INTERVAL_SECONDS = 3600 @@ -95,20 +97,42 @@ def _run_refresh_with_retries( f"app.historical_runner refresh:{server_slug or 'all-servers'}" ) ): - refresh_result = run_incremental_refresh( - server_slug=server_slug, - max_pages=max_pages, - page_size=page_size, - rebuild_snapshots=False, - ) - snapshot_result = generate_historical_snapshots( - server_slug=server_slug, - run_number=run_number, + rcon_capture_result = _run_primary_rcon_capture() + should_run_classic_fallback, classic_fallback_reason = ( + _resolve_classic_fallback_policy( + server_slug=server_slug, + run_number=run_number, + rcon_capture_result=rcon_capture_result, + ) ) + if should_run_classic_fallback: + refresh_result = run_incremental_refresh( + server_slug=server_slug, + max_pages=max_pages, + page_size=page_size, + rebuild_snapshots=False, + ) + snapshot_result = generate_historical_snapshots( + server_slug=server_slug, + run_number=run_number, + ) + else: + refresh_result = { + "status": "skipped", + "reason": "rcon-primary-cycle-no-classic-fallback-needed", + } + snapshot_result = { + "status": "skipped", + "reason": "rcon-primary-cycle-no-classic-fallback-needed", + "generation_policy": "classic-historical-fallback-only", + } return { "status": "ok", "attempts_used": attempt, "max_retries": max_retries, + "rcon_capture_result": rcon_capture_result, + "classic_fallback_used": should_run_classic_fallback, + "classic_fallback_reason": classic_fallback_reason, "refresh_result": refresh_result, "snapshot_result": snapshot_result, } @@ -164,6 +188,43 @@ def _describe_snapshot_scope(server_slug: str | None) -> list[str]: return [*DEFAULT_HISTORICAL_SERVER_SCOPE, "all-servers"] +def _run_primary_rcon_capture() -> dict[str, Any]: + if get_historical_data_source_kind() != "rcon": + return { + "status": "skipped", + "reason": "historical-data-source-configured-without-rcon-primary", + } + return run_rcon_historical_capture() + + +def _resolve_classic_fallback_policy( + *, + server_slug: str | None, + run_number: int, + rcon_capture_result: dict[str, Any], +) -> tuple[bool, str]: + if get_historical_data_source_kind() != "rcon": + return True, "public-scoreboard-configured-as-primary-historical-source" + + if not _rcon_capture_has_usable_results(rcon_capture_result): + return True, "rcon-historical-capture-failed-or-returned-no-usable-targets" + + if server_slug: + return True, "manual-server-scope-still-needs-classic-historical-fallback" + + if run_number % get_historical_full_snapshot_every_runs() == 0: + return True, "periodic-classic-fallback-for-competitive-historical-coverage" + + return False, "rcon-primary-cycle-succeeded-without-needing-classic-fallback" + + +def _rcon_capture_has_usable_results(rcon_capture_result: dict[str, Any]) -> bool: + if rcon_capture_result.get("status") != "ok": + return False + targets = rcon_capture_result.get("targets") + return isinstance(targets, list) and len(targets) > 0 + + def main() -> None: """Allow local scheduled historical refresh execution without external infra.""" parser = argparse.ArgumentParser( diff --git a/backend/app/payloads.py b/backend/app/payloads.py index 91965c0..ad62a24 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -9,7 +9,15 @@ from .config import ( get_live_data_source_kind, get_refresh_interval_seconds, ) -from .data_sources import RconHistoricalDataSource, get_historical_data_source, get_live_data_source +from .data_sources import ( + LIVE_SOURCE_A2S, + SOURCE_KIND_PUBLIC_SCOREBOARD, + SOURCE_KIND_RCON, + build_source_attempt, + build_source_policy, + get_live_data_source, + get_rcon_historical_read_model, +) from .historical_snapshot_storage import get_historical_snapshot from .historical_snapshots import ( DEFAULT_MONTHLY_SNAPSHOT_WINDOW, @@ -29,6 +37,7 @@ from .historical_snapshots import ( ) from .historical_storage import ( ALL_SERVERS_SLUG, + DEFAULT_HISTORICAL_SERVERS, get_historical_player_profile, list_historical_server_summaries, list_monthly_leaderboard, @@ -102,21 +111,28 @@ def build_servers_payload() -> dict[str, object]: max_snapshot_age_seconds, ) refresh_errors: list[dict[str, object]] = [] + refresh_source_policy = build_source_policy( + primary_source=get_live_data_source_kind(), + selected_source="none", + fallback_reason=None, + source_attempts=[], + ) if refresh_attempted: - refreshed_items, refresh_errors = _try_collect_real_time_snapshot() + refreshed_items, refresh_errors, refresh_source_policy = _try_collect_real_time_snapshot() if refreshed_items: refreshed_snapshot_at = _resolve_last_snapshot_at(refreshed_items) refreshed_snapshot_age_seconds = _calculate_snapshot_age_seconds(refreshed_snapshot_at) return _build_servers_response( items=refreshed_items, - response_source="real-time-a2s-refresh", + response_source=_build_live_response_source(refresh_source_policy), last_snapshot_at=refreshed_snapshot_at, snapshot_age_seconds=refreshed_snapshot_age_seconds, max_snapshot_age_seconds=max_snapshot_age_seconds, refresh_attempted=True, refresh_status="success", refresh_errors=refresh_errors, + source_policy=refresh_source_policy, ) if persisted_items: @@ -133,6 +149,11 @@ def build_servers_payload() -> dict[str, object]: refresh_attempted=refresh_attempted, refresh_status=refresh_status, refresh_errors=refresh_errors, + source_policy=_infer_live_source_policy_from_items( + persisted_items, + refresh_attempted=refresh_attempted, + refresh_errors=refresh_errors, + ), ) return { @@ -150,6 +171,7 @@ def build_servers_payload() -> dict[str, object]: "refresh_attempted": refresh_attempted, "refresh_status": "failed" if refresh_attempted else "not-needed", "refresh_errors": refresh_errors, + **refresh_source_policy, "items": [], }, } @@ -219,14 +241,6 @@ def build_weekly_top_kills_payload( server_id: str | None = None, ) -> dict[str, object]: """Return weekly top kills grouped by real community server.""" - rcon_payload = _build_rcon_historical_unsupported_payload( - title="Top kills semanales por servidor", - context="historical-top-kills", - server_key=server_id, - limit=limit, - ) - if rcon_payload is not None: - return rcon_payload result = list_weekly_top_kills(limit=limit, server_id=server_id) return { "status": "ok", @@ -239,6 +253,9 @@ def build_weekly_top_kills_payload( "window_start": result["window_start"], "window_end": result["window_end"], "limit": limit, + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-weekly-top-kills", + ), "items": result["items"], }, } @@ -252,16 +269,6 @@ def build_historical_leaderboard_payload( timeframe: str = "weekly", ) -> dict[str, object]: """Return one historical leaderboard for the requested timeframe and metric.""" - rcon_payload = _build_rcon_historical_unsupported_payload( - title="Ranking historico", - context="historical-leaderboard", - server_key=server_id, - limit=limit, - metric=metric, - timeframe=timeframe, - ) - if rcon_payload is not None: - return rcon_payload normalized_timeframe = timeframe.strip().lower() if isinstance(timeframe, str) else "weekly" if normalized_timeframe == "monthly": result = list_monthly_leaderboard(limit=limit, server_id=server_id, metric=metric) @@ -301,6 +308,9 @@ def build_historical_leaderboard_payload( "previous_month_closed_matches": result.get("previous_month_closed_matches"), "sufficient_sample": result.get("sufficient_sample"), "limit": limit, + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-competitive-leaderboards", + ), "items": result["items"], }, } @@ -343,25 +353,37 @@ def build_recent_historical_matches_payload( ) -> dict[str, object]: """Return recent historical matches from persisted CRCON data.""" if get_historical_data_source_kind() == "rcon": - data_source = get_historical_data_source() - if isinstance(data_source, RconHistoricalDataSource): + data_source = get_rcon_historical_read_model() + if data_source is not None: items = data_source.list_recent_activity(server_key=server_slug, limit=limit) capabilities = data_source.describe_capabilities() - return { - "status": "ok", - "data": { - "title": "Actividad reciente capturada por RCON", - "context": "historical-recent-matches", - "source": "rcon-historical-read-model", - "historical_data_source": "rcon", - "supported": True, - "coverage_basis": "prospective-rcon-samples", - "limit": limit, - "server_slug": server_slug, - "items": items, - "capabilities": capabilities, - }, - } + if items: + return { + "status": "ok", + "data": { + "title": "Actividad reciente capturada por RCON", + "context": "historical-recent-matches", + "source": "rcon-historical-read-model", + "historical_data_source": "rcon", + "supported": True, + "coverage_basis": "prospective-rcon-samples", + "limit": limit, + "server_slug": server_slug, + **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", + ) + ], + ), + "items": items, + "capabilities": capabilities, + }, + } items = list_recent_historical_matches(limit=limit, server_slug=server_slug) return { "status": "ok", @@ -371,6 +393,9 @@ def build_recent_historical_matches_payload( "source": "historical-crcon-storage", "limit": limit, "server_slug": server_slug, + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-has-no-recent-activity", + ), "items": items, }, } @@ -382,14 +407,6 @@ def build_monthly_mvp_payload( server_id: str | None = None, ) -> dict[str, object]: """Return the precomputed monthly MVP payload through the stable API surface.""" - rcon_payload = _build_rcon_historical_unsupported_payload( - title="Top MVP mensual", - context="historical-monthly-mvp", - server_key=server_id, - limit=limit, - ) - if rcon_payload is not None: - return rcon_payload snapshot_payload = build_monthly_mvp_snapshot_payload( limit=limit, server_id=server_id, @@ -405,6 +422,9 @@ def build_monthly_mvp_payload( ), "context": "historical-monthly-mvp", "source": "historical-precomputed-snapshots", + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-monthly-mvp-yet", + ), }, } @@ -416,15 +436,6 @@ def build_player_event_payload( view: str = "most-killed", ) -> dict[str, object]: """Return one V2 player-event payload through the stable API surface.""" - rcon_payload = _build_rcon_historical_unsupported_payload( - title="Metricas V2 mensuales", - context="historical-player-events", - server_key=server_id, - limit=limit, - metric=view, - ) - if rcon_payload is not None: - return rcon_payload snapshot_payload = build_player_event_snapshot_payload( limit=limit, server_id=server_id, @@ -442,6 +453,9 @@ def build_player_event_payload( ), "context": "historical-player-events", "source": "historical-precomputed-player-event-snapshots", + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-player-events-yet", + ), }, } @@ -452,14 +466,6 @@ def build_monthly_mvp_v2_payload( server_id: str | None = None, ) -> dict[str, object]: """Return the precomputed monthly MVP V2 payload through the stable API surface.""" - rcon_payload = _build_rcon_historical_unsupported_payload( - title="Top MVP mensual V2", - context="historical-monthly-mvp-v2", - server_key=server_id, - limit=limit, - ) - if rcon_payload is not None: - return rcon_payload snapshot_payload = build_monthly_mvp_v2_snapshot_payload( limit=limit, server_id=server_id, @@ -475,6 +481,9 @@ def build_monthly_mvp_v2_payload( ), "context": "historical-monthly-mvp-v2", "source": "historical-precomputed-snapshots", + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-monthly-mvp-v2-yet", + ), }, } @@ -484,13 +493,6 @@ def build_historical_server_summary_snapshot_payload( server_slug: str | None = None, ) -> dict[str, object]: """Return one precomputed summary snapshot without recalculating aggregates.""" - rcon_payload = _build_rcon_historical_unsupported_payload( - title="Snapshot historico de resumen por servidor", - context="historical-server-summary-snapshot", - server_key=server_slug, - ) - if rcon_payload is not None: - return rcon_payload snapshot = _get_historical_snapshot_record( server_key=server_slug, snapshot_type=SNAPSHOT_TYPE_SERVER_SUMMARY, @@ -506,6 +508,9 @@ def build_historical_server_summary_snapshot_payload( "source": "historical-precomputed-snapshots", "server_slug": server_slug, "found": snapshot is not None and isinstance(item, dict), + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet", + ), **_build_historical_snapshot_metadata(snapshot), "item": item if isinstance(item, dict) else None, }, @@ -520,16 +525,6 @@ def build_leaderboard_snapshot_payload( timeframe: str = "weekly", ) -> dict[str, object]: """Return one precomputed leaderboard snapshot for the requested timeframe.""" - rcon_payload = _build_rcon_historical_unsupported_payload( - title="Snapshot historico de leaderboard", - context="historical-leaderboard-snapshot", - server_key=server_id, - limit=limit, - metric=metric, - timeframe=timeframe, - ) - if rcon_payload is not None: - return rcon_payload normalized_timeframe = timeframe.strip().lower() if isinstance(timeframe, str) else "weekly" if normalized_timeframe == "monthly": snapshot_type = SNAPSHOT_TYPE_MONTHLY_LEADERBOARD @@ -591,6 +586,9 @@ def build_leaderboard_snapshot_payload( "sufficient_sample": payload.get("sufficient_sample") if isinstance(payload, dict) else None, "snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None, "limit": limit, + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet", + ), "items": sliced_items, }, } @@ -632,14 +630,6 @@ def build_recent_historical_matches_snapshot_payload( server_slug: str | None = None, ) -> dict[str, object]: """Return one precomputed recent-matches snapshot.""" - rcon_payload = _build_rcon_historical_unsupported_payload( - title="Snapshot historico de actividad reciente por servidor", - context="historical-recent-matches-snapshot", - server_key=server_slug, - limit=limit, - ) - if rcon_payload is not None: - return rcon_payload snapshot = _get_historical_snapshot_record( server_key=server_slug, snapshot_type=SNAPSHOT_TYPE_RECENT_MATCHES, @@ -659,6 +649,9 @@ def build_recent_historical_matches_snapshot_payload( **_build_historical_snapshot_metadata(snapshot), "snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None, "limit": limit, + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet", + ), "items": sliced_items, }, } @@ -670,14 +663,6 @@ def build_monthly_mvp_snapshot_payload( server_id: str | None = None, ) -> dict[str, object]: """Return one precomputed monthly MVP snapshot.""" - rcon_payload = _build_rcon_historical_unsupported_payload( - title="Snapshot top MVP mensual", - context="historical-monthly-mvp-snapshot", - server_key=server_id, - limit=limit, - ) - if rcon_payload is not None: - return rcon_payload snapshot = _get_historical_snapshot_record( server_key=server_id, snapshot_type=SNAPSHOT_TYPE_MONTHLY_MVP, @@ -723,6 +708,9 @@ def build_monthly_mvp_snapshot_payload( ), "snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None, "limit": limit, + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet", + ), "items": sliced_items, }, } @@ -734,14 +722,6 @@ def build_monthly_mvp_v2_snapshot_payload( server_id: str | None = None, ) -> dict[str, object]: """Return one precomputed monthly MVP V2 snapshot.""" - rcon_payload = _build_rcon_historical_unsupported_payload( - title="Snapshot top MVP mensual V2", - context="historical-monthly-mvp-v2-snapshot", - server_key=server_id, - limit=limit, - ) - if rcon_payload is not None: - return rcon_payload snapshot = _get_historical_snapshot_record( server_key=server_id, snapshot_type=SNAPSHOT_TYPE_MONTHLY_MVP_V2, @@ -789,6 +769,9 @@ def build_monthly_mvp_v2_snapshot_payload( ), "snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None, "limit": limit, + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet", + ), "items": sliced_items, }, } @@ -801,15 +784,6 @@ def build_player_event_snapshot_payload( view: str = "most-killed", ) -> dict[str, object]: """Return one precomputed V2 player-event snapshot.""" - rcon_payload = _build_rcon_historical_unsupported_payload( - title="Snapshot metricas V2 mensuales", - context="historical-player-events-snapshot", - server_key=server_id, - limit=limit, - metric=view, - ) - if rcon_payload is not None: - return rcon_payload snapshot_type = _resolve_player_event_snapshot_type(view) snapshot = _get_historical_snapshot_record( server_key=server_id, @@ -839,6 +813,9 @@ def build_player_event_snapshot_payload( "month_key": payload.get("month_key") if isinstance(payload, dict) else None, "snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None, "limit": limit, + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet", + ), "items": sliced_items, }, } @@ -850,28 +827,43 @@ def build_historical_server_summary_payload( ) -> dict[str, object]: """Return aggregated historical metrics per server.""" if get_historical_data_source_kind() == "rcon": - data_source = get_historical_data_source() - if isinstance(data_source, RconHistoricalDataSource): + data_source = get_rcon_historical_read_model() + if data_source is not None: items = data_source.list_server_summaries(server_key=server_slug) capabilities = data_source.describe_capabilities() - return { - "status": "ok", - "data": { - "title": ( - "Cobertura historica minima por RCON" - if server_slug != ALL_SERVERS_SLUG - else "Cobertura historica minima RCON agregada" - ), - "context": "historical-server-summary", - "source": "rcon-historical-read-model", - "historical_data_source": "rcon", - "summary_basis": "prospective-rcon-samples", - "server_slug": server_slug, - "supported": True, - "items": items, - "capabilities": capabilities, - }, - } + if items and any( + item.get("coverage", {}).get("status") != "empty" + for item in items + ): + return { + "status": "ok", + "data": { + "title": ( + "Cobertura historica minima por RCON" + if server_slug != ALL_SERVERS_SLUG + else "Cobertura historica minima RCON agregada" + ), + "context": "historical-server-summary", + "source": "rcon-historical-read-model", + "historical_data_source": "rcon", + "summary_basis": "prospective-rcon-samples", + "server_slug": server_slug, + "supported": True, + **build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source=SOURCE_KIND_RCON, + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="success", + ) + ], + ), + "items": items, + "capabilities": capabilities, + }, + } items = list_historical_server_summaries(server_slug=server_slug) return { "status": "ok", @@ -886,6 +878,9 @@ def build_historical_server_summary_payload( "summary_basis": "persisted-import", "weekly_ranking_window_days": 7, "server_slug": server_slug, + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-has-no-summary-coverage", + ), "items": items, }, } @@ -893,13 +888,6 @@ def build_historical_server_summary_payload( def build_historical_player_profile_payload(player_id: str) -> dict[str, object]: """Return aggregate historical metrics for one player identity.""" - rcon_payload = _build_rcon_historical_unsupported_payload( - title="Perfil historico de jugador", - context="historical-player-profile", - server_key=player_id, - ) - if rcon_payload is not None: - return rcon_payload profile = get_historical_player_profile(player_id) return { "status": "ok", @@ -909,6 +897,9 @@ def build_historical_player_profile_payload(player_id: str) -> dict[str, object] "source": "historical-crcon-storage", "player_id": player_id, "found": profile is not None, + **_resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-player-profile-yet", + ), "profile": profile, }, } @@ -931,45 +922,6 @@ def _get_historical_snapshot_record( ) -def _build_rcon_historical_unsupported_payload( - *, - title: str, - context: str, - server_key: str | None = None, - limit: int | None = None, - metric: str | None = None, - timeframe: str | None = None, -) -> dict[str, object] | None: - if get_historical_data_source_kind() != "rcon": - return None - - data_source = get_historical_data_source() - if not isinstance(data_source, RconHistoricalDataSource): - return None - - return { - "status": "ok", - "data": { - "title": title, - "context": context, - "source": "rcon-historical-read-model", - "historical_data_source": "rcon", - "supported": False, - "server_slug": server_key, - "limit": limit, - "metric": metric, - "timeframe": timeframe, - "items": [], - "item": None, - "profile": None, - "found": False, - "missing_reason": "rcon-minimal-read-model-does-not-support-this-endpoint-yet", - "supported_historical_source_for_full_contract": "public-scoreboard", - "capabilities": data_source.describe_capabilities(), - }, - } - - def _build_historical_snapshot_metadata(snapshot: dict[str, object] | None) -> dict[str, object]: if snapshot is None: return { @@ -1087,11 +1039,14 @@ def _enrich_server_item( ) -> dict[str, object]: enriched = dict(item) enriched["current_map"] = normalize_map_name(enriched.get("current_map")) + history_url = _resolve_community_history_url(enriched.get("external_server_id")) + enriched["community_history_url"] = history_url + enriched["community_history_available"] = bool(history_url) external_server_id = enriched.get("external_server_id") snapshot_origin = enriched.get("snapshot_origin") target = target_index.get(external_server_id) - if not target or snapshot_origin != "real-a2s": + if not target or snapshot_origin not in {"real-a2s", "real-rcon"}: enriched["host"] = None enriched["query_port"] = None enriched["game_port"] = None @@ -1129,12 +1084,26 @@ def _should_refresh_snapshot( return snapshot_age_seconds > max_snapshot_age_seconds -def _try_collect_real_time_snapshot() -> tuple[list[dict[str, object]], list[dict[str, object]]]: +def _try_collect_real_time_snapshot() -> tuple[ + list[dict[str, object]], + list[dict[str, object]], + dict[str, object], +]: payload = get_live_data_source().collect_snapshots(persist=False) snapshots = payload.get("snapshots") items = _select_primary_snapshot_items(_enrich_server_items(list(snapshots or []))) errors = payload.get("errors") - return items, list(errors or []) + return ( + items, + list(errors or []), + { + "primary_source": payload.get("primary_source"), + "selected_source": payload.get("selected_source"), + "fallback_used": bool(payload.get("fallback_used")), + "fallback_reason": payload.get("fallback_reason"), + "source_attempts": list(payload.get("source_attempts") or []), + }, + ) def _build_servers_response( @@ -1147,6 +1116,7 @@ def _build_servers_response( refresh_attempted: bool, refresh_status: str, refresh_errors: list[dict[str, object]], + source_policy: dict[str, object], ) -> dict[str, object]: freshness = ( "fresh" @@ -1168,6 +1138,7 @@ def _build_servers_response( "refresh_attempted": refresh_attempted, "refresh_status": refresh_status, "refresh_errors": refresh_errors, + **source_policy, "items": items, }, } @@ -1191,3 +1162,100 @@ def _to_snapshot_age_minutes(snapshot_age_seconds: int | None) -> int | None: return None return snapshot_age_seconds // 60 + + +def _resolve_historical_fallback_policy(*, fallback_reason: str) -> dict[str, object]: + if get_historical_data_source_kind() != SOURCE_KIND_RCON: + return build_source_policy( + primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD, + selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD, + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_PUBLIC_SCOREBOARD, + role="primary", + status="success", + ) + ], + ) + + return build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD, + fallback_used=True, + fallback_reason=fallback_reason, + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="unsupported", + reason=fallback_reason, + ), + build_source_attempt( + source=SOURCE_KIND_PUBLIC_SCOREBOARD, + role="fallback", + status="success", + ), + ], + ) + + +def _infer_live_source_policy_from_items( + items: list[dict[str, object]], + *, + refresh_attempted: bool, + refresh_errors: list[dict[str, object]], +) -> dict[str, object]: + selected_source = "persisted-snapshot" + fallback_used = False + fallback_reason = None + snapshot_origins = { + str(item.get("snapshot_origin") or "").strip() + for item in items + if item.get("snapshot_origin") + } + if "real-rcon" in snapshot_origins: + selected_source = SOURCE_KIND_RCON + elif "real-a2s" in snapshot_origins: + selected_source = LIVE_SOURCE_A2S + if get_live_data_source_kind() == SOURCE_KIND_RCON: + fallback_used = True + fallback_reason = "persisted-live-snapshot-came-from-a2s" + + attempt_status = "success" if items else ("error" if refresh_attempted else "cached") + attempt_reason = None if items else "no-live-snapshot-items" + if refresh_errors and attempt_reason is None: + attempt_reason = "live-refresh-errors-present" + + return build_source_policy( + primary_source=get_live_data_source_kind(), + selected_source=selected_source, + fallback_used=fallback_used, + fallback_reason=fallback_reason, + source_attempts=[ + build_source_attempt( + source=selected_source, + role="served-response", + status=attempt_status, + reason=attempt_reason, + ) + ], + ) + + +def _build_live_response_source(source_policy: dict[str, object]) -> str: + selected_source = str(source_policy.get("selected_source") or "") + if selected_source == SOURCE_KIND_RCON: + return "real-time-rcon-refresh" + if selected_source == LIVE_SOURCE_A2S: + return "real-time-a2s-fallback" + return "real-time-refresh" + + +def _resolve_community_history_url(external_server_id: object) -> str | None: + normalized_server_id = str(external_server_id or "").strip() + if not normalized_server_id: + return None + for server in DEFAULT_HISTORICAL_SERVERS: + if server.slug == normalized_server_id: + return f"{server.scoreboard_base_url}/games" + return None diff --git a/backend/app/player_event_source.py b/backend/app/player_event_source.py index 3f933f2..6268516 100644 --- a/backend/app/player_event_source.py +++ b/backend/app/player_event_source.py @@ -2,9 +2,16 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Protocol from .config import get_historical_data_source_kind +from .data_sources import ( + SOURCE_KIND_PUBLIC_SCOREBOARD, + SOURCE_KIND_RCON, + build_source_attempt, + build_source_policy, +) from .player_event_models import PlayerEventRecord from .providers.player_event_source_provider import PublicScoreboardPlayerEventSource @@ -52,11 +59,53 @@ class RconPlayerEventSource: } -def get_player_event_source() -> PlayerEventSource: - """Select the event adapter that best matches the configured historical source.""" +@dataclass(frozen=True, slots=True) +class PlayerEventSourceSelection: + """Resolved player-event adapter plus source-policy metadata.""" + + source: PlayerEventSource + source_policy: dict[str, object] + + +def resolve_player_event_source() -> PlayerEventSourceSelection: + """Select the event adapter with safe fallback when raw RCON events are unavailable.""" source_kind = get_historical_data_source_kind() - if source_kind == "public-scoreboard": - return PublicScoreboardPlayerEventSource() - if source_kind == "rcon": - return RconPlayerEventSource() + if source_kind == SOURCE_KIND_PUBLIC_SCOREBOARD: + return PlayerEventSourceSelection( + source=PublicScoreboardPlayerEventSource(), + source_policy=build_source_policy( + primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD, + selected_source="public-scoreboard-match-summary", + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_PUBLIC_SCOREBOARD, + role="primary", + status="success", + ) + ], + ), + ) + if source_kind == SOURCE_KIND_RCON: + return PlayerEventSourceSelection( + source=PublicScoreboardPlayerEventSource(), + source_policy=build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source="public-scoreboard-match-summary", + fallback_used=True, + fallback_reason="rcon-player-events-not-implemented-yet", + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="unsupported", + reason="rcon-player-events-not-implemented-yet", + ), + build_source_attempt( + source="public-scoreboard-match-summary", + role="fallback", + status="success", + ), + ], + ), + ) raise ValueError(f"Unsupported player event source: {source_kind}") diff --git a/backend/app/player_event_worker.py b/backend/app/player_event_worker.py index 84162c4..4724fa3 100644 --- a/backend/app/player_event_worker.py +++ b/backend/app/player_event_worker.py @@ -16,9 +16,9 @@ from .config import ( get_player_event_refresh_overlap_hours, get_player_event_refresh_retry_delay_seconds, ) -from .data_sources import get_historical_data_source +from .data_sources import resolve_historical_ingestion_data_source from .historical_storage import list_historical_servers -from .player_event_source import get_player_event_source +from .player_event_source import resolve_player_event_source from .player_event_storage import ( finalize_player_event_ingestion_run, finalize_player_event_progress, @@ -62,8 +62,9 @@ def run_player_event_refresh( ) ): initialize_player_event_storage() - data_source = get_historical_data_source() - event_source = get_player_event_source() + data_source, data_source_policy = resolve_historical_ingestion_data_source() + event_source_selection = resolve_player_event_source() + event_source = event_source_selection.source resolved_page_size = page_size or get_historical_crcon_page_size() resolved_detail_workers = detail_workers or get_historical_crcon_detail_workers() resolved_overlap_hours = ( @@ -153,7 +154,9 @@ def run_player_event_refresh( "status": "ok", "mode": "refresh", "source_provider": data_source.source_kind, + "source_policy": data_source_policy, "event_adapter": event_source.source_kind, + "event_source_policy": event_source_selection.source_policy, "page_size": resolved_page_size, "detail_workers": resolved_detail_workers, "overlap_hours": resolved_overlap_hours, diff --git a/backend/data/hll_vietnam_dev.writer.lock b/backend/data/hll_vietnam_dev.writer.lock deleted file mode 100644 index 342a6cd..0000000 --- a/backend/data/hll_vietnam_dev.writer.lock +++ /dev/null @@ -1,8 +0,0 @@ -{ - "lock_token": "3b2e9b3e3e614fefa86d9e14e4e820ad", - "holder": "app.historical_runner refresh:all-servers [/app/app/historical_runner.py --hourly]", - "started_at": "2026-03-25T11:23:47.790251Z", - "hostname": "2a2c611b2eca", - "pid": 1, - "cwd": "/app" -} diff --git a/docker-compose.yml b/docker-compose.yml index 18aed61..39ca0c4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,8 @@ services: env_file: - ./backend/.env.example environment: - HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-a2s} - HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-public-scoreboard} + HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon} + HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon} HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-10} HLL_BACKEND_RCON_TARGETS: ${HLL_BACKEND_RCON_TARGETS:-} ports: @@ -24,8 +24,8 @@ services: env_file: - ./backend/.env.example environment: - HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-a2s} - HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-public-scoreboard} + HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon} + HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon} HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-10} HLL_BACKEND_RCON_TARGETS: ${HLL_BACKEND_RCON_TARGETS:-} depends_on: @@ -38,15 +38,15 @@ services: build: context: ./backend container_name: hll-vietnam-rcon-historical-worker - command: ["python", "-m", "app.rcon_historical_worker", "loop", "--interval", "120"] + command: ["python", "-m", "app.rcon_historical_worker", "loop"] env_file: - ./backend/.env.example environment: - HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-a2s} - HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-public-scoreboard} + HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon} + HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon} HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-10} HLL_BACKEND_RCON_TARGETS: ${HLL_BACKEND_RCON_TARGETS:-} - HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS:-120} + HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS:-600} HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES: ${HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES:-2} HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS:-15} depends_on: diff --git a/frontend/assets/js/main.js b/frontend/assets/js/main.js index 8884dd5..45f2294 100644 --- a/frontend/assets/js/main.js +++ b/frontend/assets/js/main.js @@ -1,8 +1,9 @@ // Progressive enhancement for local frontend-backend checks. const DEFAULT_SERVER_POLL_INTERVAL_MS = 300 * 1000; -const SERVER_HISTORY_URLS = Object.freeze({ +const SERVER_HISTORY_URLS_FALLBACK = Object.freeze({ "comunidad-hispana-01": "https://scoreboard.comunidadhll.es/games", "comunidad-hispana-02": "https://scoreboard.comunidadhll.es:5443/games", + "comunidad-hispana-03": "https://scoreboard.comunidadhll.es:3443/games", }); const COMMUNITY_CLANS = Object.freeze([ { @@ -244,13 +245,14 @@ function renderServerStatsCard(server) { const statusLabel = formatServerStatus(server.status); const stateClass = server.status === "online" ? "server-state--online" : "server-state--offline"; - const isRealSnapshot = server.snapshot_origin === "real-a2s"; + const isRealSnapshot = isRealLiveSnapshot(server); const currentMap = server.current_map || "Sin mapa disponible"; const region = server.region || "Region pendiente"; const players = Number.isFinite(server.players) ? server.players : 0; const maxPlayers = Number.isFinite(server.max_players) ? server.max_players : 0; const actionMarkup = renderServerAction(server); const cardVariantClass = isRealSnapshot ? "server-card--real" : "server-card--reference"; + const eyebrowLabel = isRealSnapshot ? "Servidor de comunidad" : "Referencia actual"; const quickFacts = renderQuickFacts([ { label: "Mapa", value: currentMap, valueClassName: "server-card__quickfact-value--map" }, { label: "Region", value: region }, @@ -260,7 +262,7 @@ function renderServerStatsCard(server) {
-

${isRealSnapshot ? "Servidor de comunidad" : "Referencia actual"}

+

${escapeHtml(eyebrowLabel)}

${escapeHtml(serverName)}

@@ -279,7 +281,21 @@ function renderServerSections(latestItems) { } function renderServerAction(server) { - const historyUrl = getServerHistoryUrl(server); + const historyState = getServerHistoryState(server); + if (!historyState.available) { + return ` +
+ + Historico no disponible + +
+ `; + } + + const historyUrl = historyState.url; if (!historyUrl) { return ""; } @@ -396,6 +412,10 @@ function renderQuickFacts(items) { } function getServerHistoryUrl(server) { + if (typeof server?.community_history_url === "string" && server.community_history_url.trim()) { + return server.community_history_url.trim(); + } + const externalServerId = typeof server?.external_server_id === "string" ? server.external_server_id.trim() @@ -404,7 +424,15 @@ function getServerHistoryUrl(server) { return ""; } - return SERVER_HISTORY_URLS[externalServerId] || ""; + return SERVER_HISTORY_URLS_FALLBACK[externalServerId] || ""; +} + +function getServerHistoryState(server) { + const historyUrl = getServerHistoryUrl(server); + return { + available: Boolean(historyUrl), + url: historyUrl, + }; } function selectPrimaryServerItems(items) { @@ -412,12 +440,12 @@ function selectPrimaryServerItems(items) { return []; } - const realItems = items.filter(isRealA2SSnapshot); + const realItems = items.filter(isRealLiveSnapshot); return realItems.length > 0 ? realItems : items; } -function isRealA2SSnapshot(item) { - return item?.snapshot_origin === "real-a2s"; +function isRealLiveSnapshot(item) { + return item?.snapshot_origin === "real-a2s" || item?.snapshot_origin === "real-rcon"; } function deriveSnapshotState(serversData) { diff --git a/frontend/index.html b/frontend/index.html index 67aff7b..1d647ae 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -161,6 +161,38 @@
+
+
+
+

Servidor de comunidad

+

Comunidad Hispana #03

+
+
+ Offline +

0 / 100

+ +
+
+
+
+

Mapa

+ Sin mapa disponible +
+
+

Region

+ ES +
+
+