diff --git a/ai/tasks/pending/TASK-129-parse-materialize-player-profile-message-blocks.md b/ai/tasks/done/TASK-129-parse-materialize-player-profile-message-blocks.md similarity index 82% rename from ai/tasks/pending/TASK-129-parse-materialize-player-profile-message-blocks.md rename to ai/tasks/done/TASK-129-parse-materialize-player-profile-message-blocks.md index 1e23b09..020497e 100644 --- a/ai/tasks/pending/TASK-129-parse-materialize-player-profile-message-blocks.md +++ b/ai/tasks/done/TASK-129-parse-materialize-player-profile-message-blocks.md @@ -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. diff --git a/backend/app/rcon_admin_log_parser.py b/backend/app/rcon_admin_log_parser.py index 6a5e433..e8e2c0c 100644 --- a/backend/app/rcon_admin_log_parser.py +++ b/backend/app/rcon_admin_log_parser.py @@ -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", "vã­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", + "vã­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 diff --git a/backend/app/rcon_admin_log_storage.py b/backend/app/rcon_admin_log_storage.py index 459e95e..87ceeed 100644 --- a/backend/app/rcon_admin_log_storage.py +++ b/backend/app/rcon_admin_log_storage.py @@ -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) diff --git a/backend/tests/test_rcon_admin_log_parser.py b/backend/tests/test_rcon_admin_log_parser.py index a301df0..94176b6 100644 --- a/backend/tests/test_rcon_admin_log_parser.py +++ b/backend/tests/test_rcon_admin_log_parser.py @@ -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 diff --git a/backend/tests/test_rcon_admin_log_storage.py b/backend/tests/test_rcon_admin_log_storage.py index 8648714..b59ff91 100644 --- a/backend/tests/test_rcon_admin_log_storage.py +++ b/backend/tests/test_rcon_admin_log_storage.py @@ -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 = {