diff --git a/client.py b/client.py index d87abc4..6c4aa04 100644 --- a/client.py +++ b/client.py @@ -111,6 +111,9 @@ def __init__(self, host: str = "localhost", port: int = 8000) -> None: self._connected = False self._last_update: Optional[float] = None self._task: Optional[asyncio.Task] = None + self._box_elapsed: Dict[str, int] = {} # key: "." + self._prev_jam_clock_ms: Optional[int] = None + self._prev_jam_running: Optional[bool] = None @property def connected(self) -> bool: @@ -152,6 +155,74 @@ def _get(self, suffix: str, target_type: type = str) -> Any: return None return _coerce(raw, target_type) + _PENALTY_MS = 30_000 # single-penalty duration; used to compute remaining + + def _reset_timer_state(self) -> None: + """Reset per-connection timer state. + + Called at the start of every new WebSocket connection so that stale + elapsed values and prev-clock/running flags from a previous session + cannot produce a large spurious delta immediately after reconnect. + """ + self._box_elapsed: Dict[str, int] = {} + self._prev_jam_clock_ms = None + self._prev_jam_running = None + + def _tick_box_timers(self) -> None: + """Accumulate jam-clock elapsed time for each in-box skater. + + Called after every state update. Uses a two-phase approach so that a + scoreboard message containing both a PenaltyBox change and a clock + tick is handled correctly: + + Phase 1 — apply delta to the *previous* in-box set. A skater who + just exited the box receives their final elapsed credit; a skater who + just entered does not receive credit for time before they arrived. + + Phase 2 — update membership from the current PenaltyBox values. + Exiting skaters are removed; entering skaters are initialised at 0. + """ + jam_clock = self._get("Clock(Jam).Time", int) + jam_running = self._get("Clock(Jam).Running", bool) + + # Phase 1: accumulate delta against the *previous* in-box set. + # Only runs when the jam was already running at the previous tick so + # that a jam-start clock jump (0 → 120 s) is not mistakenly counted. + if ( + jam_running + and self._prev_jam_running + and self._prev_jam_clock_ms is not None + and jam_clock is not None + ): + delta = self._prev_jam_clock_ms - jam_clock + if delta > 0: # negative means clock was reset; skip + for key in list(self._box_elapsed.keys()): + self._box_elapsed[key] += delta + + # Phase 2: update in-box membership from current PenaltyBox values. + for team_n in (1, 2): + for crg_pos in POSITION_MAP.values(): + key = f"{team_n}.{crg_pos}" + in_box = ( + self._get(f"Team({team_n}).Position({crg_pos}).PenaltyBox", bool) + or False + ) + if not in_box: + self._box_elapsed.pop(key, None) + elif key not in self._box_elapsed: + # Skater just entered — initialise at zero + self._box_elapsed[key] = 0 + + self._prev_jam_clock_ms = jam_clock + self._prev_jam_running = jam_running + + def _remaining_ms(self, key: str) -> Optional[int]: + """Convert internal elapsed counter to a clamped countdown value.""" + elapsed = self._box_elapsed.get(key) + if elapsed is None: + return None + return max(0, self._PENALTY_MS - elapsed) + def _team(self, n: int) -> TeamState: """Build a TeamState for team n using TEAM_FIELD_MAP and POSITION_MAP.""" prefix = f"Team({n})." @@ -164,6 +235,7 @@ def _team(self, n: int) -> TeamState: name=self._get(f"{prefix}Position({crg_pos}).Name"), number=self._get(f"{prefix}Position({crg_pos}).RosterNumber"), in_box=self._get(f"{prefix}Position({crg_pos}).PenaltyBox", bool) or False, + box_time_remaining_ms=self._remaining_ms(f"{n}.{crg_pos}"), ) for field, crg_pos in POSITION_MAP.items() } @@ -185,6 +257,7 @@ async def _run_once(self, uri: str) -> None: """Connect, subscribe, and receive updates until disconnect.""" logger.info("Connecting to %s", uri) async with websockets.connect(uri, ping_interval=None) as ws: + self._reset_timer_state() self._connected = True logger.info("Connected to scoreboard WS") await ws.send(REGISTER_MSG) @@ -203,6 +276,7 @@ async def _run_once(self, uri: str) -> None: try: if "state" in msg: self._apply_update(msg["state"]) + self._tick_box_timers() elif "error" in msg: logger.warning("Scoreboard error: %s", msg["error"]) except Exception: diff --git a/models.py b/models.py index 1bec3c5..05976ba 100644 --- a/models.py +++ b/models.py @@ -7,6 +7,13 @@ class SkaterPosition(BaseModel): name: Optional[str] = None number: Optional[str] = None in_box: bool = False + box_time_remaining_ms: Optional[int] = None + """Milliseconds remaining in this skater's penalty, counting jam time only. + None when the skater is not in the box. Starts at 30_000 on box entry and + counts down only while jam_running is True. Clamped to 0 — will not go + negative. Assumes a single 30-second penalty; for stacking, a future + version can multiply by penalty count before this is computed. + """ class TeamState(BaseModel): diff --git a/tests/test_client.py b/tests/test_client.py index 0a937a3..36f2144 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -294,3 +294,280 @@ async def test_seconds_since_update_is_populated_after_connect(mock_server): finally: client.stop() await asyncio.sleep(0.05) + + +# --------------------------------------------------------------------------- +# box_time_remaining_ms tests +# --------------------------------------------------------------------------- + +async def test_box_remaining_is_none_when_not_in_box(mock_server): + """box_time_remaining_ms is None for skaters not in the penalty box.""" + client, task = await _connected_client(mock_server) + try: + state = client.get_live_state() + assert state.team1.blocker2.in_box is False + assert state.team1.blocker2.box_time_remaining_ms is None + finally: + client.stop() + await asyncio.sleep(0.05) + + +async def test_box_remaining_starts_at_30000_on_box_entry(mock_server): + """box_time_remaining_ms starts at 30 000 the moment in_box becomes True.""" + client, task = await _connected_client(mock_server) + try: + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Team(1).Position(Blocker1).PenaltyBox": True, + }) + await asyncio.sleep(0.1) + state = client.get_live_state() + assert state.team1.blocker1.in_box is True + assert state.team1.blocker1.box_time_remaining_ms == 30_000 + finally: + client.stop() + await asyncio.sleep(0.05) + + +async def test_box_remaining_counts_down_while_jam_running(mock_server): + """box_time_remaining_ms decreases by the jam-clock delta when jam_running is True.""" + client, task = await _connected_client(mock_server) + try: + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Team(1).Position(Blocker2).PenaltyBox": True, + "ScoreBoard.CurrentGame.Clock(Jam).Running": True, + "ScoreBoard.CurrentGame.Clock(Jam).Time": 60000, + }) + await asyncio.sleep(0.1) + + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Clock(Jam).Time": 59000, + }) + await asyncio.sleep(0.1) + state = client.get_live_state() + assert state.team1.blocker2.box_time_remaining_ms == 29_000 + + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Clock(Jam).Time": 57000, + }) + await asyncio.sleep(0.1) + state = client.get_live_state() + assert state.team1.blocker2.box_time_remaining_ms == 27_000 + finally: + client.stop() + await asyncio.sleep(0.05) + + +async def test_box_remaining_pauses_when_jam_not_running(mock_server): + """box_time_remaining_ms stays flat between jams (jam_running=False).""" + client, task = await _connected_client(mock_server) + try: + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Team(2).Position(Jammer).PenaltyBox": True, + "ScoreBoard.CurrentGame.Clock(Jam).Running": True, + "ScoreBoard.CurrentGame.Clock(Jam).Time": 30000, + }) + await asyncio.sleep(0.1) + + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Clock(Jam).Time": 28000, + }) + await asyncio.sleep(0.1) + state = client.get_live_state() + assert state.team2.jammer.box_time_remaining_ms == 28_000 + + # Jam ends + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Clock(Jam).Running": False, + "ScoreBoard.CurrentGame.Clock(Jam).Time": 28000, + }) + await asyncio.sleep(0.1) + + # Clock update while jam stopped — should not change remaining + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Clock(Jam).Time": 27000, + }) + await asyncio.sleep(0.1) + state = client.get_live_state() + assert state.team2.jammer.box_time_remaining_ms == 28_000 + finally: + client.stop() + await asyncio.sleep(0.05) + + +async def test_box_remaining_is_none_after_box_exit(mock_server): + """box_time_remaining_ms returns to None when in_box transitions to False.""" + client, task = await _connected_client(mock_server) + try: + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Team(1).Position(Pivot).PenaltyBox": True, + "ScoreBoard.CurrentGame.Clock(Jam).Running": True, + "ScoreBoard.CurrentGame.Clock(Jam).Time": 20000, + }) + await asyncio.sleep(0.1) + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Clock(Jam).Time": 10000, + }) + await asyncio.sleep(0.1) + state = client.get_live_state() + assert state.team1.pivot.box_time_remaining_ms == 20_000 + + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Team(1).Position(Pivot).PenaltyBox": False, + }) + await asyncio.sleep(0.1) + state = client.get_live_state() + assert state.team1.pivot.in_box is False + assert state.team1.pivot.box_time_remaining_ms is None + finally: + client.stop() + await asyncio.sleep(0.05) + + +async def test_box_remaining_clamped_to_zero(mock_server): + """box_time_remaining_ms never goes below 0 even if elapsed exceeds 30 s.""" + client, task = await _connected_client(mock_server) + try: + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Team(1).Position(Blocker3).PenaltyBox": True, + "ScoreBoard.CurrentGame.Clock(Jam).Running": True, + "ScoreBoard.CurrentGame.Clock(Jam).Time": 60000, + }) + await asyncio.sleep(0.1) + + # Tick past 30 s of elapsed time + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Clock(Jam).Time": 25000, + }) + await asyncio.sleep(0.1) + state = client.get_live_state() + assert state.team1.blocker3.box_time_remaining_ms == 0 + finally: + client.stop() + await asyncio.sleep(0.05) + + +async def test_box_remaining_only_counts_down_for_in_box_skaters(mock_server): + """Remaining time does not change for skaters who are not in the box.""" + client, task = await _connected_client(mock_server) + try: + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Team(1).Position(Blocker3).PenaltyBox": True, + "ScoreBoard.CurrentGame.Clock(Jam).Running": True, + "ScoreBoard.CurrentGame.Clock(Jam).Time": 60000, + }) + await asyncio.sleep(0.1) + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Clock(Jam).Time": 55000, + }) + await asyncio.sleep(0.1) + state = client.get_live_state() + assert state.team1.blocker3.box_time_remaining_ms == 25_000 + assert state.team1.blocker1.box_time_remaining_ms is None + finally: + client.stop() + await asyncio.sleep(0.05) + + +async def test_box_remaining_entry_simultaneous_with_clock_tick(mock_server): + """Skater entering box in same message as a clock tick should NOT have + the prior interval's elapsed subtracted — remaining must still be 30_000.""" + client, task = await _connected_client(mock_server) + try: + # Jam already running with clock at 50000 + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Clock(Jam).Running": True, + "ScoreBoard.CurrentGame.Clock(Jam).Time": 50000, + }) + await asyncio.sleep(0.1) + + # Box entry AND 2-second clock tick in the same message + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Team(1).Position(Blocker1).PenaltyBox": True, + "ScoreBoard.CurrentGame.Clock(Jam).Time": 48000, + }) + await asyncio.sleep(0.1) + + state = client.get_live_state() + # The 2000 ms delta precedes the skater's entry; remaining must be 30_000. + assert state.team1.blocker1.in_box is True + assert state.team1.blocker1.box_time_remaining_ms == 30_000 + finally: + client.stop() + await asyncio.sleep(0.05) + + +async def test_box_remaining_exit_simultaneous_with_clock_tick(mock_server): + """Skater exiting box in same message as a clock tick: other skaters' + remaining is unaffected and the exiting skater ends up None.""" + client, task = await _connected_client(mock_server) + try: + # Blocker1 and Blocker2 both in box, jam running + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Team(1).Position(Blocker1).PenaltyBox": True, + "ScoreBoard.CurrentGame.Team(1).Position(Blocker2).PenaltyBox": True, + "ScoreBoard.CurrentGame.Clock(Jam).Running": True, + "ScoreBoard.CurrentGame.Clock(Jam).Time": 60000, + }) + await asyncio.sleep(0.1) + + # 5 seconds elapse + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Clock(Jam).Time": 55000, + }) + await asyncio.sleep(0.1) + state = client.get_live_state() + assert state.team1.blocker1.box_time_remaining_ms == 25_000 + assert state.team1.blocker2.box_time_remaining_ms == 25_000 + + # Blocker2 exits AND 3-second tick arrive together + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Team(1).Position(Blocker2).PenaltyBox": False, + "ScoreBoard.CurrentGame.Clock(Jam).Time": 52000, + }) + await asyncio.sleep(0.1) + + state = client.get_live_state() + # Blocker2 is gone + assert state.team1.blocker2.in_box is False + assert state.team1.blocker2.box_time_remaining_ms is None + # Blocker1 received the 3-second delta correctly (25_000 - 3_000 = 22_000) + assert state.team1.blocker1.box_time_remaining_ms == 22_000 + finally: + client.stop() + await asyncio.sleep(0.05) + + +async def test_timer_state_resets_on_reconnect(mock_server): + """Stale _box_elapsed / _prev_jam_clock_ms must not carry over after reconnect.""" + client, task = await _connected_client(mock_server) + try: + # Put a skater in the box with elapsed time accumulated + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Team(1).Position(Blocker1).PenaltyBox": True, + "ScoreBoard.CurrentGame.Clock(Jam).Running": True, + "ScoreBoard.CurrentGame.Clock(Jam).Time": 30000, + }) + await asyncio.sleep(0.1) + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Clock(Jam).Time": 25000, + }) + await asyncio.sleep(0.1) + state = client.get_live_state() + assert state.team1.blocker1.box_time_remaining_ms == 25_000 + + # Force a reconnect by bouncing the server + await mock_server.stop() + await asyncio.sleep(0.15) + await mock_server.start() + for _ in range(60): + if client.connected: + break + await asyncio.sleep(0.1) + + # After reconnect the initial snapshot re-sends PenaltyBox=False for everyone, + # so box_time_remaining_ms must be None — not a stale countdown. + state = client.get_live_state() + assert state.team1.blocker1.box_time_remaining_ms is None + finally: + client.stop() + await asyncio.sleep(0.05)