Implement real player active time intervals and historical KPM
This commit is contained in:
@@ -47,6 +47,7 @@ DEFAULT_RCON_HISTORICAL_CAPTURE_MAX_RETRIES = 2
|
||||
DEFAULT_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS = 15
|
||||
DEFAULT_RCON_CURRENT_MATCH_CAPTURE_INTERVAL_SECONDS = 5
|
||||
DEFAULT_RCON_CURRENT_MATCH_WRITER_LOCK_TIMEOUT_SECONDS = 4.0
|
||||
DEFAULT_KPM_MIN_ACTIVE_SECONDS = 60
|
||||
DEFAULT_RCON_BACKFILL_CHUNK_HOURS = 6
|
||||
DEFAULT_RCON_BACKFILL_SLEEP_SECONDS = 1.0
|
||||
DEFAULT_RCON_BACKFILL_MAX_DAYS_BACK = 45
|
||||
@@ -677,6 +678,15 @@ def get_rcon_current_match_writer_lock_timeout_seconds() -> float:
|
||||
)
|
||||
|
||||
|
||||
def get_kpm_min_active_seconds() -> int:
|
||||
"""Return the minimum observed active seconds required before KPM is considered valid."""
|
||||
return _read_int_env(
|
||||
"HLL_KPM_MIN_ACTIVE_SECONDS",
|
||||
str(DEFAULT_KPM_MIN_ACTIVE_SECONDS),
|
||||
minimum=1,
|
||||
)
|
||||
|
||||
|
||||
def get_rcon_backfill_chunk_hours() -> int:
|
||||
"""Return the AdminLog backfill chunk size in hours."""
|
||||
return _read_int_env(
|
||||
|
||||
@@ -183,6 +183,8 @@ CREATE TABLE IF NOT EXISTS rcon_match_player_stats (
|
||||
death_by_json TEXT NOT NULL DEFAULT '{}',
|
||||
first_seen_server_time BIGINT,
|
||||
last_seen_server_time BIGINT,
|
||||
player_active_seconds INTEGER,
|
||||
active_time_source TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(target_key, match_key, player_id)
|
||||
@@ -415,6 +417,14 @@ BEGIN
|
||||
END $$;
|
||||
"""
|
||||
|
||||
POSTGRES_RCON_MATCH_PLAYER_STATS_ACTIVE_TIME_MIGRATION_SQL = """
|
||||
ALTER TABLE rcon_match_player_stats
|
||||
ADD COLUMN IF NOT EXISTS player_active_seconds INTEGER;
|
||||
|
||||
ALTER TABLE rcon_match_player_stats
|
||||
ADD COLUMN IF NOT EXISTS active_time_source TEXT;
|
||||
"""
|
||||
|
||||
|
||||
def initialize_postgres_rcon_storage() -> None:
|
||||
"""Create deterministic PostgreSQL schema for migrated RCON domains."""
|
||||
@@ -422,6 +432,7 @@ def initialize_postgres_rcon_storage() -> None:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(RCON_SCHEMA_SQL)
|
||||
cursor.execute(POSTGRES_ANNUAL_RANKING_SCHEMA_MIGRATION_SQL)
|
||||
cursor.execute(POSTGRES_RCON_MATCH_PLAYER_STATS_ACTIVE_TIME_MIGRATION_SQL)
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
||||
@@ -96,6 +96,8 @@ def initialize_rcon_materialized_storage(*, db_path: Path | None = None) -> Path
|
||||
death_by_json TEXT NOT NULL DEFAULT '{}',
|
||||
first_seen_server_time INTEGER,
|
||||
last_seen_server_time INTEGER,
|
||||
player_active_seconds INTEGER,
|
||||
active_time_source TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(target_key, match_key, player_id)
|
||||
@@ -153,6 +155,7 @@ def initialize_rcon_materialized_storage(*, db_path: Path | None = None) -> Path
|
||||
ON rcon_annual_ranking_snapshot_items(snapshot_id, player_id);
|
||||
"""
|
||||
)
|
||||
_ensure_materialized_player_stat_columns(connection)
|
||||
return resolved_path
|
||||
|
||||
|
||||
@@ -752,6 +755,12 @@ def _derive_player_stats_for_match(
|
||||
|
||||
stats = []
|
||||
for player in players.values():
|
||||
active_time = _build_player_active_time_payload(
|
||||
connection,
|
||||
match=match,
|
||||
player=player,
|
||||
match_rows=rows,
|
||||
)
|
||||
stats.append(
|
||||
{
|
||||
"target_key": match["target_key"],
|
||||
@@ -769,6 +778,8 @@ def _derive_player_stats_for_match(
|
||||
"death_by_json": _dump_counter(player["death_by"]),
|
||||
"first_seen_server_time": player.get("first_seen_server_time"),
|
||||
"last_seen_server_time": player.get("last_seen_server_time"),
|
||||
"player_active_seconds": active_time["player_active_seconds"],
|
||||
"active_time_source": active_time["active_time_source"],
|
||||
}
|
||||
)
|
||||
return stats
|
||||
@@ -781,8 +792,9 @@ def _insert_player_stat(connection: sqlite3.Connection, stat: dict[str, object])
|
||||
target_key, match_key, player_id, player_name, team,
|
||||
kills, deaths, teamkills, deaths_by_teamkill,
|
||||
weapons_json, death_by_weapons_json, most_killed_json, death_by_json,
|
||||
first_seen_server_time, last_seen_server_time
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
first_seen_server_time, last_seen_server_time,
|
||||
player_active_seconds, active_time_source
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
stat["target_key"],
|
||||
@@ -800,6 +812,8 @@ def _insert_player_stat(connection: sqlite3.Connection, stat: dict[str, object])
|
||||
stat["death_by_json"],
|
||||
stat.get("first_seen_server_time"),
|
||||
stat.get("last_seen_server_time"),
|
||||
stat.get("player_active_seconds"),
|
||||
stat.get("active_time_source"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -846,6 +860,171 @@ def _touch_player(player: dict[str, object], server_time: int | None) -> None:
|
||||
player["last_seen_server_time"] = server_time if last_seen is None else max(last_seen, server_time)
|
||||
|
||||
|
||||
def _calculate_event_span_seconds(
|
||||
*,
|
||||
first_seen_server_time: object,
|
||||
last_seen_server_time: object,
|
||||
) -> int | None:
|
||||
first_seen = _coerce_int(first_seen_server_time)
|
||||
last_seen = _coerce_int(last_seen_server_time)
|
||||
if first_seen is None or last_seen is None:
|
||||
return None
|
||||
return max(0, last_seen - first_seen)
|
||||
|
||||
|
||||
def _build_player_active_time_payload(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
match: dict[str, object],
|
||||
player: dict[str, object],
|
||||
match_rows: list[sqlite3.Row],
|
||||
) -> dict[str, object]:
|
||||
lower = _coerce_int(match.get("started_server_time"))
|
||||
upper = _coerce_int(match.get("ended_server_time"))
|
||||
fallback_seconds = _calculate_event_span_seconds(
|
||||
first_seen_server_time=player.get("first_seen_server_time"),
|
||||
last_seen_server_time=player.get("last_seen_server_time"),
|
||||
)
|
||||
player_id = str(player.get("player_id") or "").strip()
|
||||
|
||||
if lower is None or upper is None or upper < lower:
|
||||
return {
|
||||
"player_active_seconds": fallback_seconds,
|
||||
"active_time_source": "event_span_fallback" if fallback_seconds is not None else "unavailable",
|
||||
}
|
||||
|
||||
if not player_id or player_id.startswith("name:"):
|
||||
return {
|
||||
"player_active_seconds": fallback_seconds,
|
||||
"active_time_source": "event_span_fallback" if fallback_seconds is not None else "unavailable",
|
||||
}
|
||||
|
||||
interval_events = _collect_player_connection_events_from_match_rows(
|
||||
match_rows,
|
||||
player_id=player_id,
|
||||
)
|
||||
prior_connected = _player_was_connected_at_match_start(
|
||||
connection,
|
||||
target_key=str(match["target_key"]),
|
||||
player_id=player_id,
|
||||
match_start_server_time=lower,
|
||||
)
|
||||
interval_seconds, interval_source = _calculate_connection_interval_active_seconds(
|
||||
match_start_server_time=lower,
|
||||
match_end_server_time=upper,
|
||||
prior_connected=prior_connected,
|
||||
interval_events=interval_events,
|
||||
)
|
||||
if interval_source is not None:
|
||||
return {
|
||||
"player_active_seconds": interval_seconds,
|
||||
"active_time_source": interval_source,
|
||||
}
|
||||
|
||||
return {
|
||||
"player_active_seconds": fallback_seconds,
|
||||
"active_time_source": "event_span_fallback" if fallback_seconds is not None else "unavailable",
|
||||
}
|
||||
|
||||
|
||||
def _collect_player_connection_events_from_match_rows(
|
||||
rows: list[sqlite3.Row],
|
||||
*,
|
||||
player_id: str,
|
||||
) -> list[tuple[str, int]]:
|
||||
events: list[tuple[str, int]] = []
|
||||
for row in rows:
|
||||
event_type = str(row["event_type"] or "")
|
||||
if event_type not in {"connected", "disconnected"}:
|
||||
continue
|
||||
payload = _json_object(row["parsed_payload_json"])
|
||||
event_player_id = str(payload.get("player_id") or "").strip()
|
||||
server_time = _coerce_int(row["server_time"])
|
||||
if event_player_id == player_id and server_time is not None:
|
||||
events.append((event_type, server_time))
|
||||
events.sort(key=lambda item: item[1])
|
||||
return events
|
||||
|
||||
|
||||
def _player_was_connected_at_match_start(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
target_key: str,
|
||||
player_id: str,
|
||||
match_start_server_time: int,
|
||||
) -> bool:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT event_type
|
||||
FROM rcon_admin_log_events
|
||||
WHERE target_key = ?
|
||||
AND server_time IS NOT NULL
|
||||
AND server_time < ?
|
||||
AND event_type IN ('connected', 'disconnected')
|
||||
AND parsed_payload_json LIKE ?
|
||||
ORDER BY server_time DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(
|
||||
target_key,
|
||||
match_start_server_time,
|
||||
f'%"player_id":"{player_id}"%',
|
||||
),
|
||||
).fetchone()
|
||||
return bool(row and row["event_type"] == "connected")
|
||||
|
||||
|
||||
def _calculate_connection_interval_active_seconds(
|
||||
*,
|
||||
match_start_server_time: int,
|
||||
match_end_server_time: int,
|
||||
prior_connected: bool,
|
||||
interval_events: list[tuple[str, int]],
|
||||
) -> tuple[int | None, str | None]:
|
||||
open_since = match_start_server_time if prior_connected else None
|
||||
total_seconds = 0
|
||||
used_carryover = prior_connected
|
||||
has_reliable_intervals = prior_connected
|
||||
|
||||
for event_type, server_time in interval_events:
|
||||
clamped_time = max(match_start_server_time, min(match_end_server_time, server_time))
|
||||
if event_type == "connected":
|
||||
if open_since is None:
|
||||
open_since = clamped_time
|
||||
has_reliable_intervals = True
|
||||
continue
|
||||
if open_since is None:
|
||||
continue
|
||||
total_seconds += max(0, clamped_time - open_since)
|
||||
open_since = None
|
||||
has_reliable_intervals = True
|
||||
|
||||
if open_since is not None:
|
||||
total_seconds += max(0, match_end_server_time - open_since)
|
||||
|
||||
if not has_reliable_intervals:
|
||||
return None, None
|
||||
return (
|
||||
total_seconds,
|
||||
"connection_intervals_carryover" if used_carryover else "connection_intervals",
|
||||
)
|
||||
|
||||
|
||||
def _ensure_materialized_player_stat_columns(connection: sqlite3.Connection) -> None:
|
||||
columns = {
|
||||
row["name"]
|
||||
for row in connection.execute("PRAGMA table_info(rcon_match_player_stats)").fetchall()
|
||||
}
|
||||
if "player_active_seconds" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE rcon_match_player_stats ADD COLUMN player_active_seconds INTEGER"
|
||||
)
|
||||
if "active_time_source" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE rcon_match_player_stats ADD COLUMN active_time_source TEXT"
|
||||
)
|
||||
|
||||
|
||||
def _counter(player: dict[str, object], key: str) -> Counter[str]:
|
||||
value = player[key]
|
||||
if isinstance(value, Counter):
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .config import get_kpm_min_active_seconds
|
||||
from .historical_storage import ALL_SERVERS_SLUG
|
||||
from .normalizers import normalize_map_name
|
||||
from .player_external_profiles import build_external_player_profile_fields
|
||||
@@ -293,6 +294,12 @@ def _build_player_row(
|
||||
) -> dict[str, object]:
|
||||
kills = _coerce_optional_int(row.get("kills")) or 0
|
||||
deaths = _coerce_optional_int(row.get("deaths")) or 0
|
||||
player_active_seconds = _coerce_optional_int(row.get("player_active_seconds"))
|
||||
active_time_payload = _build_player_active_time_payload(
|
||||
kills=kills,
|
||||
player_active_seconds=player_active_seconds,
|
||||
active_time_source=row.get("active_time_source"),
|
||||
)
|
||||
return {
|
||||
"player_name": row.get("player_name"),
|
||||
"team": row.get("team"),
|
||||
@@ -303,10 +310,60 @@ def _build_player_row(
|
||||
"top_weapons": _top_counter(row.get("weapons_json")),
|
||||
"most_killed": _top_counter(row.get("most_killed_json")),
|
||||
"death_by": _top_counter(row.get("death_by_json")),
|
||||
**active_time_payload,
|
||||
**build_external_player_profile_fields(player_id=row.get("player_id")),
|
||||
}
|
||||
|
||||
|
||||
def _build_player_active_time_payload(
|
||||
*,
|
||||
kills: int,
|
||||
player_active_seconds: int | None,
|
||||
active_time_source: object,
|
||||
) -> dict[str, object]:
|
||||
min_active_seconds = get_kpm_min_active_seconds()
|
||||
resolved_source = str(active_time_source or "").strip() or None
|
||||
is_real_connection_time = resolved_source in {
|
||||
"connection_intervals",
|
||||
"connection_intervals_carryover",
|
||||
}
|
||||
if player_active_seconds is None:
|
||||
return {
|
||||
"player_active_seconds": None,
|
||||
"player_active_minutes": None,
|
||||
"kpm": None,
|
||||
"kpm_status": "missing_active_time",
|
||||
"active_time_source": resolved_source,
|
||||
}
|
||||
|
||||
player_active_minutes = round(player_active_seconds / 60, 3)
|
||||
if not is_real_connection_time:
|
||||
return {
|
||||
"player_active_seconds": player_active_seconds,
|
||||
"player_active_minutes": player_active_minutes,
|
||||
"kpm": None,
|
||||
"kpm_status": "missing_connection_intervals",
|
||||
"active_time_source": resolved_source or "event_span_fallback",
|
||||
}
|
||||
if player_active_seconds < min_active_seconds:
|
||||
return {
|
||||
"player_active_seconds": player_active_seconds,
|
||||
"player_active_minutes": player_active_minutes,
|
||||
"kpm": None,
|
||||
"kpm_status": "insufficient_active_time",
|
||||
"active_time_source": resolved_source or "event_log",
|
||||
}
|
||||
|
||||
kpm = round(kills / (player_active_seconds / 60), 2)
|
||||
return {
|
||||
"player_active_seconds": player_active_seconds,
|
||||
"player_active_minutes": player_active_minutes,
|
||||
"kpm": kpm,
|
||||
"kpm_status": "ready",
|
||||
"active_time_source": resolved_source or "event_log",
|
||||
}
|
||||
|
||||
|
||||
def _top_counter(raw_value: object, *, limit: int = 5) -> list[dict[str, object]]:
|
||||
if not isinstance(raw_value, str) or not raw_value.strip():
|
||||
return []
|
||||
|
||||
Reference in New Issue
Block a user