From 33899f8c173f75d865d3caa924424dd241a713cb Mon Sep 17 00:00:00 2001 From: mwiegand Date: Tue, 12 May 2026 21:58:30 +0200 Subject: [PATCH] feat(live-state): reconcile player sessions on each poll Co-Authored-By: Claude Sonnet 4.6 --- l4d2web/services/live_state_poller.py | 53 +++++++++++- l4d2web/tests/test_live_state_poller.py | 103 ++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) diff --git a/l4d2web/services/live_state_poller.py b/l4d2web/services/live_state_poller.py index 31d9828..b3a8332 100644 --- a/l4d2web/services/live_state_poller.py +++ b/l4d2web/services/live_state_poller.py @@ -12,7 +12,7 @@ This file is built up across Tasks 6-10. from __future__ import annotations import logging -from datetime import datetime, UTC +from datetime import datetime, timedelta, UTC from sqlalchemy import select @@ -20,6 +20,7 @@ from l4d2web.db import session_scope from l4d2web.models import ( Server, ServerLiveState, + ServerPlayerSession, ) from l4d2web.services.rcon import RconError, StatusResponse, query_status @@ -49,6 +50,7 @@ def poll_once() -> None: continue _record_snapshot(server_id, status) + _reconcile_sessions(server_id, status) def _record_snapshot(server_id: int, status: StatusResponse) -> None: @@ -88,3 +90,52 @@ def _matches(row: ServerLiveState, status: StatusResponse) -> bool: and row.map == status.map and row.hibernating == status.hibernating ) + + +_STEAM_ID_64_PREFIX = "7656" # all SteamID64s start with this; bots/anon do not + + +def _is_valid_steam_id_64(value: str) -> bool: + return value.startswith(_STEAM_ID_64_PREFIX) and value.isdigit() and len(value) == 17 + + +def _reconcile_sessions(server_id: int, status: StatusResponse) -> None: + """Open new sessions, update ping ranges, close departed sessions.""" + now = _now() + roster = [p for p in status.roster if _is_valid_steam_id_64(p.steam_id_64)] + seen_ids = {p.steam_id_64 for p in roster} + + with session_scope() as db: + open_rows = db.scalars( + select(ServerPlayerSession).where( + ServerPlayerSession.server_id == server_id, + ServerPlayerSession.left_at.is_(None), + ) + ).all() + open_by_sid = {r.steam_id_64: r for r in open_rows} + + # Close sessions for players no longer in the roster. + for sid, row in open_by_sid.items(): + if sid not in seen_ids: + row.left_at = now + + # Open / update sessions for current roster. + for p in roster: + existing = open_by_sid.get(p.steam_id_64) + if existing is None: + db.add( + ServerPlayerSession( + server_id=server_id, + steam_id_64=p.steam_id_64, + joined_at=now - timedelta(seconds=p.connected_seconds), + left_at=None, + name_at_join=p.name, + min_ping=p.ping, + max_ping=p.ping, + ) + ) + else: + if p.ping < existing.min_ping: + existing.min_ping = p.ping + if p.ping > existing.max_ping: + existing.max_ping = p.ping diff --git a/l4d2web/tests/test_live_state_poller.py b/l4d2web/tests/test_live_state_poller.py index 865672f..8636b72 100644 --- a/l4d2web/tests/test_live_state_poller.py +++ b/l4d2web/tests/test_live_state_poller.py @@ -16,6 +16,7 @@ from l4d2web.models import ( Blueprint, Server, ServerLiveState, + ServerPlayerSession, User, ) from l4d2web.services import live_state_poller @@ -122,3 +123,105 @@ def test_skips_non_running_servers(tmp_path, monkeypatch) -> None: live_state_poller.poll_once() assert called == [] + + +def _player(steam_id: str = "76561197960828710", name: str = "Alice", + connected: int = 21, ping: int = 60) -> PlayerRow: + return PlayerRow( + steam_id_64=steam_id, name=name, connected_seconds=connected, ping=ping, + ) + + +def test_new_player_opens_session_with_backfilled_join(tmp_path, monkeypatch) -> None: + app, sid = _seed(tmp_path) + monkeypatch.setattr( + live_state_poller, "query_status", + lambda *a, **kw: _status(players=1, roster=[_player(connected=30)]), + ) + + with app.app_context(): + live_state_poller.poll_once() + + with session_scope() as db: + sessions = db.scalars( + select(ServerPlayerSession).where(ServerPlayerSession.server_id == sid) + ).all() + assert len(sessions) == 1 + s = sessions[0] + assert s.steam_id_64 == "76561197960828710" + assert s.name_at_join == "Alice" + assert s.left_at is None + assert s.min_ping == 60 + assert s.max_ping == 60 + # joined_at should be ~30s before now + delta = (datetime.now(UTC).replace(tzinfo=None) - s.joined_at).total_seconds() + assert 25 <= delta <= 60 + + +def test_session_closes_when_player_no_longer_in_roster(tmp_path, monkeypatch) -> None: + app, sid = _seed(tmp_path) + snapshots = iter([ + _status(players=1, roster=[_player()]), + _status(players=0, roster=[]), + ]) + monkeypatch.setattr( + live_state_poller, "query_status", + lambda *a, **kw: next(snapshots), + ) + + with app.app_context(): + live_state_poller.poll_once() + live_state_poller.poll_once() + + with session_scope() as db: + sessions = db.scalars( + select(ServerPlayerSession).where(ServerPlayerSession.server_id == sid) + ).all() + assert len(sessions) == 1 + assert sessions[0].left_at is not None + + +def test_session_ping_range_extends(tmp_path, monkeypatch) -> None: + app, sid = _seed(tmp_path) + snapshots = iter([ + _status(players=1, roster=[_player(ping=60)]), + _status(players=1, roster=[_player(ping=200)]), + _status(players=1, roster=[_player(ping=40)]), + ]) + monkeypatch.setattr( + live_state_poller, "query_status", + lambda *a, **kw: next(snapshots), + ) + + with app.app_context(): + live_state_poller.poll_once() + live_state_poller.poll_once() + live_state_poller.poll_once() + + with session_scope() as db: + s = db.scalar(select(ServerPlayerSession).where(ServerPlayerSession.server_id == sid)) + assert s.min_ping == 40 + assert s.max_ping == 200 + + +def test_session_skips_bots(tmp_path, monkeypatch) -> None: + # Bots have non-STEAM uniqueid; our parser already drops them, but verify + # that even if a non-STEAM steam_id_64 makes it through, sessions filter + # it out. We exercise the filter directly by giving a string that doesn't + # match the expected SteamID64 numeric form. + app, sid = _seed(tmp_path) + monkeypatch.setattr( + live_state_poller, "query_status", + lambda *a, **kw: _status( + players=0, roster=[_player(steam_id="BOT", name="Coach")] + ), + ) + + with app.app_context(): + live_state_poller.poll_once() + + with session_scope() as db: + sessions = db.scalars( + select(ServerPlayerSession).where(ServerPlayerSession.server_id == sid) + ).all() + assert sessions == []