Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<team_n>.<crg_pos>"
self._prev_jam_clock_ms: Optional[int] = None
self._prev_jam_running: Optional[bool] = None

@property
def connected(self) -> bool:
Expand Down Expand Up @@ -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})."
Expand All @@ -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()
}
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
228 changes: 228 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading