Add RCON player profile enrichment
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: TASK-130
|
id: TASK-130
|
||||||
title: Add player profile enrichment API
|
title: Add player profile enrichment API
|
||||||
status: pending
|
status: done
|
||||||
type: backend
|
type: backend
|
||||||
team: Backend Senior
|
team: Backend Senior
|
||||||
supporting_teams:
|
supporting_teams:
|
||||||
@@ -71,3 +71,13 @@ Profile MESSAGE snapshots can enrich player rows later, but they are historical
|
|||||||
- Stage only intended files.
|
- Stage only intended files.
|
||||||
- Commit the completed implementation.
|
- Commit the completed implementation.
|
||||||
- Push the branch to origin.
|
- 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.
|
||||||
@@ -298,3 +298,74 @@ def list_rcon_admin_log_event_counts(*, db_path: Path | None = None) -> list[dic
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
return [dict(row) for row in rows]
|
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 {}
|
||||||
|
|||||||
@@ -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]:
|
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"]
|
match = materialized["match"]
|
||||||
recent_item = _build_materialized_recent_item(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 {
|
return {
|
||||||
**recent_item,
|
**recent_item,
|
||||||
"match_id": match["match_key"],
|
"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
|
kills = _coerce_optional_int(row.get("kills")) or 0
|
||||||
deaths = _coerce_optional_int(row.get("deaths")) or 0
|
deaths = _coerce_optional_int(row.get("deaths")) or 0
|
||||||
return {
|
player = {
|
||||||
"player_name": row.get("player_name"),
|
"player_name": row.get("player_name"),
|
||||||
"team": row.get("team"),
|
"team": row.get("team"),
|
||||||
"kills": kills,
|
"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")),
|
"most_killed": _top_counter(row.get("most_killed_json")),
|
||||||
"death_by": _top_counter(row.get("death_by_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]]:
|
def _top_counter(raw_value: object, *, limit: int = 5) -> list[dict[str, object]]:
|
||||||
|
|||||||
@@ -77,6 +77,58 @@ class RconMaterializationPipelineTests(unittest.TestCase):
|
|||||||
self.assertIn("kd_ratio", detail["players"][0])
|
self.assertIn("kd_ratio", detail["players"][0])
|
||||||
gc.collect()
|
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:
|
def test_recent_matches_prefer_materialized_rcon_over_scoreboard_fallback(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||||
|
|||||||
Reference in New Issue
Block a user