feat(live-state): reconcile player sessions on each poll
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c9cd2557fd
commit
33899f8c17
2 changed files with 155 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
|
|||
Loading…
Reference in a new issue