diff --git a/client.py b/client.py index d87abc4..d0f0d8b 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,54 @@ 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. 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 _team(self, n: int) -> TeamState: """Build a TeamState for team n using TEAM_FIELD_MAP and POSITION_MAP.""" prefix = f"Team({n})." @@ -164,6 +215,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 +255,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..7500214 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -294,3 +294,231 @@ 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) + + +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)