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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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 == []
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue