From 87c916a7e4c0ad21e00569c9b6a41586ced31f9a Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Tue, 19 May 2026 15:34:42 +0200 Subject: [PATCH] Add RCON player profile enrichment --- ...K-130-add-player-profile-enrichment-api.md | 12 +++- backend/app/rcon_admin_log_storage.py | 71 +++++++++++++++++++ backend/app/rcon_historical_read_model.py | 25 ++++++- .../test_rcon_materialization_pipeline.py | 52 ++++++++++++++ 4 files changed, 156 insertions(+), 4 deletions(-) rename ai/tasks/{pending => done}/TASK-130-add-player-profile-enrichment-api.md (77%) diff --git a/ai/tasks/pending/TASK-130-add-player-profile-enrichment-api.md b/ai/tasks/done/TASK-130-add-player-profile-enrichment-api.md similarity index 77% rename from ai/tasks/pending/TASK-130-add-player-profile-enrichment-api.md rename to ai/tasks/done/TASK-130-add-player-profile-enrichment-api.md index 9b0e674..286be56 100644 --- a/ai/tasks/pending/TASK-130-add-player-profile-enrichment-api.md +++ b/ai/tasks/done/TASK-130-add-player-profile-enrichment-api.md @@ -1,7 +1,7 @@ --- id: TASK-130 title: Add player profile enrichment API -status: pending +status: done type: backend team: Backend Senior supporting_teams: @@ -71,3 +71,13 @@ Profile MESSAGE snapshots can enrich player rows later, but they are historical - Stage only intended files. - Commit the completed implementation. - Push the branch to origin. + +## Outcome + +- Added safe latest profile summaries for stored RCON profile snapshots. +- Enriched materialized RCON match detail player rows with optional `profile_summary` when a snapshot exists. +- Kept raw full `MESSAGE` content and player ids out of the public match-detail row. +- Missing snapshots remain omitted and do not break match detail. +- Validation: `python -m compileall backend/app` passed. +- Validation: `$env:PYTHONPATH='backend'; python -m unittest backend.tests.test_rcon_materialization_pipeline` passed. +- Validation blocked: `python -m pytest backend/tests/test_rcon_materialization_pipeline.py` could not run because `pytest` is not installed in this environment. diff --git a/backend/app/rcon_admin_log_storage.py b/backend/app/rcon_admin_log_storage.py index 87ceeed..da57346 100644 --- a/backend/app/rcon_admin_log_storage.py +++ b/backend/app/rcon_admin_log_storage.py @@ -298,3 +298,74 @@ def list_rcon_admin_log_event_counts(*, db_path: Path | None = None) -> list[dic ).fetchall() return [dict(row) for row in rows] + + +def get_latest_rcon_player_profile_summaries( + *, + target_key: str, + player_ids: list[str], + db_path: Path | None = None, +) -> dict[str, dict[str, object]]: + """Return safe latest profile summaries keyed by player id.""" + requested_ids = [str(player_id).strip() for player_id in player_ids if str(player_id).strip()] + if not target_key or not requested_ids: + return {} + + resolved_path = db_path or get_storage_path() + initialize_rcon_admin_log_storage(db_path=resolved_path) + placeholders = ",".join("?" for _ in requested_ids) + with sqlite3.connect(resolved_path) as connection: + connection.row_factory = sqlite3.Row + rows = connection.execute( + f""" + SELECT snapshots.* + FROM rcon_player_profile_snapshots AS snapshots + INNER JOIN ( + SELECT player_id, MAX(source_server_time) AS latest_source_server_time + FROM rcon_player_profile_snapshots + WHERE target_key = ? + AND player_id IN ({placeholders}) + GROUP BY player_id + ) AS latest + ON latest.player_id = snapshots.player_id + AND latest.latest_source_server_time = snapshots.source_server_time + WHERE snapshots.target_key = ? + """, + [target_key, *requested_ids, target_key], + ).fetchall() + + return {str(row["player_id"]): _build_safe_profile_summary(row) for row in rows} + + +def _build_safe_profile_summary(row: sqlite3.Row) -> dict[str, object]: + return { + "player_name": row["player_name"], + "source_server_time": row["source_server_time"], + "event_timestamp": row["event_timestamp"], + "first_seen": row["first_seen"], + "sessions": row["sessions"], + "matches_played": row["matches_played"], + "play_time": row["play_time"], + "totals": { + "kills": row["total_kills"], + "deaths": row["total_deaths"], + "teamkills_done": row["teamkills_done"], + "teamkills_received": row["teamkills_received"], + "kd_ratio": row["kd_ratio"], + }, + "favorite_weapons": _json_mapping(row["favorite_weapons_json"]), + "victims": _json_mapping(row["victims_json"]), + "nemesis": _json_mapping(row["nemesis_json"]), + "averages": _json_mapping(row["averages_json"]), + "sanctions": _json_mapping(row["sanctions_json"]), + } + + +def _json_mapping(raw_value: object) -> dict[str, object]: + if not isinstance(raw_value, str) or not raw_value.strip(): + return {} + try: + parsed = json.loads(raw_value) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} diff --git a/backend/app/rcon_historical_read_model.py b/backend/app/rcon_historical_read_model.py index ade8515..cffc95e 100644 --- a/backend/app/rcon_historical_read_model.py +++ b/backend/app/rcon_historical_read_model.py @@ -253,9 +253,21 @@ def _build_materialized_recent_item(item: dict[str, object]) -> dict[str, object def _build_materialized_detail_item(materialized: dict[str, object]) -> dict[str, object]: + from .rcon_admin_log_storage import get_latest_rcon_player_profile_summaries + match = materialized["match"] recent_item = _build_materialized_recent_item(match) - players = [_build_player_row(row) for row in materialized["players"]] + profile_summaries = get_latest_rcon_player_profile_summaries( + target_key=str(match["target_key"]), + player_ids=[str(row["player_id"]) for row in materialized["players"] if row.get("player_id")], + ) + players = [ + _build_player_row( + row, + profile_summary=profile_summaries.get(str(row.get("player_id"))), + ) + for row in materialized["players"] + ] return { **recent_item, "match_id": match["match_key"], @@ -270,10 +282,14 @@ def _build_materialized_detail_item(materialized: dict[str, object]) -> dict[str } -def _build_player_row(row: dict[str, object]) -> dict[str, object]: +def _build_player_row( + row: dict[str, object], + *, + profile_summary: dict[str, object] | None = None, +) -> dict[str, object]: kills = _coerce_optional_int(row.get("kills")) or 0 deaths = _coerce_optional_int(row.get("deaths")) or 0 - return { + player = { "player_name": row.get("player_name"), "team": row.get("team"), "kills": kills, @@ -284,6 +300,9 @@ def _build_player_row(row: dict[str, object]) -> dict[str, object]: "most_killed": _top_counter(row.get("most_killed_json")), "death_by": _top_counter(row.get("death_by_json")), } + if profile_summary: + player["profile_summary"] = profile_summary + return player def _top_counter(raw_value: object, *, limit: int = 5) -> list[dict[str, object]]: diff --git a/backend/tests/test_rcon_materialization_pipeline.py b/backend/tests/test_rcon_materialization_pipeline.py index cc39d72..0a3497d 100644 --- a/backend/tests/test_rcon_materialization_pipeline.py +++ b/backend/tests/test_rcon_materialization_pipeline.py @@ -77,6 +77,58 @@ class RconMaterializationPipelineTests(unittest.TestCase): self.assertIn("kd_ratio", detail["players"][0]) gc.collect() + def test_match_detail_adds_safe_profile_summary_when_snapshot_exists(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH") + os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path) + try: + _persist_admin_log_fixture(db_path) + persist_rcon_admin_log_entries( + target={ + "target_key": "comunidad-hispana-01", + "external_server_id": "comunidad-hispana-01", + }, + entries=[ + { + "timestamp": "2026-05-01T10:30:00Z", + "message": ( + "[31 min (300)] MESSAGE: player [Alpha(76561198000000001)], " + "content [─ Alpha ─\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" + "▒ Armas favoritas ▒\n" + "M1 Garand : 31]" + ), + } + ], + db_path=db_path, + ) + materialize_rcon_admin_log(db_path=db_path) + detail = get_rcon_historical_match_detail( + server_key="comunidad-hispana-01", + match_id="comunidad-hispana-01:100:500:stmariedumontwarfare", + ) + finally: + _restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path) + + self.assertIsNotNone(detail) + players = {row["player_name"]: row for row in detail["players"]} + self.assertIn("profile_summary", players["Alpha"]) + self.assertNotIn("profile_summary", players["Bravo"]) + profile_summary = players["Alpha"]["profile_summary"] + self.assertEqual(profile_summary["sessions"], 12) + self.assertEqual(profile_summary["matches_played"], 9) + self.assertEqual(profile_summary["totals"]["kills"], 141) + self.assertEqual(profile_summary["favorite_weapons"], {"M1 Garand": 31}) + self.assertNotIn("raw_content", profile_summary) + self.assertNotIn("player_id", players["Alpha"]) + gc.collect() + def test_recent_matches_prefer_materialized_rcon_over_scoreboard_fallback(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "historical.sqlite3"