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