This commit is contained in:
devRaGonSa
2026-06-05 16:57:25 +02:00
commit 0da8338ba8
310 changed files with 45849 additions and 0 deletions

View File

@@ -0,0 +1,415 @@
"""Player event adapter backed by public CRCON scoreboard match details."""
from __future__ import annotations
import hashlib
from collections.abc import Mapping
from dataclasses import dataclass
from ..player_event_models import PlayerEventRecord
@dataclass(frozen=True, slots=True)
class _PlayerIdentity:
stable_player_key: str
display_name: str | None
@dataclass(frozen=True, slots=True)
class PublicScoreboardPlayerEventSource:
"""Normalize partial duel and weapon signals from CRCON match detail payloads."""
source_kind: str = "public-scoreboard-match-summary"
def extract_match_events(
self,
*,
server_slug: str,
match_payload: dict[str, object],
source_ref: str | None = None,
) -> list[PlayerEventRecord]:
match_id = _stringify(match_payload.get("id"))
if not match_id:
return []
occurred_at = _pick_match_timestamp(match_payload)
player_rows = _coerce_player_rows(match_payload.get("player_stats"))
if not player_rows:
return []
identity_index = _build_identity_index(player_rows)
events: list[PlayerEventRecord] = []
for player_row in player_rows:
actor = _build_player_identity(player_row)
if actor is None:
continue
top_kill_type_name = _extract_primary_name(player_row.get("kills_by_type"))
for victim_name, victim_count in _extract_named_counts(player_row.get("most_killed")):
victim = _find_identity_by_name(identity_index, victim_name)
if victim is None or victim_count <= 0:
continue
events.append(
_build_event(
event_type="player_kill_summary",
occurred_at=occurred_at,
server_slug=server_slug,
match_id=match_id,
source_kind=self.source_kind,
source_ref=source_ref,
raw_event_ref=(
f"match:{match_id}:player:{actor.stable_player_key}:most-killed:{victim.stable_player_key}"
),
killer=actor,
victim=victim,
weapon_name=None,
kill_category=top_kill_type_name,
is_teamkill=False,
event_value=victim_count,
)
)
for killer_name, killer_count in _extract_named_counts(player_row.get("death_by")):
killer = _find_identity_by_name(identity_index, killer_name)
if killer is None or killer_count <= 0:
continue
events.append(
_build_event(
event_type="player_death_summary",
occurred_at=occurred_at,
server_slug=server_slug,
match_id=match_id,
source_kind=self.source_kind,
source_ref=source_ref,
raw_event_ref=(
f"match:{match_id}:player:{actor.stable_player_key}:death-by:{killer.stable_player_key}"
),
killer=killer,
victim=actor,
weapon_name=None,
kill_category=None,
is_teamkill=False,
event_value=killer_count,
)
)
for weapon_name, weapon_count in _extract_named_counts(player_row.get("weapons")):
events.append(
_build_event(
event_type="player_weapon_kill_summary",
occurred_at=occurred_at,
server_slug=server_slug,
match_id=match_id,
source_kind=self.source_kind,
source_ref=source_ref,
raw_event_ref=(
f"match:{match_id}:player:{actor.stable_player_key}:weapons:{weapon_name}"
),
killer=actor,
victim=None,
weapon_name=weapon_name,
kill_category=top_kill_type_name,
is_teamkill=False,
event_value=weapon_count,
)
)
for weapon_name, weapon_count in _extract_named_counts(player_row.get("death_by_weapons")):
events.append(
_build_event(
event_type="player_weapon_death_summary",
occurred_at=occurred_at,
server_slug=server_slug,
match_id=match_id,
source_kind=self.source_kind,
source_ref=source_ref,
raw_event_ref=(
f"match:{match_id}:player:{actor.stable_player_key}:death-by-weapons:{weapon_name}"
),
killer=None,
victim=actor,
weapon_name=weapon_name,
kill_category=None,
is_teamkill=False,
event_value=weapon_count,
)
)
teamkills = _coerce_int(player_row.get("teamkills")) or 0
if teamkills > 0:
events.append(
_build_event(
event_type="player_teamkill_summary",
occurred_at=occurred_at,
server_slug=server_slug,
match_id=match_id,
source_kind=self.source_kind,
source_ref=source_ref,
raw_event_ref=f"match:{match_id}:player:{actor.stable_player_key}:teamkills",
killer=actor,
victim=None,
weapon_name=None,
kill_category=top_kill_type_name,
is_teamkill=True,
event_value=teamkills,
)
)
return events
def describe_scope(self) -> dict[str, object]:
return {
"source_kind": self.source_kind,
"supports_raw_kill_events": False,
"captures": [
"Encounter summaries per player from most_killed",
"Death summaries per player from death_by",
"Weapon kill summaries per player from weapons",
"Weapon death summaries per player from death_by_weapons",
"Aggregated teamkills per player and match",
],
"limitations": [
"The current source is match-summary data, not a true per-kill event feed.",
"occurred_at uses the match end/start timestamp, not the exact kill timestamp.",
"Only summary counters exposed by the CRCON detail payload are normalized.",
"Full killer->victim ledgers, complete weapon breakdowns, and exact per-event teamkills still require a dedicated raw event/log source.",
],
}
def _build_identity_index(player_rows: list[dict[str, object]]) -> dict[str, _PlayerIdentity]:
identity_index: dict[str, _PlayerIdentity] = {}
for player_row in player_rows:
identity = _build_player_identity(player_row)
if identity is None or not identity.display_name:
continue
identity_index[_normalize_name(identity.display_name)] = identity
return identity_index
def _build_player_identity(player_row: dict[str, object]) -> _PlayerIdentity | None:
display_name = _stringify(player_row.get("player")) or _stringify(player_row.get("name"))
source_player_id = _stringify(player_row.get("player_id")) or _stringify(player_row.get("id"))
steam_id = _extract_steam_id(player_row.get("steaminfo"))
stable_player_key = _build_stable_player_key(steam_id=steam_id, source_player_id=source_player_id)
if stable_player_key is None:
return None
return _PlayerIdentity(
stable_player_key=stable_player_key,
display_name=display_name or stable_player_key,
)
def _find_identity_by_name(
identity_index: dict[str, _PlayerIdentity],
player_name: str | None,
) -> _PlayerIdentity | None:
if not player_name:
return None
return identity_index.get(_normalize_name(player_name))
def _build_event(
*,
event_type: str,
occurred_at: str | None,
server_slug: str,
match_id: str,
source_kind: str,
source_ref: str | None,
raw_event_ref: str,
killer: _PlayerIdentity | None,
victim: _PlayerIdentity | None,
weapon_name: str | None,
kill_category: str | None,
is_teamkill: bool,
event_value: int,
) -> PlayerEventRecord:
event_id = _build_event_id(
event_type=event_type,
occurred_at=occurred_at,
server_slug=server_slug,
match_id=match_id,
killer_player_key=killer.stable_player_key if killer else None,
victim_player_key=victim.stable_player_key if victim else None,
weapon_name=weapon_name,
is_teamkill=is_teamkill,
event_value=event_value,
)
return PlayerEventRecord(
event_id=event_id,
event_type=event_type,
occurred_at=occurred_at,
server_slug=server_slug,
external_match_id=match_id,
source_kind=source_kind,
source_ref=source_ref,
raw_event_ref=raw_event_ref,
killer_player_key=killer.stable_player_key if killer else None,
killer_display_name=killer.display_name if killer else None,
victim_player_key=victim.stable_player_key if victim else None,
victim_display_name=victim.display_name if victim else None,
weapon_name=weapon_name,
weapon_category=None,
kill_category=kill_category,
is_teamkill=is_teamkill,
event_value=max(1, event_value),
)
def _build_event_id(
*,
event_type: str,
occurred_at: str | None,
server_slug: str,
match_id: str,
killer_player_key: str | None,
victim_player_key: str | None,
weapon_name: str | None,
is_teamkill: bool,
event_value: int,
) -> str:
raw_key = "|".join(
[
event_type,
occurred_at or "",
server_slug,
match_id,
killer_player_key or "",
victim_player_key or "",
weapon_name or "",
"1" if is_teamkill else "0",
str(event_value),
]
)
return hashlib.sha1(raw_key.encode("utf-8")).hexdigest()
def _pick_match_timestamp(match_payload: Mapping[str, object]) -> str | None:
for key in ("end", "start", "creation_time"):
value = _stringify(match_payload.get(key))
if value:
return value
return None
def _extract_primary_name(value: object) -> str | None:
named_counts = _extract_named_counts(value)
if not named_counts:
return None
return named_counts[0][0]
def _extract_named_counts(value: object) -> list[tuple[str, int]]:
aggregated: dict[str, tuple[str, int]] = {}
for name, count in _iter_named_counts(value):
normalized_name = _normalize_name(name)
existing = aggregated.get(normalized_name)
if existing is None:
aggregated[normalized_name] = (name, count)
continue
aggregated[normalized_name] = (existing[0], existing[1] + count)
return sorted(
aggregated.values(),
key=lambda item: (-item[1], item[0].casefold()),
)
def _iter_named_counts(value: object) -> list[tuple[str, int]]:
if isinstance(value, str):
name = _stringify(value)
return [(name, 1)] if name else []
if isinstance(value, Mapping):
named_count = _extract_named_count_mapping(value)
if named_count is not None:
return [named_count]
items: list[tuple[str, int]] = []
for raw_name, raw_count in value.items():
name = _stringify(raw_name)
count = _coerce_int(raw_count)
if name and count and count > 0:
items.append((name, count))
return items
if isinstance(value, list):
items: list[tuple[str, int]] = []
for item in value:
items.extend(_iter_named_counts(item))
return items
return []
def _extract_named_count_mapping(value: Mapping[str, object]) -> tuple[str, int] | None:
nested_name = None
nested_player = value.get("player")
if isinstance(nested_player, Mapping):
nested_name = _stringify(nested_player.get("name")) or _stringify(nested_player.get("player"))
name = (
_stringify(value.get("name"))
or _stringify(value.get("player"))
or _stringify(value.get("victim"))
or _stringify(value.get("killer"))
or nested_name
)
if not name:
return None
count = (
_coerce_int(value.get("count"))
or _coerce_int(value.get("kills"))
or _coerce_int(value.get("deaths"))
or _coerce_int(value.get("value"))
or _coerce_int(value.get("total"))
or 1
)
return name, max(1, count)
def _extract_steam_id(value: object) -> str | None:
if isinstance(value, Mapping):
profile = value.get("profile")
if isinstance(profile, Mapping):
steam_id = _stringify(profile.get("steamid"))
if steam_id:
return steam_id
return _stringify(value.get("id"))
return None
def _build_stable_player_key(
*,
steam_id: str | None,
source_player_id: str | None,
) -> str | None:
if steam_id:
return f"steam:{steam_id}"
if source_player_id:
return f"crcon-player:{source_player_id}"
return None
def _coerce_player_rows(value: object) -> list[dict[str, object]]:
if not isinstance(value, list):
return []
return [item for item in value if isinstance(item, dict)]
def _normalize_name(value: str) -> str:
return value.strip().casefold()
def _stringify(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _coerce_int(value: object) -> int | None:
if value in (None, ""):
return None
try:
return int(value)
except (TypeError, ValueError):
return None

View File

@@ -0,0 +1,139 @@
"""Public scoreboard provider adapter for historical HLL data."""
from __future__ import annotations
import json
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from ..config import (
get_historical_crcon_request_retries,
get_historical_crcon_request_timeout_seconds,
get_historical_crcon_retry_delay_seconds,
)
PUBLIC_INFO_ENDPOINT = "/api/get_public_info"
MATCH_LIST_ENDPOINT = "/api/get_scoreboard_maps"
MATCH_DETAIL_ENDPOINT = "/api/get_map_scoreboard"
@dataclass(frozen=True, slots=True)
class PublicScoreboardHistoricalDataSource:
"""Historical provider backed by the public CRCON scoreboard JSON API."""
source_kind: str = "public-scoreboard"
def fetch_public_info(self, *, base_url: str) -> dict[str, object]:
return self._fetch_dict_payload(base_url, PUBLIC_INFO_ENDPOINT)
def fetch_match_page(self, *, base_url: str, page: int, limit: int) -> dict[str, object]:
return self._fetch_dict_payload(
base_url,
MATCH_LIST_ENDPOINT,
{"page": page, "limit": limit},
context=f"page={page}",
)
def fetch_match_details(
self,
*,
base_url: str,
match_ids: list[str],
max_workers: int,
) -> list[dict[str, object]]:
if not match_ids:
return []
if max_workers <= 1:
return [
self._fetch_match_detail(base_url=base_url, match_id=match_id)
for match_id in match_ids
]
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [
executor.submit(self._fetch_match_detail, base_url=base_url, match_id=match_id)
for match_id in match_ids
]
return [future.result() for future in futures]
def _fetch_match_detail(self, *, base_url: str, match_id: str) -> dict[str, object]:
return self._fetch_dict_payload(
base_url,
MATCH_DETAIL_ENDPOINT,
{"map_id": match_id},
context=f"match={match_id}",
)
def _fetch_json(
self,
*,
base_url: str,
endpoint: str,
query: dict[str, object] | None = None,
) -> object:
url = f"{base_url}{endpoint}"
if query:
url = f"{url}?{urlencode(query)}"
request = Request(
url,
headers={
"Accept": "application/json",
"User-Agent": "HLL-Vietnam-Historical-Ingestion/0.1",
},
)
try:
with urlopen(
request,
timeout=get_historical_crcon_request_timeout_seconds(),
) as response:
return json.loads(response.read().decode("utf-8"))
except HTTPError as exc:
raise RuntimeError(f"Historical provider request failed: {url} ({exc.code})") from exc
except URLError as exc:
raise RuntimeError(f"Historical provider request failed: {url} ({exc.reason})") from exc
def _fetch_dict_payload(
self,
base_url: str,
endpoint: str,
query: dict[str, object] | None = None,
*,
context: str = "",
retries: int | None = None,
) -> dict[str, object]:
resolved_retries = retries or get_historical_crcon_request_retries()
base_retry_delay_seconds = get_historical_crcon_retry_delay_seconds()
last_error: Exception | None = None
for attempt in range(1, resolved_retries + 1):
try:
payload = _unwrap_result(
self._fetch_json(base_url=base_url, endpoint=endpoint, query=query)
)
except Exception as exc: # pragma: no cover - network path
last_error = exc
else:
if isinstance(payload, dict):
return payload
last_error = ValueError(
f"Unexpected payload type for {base_url}{endpoint} {context}".strip()
)
if attempt < resolved_retries:
time.sleep(base_retry_delay_seconds * attempt)
assert last_error is not None
raise last_error
def _unwrap_result(payload: object) -> object:
if not isinstance(payload, dict):
return payload
if "result" not in payload:
return payload
return payload.get("result")

View File

@@ -0,0 +1,67 @@
"""RCON provider adapter for live HLL server state."""
from __future__ import annotations
from dataclasses import dataclass
from ..rcon_client import (
RconServerTarget,
load_rcon_targets,
query_live_server_sample,
)
from ..snapshots import build_snapshot_batch, utc_now
from ..storage import persist_snapshot_batch
@dataclass(frozen=True, slots=True)
class RconLiveDataSource:
"""Live provider backed by direct HLL RCON access."""
source_kind: str = "rcon"
def collect_snapshots(self, *, persist: bool) -> dict[str, object]:
configured_targets = load_rcon_targets()
if not configured_targets:
raise RuntimeError("No RCON targets configured in HLL_BACKEND_RCON_TARGETS.")
captured_at = utc_now()
normalized_records: list[dict[str, object]] = []
errors: list[dict[str, object]] = []
for target in configured_targets:
try:
normalized_records.append(query_live_server_sample(target)["normalized"])
except Exception as error: # noqa: BLE001 - keep provider failures controlled
errors.append(
{
"target": target.name,
"host": target.host,
"port": target.port,
"message": str(error),
}
)
payload = {
"source_name": "hll-rcon",
"collection_mode": "rcon",
"fallback_used": False,
"target_count": len(configured_targets),
"success_count": len(normalized_records),
"errors": errors,
"captured_at": captured_at.isoformat().replace("+00:00", "Z"),
"snapshots": build_snapshot_batch(normalized_records, captured_at=captured_at),
}
if persist:
payload["storage"] = persist_snapshot_batch(
payload["snapshots"],
source_name=payload["source_name"],
captured_at=payload["captured_at"],
)
return payload
def build_target_index(self) -> dict[str | None, RconServerTarget]:
return {
target.external_server_id: target
for target in load_rcon_targets()
if target.external_server_id
}