578 lines
19 KiB
Python
578 lines
19 KiB
Python
"""Payload builders for the HLL Vietnam backend."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from .collector import collect_server_snapshots
|
|
from .config import get_refresh_interval_seconds
|
|
from .historical_snapshot_storage import get_historical_snapshot
|
|
from .historical_snapshots import (
|
|
DEFAULT_SNAPSHOT_WINDOW,
|
|
DEFAULT_WEEKLY_SNAPSHOT_WINDOW,
|
|
SNAPSHOT_TYPE_RECENT_MATCHES,
|
|
SNAPSHOT_TYPE_SERVER_SUMMARY,
|
|
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
|
|
)
|
|
from .historical_storage import (
|
|
get_historical_player_profile,
|
|
list_historical_server_summaries,
|
|
list_recent_historical_matches,
|
|
list_weekly_leaderboard,
|
|
list_weekly_top_kills,
|
|
)
|
|
from .normalizers import normalize_map_name
|
|
from .server_targets import load_a2s_targets
|
|
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history
|
|
|
|
|
|
def build_health_payload() -> dict[str, str]:
|
|
"""Return a small status payload without committing to business contracts."""
|
|
return {
|
|
"status": "ok",
|
|
"service": "hll-vietnam-backend",
|
|
"phase": "bootstrap",
|
|
}
|
|
|
|
|
|
def build_community_payload() -> dict[str, object]:
|
|
"""Return placeholder community content aligned with the documented contract."""
|
|
return {
|
|
"status": "ok",
|
|
"data": {
|
|
"title": "Comunidad Hispana HLL Vietnam",
|
|
"summary": "Punto de encuentro para jugadores, escuadras y comunidad.",
|
|
"discord_invite_url": "https://discord.com/invite/PedEqZ2Xsa",
|
|
},
|
|
}
|
|
|
|
|
|
def build_trailer_payload() -> dict[str, object]:
|
|
"""Return placeholder trailer metadata for future frontend consumption."""
|
|
return {
|
|
"status": "ok",
|
|
"data": {
|
|
"video_url": "https://www.youtube.com/embed/JzYzYNVWZ_A",
|
|
"title": "Trailer HLL Vietnam",
|
|
"provider": "youtube",
|
|
},
|
|
}
|
|
|
|
|
|
def build_discord_payload() -> dict[str, object]:
|
|
"""Return public Discord placeholder data without real integration."""
|
|
return {
|
|
"status": "ok",
|
|
"data": {
|
|
"invite_url": "https://discord.com/invite/PedEqZ2Xsa",
|
|
"label": "Unirse al Discord",
|
|
"availability": "manual",
|
|
},
|
|
}
|
|
|
|
|
|
def build_servers_payload() -> dict[str, object]:
|
|
"""Return current server status, refreshing stale snapshots before responding."""
|
|
max_snapshot_age_seconds = get_refresh_interval_seconds()
|
|
persisted_items = _select_primary_snapshot_items(
|
|
_enrich_server_items(list_latest_snapshots())
|
|
)
|
|
persisted_snapshot_at = _resolve_last_snapshot_at(persisted_items)
|
|
persisted_snapshot_age_seconds = _calculate_snapshot_age_seconds(persisted_snapshot_at)
|
|
|
|
refresh_attempted = _should_refresh_snapshot(
|
|
persisted_items,
|
|
persisted_snapshot_age_seconds,
|
|
max_snapshot_age_seconds,
|
|
)
|
|
refresh_errors: list[dict[str, object]] = []
|
|
|
|
if refresh_attempted:
|
|
refreshed_items, refresh_errors = _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",
|
|
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,
|
|
)
|
|
|
|
if persisted_items:
|
|
refresh_status = "failed" if refresh_attempted else "not-needed"
|
|
response_source = (
|
|
"persisted-stale-snapshot" if refresh_attempted else "persisted-fresh-snapshot"
|
|
)
|
|
return _build_servers_response(
|
|
items=persisted_items,
|
|
response_source=response_source,
|
|
last_snapshot_at=persisted_snapshot_at,
|
|
snapshot_age_seconds=persisted_snapshot_age_seconds,
|
|
max_snapshot_age_seconds=max_snapshot_age_seconds,
|
|
refresh_attempted=refresh_attempted,
|
|
refresh_status=refresh_status,
|
|
refresh_errors=refresh_errors,
|
|
)
|
|
|
|
return {
|
|
"status": "ok",
|
|
"data": {
|
|
"title": "Estado actual de servidores",
|
|
"context": "current-hll-status",
|
|
"source": "no-snapshot-available",
|
|
"last_snapshot_at": None,
|
|
"snapshot_age_seconds": None,
|
|
"snapshot_age_minutes": None,
|
|
"max_snapshot_age_seconds": max_snapshot_age_seconds,
|
|
"is_stale": True,
|
|
"freshness": "stale",
|
|
"refresh_attempted": refresh_attempted,
|
|
"refresh_status": "failed" if refresh_attempted else "not-needed",
|
|
"refresh_errors": refresh_errors,
|
|
"items": [],
|
|
},
|
|
}
|
|
|
|
|
|
def build_server_latest_payload() -> dict[str, object]:
|
|
"""Return the latest persisted snapshot for each known server."""
|
|
items = _enrich_server_items(list_latest_snapshots())
|
|
return {
|
|
"status": "ok",
|
|
"data": {
|
|
"title": "Ultimo estado conocido de servidores",
|
|
"context": "current-hll-history",
|
|
"source": "local-snapshot-storage",
|
|
"summary_window_size": 6,
|
|
"items": items,
|
|
},
|
|
}
|
|
|
|
|
|
def build_server_history_payload(*, limit: int = 20) -> dict[str, object]:
|
|
"""Return recent persisted snapshots across all known servers."""
|
|
items = _enrich_server_items(list_snapshot_history(limit=limit))
|
|
return {
|
|
"status": "ok",
|
|
"data": {
|
|
"title": "Historial reciente de servidores",
|
|
"context": "current-hll-history",
|
|
"source": "local-snapshot-storage",
|
|
"limit": limit,
|
|
"items": items,
|
|
},
|
|
}
|
|
|
|
|
|
def build_server_detail_history_payload(
|
|
server_id: str,
|
|
*,
|
|
limit: int = 20,
|
|
) -> dict[str, object]:
|
|
"""Return recent persisted snapshots for one server."""
|
|
items = _enrich_server_items(list_server_history(server_id, limit=limit))
|
|
return {
|
|
"status": "ok",
|
|
"data": {
|
|
"title": "Historial por servidor",
|
|
"context": "current-hll-history",
|
|
"source": "local-snapshot-storage",
|
|
"server_id": server_id,
|
|
"limit": limit,
|
|
"items": items,
|
|
},
|
|
}
|
|
|
|
|
|
def build_error_payload(message: str) -> dict[str, str]:
|
|
"""Return the shared error payload shape used by the backend bootstrap."""
|
|
return {
|
|
"status": "error",
|
|
"message": message,
|
|
}
|
|
|
|
|
|
def build_weekly_top_kills_payload(
|
|
*,
|
|
limit: int = 10,
|
|
server_id: str | None = None,
|
|
) -> dict[str, object]:
|
|
"""Return weekly top kills grouped by real community server."""
|
|
result = list_weekly_top_kills(limit=limit, server_id=server_id)
|
|
return {
|
|
"status": "ok",
|
|
"data": {
|
|
"title": "Top kills semanales por servidor",
|
|
"context": "historical-top-kills",
|
|
"metric": "kills",
|
|
"summary_basis": "closed-matches-last-7-days",
|
|
"window_days": 7,
|
|
"window_start": result["window_start"],
|
|
"window_end": result["window_end"],
|
|
"limit": limit,
|
|
"items": result["items"],
|
|
},
|
|
}
|
|
|
|
|
|
def build_weekly_leaderboard_payload(
|
|
*,
|
|
limit: int = 10,
|
|
server_id: str | None = None,
|
|
metric: str = "kills",
|
|
) -> dict[str, object]:
|
|
"""Return one weekly historical leaderboard for the requested metric."""
|
|
result = list_weekly_leaderboard(limit=limit, server_id=server_id, metric=metric)
|
|
title_by_metric = {
|
|
"kills": "Top kills semanales por servidor",
|
|
"deaths": "Top muertes semanales por servidor",
|
|
"support": "Top puntos de soporte semanales por servidor",
|
|
"matches_over_100_kills": "Top partidas de 100+ kills semanales por servidor",
|
|
}
|
|
return {
|
|
"status": "ok",
|
|
"data": {
|
|
"title": title_by_metric.get(metric, "Ranking semanal por servidor"),
|
|
"context": "historical-weekly-leaderboard",
|
|
"metric": metric,
|
|
"summary_basis": "closed-matches-last-7-days",
|
|
"window_days": 7,
|
|
"window_start": result["window_start"],
|
|
"window_end": result["window_end"],
|
|
"limit": limit,
|
|
"items": result["items"],
|
|
},
|
|
}
|
|
|
|
|
|
def build_recent_historical_matches_payload(
|
|
*,
|
|
limit: int = 20,
|
|
server_slug: str | None = None,
|
|
) -> dict[str, object]:
|
|
"""Return recent historical matches from persisted CRCON data."""
|
|
items = list_recent_historical_matches(limit=limit, server_slug=server_slug)
|
|
return {
|
|
"status": "ok",
|
|
"data": {
|
|
"title": "Partidas recientes por servidor",
|
|
"context": "historical-recent-matches",
|
|
"source": "historical-crcon-storage",
|
|
"limit": limit,
|
|
"server_slug": server_slug,
|
|
"items": items,
|
|
},
|
|
}
|
|
|
|
|
|
def build_historical_server_summary_snapshot_payload(
|
|
*,
|
|
server_slug: str | None = None,
|
|
) -> dict[str, object]:
|
|
"""Return one precomputed summary snapshot without recalculating aggregates."""
|
|
snapshot = _get_historical_snapshot_record(
|
|
server_key=server_slug,
|
|
snapshot_type=SNAPSHOT_TYPE_SERVER_SUMMARY,
|
|
window=DEFAULT_SNAPSHOT_WINDOW,
|
|
)
|
|
payload = snapshot.get("payload") if snapshot else {}
|
|
item = payload.get("item") if isinstance(payload, dict) else None
|
|
return {
|
|
"status": "ok",
|
|
"data": {
|
|
"title": "Snapshot historico de resumen por servidor",
|
|
"context": "historical-server-summary-snapshot",
|
|
"source": "historical-precomputed-snapshots",
|
|
"server_slug": server_slug,
|
|
"found": snapshot is not None and isinstance(item, dict),
|
|
**_build_historical_snapshot_metadata(snapshot),
|
|
"item": item if isinstance(item, dict) else None,
|
|
},
|
|
}
|
|
|
|
|
|
def build_weekly_leaderboard_snapshot_payload(
|
|
*,
|
|
limit: int = 10,
|
|
server_id: str | None = None,
|
|
metric: str = "kills",
|
|
) -> dict[str, object]:
|
|
"""Return one precomputed weekly leaderboard snapshot."""
|
|
snapshot = _get_historical_snapshot_record(
|
|
server_key=server_id,
|
|
snapshot_type=SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
|
|
metric=metric,
|
|
window=DEFAULT_WEEKLY_SNAPSHOT_WINDOW,
|
|
)
|
|
payload = snapshot.get("payload") if snapshot else {}
|
|
items = payload.get("items") if isinstance(payload, dict) else None
|
|
sliced_items = list(items[:limit]) if isinstance(items, list) else []
|
|
title_by_metric = {
|
|
"kills": "Snapshot semanal de top kills por servidor",
|
|
"deaths": "Snapshot semanal de top muertes por servidor",
|
|
"support": "Snapshot semanal de top soporte por servidor",
|
|
"matches_over_100_kills": "Snapshot semanal de partidas 100+ kills por servidor",
|
|
}
|
|
return {
|
|
"status": "ok",
|
|
"data": {
|
|
"title": title_by_metric.get(metric, "Snapshot semanal por servidor"),
|
|
"context": "historical-weekly-leaderboard-snapshot",
|
|
"source": "historical-precomputed-snapshots",
|
|
"server_slug": server_id,
|
|
"metric": metric,
|
|
"found": snapshot is not None,
|
|
**_build_historical_snapshot_metadata(snapshot),
|
|
"window_days": payload.get("window_days") if isinstance(payload, dict) else 7,
|
|
"window_start": payload.get("window_start") if isinstance(payload, dict) else None,
|
|
"window_end": payload.get("window_end") if isinstance(payload, dict) else None,
|
|
"snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None,
|
|
"limit": limit,
|
|
"items": sliced_items,
|
|
},
|
|
}
|
|
|
|
|
|
def build_recent_historical_matches_snapshot_payload(
|
|
*,
|
|
limit: int = 20,
|
|
server_slug: str | None = None,
|
|
) -> dict[str, object]:
|
|
"""Return one precomputed recent-matches snapshot."""
|
|
snapshot = _get_historical_snapshot_record(
|
|
server_key=server_slug,
|
|
snapshot_type=SNAPSHOT_TYPE_RECENT_MATCHES,
|
|
window=DEFAULT_SNAPSHOT_WINDOW,
|
|
)
|
|
payload = snapshot.get("payload") if snapshot else {}
|
|
items = payload.get("items") if isinstance(payload, dict) else None
|
|
sliced_items = list(items[:limit]) if isinstance(items, list) else []
|
|
return {
|
|
"status": "ok",
|
|
"data": {
|
|
"title": "Snapshot historico de partidas recientes por servidor",
|
|
"context": "historical-recent-matches-snapshot",
|
|
"source": "historical-precomputed-snapshots",
|
|
"server_slug": server_slug,
|
|
"found": snapshot is not None,
|
|
**_build_historical_snapshot_metadata(snapshot),
|
|
"snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None,
|
|
"limit": limit,
|
|
"items": sliced_items,
|
|
},
|
|
}
|
|
|
|
|
|
def build_historical_server_summary_payload(
|
|
*,
|
|
server_slug: str | None = None,
|
|
) -> dict[str, object]:
|
|
"""Return aggregated historical metrics per server."""
|
|
items = list_historical_server_summaries(server_slug=server_slug)
|
|
return {
|
|
"status": "ok",
|
|
"data": {
|
|
"title": "Cobertura historica importada por servidor",
|
|
"context": "historical-server-summary",
|
|
"source": "historical-crcon-storage",
|
|
"summary_basis": "persisted-import",
|
|
"weekly_ranking_window_days": 7,
|
|
"server_slug": server_slug,
|
|
"items": items,
|
|
},
|
|
}
|
|
|
|
|
|
def build_historical_player_profile_payload(player_id: str) -> dict[str, object]:
|
|
"""Return aggregate historical metrics for one player identity."""
|
|
profile = get_historical_player_profile(player_id)
|
|
return {
|
|
"status": "ok",
|
|
"data": {
|
|
"title": "Perfil historico de jugador",
|
|
"context": "historical-player-profile",
|
|
"source": "historical-crcon-storage",
|
|
"player_id": player_id,
|
|
"found": profile is not None,
|
|
"profile": profile,
|
|
},
|
|
}
|
|
|
|
|
|
def _get_historical_snapshot_record(
|
|
*,
|
|
server_key: str | None,
|
|
snapshot_type: str,
|
|
metric: str | None = None,
|
|
window: str | None = None,
|
|
) -> dict[str, object] | None:
|
|
if not server_key:
|
|
return None
|
|
return get_historical_snapshot(
|
|
server_key=server_key,
|
|
snapshot_type=snapshot_type,
|
|
metric=metric,
|
|
window=window,
|
|
)
|
|
|
|
|
|
def _build_historical_snapshot_metadata(snapshot: dict[str, object] | None) -> dict[str, object]:
|
|
if snapshot is None:
|
|
return {
|
|
"generated_at": None,
|
|
"source_range_start": None,
|
|
"source_range_end": None,
|
|
"is_stale": True,
|
|
"freshness": "stale",
|
|
}
|
|
is_stale = bool(snapshot.get("is_stale", False))
|
|
return {
|
|
"generated_at": snapshot.get("generated_at"),
|
|
"source_range_start": snapshot.get("source_range_start"),
|
|
"source_range_end": snapshot.get("source_range_end"),
|
|
"is_stale": is_stale,
|
|
"freshness": "stale" if is_stale else "fresh",
|
|
}
|
|
|
|
|
|
def _enrich_server_items(items: list[dict[str, object]]) -> list[dict[str, object]]:
|
|
target_index = {
|
|
target.external_server_id: target
|
|
for target in load_a2s_targets()
|
|
if target.external_server_id
|
|
}
|
|
enriched_items: list[dict[str, object]] = []
|
|
for item in items:
|
|
enriched_items.append(_enrich_server_item(item, target_index))
|
|
return enriched_items
|
|
|
|
|
|
def _select_primary_snapshot_items(items: list[dict[str, object]]) -> list[dict[str, object]]:
|
|
real_items = [
|
|
item
|
|
for item in items
|
|
if item.get("snapshot_origin") == "real-a2s"
|
|
]
|
|
return real_items or items
|
|
|
|
|
|
def _enrich_server_item(
|
|
item: dict[str, object],
|
|
target_index: dict[str, object],
|
|
) -> dict[str, object]:
|
|
enriched = dict(item)
|
|
enriched["current_map"] = normalize_map_name(enriched.get("current_map"))
|
|
external_server_id = enriched.get("external_server_id")
|
|
snapshot_origin = enriched.get("snapshot_origin")
|
|
target = target_index.get(external_server_id)
|
|
|
|
if not target or snapshot_origin != "real-a2s":
|
|
enriched["host"] = None
|
|
enriched["query_port"] = None
|
|
enriched["game_port"] = None
|
|
return enriched
|
|
|
|
enriched["host"] = target.host
|
|
enriched["query_port"] = target.query_port
|
|
enriched["game_port"] = target.game_port
|
|
return enriched
|
|
|
|
|
|
def _resolve_last_snapshot_at(items: list[dict[str, object]]) -> str | None:
|
|
timestamps = [
|
|
str(item["captured_at"])
|
|
for item in items
|
|
if item.get("captured_at")
|
|
]
|
|
if not timestamps:
|
|
return None
|
|
|
|
return max(timestamps)
|
|
|
|
|
|
def _should_refresh_snapshot(
|
|
items: list[dict[str, object]],
|
|
snapshot_age_seconds: int | None,
|
|
max_snapshot_age_seconds: int,
|
|
) -> bool:
|
|
if not items:
|
|
return True
|
|
|
|
if snapshot_age_seconds is None:
|
|
return True
|
|
|
|
return snapshot_age_seconds > max_snapshot_age_seconds
|
|
|
|
|
|
def _try_collect_real_time_snapshot() -> tuple[list[dict[str, object]], list[dict[str, object]]]:
|
|
payload = collect_server_snapshots(
|
|
source_mode="a2s",
|
|
allow_controlled_fallback=False,
|
|
persist=True,
|
|
)
|
|
snapshots = payload.get("snapshots")
|
|
items = _select_primary_snapshot_items(_enrich_server_items(list(snapshots or [])))
|
|
errors = payload.get("errors")
|
|
return items, list(errors or [])
|
|
|
|
|
|
def _build_servers_response(
|
|
*,
|
|
items: list[dict[str, object]],
|
|
response_source: str,
|
|
last_snapshot_at: str | None,
|
|
snapshot_age_seconds: int | None,
|
|
max_snapshot_age_seconds: int,
|
|
refresh_attempted: bool,
|
|
refresh_status: str,
|
|
refresh_errors: list[dict[str, object]],
|
|
) -> dict[str, object]:
|
|
freshness = (
|
|
"fresh"
|
|
if snapshot_age_seconds is not None and snapshot_age_seconds <= max_snapshot_age_seconds
|
|
else "stale"
|
|
)
|
|
return {
|
|
"status": "ok",
|
|
"data": {
|
|
"title": "Estado actual de servidores",
|
|
"context": "current-hll-status",
|
|
"source": response_source,
|
|
"last_snapshot_at": last_snapshot_at,
|
|
"snapshot_age_seconds": snapshot_age_seconds,
|
|
"snapshot_age_minutes": _to_snapshot_age_minutes(snapshot_age_seconds),
|
|
"max_snapshot_age_seconds": max_snapshot_age_seconds,
|
|
"is_stale": freshness == "stale",
|
|
"freshness": freshness,
|
|
"refresh_attempted": refresh_attempted,
|
|
"refresh_status": refresh_status,
|
|
"refresh_errors": refresh_errors,
|
|
"items": items,
|
|
},
|
|
}
|
|
|
|
|
|
def _calculate_snapshot_age_seconds(timestamp: str | None) -> int | None:
|
|
if not timestamp:
|
|
return None
|
|
|
|
normalized = timestamp.replace("Z", "+00:00")
|
|
captured_at = datetime.fromisoformat(normalized)
|
|
if captured_at.tzinfo is None:
|
|
captured_at = captured_at.replace(tzinfo=timezone.utc)
|
|
|
|
age = datetime.now(timezone.utc) - captured_at.astimezone(timezone.utc)
|
|
return max(0, int(age.total_seconds()))
|
|
|
|
|
|
def _to_snapshot_age_minutes(snapshot_age_seconds: int | None) -> int | None:
|
|
if snapshot_age_seconds is None:
|
|
return None
|
|
|
|
return snapshot_age_seconds // 60
|