feat(live-state): reconcile player sessions on each poll

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-12 21:58:30 +02:00
parent c9cd2557fd
commit 33899f8c17
No known key found for this signature in database
2 changed files with 155 additions and 1 deletions

View file

@ -12,7 +12,7 @@ This file is built up across Tasks 6-10.
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import datetime, UTC from datetime import datetime, timedelta, UTC
from sqlalchemy import select from sqlalchemy import select
@ -20,6 +20,7 @@ from l4d2web.db import session_scope
from l4d2web.models import ( from l4d2web.models import (
Server, Server,
ServerLiveState, ServerLiveState,
ServerPlayerSession,
) )
from l4d2web.services.rcon import RconError, StatusResponse, query_status from l4d2web.services.rcon import RconError, StatusResponse, query_status
@ -49,6 +50,7 @@ def poll_once() -> None:
continue continue
_record_snapshot(server_id, status) _record_snapshot(server_id, status)
_reconcile_sessions(server_id, status)
def _record_snapshot(server_id: int, status: StatusResponse) -> None: 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.map == status.map
and row.hibernating == status.hibernating 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

View file

@ -16,6 +16,7 @@ from l4d2web.models import (
Blueprint, Blueprint,
Server, Server,
ServerLiveState, ServerLiveState,
ServerPlayerSession,
User, User,
) )
from l4d2web.services import live_state_poller 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() live_state_poller.poll_once()
assert called == [] 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 == []