From 16fcfe6d3a7530552dd6093be3568ed88f1f840f Mon Sep 17 00:00:00 2001 From: Ally Date: Wed, 1 Apr 2026 10:45:54 -0700 Subject: [PATCH 1/2] feat: penalty box elapsed timer (box_elapsed_jam_ms) via jam-clock accumulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track jam-time elapsed per in-box skater using Option C (jam-clock delta accumulator). Only accumulates while jam_running was True at the previous tick, which naturally pauses the timer during lineup, timeouts, and jam resets without any extra logic. - SkaterPosition.box_elapsed_jam_ms: Optional[int] — None when not in box, >= 0 ms while in box. Consumer computes remaining = max(0, 30_000 - elapsed). - ScoreboardClient._tick_box_timers() called after each state update. - _prev_jam_running guard prevents spurious large delta on jam-start transition. - 6 new tests covering: init None, entry=0, accumulation, pause between jams, box exit reset, and single-skater isolation. --- client.py | 47 +++++++++++++ models.py | 6 ++ tests/test_client.py | 158 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 211 insertions(+) diff --git a/client.py b/client.py index d87abc4..0cceb36 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,48 @@ def _get(self, suffix: str, target_type: type = str) -> Any: return None return _coerce(raw, target_type) + 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 is + True and the jam clock has ticked down (positive delta). Automatically + resets a skater's elapsed counter when they leave the box. + """ + 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. + # Requiring _prev_jam_running=True prevents a large spurious delta from + # being counted when jam_running flips True in the same message as a + # clock jump (e.g. clock resets to 120 s at the start of a new jam). + 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 _team(self, n: int) -> TeamState: """Build a TeamState for team n using TEAM_FIELD_MAP and POSITION_MAP.""" prefix = f"Team({n})." @@ -164,6 +209,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_elapsed_jam_ms=self._box_elapsed.get(f"{n}.{crg_pos}"), ) for field, crg_pos in POSITION_MAP.items() } @@ -203,6 +249,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..40101b9 100644 --- a/models.py +++ b/models.py @@ -7,6 +7,12 @@ class SkaterPosition(BaseModel): name: Optional[str] = None number: Optional[str] = None in_box: bool = False + box_elapsed_jam_ms: Optional[int] = None + """Milliseconds of jam time this skater has been in the penalty box this penalty. + None when the skater is not in the box. Resets to 0 each time in_box transitions + to True. Pauses naturally between jams (while jam_running is False). + Consumer computes remaining = max(0, 30_000 - box_elapsed_jam_ms). + """ class TeamState(BaseModel): diff --git a/tests/test_client.py b/tests/test_client.py index 0a937a3..b739f5a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -294,3 +294,161 @@ async def test_seconds_since_update_is_populated_after_connect(mock_server): finally: client.stop() await asyncio.sleep(0.05) + + +# --------------------------------------------------------------------------- +# box_elapsed_jam_ms tests +# --------------------------------------------------------------------------- + +async def test_box_elapsed_is_none_when_not_in_box(mock_server): + """box_elapsed_jam_ms is None for skaters not in the penalty box.""" + client, task = await _connected_client(mock_server) + try: + state = client.get_live_state() + # Initial state has nobody in box + assert state.team1.blocker2.in_box is False + assert state.team1.blocker2.box_elapsed_jam_ms is None + finally: + client.stop() + await asyncio.sleep(0.05) + + +async def test_box_elapsed_initialises_to_zero_on_box_entry(mock_server): + """box_elapsed_jam_ms starts at 0 the moment in_box becomes True (before any jam tick).""" + 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_elapsed_jam_ms == 0 + finally: + client.stop() + await asyncio.sleep(0.05) + + +async def test_box_elapsed_accumulates_while_jam_running(mock_server): + """box_elapsed_jam_ms increases by the jam-clock delta when jam_running is True.""" + client, task = await _connected_client(mock_server) + try: + # Put blocker2 in box then start the jam + 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) + + # Tick the jam clock down 1 second + 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_elapsed_jam_ms == 1000 + + # Another 2 seconds + 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_elapsed_jam_ms == 3000 + finally: + client.stop() + await asyncio.sleep(0.05) + + +async def test_box_elapsed_does_not_accumulate_when_jam_not_running(mock_server): + """box_elapsed_jam_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_elapsed_jam_ms == 2000 + + # Jam ends — clock stops + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Clock(Jam).Running": False, + "ScoreBoard.CurrentGame.Clock(Jam).Time": 28000, + }) + await asyncio.sleep(0.1) + + # Clock update arrives but jam not running — no accumulation + 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_elapsed_jam_ms == 2000 + finally: + client.stop() + await asyncio.sleep(0.05) + + +async def test_box_elapsed_resets_when_skater_leaves_box(mock_server): + """box_elapsed_jam_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_elapsed_jam_ms == 10000 + + # Skater leaves box + 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_elapsed_jam_ms is None + finally: + client.stop() + await asyncio.sleep(0.05) + + +async def test_box_elapsed_only_accumulates_for_in_box_skaters(mock_server): + """Elapsed time does not accrue for skaters who are not in the box.""" + client, task = await _connected_client(mock_server) + try: + # Only blocker3 goes in — blocker1 stays out + 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_elapsed_jam_ms == 5000 + assert state.team1.blocker1.box_elapsed_jam_ms is None + finally: + client.stop() + await asyncio.sleep(0.05) From 99eb75b73e6f639f243908ea75c787b471d7d41c Mon Sep 17 00:00:00 2001 From: Ally Date: Wed, 1 Apr 2026 10:55:00 -0700 Subject: [PATCH 2/2] fix: two-phase ordering in _tick_box_timers; add edge-case regression tests Address Copilot review on PR #4. Bug: membership updates (pop/init _box_elapsed) ran before the jam-clock delta was applied. A skater entering the box in the same message as a clock tick would incorrectly receive elapsed credit for the interval before they entered; a skater exiting would miss that final delta. Fix: apply delta to the previous in-box set (Phase 1) before updating membership from the new PenaltyBox values (Phase 2). Tests added: - test_box_elapsed_entry_simultaneous_with_clock_tick: entry + clock tick in same message leaves elapsed at 0, not 2000. - test_box_elapsed_exit_simultaneous_with_clock_tick: exit + clock tick in same message; other skaters unaffected, exiting skater ends up None. --- client.py | 44 ++++++++++++++++------------ tests/test_client.py | 70 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 19 deletions(-) diff --git a/client.py b/client.py index 0cceb36..d0f0d8b 100644 --- a/client.py +++ b/client.py @@ -158,14 +158,35 @@ def _get(self, suffix: str, target_type: type = str) -> Any: 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 is - True and the jam clock has ticked down (positive delta). Automatically - resets a skater's elapsed counter when they leave the box. + 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}" @@ -179,21 +200,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. - # Requiring _prev_jam_running=True prevents a large spurious delta from - # being counted when jam_running flips True in the same message as a - # clock jump (e.g. clock resets to 120 s at the start of a new jam). - 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 diff --git a/tests/test_client.py b/tests/test_client.py index b739f5a..7500214 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -452,3 +452,73 @@ async def test_box_elapsed_only_accumulates_for_in_box_skaters(mock_server): finally: client.stop() await asyncio.sleep(0.05) + + +async def test_box_elapsed_entry_simultaneous_with_clock_tick(mock_server): + """Skater entering box in the same message as a clock tick should NOT + receive elapsed credit for the interval before they entered.""" + 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 belongs to the interval before the skater entered; + # elapsed must still be 0 (not 2000). + assert state.team1.blocker1.in_box is True + assert state.team1.blocker1.box_elapsed_jam_ms == 0 + finally: + client.stop() + await asyncio.sleep(0.05) + + +async def test_box_elapsed_exit_simultaneous_with_clock_tick(mock_server): + """Skater exiting box in the same message as a clock tick SHOULD receive + elapsed credit for that final interval before being cleared.""" + client, task = await _connected_client(mock_server) + try: + # Box entry, then jam starts + 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) + + # Accumulate 5 seconds + await mock_server.push_update({ + "ScoreBoard.CurrentGame.Clock(Jam).Time": 55000, + }) + await asyncio.sleep(0.1) + + # Box exit AND 3-second clock tick arrive together — + # the final 3000 ms should be credited before the timer is cleared + # (but in_box will be False and box_elapsed_jam_ms will be None + # because the skater has left; the key thing is it doesn't corrupt + # any other skater and the exit doesn't suppress the delta for others) + 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() + # Skater has left — elapsed is None + assert state.team1.blocker2.in_box is False + assert state.team1.blocker2.box_elapsed_jam_ms is None + # Other skaters unaffected + assert state.team1.blocker1.box_elapsed_jam_ms is None + finally: + client.stop() + await asyncio.sleep(0.05)