From adfc39224dfa34f460cb1589be2d22dd86c11cd0 Mon Sep 17 00:00:00 2001 From: Ally Date: Wed, 1 Apr 2026 10:47:52 -0700 Subject: [PATCH 1/2] feat: penalty box countdown timer (box_time_remaining_ms) via jam-clock accumulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track jam-time remaining per in-box skater using Option C (jam-clock delta accumulator). Server computes max(0, 30_000 - elapsed) so the overlay can read and render directly without any arithmetic. - SkaterPosition.box_time_remaining_ms: Optional[int] — None when not in box, 30_000 on entry, counts down to 0 while jam_running is True. - ScoreboardClient._tick_box_timers() + _remaining_ms() called after each state update; _prev_jam_running guard prevents spurious delta on jam start. - 7 new tests covering: init None, entry=30000, countdown, pause between jams, box exit reset, 0-clamp, and single-skater isolation. --- client.py | 54 ++++++++++++++ models.py | 7 ++ tests/test_client.py | 172 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+) diff --git a/client.py b/client.py index d87abc4..3c3fb91 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,55 @@ 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 _tick_box_timers(self) -> None: + """Accumulate jam-clock elapsed time for each in-box skater. + + Called after every state update. Only adds time when jam_running was + True at both the previous and current tick, which prevents a large + spurious delta from being counted when jam_running flips True at jam + start (clock jumps from 0 to 120 s). + """ + jam_clock = self._get("Clock(Jam).Time", int) + jam_running = self._get("Clock(Jam).Running", bool) + + # Maintain per-position elapsed counters — reset on box exit. + 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 + + # Accumulate only when the jam was *already* running at the previous tick. + 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 + + 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 +216,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() } @@ -203,6 +256,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..dacd87e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -294,3 +294,175 @@ 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) From 4f030f83aa5659f402eb2abdc3b743b179195a69 Mon Sep 17 00:00:00 2001 From: Ally Date: Wed, 1 Apr 2026 10:56:52 -0700 Subject: [PATCH 2/2] fix: two-phase ordering, reconnect reset, edge-case tests (Copilot review) Address all three Copilot review comments on PR #5. 1. Two-phase ordering in _tick_box_timers Bug: membership (pop/init _box_elapsed) was updated before the jam-clock delta was applied. Skater entering box simultaneous with a clock tick was incorrectly credited with the prior interval's elapsed. Fix: Phase 1 applies delta to the previous in-box set; Phase 2 then updates membership. 2. Stale timer state across reconnects Bug: _box_elapsed, _prev_jam_clock_ms, _prev_jam_running were never reset between disconnects, so elapsed from the previous session could carry over and produce incorrect remaining values after reconnect. Fix: _reset_timer_state() clears these fields and is called at the top of _run_once() before each new connection. 3. Regression tests added - test_box_remaining_entry_simultaneous_with_clock_tick: entry + tick in same message leaves remaining at 30_000, not less. - test_box_remaining_exit_simultaneous_with_clock_tick: exit + tick in same message; exiting skater clears to None, other skaters unaffected. - test_timer_state_resets_on_reconnect: after server bounce the initial snapshot (PenaltyBox=False) leaves remaining as None, not stale. --- client.py | 54 +++++++++++++++------- tests/test_client.py | 105 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 17 deletions(-) diff --git a/client.py b/client.py index 3c3fb91..6c4aa04 100644 --- a/client.py +++ b/client.py @@ -157,18 +157,49 @@ def _get(self, suffix: str, target_type: type = str) -> Any: _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. Only adds time when jam_running was - True at both the previous and current tick, which prevents a large - spurious delta from being counted when jam_running flips True at jam - start (clock jumps from 0 to 120 s). + 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) - # Maintain per-position elapsed counters — reset on box exit. + # 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}" @@ -182,18 +213,6 @@ def _tick_box_timers(self) -> None: # Skater just entered — initialise at zero self._box_elapsed[key] = 0 - # Accumulate only when the jam was *already* running at the previous tick. - 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 - self._prev_jam_clock_ms = jam_clock self._prev_jam_running = jam_running @@ -238,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) diff --git a/tests/test_client.py b/tests/test_client.py index dacd87e..36f2144 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -466,3 +466,108 @@ async def test_box_remaining_only_counts_down_for_in_box_skaters(mock_server): 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)