416 lines
14 KiB
Python
416 lines
14 KiB
Python
"""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
|