Add RCON player profile snapshots

This commit is contained in:
devRaGonSa
2026-05-19 15:32:35 +02:00
parent e98a60be31
commit 419d686671
5 changed files with 488 additions and 1 deletions

View File

@@ -1,7 +1,7 @@
---
id: TASK-129
title: Parse and materialize player profile MESSAGE blocks
status: pending
status: done
type: backend
team: Backend Senior
supporting_teams:
@@ -74,3 +74,12 @@ Observed AdminLog `MESSAGE` blocks can include profile-like stats such as first
- Stage only intended files.
- Commit the completed implementation.
- Push the branch to origin.
## Outcome
- Added profile MESSAGE parsing for anonymized long-term player snapshot fields.
- Added `rcon_player_profile_snapshots` storage with idempotent upsert by target, player id and source server time.
- Verified non-profile MESSAGE entries are ignored.
- Validation: `python -m compileall backend/app` passed.
- Validation blocked: `python -m pytest backend/tests/test_rcon_admin_log_parser.py backend/tests/test_rcon_admin_log_storage.py` could not run because `pytest` is not installed in this environment.
- Supplemental check: direct Python execution of the new parser/storage checks passed.

View File

@@ -107,6 +107,29 @@ class ParsedRconAdminLogEvent:
reason: str | None = None
@dataclass(frozen=True, slots=True)
class ParsedRconPlayerProfileSnapshot:
player_name: str
player_id: str
source_server_time: int | None
event_timestamp: object
first_seen: str | None
sessions: int | None
matches_played: int | None
play_time: str | None
total_kills: int | None
total_deaths: int | None
teamkills_done: int | None
teamkills_received: int | None
kd_ratio: float | None
favorite_weapons: dict[str, int]
victims: dict[str, int]
nemesis: dict[str, int]
averages: dict[str, object]
sanctions: dict[str, object]
raw_content: str
def parse_rcon_admin_log_message(message: str) -> ParsedRconAdminLogEvent:
raw_message = str(message or "")
prefix_match = _PREFIX_RE.match(raw_message)
@@ -224,6 +247,66 @@ def parse_rcon_admin_log_entry(entry: dict[str, object]) -> dict[str, object]:
return payload
def parse_rcon_player_profile_snapshot(
parsed_event: ParsedRconAdminLogEvent | dict[str, object],
*,
event_timestamp: object = None,
) -> ParsedRconPlayerProfileSnapshot | None:
"""Extract long-term player profile data from bot-generated MESSAGE content."""
if isinstance(parsed_event, ParsedRconAdminLogEvent):
event_type = parsed_event.event_type
player_name = parsed_event.player_name
player_id = parsed_event.player_id
server_time = parsed_event.server_time
content = parsed_event.content
else:
event_type = parsed_event.get("event_type")
player_name = parsed_event.get("player_name")
player_id = parsed_event.get("player_id")
server_time = parsed_event.get("server_time")
content = parsed_event.get("content")
event_timestamp = event_timestamp if event_timestamp is not None else parsed_event.get("timestamp")
source_server_time = _coerce_int(server_time)
if event_type != "message" or not player_name or not player_id or not content:
return None
if source_server_time is None:
return None
raw_content = str(content)
lines = [_clean_profile_line(line) for line in raw_content.splitlines()]
lines = [line for line in lines if line]
if not _looks_like_profile_message(lines):
return None
sections = _profile_sections(lines)
flat_values = _profile_key_values(lines)
total_kills, teamkills_done = _parse_total_with_teamkills(flat_values, "bajas")
total_deaths, teamkills_received = _parse_total_with_teamkills(flat_values, "muertes")
return ParsedRconPlayerProfileSnapshot(
player_name=str(player_name),
player_id=str(player_id),
source_server_time=source_server_time,
event_timestamp=event_timestamp,
first_seen=_first_value(flat_values, "first seen", "visto por primera vez", "primer visto"),
sessions=_first_int(flat_values, "sessions", "sesiones"),
matches_played=_first_int(flat_values, "matches played", "partidas jugadas", "partidas"),
play_time=_first_value(flat_values, "play time", "tiempo jugado", "tiempo de juego"),
total_kills=total_kills,
total_deaths=total_deaths,
teamkills_done=teamkills_done,
teamkills_received=teamkills_received,
kd_ratio=_first_float(flat_values, "k/d", "kd"),
favorite_weapons=_int_mapping(sections, "armas favoritas", "favorite weapons"),
victims=_int_mapping(sections, "victimas", "víctimas", "­ctimas", "victims"),
nemesis=_int_mapping(sections, "nemesis", "némesis", "nã©mesis"),
averages=_object_mapping(sections, "promedios", "averages"),
sanctions=_object_mapping(sections, "sanciones", "sanctions"),
raw_content=raw_content,
)
def _clean(value: str | None) -> str | None:
if value is None:
return None
@@ -238,6 +321,19 @@ def _coerce_int(value: object) -> int | None:
return None
def _coerce_float(value: object) -> float | None:
if value is None:
return None
normalized = str(value).strip().replace(",", ".")
match = re.search(r"-?\d+(?:\.\d+)?", normalized)
if not match:
return None
try:
return float(match.group(0))
except ValueError:
return None
def _resolve_winner(allied_score: int | None, axis_score: int | None) -> str | None:
if allied_score is None or axis_score is None:
return None
@@ -246,3 +342,123 @@ def _resolve_winner(allied_score: int | None, axis_score: int | None) -> str | N
if axis_score > allied_score:
return "axis"
return "draw"
def _clean_profile_line(value: str) -> str:
cleaned = value.strip().strip("─-").strip()
return cleaned.strip("").strip()
def _looks_like_profile_message(lines: list[str]) -> bool:
labels = {_normalize_profile_label(line.split(":", 1)[0]) for line in lines if ":" in line}
section_labels = {_normalize_profile_label(line) for line in lines if ":" not in line}
required = {"bajas", "muertes"}
known_sections = {
"totales",
"victimas",
"­ctimas",
"nemesis",
"nã©mesis",
"armas favoritas",
"promedios",
"sanciones",
}
return required.issubset(labels) and bool(section_labels & known_sections)
def _profile_sections(lines: list[str]) -> dict[str, list[str]]:
sections: dict[str, list[str]] = {}
current = "root"
for line in lines:
if ":" not in line:
current = _normalize_profile_label(line)
sections.setdefault(current, [])
continue
sections.setdefault(current, []).append(line)
return sections
def _profile_key_values(lines: list[str]) -> dict[str, str]:
values: dict[str, str] = {}
for line in lines:
if ":" not in line:
continue
key, value = line.split(":", 1)
values[_normalize_profile_label(key)] = value.strip()
return values
def _normalize_profile_label(value: object) -> str:
return (
str(value or "")
.strip()
.lower()
.replace("\u00ad", "")
.replace("í", "i")
.replace("é", "e")
.replace("ã­", "i")
.replace("ã©", "e")
)
def _first_value(values: dict[str, str], *keys: str) -> str | None:
for key in keys:
value = values.get(_normalize_profile_label(key))
if value:
return value
return None
def _first_int(values: dict[str, str], *keys: str) -> int | None:
return _coerce_int_from_text(_first_value(values, *keys))
def _first_float(values: dict[str, str], *keys: str) -> float | None:
return _coerce_float(_first_value(values, *keys))
def _parse_total_with_teamkills(values: dict[str, str], key: str) -> tuple[int | None, int | None]:
raw_value = _first_value(values, key)
if not raw_value:
return None, None
return _coerce_int_from_text(raw_value), _coerce_int_from_text(_inside_parentheses(raw_value))
def _inside_parentheses(value: str) -> str | None:
match = re.search(r"\((.*?)\)", value)
return match.group(1) if match else None
def _int_mapping(sections: dict[str, list[str]], *section_names: str) -> dict[str, int]:
mapped: dict[str, int] = {}
for line in _section_lines(sections, *section_names):
key, value = line.split(":", 1)
parsed = _coerce_int_from_text(value)
if parsed is not None:
mapped[key.strip()] = parsed
return mapped
def _object_mapping(sections: dict[str, list[str]], *section_names: str) -> dict[str, object]:
mapped: dict[str, object] = {}
for line in _section_lines(sections, *section_names):
key, value = line.split(":", 1)
cleaned = value.strip()
mapped[key.strip()] = _coerce_float(cleaned) if re.search(r"\d", cleaned) else cleaned
return mapped
def _section_lines(sections: dict[str, list[str]], *section_names: str) -> list[str]:
lines: list[str] = []
wanted = {_normalize_profile_label(name) for name in section_names}
for section_name, section_lines in sections.items():
if _normalize_profile_label(section_name) in wanted:
lines.extend(section_lines)
return lines
def _coerce_int_from_text(value: object) -> int | None:
if value is None:
return None
match = re.search(r"-?\d+", str(value))
return _coerce_int(match.group(0)) if match else None

View File

@@ -10,6 +10,7 @@ from pathlib import Path
from .config import get_storage_path
from .rcon_admin_log_parser import parse_rcon_admin_log_entry
from .rcon_admin_log_parser import parse_rcon_player_profile_snapshot
from .rcon_historical_storage import initialize_rcon_historical_storage
from .sqlite_utils import connect_sqlite_writer
@@ -44,6 +45,37 @@ def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path:
CREATE INDEX IF NOT EXISTS idx_rcon_admin_log_events_type
ON rcon_admin_log_events(event_type);
CREATE TABLE IF NOT EXISTS rcon_player_profile_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_key TEXT NOT NULL,
external_server_id TEXT,
player_id TEXT NOT NULL,
player_name TEXT NOT NULL,
source_server_time INTEGER NOT NULL,
event_timestamp TEXT,
first_seen TEXT,
sessions INTEGER,
matches_played INTEGER,
play_time TEXT,
total_kills INTEGER,
total_deaths INTEGER,
teamkills_done INTEGER,
teamkills_received INTEGER,
kd_ratio REAL,
favorite_weapons_json TEXT NOT NULL DEFAULT '{}',
victims_json TEXT NOT NULL DEFAULT '{}',
nemesis_json TEXT NOT NULL DEFAULT '{}',
averages_json TEXT NOT NULL DEFAULT '{}',
sanctions_json TEXT NOT NULL DEFAULT '{}',
raw_content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(target_key, player_id, source_server_time)
);
CREATE INDEX IF NOT EXISTS idx_rcon_player_profile_snapshots_player
ON rcon_player_profile_snapshots(target_key, player_id, source_server_time DESC);
"""
)
_ensure_canonical_message_column(connection)
@@ -115,6 +147,12 @@ def persist_rcon_admin_log_entries(
inserted += 1
else:
duplicates += 1
_persist_profile_snapshot_if_present(
connection,
target_key=target_key,
external_server_id=external_server_id,
parsed=parsed,
)
return {
"events_seen": len(entries),
@@ -123,6 +161,88 @@ def persist_rcon_admin_log_entries(
}
def _persist_profile_snapshot_if_present(
connection: sqlite3.Connection,
*,
target_key: str,
external_server_id: object,
parsed: dict[str, object],
) -> None:
snapshot = parse_rcon_player_profile_snapshot(parsed)
if snapshot is None:
return
connection.execute(
"""
INSERT INTO rcon_player_profile_snapshots (
target_key,
external_server_id,
player_id,
player_name,
source_server_time,
event_timestamp,
first_seen,
sessions,
matches_played,
play_time,
total_kills,
total_deaths,
teamkills_done,
teamkills_received,
kd_ratio,
favorite_weapons_json,
victims_json,
nemesis_json,
averages_json,
sanctions_json,
raw_content
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(target_key, player_id, source_server_time) DO UPDATE SET
external_server_id = excluded.external_server_id,
player_name = excluded.player_name,
event_timestamp = excluded.event_timestamp,
first_seen = excluded.first_seen,
sessions = excluded.sessions,
matches_played = excluded.matches_played,
play_time = excluded.play_time,
total_kills = excluded.total_kills,
total_deaths = excluded.total_deaths,
teamkills_done = excluded.teamkills_done,
teamkills_received = excluded.teamkills_received,
kd_ratio = excluded.kd_ratio,
favorite_weapons_json = excluded.favorite_weapons_json,
victims_json = excluded.victims_json,
nemesis_json = excluded.nemesis_json,
averages_json = excluded.averages_json,
sanctions_json = excluded.sanctions_json,
raw_content = excluded.raw_content,
updated_at = CURRENT_TIMESTAMP
""",
(
target_key,
external_server_id,
snapshot.player_id,
snapshot.player_name,
snapshot.source_server_time,
snapshot.event_timestamp,
snapshot.first_seen,
snapshot.sessions,
snapshot.matches_played,
snapshot.play_time,
snapshot.total_kills,
snapshot.total_deaths,
snapshot.teamkills_done,
snapshot.teamkills_received,
snapshot.kd_ratio,
json.dumps(snapshot.favorite_weapons, ensure_ascii=False, separators=(",", ":")),
json.dumps(snapshot.victims, ensure_ascii=False, separators=(",", ":")),
json.dumps(snapshot.nemesis, ensure_ascii=False, separators=(",", ":")),
json.dumps(snapshot.averages, ensure_ascii=False, separators=(",", ":")),
json.dumps(snapshot.sanctions, ensure_ascii=False, separators=(",", ":")),
snapshot.raw_content,
),
)
_PREFIX_RE = re.compile(r"^\[.*?\(\d+\)\]\s+", re.DOTALL)

View File

@@ -1,6 +1,9 @@
from app.rcon_admin_log_parser import parse_rcon_admin_log_message
from app.rcon_admin_log_parser import parse_rcon_player_profile_snapshot
def test_parse_match_start():
parsed = parse_rcon_admin_log_message(
"[2:09:15 hours (1779178245)] MATCH START UTAH BEACH Warfare"
@@ -105,3 +108,59 @@ def test_parse_message_profile():
assert parsed.player_name == "Ekenef"
assert parsed.player_id == "76561198109813520"
assert "bajas : 141" in parsed.content
def test_parse_player_profile_snapshot_spanish_sections():
parsed = parse_rcon_admin_log_message(
"[21:34:19 hours (1779108340)] MESSAGE: player [Jugador Uno(steam-profile-1)], "
"content [─ Jugador Uno ─\n"
"▒ Totales ▒\n"
"Visto por primera vez : 2026-01-01\n"
"sesiones : 12\n"
"partidas jugadas : 9\n"
"tiempo jugado : 18 h 30 min\n"
"bajas : 141 (6 TKs)\n"
"muertes : 268 (5 TKs)\n"
"K/D : 0,53\n"
"▒ Víctimas ▒\n"
"Rival Dos : 7\n"
"▒ Némesis ▒\n"
"Rival Tres : 4\n"
"▒ Armas favoritas ▒\n"
"M1 GARAND : 31\n"
"▒ Promedios ▒\n"
"bajas por partida : 15,6\n"
"▒ Sanciones ▒\n"
"kicks : 1]"
)
snapshot = parse_rcon_player_profile_snapshot(
parsed,
event_timestamp="2026-05-19T10:00:00Z",
)
assert snapshot is not None
assert snapshot.player_name == "Jugador Uno"
assert snapshot.player_id == "steam-profile-1"
assert snapshot.source_server_time == 1779108340
assert snapshot.sessions == 12
assert snapshot.matches_played == 9
assert snapshot.total_kills == 141
assert snapshot.total_deaths == 268
assert snapshot.teamkills_done == 6
assert snapshot.teamkills_received == 5
assert snapshot.kd_ratio == 0.53
assert snapshot.favorite_weapons == {"M1 GARAND": 31}
assert snapshot.victims == {"Rival Dos": 7}
assert snapshot.nemesis == {"Rival Tres": 4}
assert snapshot.averages == {"bajas por partida": 15.6}
assert snapshot.sanctions == {"kicks": 1.0}
def test_non_profile_message_does_not_parse_as_profile_snapshot():
parsed = parse_rcon_admin_log_message(
"[21:34:19 hours (1779108340)] MESSAGE: player [Jugador Uno(steam-profile-1)], "
"content [Bienvenido al servidor]"
)
assert parse_rcon_player_profile_snapshot(parsed) is None

View File

@@ -1,4 +1,5 @@
import gc
import json
import sqlite3
from app.rcon_admin_log_storage import (
@@ -37,6 +38,7 @@ def test_initialize_rcon_admin_log_storage_creates_event_table(tmp_path):
gc.collect()
assert "rcon_admin_log_events" in table_names
assert "rcon_player_profile_snapshots" in table_names
assert {
"target_key",
"event_type",
@@ -84,6 +86,87 @@ def test_persist_rcon_admin_log_entries_inserts_then_reports_duplicates(tmp_path
gc.collect()
def test_profile_message_snapshots_are_materialized_and_deduped(tmp_path):
db_path = tmp_path / "admin_log.sqlite3"
entry = {
"timestamp": "2026-05-19T10:00:00Z",
"message": (
"[21:34:19 hours (1779108340)] MESSAGE: player [Jugador Uno(steam-profile-1)], "
"content [─ Jugador Uno ─\n"
"▒ Totales ▒\n"
"sesiones : 12\n"
"partidas jugadas : 9\n"
"bajas : 141 (6 TKs)\n"
"muertes : 268 (5 TKs)\n"
"K/D : 0.53\n"
"▒ Víctimas ▒\n"
"Rival Dos : 7\n"
"▒ Némesis ▒\n"
"Rival Tres : 4\n"
"▒ Armas favoritas ▒\n"
"M1 GARAND : 31\n"
"▒ Promedios ▒\n"
"bajas por partida : 15.6\n"
"▒ Sanciones ▒\n"
"kicks : 1]"
),
}
persist_rcon_admin_log_entries(target=TARGET, entries=[entry], db_path=db_path)
persist_rcon_admin_log_entries(target=TARGET, entries=[entry], db_path=db_path)
connection = sqlite3.connect(db_path)
connection.row_factory = sqlite3.Row
try:
rows = connection.execute("SELECT * FROM rcon_player_profile_snapshots").fetchall()
finally:
connection.close()
gc.collect()
assert len(rows) == 1
row = rows[0]
assert row["target_key"] == "test-rcon-target"
assert row["player_id"] == "steam-profile-1"
assert row["source_server_time"] == 1779108340
assert row["sessions"] == 12
assert row["matches_played"] == 9
assert row["total_kills"] == 141
assert row["total_deaths"] == 268
assert row["teamkills_done"] == 6
assert row["teamkills_received"] == 5
assert row["kd_ratio"] == 0.53
assert json.loads(row["favorite_weapons_json"]) == {"M1 GARAND": 31}
assert json.loads(row["victims_json"]) == {"Rival Dos": 7}
assert json.loads(row["nemesis_json"]) == {"Rival Tres": 4}
assert "bajas : 141" in row["raw_content"]
def test_non_profile_messages_do_not_create_profile_snapshots(tmp_path):
db_path = tmp_path / "admin_log.sqlite3"
persist_rcon_admin_log_entries(
target=TARGET,
entries=[
{
"timestamp": "2026-05-19T10:00:00Z",
"message": "[1:00 min (100)] MESSAGE: player [Player One(steam-1)], content [hello]",
}
],
db_path=db_path,
)
connection = sqlite3.connect(db_path)
try:
count = connection.execute(
"SELECT COUNT(*) FROM rcon_player_profile_snapshots"
).fetchone()[0]
finally:
connection.close()
gc.collect()
assert count == 0
def test_canonical_message_dedupes_changing_relative_prefixes(tmp_path):
db_path = tmp_path / "admin_log.sqlite3"
original_entry = {