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
74 changes: 74 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

Comment on lines +114 to 117

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new timer state (_box_elapsed/_prev_jam_clock_ms/_prev_jam_running) is never reset on disconnect/reconnect. Because run() reuses the same client instance across reconnects, stale values can carry over and produce a large/incorrect delta (or preserve elapsed for in-box skaters) immediately after reconnect. Reset these fields when a new WS connection is established (and/or when it closes) so timers restart from a known baseline.

Suggested change
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
self._reset_timer_state()
def _reset_timer_state(self) -> None:
"""Reset per-connection timer-related state.
This should be called whenever a new WebSocket connection is
established (and optionally when it is closed) to avoid carrying
over stale timer values across reconnects.
"""
# key: "<team_n>.<crg_pos>"
self._box_elapsed: Dict[str, int] = {}
self._prev_jam_clock_ms = None
self._prev_jam_running = None

Copilot uses AI. Check for mistakes.
@property
def connected(self) -> bool:
Expand Down Expand Up @@ -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})."
Expand All @@ -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()
}
Expand All @@ -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)
Expand All @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading