From 8e16ca84a289d11af3bdb2a9a45fbcf5b8ee6b44 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:49:33 -0400 Subject: [PATCH] refactor(sports): one _is_game_really_over in all nine scoreboards, with FINAL_PERIOD (family 5) Step 4 of LEDMatrix docs/SPORTS_UNIFICATION.md ("reconcile, then promote") for the game-over check. The nine SportsLive._is_game_really_over copies were five bodies; they are now one, byte-identical, and the drift report shows one variant. The per-sport fact is a class attribute on each plugin's SportsLive, FINAL_PERIOD: the period from which a 0:00 clock ends a game -- hockey 3; basketball, football, lacrosse 4; None (the clock never does) for afl, nrl, soccer (clocks count up), baseball (innings) and ufc. Differences, classified: - Fix ported: only a non-empty clock string counts for the clock rule (baseball/ufc read a missing/None/non-string clock as 0:00). - Owner decision: a score level at 0:00 is not over -- the game stays live through the break before overtime; a game that really ends tied still ends on its final text/status. A missing or unreadable score leaves the decision to the clock, as before. - Owner decision: ufc has no clock rule (FINAL_PERIOD None); a bout ends only on ESPN's final. - Noise: one docstring and one log wording; the [LIVE_PRIORITY_DEBUG] lines in the football/baseball/ufc copies are gone (nothing reads them). - Baseball's BaseballLive postponed/suspended override is unchanged. scripts/test_game_over_check.py (#621) tables regenerated with --print. Of 3,115 cells only these changed, all over -> not over: ufc 65 (no clock rule), baseball 53 (FINAL_PERIOD None; every one a game with a period, which baseball's games never carry, so dormant in practice), and the tie guard's 5 (basketball 1, football 1, lacrosse 1, hockey 2). Harness renders of all nine against core main: 192 PNGs byte-identical. Plugin tests follow the decisions: the null-period stand-ins carry FINAL_PERIOD; each clock sport pins a tie at 0:00; baseball/ufc pin that 0:00 no longer ends a game; the vegas tie tests (basketball, hockey, lacrosse) now expect the level game to stay live; ufc's derived "R5 0:00" fight stays live until ESPN calls it final. Patch bump for all nine (released 2026-10-05, floors unchanged), CHANGELOG entries, registry updated, drift baseline drops the entry. Co-Authored-By: Claude Opus 5.5 --- plugins.json | 63 ++++++++--------- plugins/afl-scoreboard/CHANGELOG.md | 8 +++ plugins/afl-scoreboard/manifest.json | 10 ++- plugins/afl-scoreboard/sports.py | 42 ++++++++++-- .../test_null_period_really_over.py | 5 +- plugins/baseball-scoreboard/CHANGELOG.md | 9 +++ plugins/baseball-scoreboard/manifest.json | 10 ++- plugins/baseball-scoreboard/sports.py | 53 ++++++++------- .../test_null_period_really_over.py | 16 +++-- plugins/basketball-scoreboard/CHANGELOG.md | 10 +++ plugins/basketball-scoreboard/manifest.json | 10 ++- plugins/basketball-scoreboard/sports.py | 31 ++++++--- .../test_null_period_really_over.py | 8 ++- .../test_vegas_elements.py | 7 +- plugins/football-scoreboard/CHANGELOG.md | 10 +++ plugins/football-scoreboard/manifest.json | 10 ++- plugins/football-scoreboard/sports.py | 48 +++++++------ .../test_null_period_really_over.py | 8 ++- plugins/hockey-scoreboard/CHANGELOG.md | 10 +++ plugins/hockey-scoreboard/manifest.json | 10 ++- plugins/hockey-scoreboard/sports.py | 31 ++++++--- .../test_null_period_really_over.py | 8 ++- .../hockey-scoreboard/test_vegas_elements.py | 13 ++-- plugins/lacrosse-scoreboard/CHANGELOG.md | 10 +++ plugins/lacrosse-scoreboard/manifest.json | 10 ++- plugins/lacrosse-scoreboard/sports.py | 31 ++++++--- .../test_null_period_really_over.py | 8 ++- .../test_vegas_elements.py | 10 +-- plugins/nrl-scoreboard/CHANGELOG.md | 8 +++ plugins/nrl-scoreboard/manifest.json | 10 ++- plugins/nrl-scoreboard/sports.py | 42 ++++++++++-- .../test_null_period_really_over.py | 5 +- plugins/soccer-scoreboard/CHANGELOG.md | 8 +++ plugins/soccer-scoreboard/manifest.json | 10 ++- plugins/soccer-scoreboard/sports.py | 42 ++++++++++-- .../test_null_period_really_over.py | 5 +- plugins/ufc-scoreboard/CHANGELOG.md | 10 +++ plugins/ufc-scoreboard/manifest.json | 10 ++- plugins/ufc-scoreboard/sports.py | 53 ++++++++------- .../test_null_period_really_over.py | 16 +++-- .../test_round_break_stays_live.py | 8 ++- plugins/ufc-scoreboard/test_vegas_elements.py | 21 ++++-- scripts/sports_drift_baseline.json | 1 - scripts/test_game_over_check.py | 68 ++++++++++--------- 44 files changed, 558 insertions(+), 258 deletions(-) diff --git a/plugins.json b/plugins.json index a6b43811..5cbcecb6 100644 --- a/plugins.json +++ b/plugins.json @@ -77,12 +77,11 @@ "plugin_path": "plugins/baseball-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-10-04", + "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "1.57.2", - "ledmatrix_min_version": "3.8.0", - "commit": "39a7a451aa6e13679e7319300248102e4597a1c5" + "latest_version": "1.57.3", + "ledmatrix_min_version": "3.8.0" }, { "id": "basketball-scoreboard", @@ -104,12 +103,11 @@ "plugin_path": "plugins/basketball-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-10-03", + "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "1.42.1", - "ledmatrix_min_version": "3.8.0", - "commit": "c202cd9a6f4b421d7cee4445b707a4e300b45490" + "latest_version": "1.42.2", + "ledmatrix_min_version": "3.8.0" }, { "id": "calendar", @@ -280,12 +278,11 @@ "plugin_path": "plugins/football-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-10-04", + "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "3.18.7", - "ledmatrix_min_version": "3.8.0", - "commit": "37f2f954c06f24ffb760b9ddbb019e0b0c7156d5" + "latest_version": "3.18.8", + "ledmatrix_min_version": "3.8.0" }, { "id": "geochron", @@ -382,13 +379,12 @@ "plugin_path": "plugins/hockey-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-10-03", + "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "1.42.3", + "latest_version": "1.42.4", "icon": "fas fa-hockey-puck", - "ledmatrix_min_version": "3.8.0", - "commit": "c202cd9a6f4b421d7cee4445b707a4e300b45490" + "ledmatrix_min_version": "3.8.0" }, { "id": "lacrosse-scoreboard", @@ -408,13 +404,12 @@ "plugin_path": "plugins/lacrosse-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-10-02", + "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "1.36.3", + "latest_version": "1.36.4", "icon": "fas fa-baseball-ball", - "ledmatrix_min_version": "3.8.0", - "commit": "886d2038d22a35c962beac0ea261543339cffec9" + "ledmatrix_min_version": "3.8.0" }, { "id": "birdnet-go", @@ -844,12 +839,11 @@ "plugin_path": "plugins/soccer-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-10-04", + "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "2.39.4", - "ledmatrix_min_version": "3.8.0", - "commit": "e73d2090f04bd9f3df1d8af4563bbbc028d70012" + "latest_version": "2.39.5", + "ledmatrix_min_version": "3.8.0" }, { "id": "static-image", @@ -1004,13 +998,12 @@ "plugin_path": "plugins/ufc-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-10-04", + "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "1.19.4", + "latest_version": "1.19.5", "icon": "fas fa-fist-raised", - "ledmatrix_min_version": "3.8.0", - "commit": "bf7fcf0170ebce40f0c561979cadd85bdf974205" + "ledmatrix_min_version": "3.8.0" }, { "id": "weather", @@ -1164,10 +1157,9 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.35.2", - "last_updated": "2026-10-02", - "ledmatrix_min_version": "3.8.0", - "commit": "886d2038d22a35c962beac0ea261543339cffec9" + "latest_version": "1.35.3", + "last_updated": "2026-10-05", + "ledmatrix_min_version": "3.8.0" }, { "id": "tidbyt-baseball-scoreboard", @@ -1210,12 +1202,11 @@ "plugin_path": "plugins/nrl-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-10-02", + "last_updated": "2026-10-05", "verified": true, "screenshot": "", - "latest_version": "1.34.2", - "ledmatrix_min_version": "3.8.0", - "commit": "886d2038d22a35c962beac0ea261543339cffec9" + "latest_version": "1.34.3", + "ledmatrix_min_version": "3.8.0" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/CHANGELOG.md b/plugins/afl-scoreboard/CHANGELOG.md index b0ca39f0..3556c938 100644 --- a/plugins/afl-scoreboard/CHANGELOG.md +++ b/plugins/afl-scoreboard/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.35.3] - 2026-10-05 + +### Changed +- No change in behaviour. `SportsLive._is_game_really_over` is now one body in + all nine scoreboards; each declares `FINAL_PERIOD`, the period from which a + 0:00 clock ends a game (None: the clock never does). Here it is None: the + clock counts up. + ## [1.35.2] - 2026-10-02 ### Documentation diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index 32a8b6b7..6b4c7e54 100644 --- a/plugins/afl-scoreboard/manifest.json +++ b/plugins/afl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "afl-scoreboard", "name": "AFL Scoreboard", - "version": "1.35.2", + "version": "1.35.3", "author": "ChuckBuilds", "description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "afl_upcoming" ], "versions": [ + { + "version": "1.35.3", + "released": "2026-10-05", + "ledmatrix_min_version": "3.8.0", + "notes": "No change in behaviour. SportsLive._is_game_really_over is now one body in all nine scoreboards; each declares FINAL_PERIOD, the period from which a 0:00 clock ends a game (None: the clock never does). Here it is None: the clock counts up." + }, { "version": "1.35.2", "released": "2026-10-02", @@ -537,7 +543,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-10-02", + "last_updated": "2026-10-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/afl-scoreboard/sports.py b/plugins/afl-scoreboard/sports.py index 2e2395a5..f0561877 100644 --- a/plugins/afl-scoreboard/sports.py +++ b/plugins/afl-scoreboard/sports.py @@ -3113,6 +3113,9 @@ def display(self, force_clear=False) -> bool: class SportsLive(SportsCelebrationMixin, SportsLiveSharedMixin, SportsCore): SKIN_MODE = "live" + #: Period from which a 0:00 clock ends a game (see _is_game_really_over). + #: None: the clock counts up, so only the final status ends a game. + FINAL_PERIOD: Optional[int] = None def __init__( self, @@ -3554,15 +3557,15 @@ def format_score(score): self.logger.error(f"Error displaying live game: {e}", exc_info=True) def _is_game_really_over(self, game: Dict) -> bool: - """Check if a game appears to be over even if API says it's live. + """Whether a game ESPN still lists as live has in fact ended. - AFL-specific: clock counts UP (e.g., 75', 90+3'), so we check for - 'final' in period_text. The 0:00 clock check used in countdown-clock - sports doesn't apply here. + It has when its period text says final. From period ``FINAL_PERIOD`` + on, a clock at 0:00 ends it too, unless the score is level: a tie at + the end of regulation goes to overtime, and a game that does end tied + says final. With ``FINAL_PERIOD = None`` the clock never ends a game. """ game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}" - # Check if period_text indicates final # ESPN can send the key as null, and .get()'s default only covers a # missing key, so a None here crashed the whole live update. raw_period_text = game.get("period_text") @@ -3574,9 +3577,34 @@ def _is_game_really_over(self, game: Dict) -> bool: ) return True + # Same for a null or non-numeric period: treat it as period 0. + try: + period = int(game.get("period") or 0) + except (TypeError, ValueError, OverflowError): + period = 0 + # Only a clock string is read: "0:00" and ":00" are zero; ":40" is not. + clock = game.get("clock") + clock_at_zero = isinstance(clock, str) and clock.replace(":", "").strip() in ("000", "00") + + if self.FINAL_PERIOD is not None and period >= self.FINAL_PERIOD and clock_at_zero: + try: + tied = int(game["away_score"]) == int(game["home_score"]) + except (KeyError, TypeError, ValueError, OverflowError): + tied = False # a missing or unreadable score leaves it to the clock + if not tied: + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning True - clock at 0:00 (clock='{clock}', period={period})" + ) + return True + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning False - tied at 0:00 (period={period}), overtime next" + ) + return False + self.logger.debug( - f"_is_game_really_over({game_str}): returning False " - f"(period_text='{period_text}', period={game.get('period', 0)})" + f"_is_game_really_over({game_str}): returning False" ) return False diff --git a/plugins/afl-scoreboard/test_null_period_really_over.py b/plugins/afl-scoreboard/test_null_period_really_over.py index 058a164f..8fc23333 100644 --- a/plugins/afl-scoreboard/test_null_period_really_over.py +++ b/plugins/afl-scoreboard/test_null_period_really_over.py @@ -67,8 +67,9 @@ def check(label, is_over, game, expected): FAILURES.append(label) -# The method only touches self.logger, so a stand-in instance is enough. -_probe = SimpleNamespace(logger=logging.getLogger("null_period_probe")) +# The method reads only self.logger and FINAL_PERIOD, so a stand-in is enough. +_probe = SimpleNamespace(logger=logging.getLogger("null_period_probe"), + FINAL_PERIOD=sports.SportsLive.FINAL_PERIOD) def shared_is_over(game): diff --git a/plugins/baseball-scoreboard/CHANGELOG.md b/plugins/baseball-scoreboard/CHANGELOG.md index 466e3911..7a188e88 100644 --- a/plugins/baseball-scoreboard/CHANGELOG.md +++ b/plugins/baseball-scoreboard/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [1.57.3] - 2026-10-05 + +### Changed +- No change in behaviour: baseball games carry no period, so the 0:00 clock + rule never applied. `SportsLive._is_game_really_over` is now one body in all + nine scoreboards; each declares `FINAL_PERIOD`, the period from which a 0:00 + clock ends a game (None: the clock never does). Here it is None. The + postponed/suspended check in `BaseballLive` is unchanged. + ## [1.57.0] - 2026-10-02 ### Added diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index 0faadaed..e1e93aac 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "baseball-scoreboard", "name": "Baseball Scoreboard", - "version": "1.57.2", + "version": "1.57.3", "update_interval": 60, "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", @@ -31,6 +31,12 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "version": "1.57.3", + "released": "2026-10-05", + "ledmatrix_min_version": "3.8.0", + "notes": "No change in behaviour: baseball games carry no period, so the 0:00 clock rule never applied. SportsLive._is_game_really_over is now one body in all nine scoreboards; each declares FINAL_PERIOD, the period from which a 0:00 clock ends a game (None: the clock never does). Here it is None. The postponed/suspended check in BaseballLive is unchanged." + }, { "version": "1.57.2", "released": "2026-10-04", @@ -854,7 +860,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-10-04", + "last_updated": "2026-10-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/baseball-scoreboard/sports.py b/plugins/baseball-scoreboard/sports.py index 679264d7..b5add8f6 100644 --- a/plugins/baseball-scoreboard/sports.py +++ b/plugins/baseball-scoreboard/sports.py @@ -2988,6 +2988,9 @@ def display(self, force_clear=False) -> bool: class SportsLive(SportsLiveSharedMixin, SportsCore): SKIN_MODE = "live" + #: Period from which a 0:00 clock ends a game (see _is_game_really_over). + #: None: innings, not a clock. + FINAL_PERIOD: Optional[int] = None def __init__( self, @@ -3047,54 +3050,54 @@ def __init__( self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default def _is_game_really_over(self, game: Dict) -> bool: - """Check if a game appears to be over even if API says it's live.""" + """Whether a game ESPN still lists as live has in fact ended. + + It has when its period text says final. From period ``FINAL_PERIOD`` + on, a clock at 0:00 ends it too, unless the score is level: a tie at + the end of regulation goes to overtime, and a game that does end tied + says final. With ``FINAL_PERIOD = None`` the clock never ends a game. + """ game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}" - # Check if period_text indicates final # ESPN can send the key as null, and .get()'s default only covers a # missing key, so a None here crashed the whole live update. raw_period_text = game.get("period_text") period_text = raw_period_text.lower() if isinstance(raw_period_text, str) else "" if "final" in period_text: self.logger.debug( - f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): " + f"_is_game_really_over({game_str}): " f"returning True - 'final' in period_text='{period_text}'" ) return True - # Check if clock is 0:00 in Q4 or OT - # Safely coerce clock to string to handle None or non-string values - raw_clock = game.get("clock") - if raw_clock is None or not isinstance(raw_clock, str): - clock = "0:00" - else: - clock = raw_clock # Same for a null or non-numeric period: treat it as period 0. try: period = int(game.get("period") or 0) except (TypeError, ValueError, OverflowError): period = 0 - # Handle various clock formats: "0:00", ":00", "0", ":40" (stuck at :40) - clock_normalized = clock.replace(":", "").strip() - - self.logger.debug( - f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): " - f"raw_clock={raw_clock!r}, clock='{clock}', clock_normalized='{clock_normalized}', period={period}, period_text='{period_text}'" - ) + # Only a clock string is read: "0:00" and ":00" are zero; ":40" is not. + clock = game.get("clock") + clock_at_zero = isinstance(clock, str) and clock.replace(":", "").strip() in ("000", "00") - if period >= 4: - # In Q4 or OT, if clock is 0:00 or appears stuck (like :40), consider it over - # Check for clock at 0:00 - various formats: "0:00", ":00", normalized "000"/"00" - # Note: Clocks like ":40", ":50" are legitimate (under 1 minute remaining) - if clock_normalized == "000" or clock_normalized == "00" or clock == "0:00" or clock == ":00": + if self.FINAL_PERIOD is not None and period >= self.FINAL_PERIOD and clock_at_zero: + try: + tied = int(game["away_score"]) == int(game["home_score"]) + except (KeyError, TypeError, ValueError, OverflowError): + tied = False # a missing or unreadable score leaves it to the clock + if not tied: self.logger.debug( - f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): " - f"returning True - clock appears to be 0:00 (clock='{clock}', normalized='{clock_normalized}', period={period})" + f"_is_game_really_over({game_str}): " + f"returning True - clock at 0:00 (clock='{clock}', period={period})" ) return True + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning False - tied at 0:00 (period={period}), overtime next" + ) + return False self.logger.debug( - f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): returning False" + f"_is_game_really_over({game_str}): returning False" ) return False diff --git a/plugins/baseball-scoreboard/test_null_period_really_over.py b/plugins/baseball-scoreboard/test_null_period_really_over.py index dd953dff..e06952e9 100644 --- a/plugins/baseball-scoreboard/test_null_period_really_over.py +++ b/plugins/baseball-scoreboard/test_null_period_really_over.py @@ -12,7 +12,8 @@ the live list was never replaced and the panel kept showing the last one. These checks pin that a null or junk value is treated as empty / period 0, and -that the baseball end-of-game rules themselves are unchanged. +that the baseball end-of-game rules themselves hold: FINAL_PERIOD is None, +so only a final period text ends a game, never a 0:00 clock. Run: /bin/python plugins/baseball-scoreboard/test_null_period_really_over.py Exit 0 pass, 1 fail, 2 skip (no LEDMatrix core checkout found). @@ -68,8 +69,9 @@ def check(label, is_over, game, expected): FAILURES.append(label) -# The method only touches self.logger, so a stand-in instance is enough. -_probe = SimpleNamespace(logger=logging.getLogger("null_period_probe")) +# The method reads only self.logger and FINAL_PERIOD, so a stand-in is enough. +_probe = SimpleNamespace(logger=logging.getLogger("null_period_probe"), + FINAL_PERIOD=sports.SportsLive.FINAL_PERIOD) def shared_is_over(game): @@ -83,16 +85,16 @@ def shared_is_over(game): {"period_text": "Top 2nd", "period": 2, "clock": "5:00"}, False) check("'Final' in period_text is over", shared_is_over, {"period_text": "Final", "period": 4, "clock": "0:00"}, True) -check("0:00 in period 4 is over", shared_is_over, - {"period_text": "Bot 9th", "period": 4, "clock": "0:00"}, True) +check("0:00 in period 4 is not over", shared_is_over, + {"period_text": "Bot 9th", "period": 4, "clock": "0:00"}, False) check("0:00 in period 1 is not over", shared_is_over, {"period_text": "Top 2nd", "period": 1, "clock": "0:00"}, False) # Null / junk values. check("period_text None, live", shared_is_over, {"period_text": None, "period": 2, "clock": "5:00"}, False) -check("period_text None still reaches the 0:00 check", shared_is_over, - {"period_text": None, "period": 4, "clock": "0:00"}, True) +check("period_text None, 0:00 in period 4 is not over", shared_is_over, + {"period_text": None, "period": 4, "clock": "0:00"}, False) check("period None is period 0 (not over at 0:00)", shared_is_over, {"period_text": "Top 2nd", "period": None, "clock": "0:00"}, False) check("non-numeric period string is period 0", shared_is_over, diff --git a/plugins/basketball-scoreboard/CHANGELOG.md b/plugins/basketball-scoreboard/CHANGELOG.md index 7e49d6be..8ddcdc55 100644 --- a/plugins/basketball-scoreboard/CHANGELOG.md +++ b/plugins/basketball-scoreboard/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.42.2] - 2026-10-05 + +### Fixed +- A game level at 0:00 at the end of the fourth quarter (or of an overtime) + stays on the live display through the break instead of leaving it as if + over; it leaves when ESPN calls it final, as a game that really ends tied + does. `SportsLive._is_game_really_over` is now one body in all nine + scoreboards; each declares `FINAL_PERIOD`, the period from which a 0:00 + clock ends a game (None: the clock never does). Here it is 4. + ## [1.42.0] - 2026-10-02 ### Added diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 208616cb..305889ea 100644 --- a/plugins/basketball-scoreboard/manifest.json +++ b/plugins/basketball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "basketball-scoreboard", "name": "Basketball Scoreboard", - "version": "1.42.1", + "version": "1.42.2", "update_interval": 60, "description": "Live, recent, and upcoming basketball games across NBA, NCAA Men's, NCAA Women's, and WNBA with real-time scores, schedules, and March Madness tournament support", "author": "ChuckBuilds", @@ -19,6 +19,12 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "version": "1.42.2", + "released": "2026-10-05", + "ledmatrix_min_version": "3.8.0", + "notes": "A game level at 0:00 at the end of the fourth quarter (or of an overtime) stays on the live display through the break instead of leaving it as if over; it leaves when ESPN calls it final, as a game that really ends tied does. SportsLive._is_game_really_over is now one body in all nine scoreboards; each declares FINAL_PERIOD, the period from which a 0:00 clock ends a game (None: the clock never does). Here it is 4." + }, { "version": "1.42.1", "released": "2026-10-03", @@ -623,7 +629,7 @@ ], "stars": 0, "downloads": 0, - "last_updated": "2026-10-02", + "last_updated": "2026-10-05", "verified": true, "screenshot": "", "display_modes": [ diff --git a/plugins/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index 876873eb..99b27e5c 100644 --- a/plugins/basketball-scoreboard/sports.py +++ b/plugins/basketball-scoreboard/sports.py @@ -3267,6 +3267,8 @@ def display(self, force_clear=False) -> bool: class SportsLive(SportsLiveSharedMixin, SportsCore): SKIN_MODE = "live" + #: Period from which a 0:00 clock ends a game (see _is_game_really_over). + FINAL_PERIOD: Optional[int] = 4 def __init__( self, @@ -3392,13 +3394,15 @@ def _build_weighted_schedule(self, games: List[Dict]) -> List[str]: return schedule def _is_game_really_over(self, game: Dict) -> bool: - """Check if a game appears to be over even if API says it's live. + """Whether a game ESPN still lists as live has in fact ended. - Basketball: Games end in Q4 or OT when clock hits 0:00. + It has when its period text says final. From period ``FINAL_PERIOD`` + on, a clock at 0:00 ends it too, unless the score is level: a tie at + the end of regulation goes to overtime, and a game that does end tied + says final. With ``FINAL_PERIOD = None`` the clock never ends a game. """ game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}" - # Check if period_text indicates final # ESPN can send the key as null, and .get()'s default only covers a # missing key, so a None here crashed the whole live update. raw_period_text = game.get("period_text") @@ -3410,24 +3414,31 @@ def _is_game_really_over(self, game: Dict) -> bool: ) return True - # Check if clock is 0:00 in Q4 or OT (period >= 4) - raw_clock = game.get("clock") # Same for a null or non-numeric period: treat it as period 0. try: period = int(game.get("period") or 0) except (TypeError, ValueError, OverflowError): period = 0 + # Only a clock string is read: "0:00" and ":00" are zero; ":40" is not. + clock = game.get("clock") + clock_at_zero = isinstance(clock, str) and clock.replace(":", "").strip() in ("000", "00") - # Only check clock-based finish if we have a valid clock string - if isinstance(raw_clock, str) and raw_clock.strip() and period >= 4: - clock = raw_clock - clock_normalized = clock.replace(":", "").strip() - if clock_normalized in ("000", "00") or clock in ("0:00", ":00"): + if self.FINAL_PERIOD is not None and period >= self.FINAL_PERIOD and clock_at_zero: + try: + tied = int(game["away_score"]) == int(game["home_score"]) + except (KeyError, TypeError, ValueError, OverflowError): + tied = False # a missing or unreadable score leaves it to the clock + if not tied: self.logger.debug( f"_is_game_really_over({game_str}): " f"returning True - clock at 0:00 (clock='{clock}', period={period})" ) return True + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning False - tied at 0:00 (period={period}), overtime next" + ) + return False self.logger.debug( f"_is_game_really_over({game_str}): returning False" diff --git a/plugins/basketball-scoreboard/test_null_period_really_over.py b/plugins/basketball-scoreboard/test_null_period_really_over.py index a8cf3882..59bf9516 100644 --- a/plugins/basketball-scoreboard/test_null_period_really_over.py +++ b/plugins/basketball-scoreboard/test_null_period_really_over.py @@ -67,8 +67,9 @@ def check(label, is_over, game, expected): FAILURES.append(label) -# The method only touches self.logger, so a stand-in instance is enough. -_probe = SimpleNamespace(logger=logging.getLogger("null_period_probe")) +# The method reads only self.logger and FINAL_PERIOD, so a stand-in is enough. +_probe = SimpleNamespace(logger=logging.getLogger("null_period_probe"), + FINAL_PERIOD=sports.SportsLive.FINAL_PERIOD) def shared_is_over(game): @@ -86,6 +87,9 @@ def shared_is_over(game): {"period_text": "Q4", "period": 4, "clock": "0:00"}, True) check("0:00 in period 1 is not over", shared_is_over, {"period_text": "Q2", "period": 1, "clock": "0:00"}, False) +check("tied at 0:00 in period 4 is not over (overtime next)", shared_is_over, + {"period_text": "Q4", "period": 4, "clock": "0:00", + "away_score": "2", "home_score": "2"}, False) # Null / junk values. check("period_text None, live", shared_is_over, diff --git a/plugins/basketball-scoreboard/test_vegas_elements.py b/plugins/basketball-scoreboard/test_vegas_elements.py index 5447950b..c84a7a02 100644 --- a/plugins/basketball-scoreboard/test_vegas_elements.py +++ b/plugins/basketball-scoreboard/test_vegas_elements.py @@ -217,15 +217,16 @@ def test_a_game_over_at_the_buzzer_is_held_as_the_final_it_is(monkeypatch): def test_a_tie_at_the_end_of_regulation_is_not_held_as_a_final(plugin): - """Level at Q4 0:00 is overtime coming, not a result: nothing is held, and - the card is back, live, once the next poll has the game in overtime.""" + """Level at Q4 0:00 is overtime coming, not a result: the game stays live + through the break (_is_game_really_over's tie guard), nothing is held, + and its card is still there, live, once the next poll has it in overtime.""" event = plugin.events["6002"] for side in event["competitions"][0]["competitors"]: side["score"] = "100" event["status"].update(copy.deepcopy(BUZZER)) _poll(plugin) assert plugin.nba_live.finished_games_snapshot() == [] - assert "game:nba:6002" not in _cards(plugin) + assert "game:nba:6002" in _cards(plugin) event["status"].update(displayClock="5:00", period=5, type=dict( BUZZER["type"], detail="OT 5:00", shortDetail="OT 5:00")) _poll(plugin) diff --git a/plugins/football-scoreboard/CHANGELOG.md b/plugins/football-scoreboard/CHANGELOG.md index 927ac757..66ae225b 100644 --- a/plugins/football-scoreboard/CHANGELOG.md +++ b/plugins/football-scoreboard/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [3.18.8] - 2026-10-05 + +### Fixed +- A game level at 0:00 at the end of the fourth quarter (or of an overtime) + stays on the live display through the break instead of leaving it as if + over; it leaves when ESPN calls it final, as a game that really ends tied + does. `SportsLive._is_game_really_over` is now one body in all nine + scoreboards; each declares `FINAL_PERIOD`, the period from which a 0:00 + clock ends a game (None: the clock never does). Here it is 4. + ## [3.18.7] - 2026-10-04 ### Fixed diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index dd727342..54cc48df 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "football-scoreboard", "name": "Football Scoreboard", - "version": "3.18.7", + "version": "3.18.8", "update_interval": 60, "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", @@ -25,6 +25,12 @@ "ncaa_fb_live" ], "versions": [ + { + "version": "3.18.8", + "released": "2026-10-05", + "ledmatrix_min_version": "3.8.0", + "notes": "A game level at 0:00 at the end of the fourth quarter (or of an overtime) stays on the live display through the break instead of leaving it as if over; it leaves when ESPN calls it final, as a game that really ends tied does. SportsLive._is_game_really_over is now one body in all nine scoreboards; each declares FINAL_PERIOD, the period from which a 0:00 clock ends a game (None: the clock never does). Here it is 4." + }, { "version": "3.18.7", "released": "2026-10-04", @@ -953,7 +959,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-10-02", + "last_updated": "2026-10-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index ac2f26ae..1f66f336 100644 --- a/plugins/football-scoreboard/sports.py +++ b/plugins/football-scoreboard/sports.py @@ -3198,6 +3198,8 @@ def display(self, force_clear=False) -> bool: class SportsLive(SportsCelebrationMixin, SportsLiveSharedMixin, SportsCore): SKIN_MODE = "live" + #: Period from which a 0:00 clock ends a game (see _is_game_really_over). + FINAL_PERIOD: Optional[int] = 4 def __init__( self, @@ -3624,50 +3626,54 @@ def display(self, force_clear: bool = False) -> bool: return super().display(force_clear) def _is_game_really_over(self, game: Dict) -> bool: - """Check if a game appears to be over even if API says it's live.""" + """Whether a game ESPN still lists as live has in fact ended. + + It has when its period text says final. From period ``FINAL_PERIOD`` + on, a clock at 0:00 ends it too, unless the score is level: a tie at + the end of regulation goes to overtime, and a game that does end tied + says final. With ``FINAL_PERIOD = None`` the clock never ends a game. + """ game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}" - # Check if period_text indicates final # ESPN can send the key as null, and .get()'s default only covers a # missing key, so a None here crashed the whole live update. raw_period_text = game.get("period_text") period_text = raw_period_text.lower() if isinstance(raw_period_text, str) else "" if "final" in period_text: self.logger.debug( - f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): " + f"_is_game_really_over({game_str}): " f"returning True - 'final' in period_text='{period_text}'" ) return True - # Check if clock is 0:00 in Q4 or OT - raw_clock = game.get("clock") # Same for a null or non-numeric period: treat it as period 0. try: period = int(game.get("period") or 0) except (TypeError, ValueError, OverflowError): period = 0 + # Only a clock string is read: "0:00" and ":00" are zero; ":40" is not. + clock = game.get("clock") + clock_at_zero = isinstance(clock, str) and clock.replace(":", "").strip() in ("000", "00") - # Only check clock-based finish if we have a valid clock string - if isinstance(raw_clock, str) and raw_clock.strip() and period >= 4: - clock = raw_clock - clock_normalized = clock.replace(":", "").strip() - - self.logger.debug( - f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): " - f"raw_clock={raw_clock!r}, clock='{clock}', clock_normalized='{clock_normalized}', period={period}, period_text='{period_text}'" - ) - - # Check for clock at 0:00 - various formats: "0:00", ":00", normalized "000"/"00" - # Note: Clocks like ":40", ":50" are legitimate (under 1 minute remaining) - if clock_normalized in ("000", "00") or clock in ("0:00", ":00"): + if self.FINAL_PERIOD is not None and period >= self.FINAL_PERIOD and clock_at_zero: + try: + tied = int(game["away_score"]) == int(game["home_score"]) + except (KeyError, TypeError, ValueError, OverflowError): + tied = False # a missing or unreadable score leaves it to the clock + if not tied: self.logger.debug( - f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): " - f"returning True - clock appears to be 0:00 (clock='{clock}', normalized='{clock_normalized}', period={period})" + f"_is_game_really_over({game_str}): " + f"returning True - clock at 0:00 (clock='{clock}', period={period})" ) return True + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning False - tied at 0:00 (period={period}), overtime next" + ) + return False self.logger.debug( - f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): returning False" + f"_is_game_really_over({game_str}): returning False" ) return False diff --git a/plugins/football-scoreboard/test_null_period_really_over.py b/plugins/football-scoreboard/test_null_period_really_over.py index b5a6c920..73c1a967 100644 --- a/plugins/football-scoreboard/test_null_period_really_over.py +++ b/plugins/football-scoreboard/test_null_period_really_over.py @@ -67,8 +67,9 @@ def check(label, is_over, game, expected): FAILURES.append(label) -# The method only touches self.logger, so a stand-in instance is enough. -_probe = SimpleNamespace(logger=logging.getLogger("null_period_probe")) +# The method reads only self.logger and FINAL_PERIOD, so a stand-in is enough. +_probe = SimpleNamespace(logger=logging.getLogger("null_period_probe"), + FINAL_PERIOD=sports.SportsLive.FINAL_PERIOD) def shared_is_over(game): @@ -86,6 +87,9 @@ def shared_is_over(game): {"period_text": "Q4", "period": 4, "clock": "0:00"}, True) check("0:00 in period 1 is not over", shared_is_over, {"period_text": "Q2", "period": 1, "clock": "0:00"}, False) +check("tied at 0:00 in period 4 is not over (overtime next)", shared_is_over, + {"period_text": "Q4", "period": 4, "clock": "0:00", + "away_score": "2", "home_score": "2"}, False) # Null / junk values. check("period_text None, live", shared_is_over, diff --git a/plugins/hockey-scoreboard/CHANGELOG.md b/plugins/hockey-scoreboard/CHANGELOG.md index 90e32e27..398cc831 100644 --- a/plugins/hockey-scoreboard/CHANGELOG.md +++ b/plugins/hockey-scoreboard/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.42.4] - 2026-10-05 + +### Fixed +- A game level at 0:00 at the end of the third period (or of an overtime) + stays on the live display through the break instead of leaving it as if + over; it leaves when ESPN calls it final, as a game that really ends tied + does. `SportsLive._is_game_really_over` is now one body in all nine + scoreboards; each declares `FINAL_PERIOD`, the period from which a 0:00 + clock ends a game (None: the clock never does). Here it is 3. + ## [1.42.2] - 2026-10-02 ### Documentation diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 56c9a576..11f79a0d 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "hockey-scoreboard", "name": "Hockey Scoreboard", - "version": "1.42.3", + "version": "1.42.4", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -54,6 +54,12 @@ } ], "versions": [ + { + "version": "1.42.4", + "released": "2026-10-05", + "ledmatrix_min_version": "3.8.0", + "notes": "A game level at 0:00 at the end of the third period (or of an overtime) stays on the live display through the break instead of leaving it as if over; it leaves when ESPN calls it final, as a game that really ends tied does. SportsLive._is_game_really_over is now one body in all nine scoreboards; each declares FINAL_PERIOD, the period from which a 0:00 clock ends a game (None: the clock never does). Here it is 3." + }, { "version": "1.42.3", "released": "2026-10-03", @@ -699,7 +705,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-10-02", + "last_updated": "2026-10-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index b0c4db31..f88155df 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -2861,6 +2861,8 @@ def display(self, force_clear=False) -> bool: class SportsLive(SportsCelebrationMixin, SportsLiveSharedMixin, SportsCore): SKIN_MODE = "live" + #: Period from which a 0:00 clock ends a game (see _is_game_really_over). + FINAL_PERIOD: Optional[int] = 3 def __init__( self, @@ -3006,13 +3008,15 @@ def _build_rotation_schedule(self, games: List[Dict]) -> List[str]: return schedule def _is_game_really_over(self, game: Dict) -> bool: - """Check if a game appears to be over even if API says it's live. + """Whether a game ESPN still lists as live has in fact ended. - Hockey: Games end in P3 or OT when clock hits 0:00 (period >= 3). + It has when its period text says final. From period ``FINAL_PERIOD`` + on, a clock at 0:00 ends it too, unless the score is level: a tie at + the end of regulation goes to overtime, and a game that does end tied + says final. With ``FINAL_PERIOD = None`` the clock never ends a game. """ game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}" - # Check if period_text indicates final # ESPN can send the key as null, and .get()'s default only covers a # missing key, so a None here crashed the whole live update. raw_period_text = game.get("period_text") @@ -3024,24 +3028,31 @@ def _is_game_really_over(self, game: Dict) -> bool: ) return True - # Check if clock is 0:00 in P3 or OT (period >= 3) - raw_clock = game.get("clock") # Same for a null or non-numeric period: treat it as period 0. try: period = int(game.get("period") or 0) except (TypeError, ValueError, OverflowError): period = 0 + # Only a clock string is read: "0:00" and ":00" are zero; ":40" is not. + clock = game.get("clock") + clock_at_zero = isinstance(clock, str) and clock.replace(":", "").strip() in ("000", "00") - # Only check clock-based finish if we have a valid clock string - if isinstance(raw_clock, str) and raw_clock.strip() and period >= 3: - clock = raw_clock - clock_normalized = clock.replace(":", "").strip() - if clock_normalized in ("000", "00") or clock in ("0:00", ":00"): + if self.FINAL_PERIOD is not None and period >= self.FINAL_PERIOD and clock_at_zero: + try: + tied = int(game["away_score"]) == int(game["home_score"]) + except (KeyError, TypeError, ValueError, OverflowError): + tied = False # a missing or unreadable score leaves it to the clock + if not tied: self.logger.debug( f"_is_game_really_over({game_str}): " f"returning True - clock at 0:00 (clock='{clock}', period={period})" ) return True + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning False - tied at 0:00 (period={period}), overtime next" + ) + return False self.logger.debug( f"_is_game_really_over({game_str}): returning False" diff --git a/plugins/hockey-scoreboard/test_null_period_really_over.py b/plugins/hockey-scoreboard/test_null_period_really_over.py index fd84d5eb..e9914a99 100644 --- a/plugins/hockey-scoreboard/test_null_period_really_over.py +++ b/plugins/hockey-scoreboard/test_null_period_really_over.py @@ -67,8 +67,9 @@ def check(label, is_over, game, expected): FAILURES.append(label) -# The method only touches self.logger, so a stand-in instance is enough. -_probe = SimpleNamespace(logger=logging.getLogger("null_period_probe")) +# The method reads only self.logger and FINAL_PERIOD, so a stand-in is enough. +_probe = SimpleNamespace(logger=logging.getLogger("null_period_probe"), + FINAL_PERIOD=sports.SportsLive.FINAL_PERIOD) def shared_is_over(game): @@ -86,6 +87,9 @@ def shared_is_over(game): {"period_text": "P3", "period": 3, "clock": "0:00"}, True) check("0:00 in period 1 is not over", shared_is_over, {"period_text": "P2", "period": 1, "clock": "0:00"}, False) +check("tied at 0:00 in period 3 is not over (overtime next)", shared_is_over, + {"period_text": "P3", "period": 3, "clock": "0:00", + "away_score": "2", "home_score": "2"}, False) # Null / junk values. check("period_text None, live", shared_is_over, diff --git a/plugins/hockey-scoreboard/test_vegas_elements.py b/plugins/hockey-scoreboard/test_vegas_elements.py index e8b0f8cd..c1e81afe 100644 --- a/plugins/hockey-scoreboard/test_vegas_elements.py +++ b/plugins/hockey-scoreboard/test_vegas_elements.py @@ -196,9 +196,10 @@ def test_a_game_that_goes_final_keeps_its_card_and_shows_final( (4, "End of OT"), # overtime over: a shootout next ]) def test_a_level_game_at_0_00_is_not_held_as_final(plugin, period, short_detail): - """Level at 0:00 is not over in hockey. Held, the card would read "Final - 3-3" and, the game gone from live_games, keep reading it after the - shootout, ahead of the recent list's real result.""" + """Level at 0:00 is not over in hockey: the game stays live through the + break (_is_game_really_over's tie guard). Held, the card would read + "Final 3-3" and keep reading it after the shootout, ahead of the recent + list's real result.""" live_manager = plugin.nhl_live live_manager.test_mode = False live_manager._fetch_data = lambda: {"events": [ @@ -208,9 +209,11 @@ def test_a_level_game_at_0_00_is_not_held_as_final(plugin, period, short_detail) ]} live_manager.last_update = 0 live_manager.update() - assert [g["id"] for g in live_manager.live_games] == ["402"] # dropped, as before + assert [g["id"] for g in live_manager.live_games] == ["401", "402"] # still live assert live_manager.finished_games_snapshot() == [] - assert list(_cards(plugin)) == ["game:nhl:402"] + cards = _cards(plugin) + assert sorted(cards) == ["game:nhl:401", "game:nhl:402"] + assert ("is_final", True) not in cards["game:nhl:401"].version[0] def test_the_harness_contract_holds(plugin): diff --git a/plugins/lacrosse-scoreboard/CHANGELOG.md b/plugins/lacrosse-scoreboard/CHANGELOG.md index a88882c5..cf54f7e5 100644 --- a/plugins/lacrosse-scoreboard/CHANGELOG.md +++ b/plugins/lacrosse-scoreboard/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.36.4] - 2026-10-05 + +### Fixed +- A game level at 0:00 at the end of the fourth quarter (or of an overtime) + stays on the live display through the break instead of leaving it as if + over; it leaves when ESPN calls it final, as a game that really ends tied + does. `SportsLive._is_game_really_over` is now one body in all nine + scoreboards; each declares `FINAL_PERIOD`, the period from which a 0:00 + clock ends a game (None: the clock never does). Here it is 4. + ## [1.36.3] - 2026-10-02 ### Changed diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index b3750197..df381bb1 100644 --- a/plugins/lacrosse-scoreboard/manifest.json +++ b/plugins/lacrosse-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "lacrosse-scoreboard", "name": "Lacrosse Scoreboard", - "version": "1.36.3", + "version": "1.36.4", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/lacrosse-scoreboard", @@ -50,6 +50,12 @@ } ], "versions": [ + { + "version": "1.36.4", + "released": "2026-10-05", + "ledmatrix_min_version": "3.8.0", + "notes": "A game level at 0:00 at the end of the fourth quarter (or of an overtime) stays on the live display through the break instead of leaving it as if over; it leaves when ESPN calls it final, as a game that really ends tied does. SportsLive._is_game_really_over is now one body in all nine scoreboards; each declares FINAL_PERIOD, the period from which a 0:00 clock ends a game (None: the clock never does). Here it is 4." + }, { "version": "1.36.3", "released": "2026-10-02", @@ -592,7 +598,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-10-02", + "last_updated": "2026-10-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/lacrosse-scoreboard/sports.py b/plugins/lacrosse-scoreboard/sports.py index ecc1b0d1..7a506ffd 100644 --- a/plugins/lacrosse-scoreboard/sports.py +++ b/plugins/lacrosse-scoreboard/sports.py @@ -2854,6 +2854,8 @@ def _swrr_schedule(weighted_ids: List[Tuple[str, int]]) -> List[str]: class SportsLive(SportsLiveSharedMixin, SportsCore): SKIN_MODE = "live" + #: Period from which a 0:00 clock ends a game (see _is_game_really_over). + FINAL_PERIOD: Optional[int] = 4 def __init__( self, @@ -2923,13 +2925,15 @@ def _is_favorite_game(self, game) -> bool: ) def _is_game_really_over(self, game: Dict) -> bool: - """Check if a game appears to be over even if API says it's live. + """Whether a game ESPN still lists as live has in fact ended. - Lacrosse: Games end in Q4 or OT when clock hits 0:00 (period >= 4). + It has when its period text says final. From period ``FINAL_PERIOD`` + on, a clock at 0:00 ends it too, unless the score is level: a tie at + the end of regulation goes to overtime, and a game that does end tied + says final. With ``FINAL_PERIOD = None`` the clock never ends a game. """ game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}" - # Check if period_text indicates final # ESPN can send the key as null, and .get()'s default only covers a # missing key, so a None here crashed the whole live update. raw_period_text = game.get("period_text") @@ -2941,24 +2945,31 @@ def _is_game_really_over(self, game: Dict) -> bool: ) return True - # Check if clock is 0:00 in P3 or OT (period >= 3) - raw_clock = game.get("clock") # Same for a null or non-numeric period: treat it as period 0. try: period = int(game.get("period") or 0) except (TypeError, ValueError, OverflowError): period = 0 + # Only a clock string is read: "0:00" and ":00" are zero; ":40" is not. + clock = game.get("clock") + clock_at_zero = isinstance(clock, str) and clock.replace(":", "").strip() in ("000", "00") - # Only check clock-based finish if we have a valid clock string - if isinstance(raw_clock, str) and raw_clock.strip() and period >= 4: - clock = raw_clock - clock_normalized = clock.replace(":", "").strip() - if clock_normalized in ("000", "00") or clock in ("0:00", ":00"): + if self.FINAL_PERIOD is not None and period >= self.FINAL_PERIOD and clock_at_zero: + try: + tied = int(game["away_score"]) == int(game["home_score"]) + except (KeyError, TypeError, ValueError, OverflowError): + tied = False # a missing or unreadable score leaves it to the clock + if not tied: self.logger.debug( f"_is_game_really_over({game_str}): " f"returning True - clock at 0:00 (clock='{clock}', period={period})" ) return True + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning False - tied at 0:00 (period={period}), overtime next" + ) + return False self.logger.debug( f"_is_game_really_over({game_str}): returning False" diff --git a/plugins/lacrosse-scoreboard/test_null_period_really_over.py b/plugins/lacrosse-scoreboard/test_null_period_really_over.py index c337818f..5ad77a6b 100644 --- a/plugins/lacrosse-scoreboard/test_null_period_really_over.py +++ b/plugins/lacrosse-scoreboard/test_null_period_really_over.py @@ -67,8 +67,9 @@ def check(label, is_over, game, expected): FAILURES.append(label) -# The method only touches self.logger, so a stand-in instance is enough. -_probe = SimpleNamespace(logger=logging.getLogger("null_period_probe")) +# The method reads only self.logger and FINAL_PERIOD, so a stand-in is enough. +_probe = SimpleNamespace(logger=logging.getLogger("null_period_probe"), + FINAL_PERIOD=sports.SportsLive.FINAL_PERIOD) def shared_is_over(game): @@ -86,6 +87,9 @@ def shared_is_over(game): {"period_text": "Q4", "period": 4, "clock": "0:00"}, True) check("0:00 in period 1 is not over", shared_is_over, {"period_text": "Q2", "period": 1, "clock": "0:00"}, False) +check("tied at 0:00 in period 4 is not over (overtime next)", shared_is_over, + {"period_text": "Q4", "period": 4, "clock": "0:00", + "away_score": "2", "home_score": "2"}, False) # Null / junk values. check("period_text None, live", shared_is_over, diff --git a/plugins/lacrosse-scoreboard/test_vegas_elements.py b/plugins/lacrosse-scoreboard/test_vegas_elements.py index 2b0f8f1e..5c0c97af 100644 --- a/plugins/lacrosse-scoreboard/test_vegas_elements.py +++ b/plugins/lacrosse-scoreboard/test_vegas_elements.py @@ -162,17 +162,17 @@ def test_a_game_that_goes_final_keeps_its_card_and_shows_final(plugin, over): def test_a_level_game_at_the_end_of_the_fourth_is_not_kept_as_final(plugin): - # Level at 0:00 is the break before sudden-victory overtime: the poll - # still drops it (_is_game_really_over), but it is no result, so its card - # keeps its last live pixels rather than turning to "Final 7-7". + # Level at 0:00 is the break before sudden-victory overtime: the game + # stays live (_is_game_really_over's tie guard) and is no result, so its + # card does not turn to "Final 7-7". live = plugin.slate.pop("401") manager = plugin.ncaa_mens_live manager.live_games = [live] _poll(manager, dict(live, period=4, clock="0:00", period_text="Q4", home_score="7", away_score="7")) - assert manager.live_games == [] + assert [g["id"] for g in manager.live_games] == ["401"] assert manager.finished_games_snapshot() == [] - assert f"game:{LEAGUE}:401" not in _cards(plugin) + assert f"game:{LEAGUE}:401" not in _cards(plugin) # nothing held as a final @pytest.mark.parametrize("size", [(128, 32), (128, 64)]) diff --git a/plugins/nrl-scoreboard/CHANGELOG.md b/plugins/nrl-scoreboard/CHANGELOG.md index bdab8c2f..bd0ea7c8 100644 --- a/plugins/nrl-scoreboard/CHANGELOG.md +++ b/plugins/nrl-scoreboard/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.34.3] - 2026-10-05 + +### Changed +- No change in behaviour. `SportsLive._is_game_really_over` is now one body in + all nine scoreboards; each declares `FINAL_PERIOD`, the period from which a + 0:00 clock ends a game (None: the clock never does). Here it is None: the + clock counts up. + ## [1.34.2] - 2026-10-02 ### Documentation diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 494178ba..0302f685 100644 --- a/plugins/nrl-scoreboard/manifest.json +++ b/plugins/nrl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "nrl-scoreboard", "name": "NRL Scoreboard", - "version": "1.34.2", + "version": "1.34.3", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NRL (National Rugby League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "nrl_upcoming" ], "versions": [ + { + "version": "1.34.3", + "released": "2026-10-05", + "ledmatrix_min_version": "3.8.0", + "notes": "No change in behaviour. SportsLive._is_game_really_over is now one body in all nine scoreboards; each declares FINAL_PERIOD, the period from which a 0:00 clock ends a game (None: the clock never does). Here it is None: the clock counts up." + }, { "version": "1.34.2", "released": "2026-10-02", @@ -547,7 +553,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-10-02", + "last_updated": "2026-10-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/nrl-scoreboard/sports.py b/plugins/nrl-scoreboard/sports.py index 1a7450bd..c5a1e8d7 100644 --- a/plugins/nrl-scoreboard/sports.py +++ b/plugins/nrl-scoreboard/sports.py @@ -3219,6 +3219,9 @@ def display(self, force_clear=False) -> bool: class SportsLive(SportsCelebrationMixin, SportsLiveSharedMixin, SportsCore): SKIN_MODE = "live" + #: Period from which a 0:00 clock ends a game (see _is_game_really_over). + #: None: the clock counts up, so only the final status ends a game. + FINAL_PERIOD: Optional[int] = None def __init__( self, @@ -3662,15 +3665,15 @@ def format_score(score): self.logger.error(f"Error displaying live game: {e}", exc_info=True) def _is_game_really_over(self, game: Dict) -> bool: - """Check if a game appears to be over even if API says it's live. + """Whether a game ESPN still lists as live has in fact ended. - NRL (like soccer): the clock counts UP (e.g., 40', 80'), so we check for - 'final' in period_text. The 0:00 clock check used in countdown-clock - sports doesn't apply here. + It has when its period text says final. From period ``FINAL_PERIOD`` + on, a clock at 0:00 ends it too, unless the score is level: a tie at + the end of regulation goes to overtime, and a game that does end tied + says final. With ``FINAL_PERIOD = None`` the clock never ends a game. """ game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}" - # Check if period_text indicates final # ESPN can send the key as null, and .get()'s default only covers a # missing key, so a None here crashed the whole live update. raw_period_text = game.get("period_text") @@ -3682,9 +3685,34 @@ def _is_game_really_over(self, game: Dict) -> bool: ) return True + # Same for a null or non-numeric period: treat it as period 0. + try: + period = int(game.get("period") or 0) + except (TypeError, ValueError, OverflowError): + period = 0 + # Only a clock string is read: "0:00" and ":00" are zero; ":40" is not. + clock = game.get("clock") + clock_at_zero = isinstance(clock, str) and clock.replace(":", "").strip() in ("000", "00") + + if self.FINAL_PERIOD is not None and period >= self.FINAL_PERIOD and clock_at_zero: + try: + tied = int(game["away_score"]) == int(game["home_score"]) + except (KeyError, TypeError, ValueError, OverflowError): + tied = False # a missing or unreadable score leaves it to the clock + if not tied: + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning True - clock at 0:00 (clock='{clock}', period={period})" + ) + return True + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning False - tied at 0:00 (period={period}), overtime next" + ) + return False + self.logger.debug( - f"_is_game_really_over({game_str}): returning False " - f"(period_text='{period_text}', period={game.get('period', 0)})" + f"_is_game_really_over({game_str}): returning False" ) return False diff --git a/plugins/nrl-scoreboard/test_null_period_really_over.py b/plugins/nrl-scoreboard/test_null_period_really_over.py index b84df576..eca7223c 100644 --- a/plugins/nrl-scoreboard/test_null_period_really_over.py +++ b/plugins/nrl-scoreboard/test_null_period_really_over.py @@ -67,8 +67,9 @@ def check(label, is_over, game, expected): FAILURES.append(label) -# The method only touches self.logger, so a stand-in instance is enough. -_probe = SimpleNamespace(logger=logging.getLogger("null_period_probe")) +# The method reads only self.logger and FINAL_PERIOD, so a stand-in is enough. +_probe = SimpleNamespace(logger=logging.getLogger("null_period_probe"), + FINAL_PERIOD=sports.SportsLive.FINAL_PERIOD) def shared_is_over(game): diff --git a/plugins/soccer-scoreboard/CHANGELOG.md b/plugins/soccer-scoreboard/CHANGELOG.md index 35e24138..fdc5b36f 100644 --- a/plugins/soccer-scoreboard/CHANGELOG.md +++ b/plugins/soccer-scoreboard/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [2.39.5] - 2026-10-05 + +### Changed +- No change in behaviour. `SportsLive._is_game_really_over` is now one body in + all nine scoreboards; each declares `FINAL_PERIOD`, the period from which a + 0:00 clock ends a game (None: the clock never does). Here it is None: the + clock counts up. + ## [2.39.4] - 2026-10-04 ### Fixed diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index 92893e13..30382cc2 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", - "version": "2.39.4", + "version": "2.39.5", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup", "category": "sports", @@ -53,6 +53,12 @@ "soccer_usa.1_upcoming" ], "versions": [ + { + "version": "2.39.5", + "released": "2026-10-05", + "ledmatrix_min_version": "3.8.0", + "notes": "No change in behaviour. SportsLive._is_game_really_over is now one body in all nine scoreboards; each declares FINAL_PERIOD, the period from which a 0:00 clock ends a game (None: the clock never does). Here it is None: the clock counts up." + }, { "version": "2.39.4", "released": "2026-10-04", @@ -711,7 +717,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-10-02", + "last_updated": "2026-10-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/soccer-scoreboard/sports.py b/plugins/soccer-scoreboard/sports.py index ccc932a8..2ced5766 100644 --- a/plugins/soccer-scoreboard/sports.py +++ b/plugins/soccer-scoreboard/sports.py @@ -3127,6 +3127,9 @@ def display(self, force_clear=False) -> bool: class SportsLive(SportsCelebrationMixin, SportsLiveSharedMixin, SportsCore): SKIN_MODE = "live" + #: Period from which a 0:00 clock ends a game (see _is_game_really_over). + #: None: the clock counts up, so only the final status ends a game. + FINAL_PERIOD: Optional[int] = None def __init__( self, @@ -3571,15 +3574,15 @@ def format_score(score): self.logger.error(f"Error displaying live game: {e}", exc_info=True) def _is_game_really_over(self, game: Dict) -> bool: - """Check if a game appears to be over even if API says it's live. + """Whether a game ESPN still lists as live has in fact ended. - Soccer-specific: clock counts UP (e.g., 75', 90+3'), so we check for - 'final' in period_text. The 0:00 clock check used in countdown-clock - sports doesn't apply here. + It has when its period text says final. From period ``FINAL_PERIOD`` + on, a clock at 0:00 ends it too, unless the score is level: a tie at + the end of regulation goes to overtime, and a game that does end tied + says final. With ``FINAL_PERIOD = None`` the clock never ends a game. """ game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}" - # Check if period_text indicates final # ESPN can send the key as null, and .get()'s default only covers a # missing key, so a None here crashed the whole live update. raw_period_text = game.get("period_text") @@ -3591,9 +3594,34 @@ def _is_game_really_over(self, game: Dict) -> bool: ) return True + # Same for a null or non-numeric period: treat it as period 0. + try: + period = int(game.get("period") or 0) + except (TypeError, ValueError, OverflowError): + period = 0 + # Only a clock string is read: "0:00" and ":00" are zero; ":40" is not. + clock = game.get("clock") + clock_at_zero = isinstance(clock, str) and clock.replace(":", "").strip() in ("000", "00") + + if self.FINAL_PERIOD is not None and period >= self.FINAL_PERIOD and clock_at_zero: + try: + tied = int(game["away_score"]) == int(game["home_score"]) + except (KeyError, TypeError, ValueError, OverflowError): + tied = False # a missing or unreadable score leaves it to the clock + if not tied: + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning True - clock at 0:00 (clock='{clock}', period={period})" + ) + return True + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning False - tied at 0:00 (period={period}), overtime next" + ) + return False + self.logger.debug( - f"_is_game_really_over({game_str}): returning False " - f"(period_text='{period_text}', period={game.get('period', 0)})" + f"_is_game_really_over({game_str}): returning False" ) return False diff --git a/plugins/soccer-scoreboard/test_null_period_really_over.py b/plugins/soccer-scoreboard/test_null_period_really_over.py index c1043886..2c18d40b 100644 --- a/plugins/soccer-scoreboard/test_null_period_really_over.py +++ b/plugins/soccer-scoreboard/test_null_period_really_over.py @@ -67,8 +67,9 @@ def check(label, is_over, game, expected): FAILURES.append(label) -# The method only touches self.logger, so a stand-in instance is enough. -_probe = SimpleNamespace(logger=logging.getLogger("null_period_probe")) +# The method reads only self.logger and FINAL_PERIOD, so a stand-in is enough. +_probe = SimpleNamespace(logger=logging.getLogger("null_period_probe"), + FINAL_PERIOD=sports.SportsLive.FINAL_PERIOD) def shared_is_over(game): diff --git a/plugins/ufc-scoreboard/CHANGELOG.md b/plugins/ufc-scoreboard/CHANGELOG.md index 8e373fb8..19a49fa7 100644 --- a/plugins/ufc-scoreboard/CHANGELOG.md +++ b/plugins/ufc-scoreboard/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.19.5] - 2026-10-05 + +### Changed +- A fight leaves the live display only when ESPN calls it final. The shared + check also called one over when its clock read 0:00 (or was missing) from + round 4 on, so a five-round fight could drop off for the moment its clock + sat at 0:00 at the horn. `SportsLive._is_game_really_over` is now one body + in all nine scoreboards; each declares `FINAL_PERIOD`, the period from which + a 0:00 clock ends a game (None: the clock never does). Here it is None. + ## [1.19.4] - 2026-10-04 ### Fixed diff --git a/plugins/ufc-scoreboard/manifest.json b/plugins/ufc-scoreboard/manifest.json index 287fc46c..4253f4c6 100644 --- a/plugins/ufc-scoreboard/manifest.json +++ b/plugins/ufc-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ufc-scoreboard", "name": "UFC Scoreboard", - "version": "1.19.4", + "version": "1.19.5", "author": "LegoGuy1000", "contributors": [ { @@ -32,6 +32,12 @@ "default_duration": 15, "config_schema": "config_schema.json", "versions": [ + { + "version": "1.19.5", + "released": "2026-10-05", + "ledmatrix_min_version": "3.8.0", + "notes": "A fight leaves the live display only when ESPN calls it final. The shared check also called one over when its clock read 0:00 (or was missing) from round 4 on, so a five-round fight could drop off for the moment its clock sat at 0:00 at the horn. SportsLive._is_game_really_over is now one body in all nine scoreboards; each declares FINAL_PERIOD, the period from which a 0:00 clock ends a game (None: the clock never does). Here it is None." + }, { "version": "1.19.4", "released": "2026-10-04", @@ -397,7 +403,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-10-02", + "last_updated": "2026-10-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/ufc-scoreboard/sports.py b/plugins/ufc-scoreboard/sports.py index dddc5db5..38272add 100644 --- a/plugins/ufc-scoreboard/sports.py +++ b/plugins/ufc-scoreboard/sports.py @@ -2631,6 +2631,9 @@ def display(self, force_clear=False) -> bool: class SportsLive(SportsLiveSharedMixin, SportsCore): SKIN_MODE = "live" + #: Period from which a 0:00 clock ends a game (see _is_game_really_over). + #: None: a bout ends only on ESPN's final status, never on a round's clock. + FINAL_PERIOD: Optional[int] = None def display(self, force_clear: bool = False) -> bool: """Render the live fight, giving it a full dwell when the mode re-enters. @@ -2691,54 +2694,54 @@ def __init__( self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default def _is_game_really_over(self, game: Dict) -> bool: - """Check if a game appears to be over even if API says it's live.""" + """Whether a game ESPN still lists as live has in fact ended. + + It has when its period text says final. From period ``FINAL_PERIOD`` + on, a clock at 0:00 ends it too, unless the score is level: a tie at + the end of regulation goes to overtime, and a game that does end tied + says final. With ``FINAL_PERIOD = None`` the clock never ends a game. + """ game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}" - # Check if period_text indicates final # ESPN can send the key as null, and .get()'s default only covers a # missing key, so a None here crashed the whole live update. raw_period_text = game.get("period_text") period_text = raw_period_text.lower() if isinstance(raw_period_text, str) else "" if "final" in period_text: self.logger.debug( - f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): " + f"_is_game_really_over({game_str}): " f"returning True - 'final' in period_text='{period_text}'" ) return True - # Check if clock is 0:00 in Q4 or OT - # Safely coerce clock to string to handle None or non-string values - raw_clock = game.get("clock") - if raw_clock is None or not isinstance(raw_clock, str): - clock = "0:00" - else: - clock = raw_clock # Same for a null or non-numeric period: treat it as period 0. try: period = int(game.get("period") or 0) except (TypeError, ValueError, OverflowError): period = 0 - # Handle various clock formats: "0:00", ":00", "0", ":40" (stuck at :40) - clock_normalized = clock.replace(":", "").strip() - - self.logger.debug( - f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): " - f"raw_clock={raw_clock!r}, clock='{clock}', clock_normalized='{clock_normalized}', period={period}, period_text='{period_text}'" - ) + # Only a clock string is read: "0:00" and ":00" are zero; ":40" is not. + clock = game.get("clock") + clock_at_zero = isinstance(clock, str) and clock.replace(":", "").strip() in ("000", "00") - if period >= 4: - # In Q4 or OT, if clock is 0:00 or appears stuck (like :40), consider it over - # Check for clock at 0:00 - various formats: "0:00", ":00", normalized "000"/"00" - # Note: Clocks like ":40", ":50" are legitimate (under 1 minute remaining) - if clock_normalized == "000" or clock_normalized == "00" or clock == "0:00" or clock == ":00": + if self.FINAL_PERIOD is not None and period >= self.FINAL_PERIOD and clock_at_zero: + try: + tied = int(game["away_score"]) == int(game["home_score"]) + except (KeyError, TypeError, ValueError, OverflowError): + tied = False # a missing or unreadable score leaves it to the clock + if not tied: self.logger.debug( - f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): " - f"returning True - clock appears to be 0:00 (clock='{clock}', normalized='{clock_normalized}', period={period})" + f"_is_game_really_over({game_str}): " + f"returning True - clock at 0:00 (clock='{clock}', period={period})" ) return True + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning False - tied at 0:00 (period={period}), overtime next" + ) + return False self.logger.debug( - f"[LIVE_PRIORITY_DEBUG] _is_game_really_over({game_str}): returning False" + f"_is_game_really_over({game_str}): returning False" ) return False diff --git a/plugins/ufc-scoreboard/test_null_period_really_over.py b/plugins/ufc-scoreboard/test_null_period_really_over.py index 81da3af0..2daf62f5 100644 --- a/plugins/ufc-scoreboard/test_null_period_really_over.py +++ b/plugins/ufc-scoreboard/test_null_period_really_over.py @@ -12,7 +12,8 @@ the live list was never replaced and the panel kept showing the last one. These checks pin that a null or junk value is treated as empty / period 0, and -that the UFC end-of-game rules themselves are unchanged. +that the UFC end-of-game rules themselves hold: FINAL_PERIOD is None, +so only a final period text ends a game, never a 0:00 clock. Run: /bin/python plugins/ufc-scoreboard/test_null_period_really_over.py Exit 0 pass, 1 fail, 2 skip (no LEDMatrix core checkout found). @@ -67,8 +68,9 @@ def check(label, is_over, game, expected): FAILURES.append(label) -# The method only touches self.logger, so a stand-in instance is enough. -_probe = SimpleNamespace(logger=logging.getLogger("null_period_probe")) +# The method reads only self.logger and FINAL_PERIOD, so a stand-in is enough. +_probe = SimpleNamespace(logger=logging.getLogger("null_period_probe"), + FINAL_PERIOD=sports.SportsLive.FINAL_PERIOD) def shared_is_over(game): @@ -82,16 +84,16 @@ def shared_is_over(game): {"period_text": "R2", "period": 2, "clock": "5:00"}, False) check("'Final' in period_text is over", shared_is_over, {"period_text": "Final", "period": 4, "clock": "0:00"}, True) -check("0:00 in period 4 is over", shared_is_over, - {"period_text": "R4", "period": 4, "clock": "0:00"}, True) +check("0:00 in period 4 is not over", shared_is_over, + {"period_text": "R4", "period": 4, "clock": "0:00"}, False) check("0:00 in period 1 is not over", shared_is_over, {"period_text": "R2", "period": 1, "clock": "0:00"}, False) # Null / junk values. check("period_text None, live", shared_is_over, {"period_text": None, "period": 2, "clock": "5:00"}, False) -check("period_text None still reaches the 0:00 check", shared_is_over, - {"period_text": None, "period": 4, "clock": "0:00"}, True) +check("period_text None, 0:00 in period 4 is not over", shared_is_over, + {"period_text": None, "period": 4, "clock": "0:00"}, False) check("period None is period 0 (not over at 0:00)", shared_is_over, {"period_text": "R2", "period": None, "clock": "0:00"}, False) check("non-numeric period string is period 0", shared_is_over, diff --git a/plugins/ufc-scoreboard/test_round_break_stays_live.py b/plugins/ufc-scoreboard/test_round_break_stays_live.py index 21d0fbdf..0d191be0 100644 --- a/plugins/ufc-scoreboard/test_round_break_stays_live.py +++ b/plugins/ufc-scoreboard/test_round_break_stays_live.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 """A fight between rounds stays on the live display, and draws "End R4". -SportsLive._is_game_really_over, shared with the team sports, calls a game over +SportsLive._is_game_really_over, shared with the team sports, called a game over at clock 0:00 from period 4. LEDMatrix#680 flagged that for ufc: if ESPN sent the break between rounds 4 and 5 of a five-round fight as "0:00, period 4", -the live manager would drop the fight for that minute. Checked against ESPN's +the live manager would drop the fight for that minute. ufc now declares +FINAL_PERIOD = None, so no clock ends a fight, but the payload checks stay. Checked against ESPN's MMA scoreboard it does not. The live clock counts down, but a round that goes the distance ends on a Round End play whose clock is "-", and the break arrives as STATUS_END_OF_ROUND, state "in", clock 0.0, displayClock "-". "-" is not a @@ -100,7 +101,8 @@ def bout_id(case): == ("STATUS_END_OF_ROUND", "in", "-"), repr(real)) print("\nthe round 4 break is not 'over'") -_probe = SimpleNamespace(logger=logging.getLogger("round_break_probe")) +_probe = SimpleNamespace(logger=logging.getLogger("round_break_probe"), + FINAL_PERIOD=sports.SportsLive.FINAL_PERIOD) mgr = live_manager() brk = mgr._extract_game_details(CASES["break_after_round_4_of_5"]["event"]) check("round 4 break extracts as period 4, clock '-', live, not final", diff --git a/plugins/ufc-scoreboard/test_vegas_elements.py b/plugins/ufc-scoreboard/test_vegas_elements.py index 9432e1ae..b7b1c309 100644 --- a/plugins/ufc-scoreboard/test_vegas_elements.py +++ b/plugins/ufc-scoreboard/test_vegas_elements.py @@ -227,8 +227,7 @@ def test_a_settings_change_redraws_every_card(plugin): def _clock_run_out(): - """The five-rounder still "in progress" at R5 0:00: the live update's - _is_game_really_over branch, not its is_final one (derived, not recorded).""" + """The five-rounder still "in progress" at R5 0:00 (derived, not recorded).""" event = copy.deepcopy(CASES["break_after_round_4_of_5"]) status = copy.deepcopy(CASES["in_round_3_of_3"]["competitions"][0]["status"]) status.update(period=5, clock=0.0, displayClock="0:00") @@ -237,7 +236,7 @@ def _clock_run_out(): return event -@pytest.mark.parametrize("ended", ["final_five_round_decision", "clock_run_out"]) +@pytest.mark.parametrize("ended", ["final_five_round_decision"]) def test_a_fight_that_ends_keeps_its_card_and_shows_the_result( plugin, tmp_path, requests_made, ended): del plugin._collect_fights_for_scroll # the plugin's own collector @@ -247,7 +246,7 @@ def test_a_fight_that_ends_keeps_its_card_and_shows_the_result( live._fetch_missing_headshots = lambda *a, **k: None live._fetch_odds = lambda *a, **k: None ending, ongoing = "game:ufc:401903509", "game:ufc:401905378" - final = _clock_run_out() if ended == "clock_run_out" else CASES[ended] + final = CASES[ended] _poll(live, CASES["break_after_round_4_of_5"], CASES["in_round_3_of_3"]) before = _cards(plugin) @@ -265,6 +264,20 @@ def test_a_fight_that_ends_keeps_its_card_and_shows_the_result( assert requests_made == [] +def test_a_fight_at_0_00_stays_live_until_espn_calls_it_final(plugin, tmp_path): + """ufc declares FINAL_PERIOD = None: no clock ends a bout, only ESPN's + final status does, so the horn at R5 0:00 does not drop it.""" + del plugin._collect_fights_for_scroll # the plugin's own collector + live = plugin.ufc_live + live.update_interval = 0 + live.logo_dir = tmp_path + live._fetch_missing_headshots = lambda *a, **k: None + live._fetch_odds = lambda *a, **k: None + _poll(live, _clock_run_out(), CASES["in_round_3_of_3"]) + assert [f["id"] for f in live.live_games] == ["401903509", "401905378"] + assert live.finished_games_snapshot() == [] + + def test_the_harness_contract_holds(plugin): report = check_vegas_elements(plugin, plugin.dm) assert report.implemented and report.ok, report.errors diff --git a/scripts/sports_drift_baseline.json b/scripts/sports_drift_baseline.json index a1f491ad..688f88ab 100644 --- a/scripts/sports_drift_baseline.json +++ b/scripts/sports_drift_baseline.json @@ -149,7 +149,6 @@ "sports.py::SportsLive._check_for_win", "sports.py::SportsLive._classify_live_S", "sports.py::SportsLive._draw_scorebug_layout", - "sports.py::SportsLive._is_S_really_over", "sports.py::SportsLive._is_favorite", "sports.py::SportsLive._is_favorite_S", "sports.py::SportsLive._should_celebrate_goal_for", diff --git a/scripts/test_game_over_check.py b/scripts/test_game_over_check.py index fdebea93..6badb13d 100644 --- a/scripts/test_game_over_check.py +++ b/scripts/test_game_over_check.py @@ -1,14 +1,15 @@ #!/usr/bin/env python3 -"""What each scoreboard's live manager calls "over", pinned before it is reconciled. +"""What each scoreboard's live manager calls "over", cell by cell. WHY THIS EXISTS --------------- ``SportsLive._is_game_really_over`` drops a game ESPN still lists as live from the live rotation (and the plugin's live-priority filters). The nine -copies are five different bodies (LEDMatrix docs/SPORTS_UNIFICATION.md, -family 5), and the reconcile that makes them one must change only the cells -the owner decides to change. This records what every plugin answers today, -so that PR's diff of the tables below is the behaviour change, cell by cell. +copies were five different bodies (LEDMatrix docs/SPORTS_UNIFICATION.md, +family 5). They are now one body, and each plugin's ``FINAL_PERIOD`` says +from which period a 0:00 clock ends a game (None: never). The tables below +record what every plugin answers, so any later change to the method or a +plugin's ``FINAL_PERIOD`` shows up in a PR as a diff of them, cell by cell. For each plugin it builds a real live manager (fake display and cache) and calls ``_is_game_really_over`` on: @@ -23,8 +24,9 @@ its own ``_extract_game_details``, including the round breaks ESPN sends as STATUS_END_OF_ROUND with displayClock "-". -The method reads only ``period_text``, ``clock``, ``period`` and (baseball) -``status``, so each status is given the period text its feed would carry. +The method reads only ``period_text``, ``clock``, ``period``, the two scores +(level at 0:00 is not over) and (baseball) ``status``, so each status is +given the period text its feed would carry; the matrix scores are 1-2. Every league's live manager in a plugin must resolve to the same method, so one manager per plugin covers them all; that is checked too. @@ -113,37 +115,37 @@ def game(status, period_text, period, clock, **extra): # -------------------------------------------------------------------------- EXPECTED_MATRIX = { ("STATUS_IN_PROGRESS", "12:00"): "....... ....... ....... ....... ....... ....... ....... ....... .......", - ("STATUS_IN_PROGRESS", "0:00"): "....... ....YYY ....YYY ....YYY ...YYYY ....YYY ....... ....... ....YYY", - ("STATUS_IN_PROGRESS", ":00"): "....... ....YYY ....YYY ....YYY ...YYYY ....YYY ....... ....... ....YYY", + ("STATUS_IN_PROGRESS", "0:00"): "....... ....... ....YYY ....YYY ...YYYY ....YYY ....... ....... .......", + ("STATUS_IN_PROGRESS", ":00"): "....... ....... ....YYY ....YYY ...YYYY ....YYY ....... ....... .......", ("STATUS_IN_PROGRESS", "0.0"): "....... ....... ....... ....... ....... ....... ....... ....... .......", ("STATUS_IN_PROGRESS", "-"): "....... ....... ....... ....... ....... ....... ....... ....... .......", ("STATUS_IN_PROGRESS", "''"): "....... ....... ....... ....... ....... ....... ....... ....... .......", - ("STATUS_IN_PROGRESS", "None"): "....... ....YYY ....... ....... ....... ....... ....... ....... ....YYY", - ("STATUS_IN_PROGRESS", "missing"): "....... ....YYY ....... ....... ....... ....... ....... ....... ....YYY", + ("STATUS_IN_PROGRESS", "None"): "....... ....... ....... ....... ....... ....... ....... ....... .......", + ("STATUS_IN_PROGRESS", "missing"): "....... ....... ....... ....... ....... ....... ....... ....... .......", ("STATUS_END_PERIOD", "12:00"): "....... ....... ....... ....... ....... ....... ....... ....... .......", - ("STATUS_END_PERIOD", "0:00"): "....... ....YYY ....YYY ....YYY ...YYYY ....YYY ....... ....... ....YYY", - ("STATUS_END_PERIOD", ":00"): "....... ....YYY ....YYY ....YYY ...YYYY ....YYY ....... ....... ....YYY", + ("STATUS_END_PERIOD", "0:00"): "....... ....... ....YYY ....YYY ...YYYY ....YYY ....... ....... .......", + ("STATUS_END_PERIOD", ":00"): "....... ....... ....YYY ....YYY ...YYYY ....YYY ....... ....... .......", ("STATUS_END_PERIOD", "0.0"): "....... ....... ....... ....... ....... ....... ....... ....... .......", ("STATUS_END_PERIOD", "-"): "....... ....... ....... ....... ....... ....... ....... ....... .......", ("STATUS_END_PERIOD", "''"): "....... ....... ....... ....... ....... ....... ....... ....... .......", - ("STATUS_END_PERIOD", "None"): "....... ....YYY ....... ....... ....... ....... ....... ....... ....YYY", - ("STATUS_END_PERIOD", "missing"): "....... ....YYY ....... ....... ....... ....... ....... ....... ....YYY", + ("STATUS_END_PERIOD", "None"): "....... ....... ....... ....... ....... ....... ....... ....... .......", + ("STATUS_END_PERIOD", "missing"): "....... ....... ....... ....... ....... ....... ....... ....... .......", ("STATUS_HALFTIME", "12:00"): "....... ....... ....... ....... ....... ....... ....... ....... .......", - ("STATUS_HALFTIME", "0:00"): "....... ....YYY ....YYY ....YYY ...YYYY ....YYY ....... ....... ....YYY", - ("STATUS_HALFTIME", ":00"): "....... ....YYY ....YYY ....YYY ...YYYY ....YYY ....... ....... ....YYY", + ("STATUS_HALFTIME", "0:00"): "....... ....... ....YYY ....YYY ...YYYY ....YYY ....... ....... .......", + ("STATUS_HALFTIME", ":00"): "....... ....... ....YYY ....YYY ...YYYY ....YYY ....... ....... .......", ("STATUS_HALFTIME", "0.0"): "....... ....... ....... ....... ....... ....... ....... ....... .......", ("STATUS_HALFTIME", "-"): "....... ....... ....... ....... ....... ....... ....... ....... .......", ("STATUS_HALFTIME", "''"): "....... ....... ....... ....... ....... ....... ....... ....... .......", - ("STATUS_HALFTIME", "None"): "....... ....YYY ....... ....... ....... ....... ....... ....... ....YYY", - ("STATUS_HALFTIME", "missing"): "....... ....YYY ....... ....... ....... ....... ....... ....... ....YYY", + ("STATUS_HALFTIME", "None"): "....... ....... ....... ....... ....... ....... ....... ....... .......", + ("STATUS_HALFTIME", "missing"): "....... ....... ....... ....... ....... ....... ....... ....... .......", ("STATUS_END_OF_ROUND", "12:00"): "....... ....... ....... ....... ....... ....... ....... ....... .......", - ("STATUS_END_OF_ROUND", "0:00"): "....... ....YYY ....YYY ....YYY ...YYYY ....YYY ....... ....... ....YYY", - ("STATUS_END_OF_ROUND", ":00"): "....... ....YYY ....YYY ....YYY ...YYYY ....YYY ....... ....... ....YYY", + ("STATUS_END_OF_ROUND", "0:00"): "....... ....... ....YYY ....YYY ...YYYY ....YYY ....... ....... .......", + ("STATUS_END_OF_ROUND", ":00"): "....... ....... ....YYY ....YYY ...YYYY ....YYY ....... ....... .......", ("STATUS_END_OF_ROUND", "0.0"): "....... ....... ....... ....... ....... ....... ....... ....... .......", ("STATUS_END_OF_ROUND", "-"): "....... ....... ....... ....... ....... ....... ....... ....... .......", ("STATUS_END_OF_ROUND", "''"): "....... ....... ....... ....... ....... ....... ....... ....... .......", - ("STATUS_END_OF_ROUND", "None"): "....... ....YYY ....... ....... ....... ....... ....... ....... ....YYY", - ("STATUS_END_OF_ROUND", "missing"): "....... ....YYY ....... ....... ....... ....... ....... ....... ....YYY", + ("STATUS_END_OF_ROUND", "None"): "....... ....... ....... ....... ....... ....... ....... ....... .......", + ("STATUS_END_OF_ROUND", "missing"): "....... ....... ....... ....... ....... ....... ....... ....... .......", ("STATUS_FINAL", "12:00"): "YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY", ("STATUS_FINAL", "0:00"): "YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY", ("STATUS_FINAL", ":00"): "YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY", @@ -153,25 +155,25 @@ def game(status, period_text, period, clock, **extra): ("STATUS_FINAL", "None"): "YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY", ("STATUS_FINAL", "missing"): "YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY YYYYYYY", ("STATUS_POSTPONED", "12:00"): "....... YYYYYYY ....... ....... ....... ....... ....... ....... .......", - ("STATUS_POSTPONED", "0:00"): "....... YYYYYYY ....YYY ....YYY ...YYYY ....YYY ....... ....... ....YYY", - ("STATUS_POSTPONED", ":00"): "....... YYYYYYY ....YYY ....YYY ...YYYY ....YYY ....... ....... ....YYY", + ("STATUS_POSTPONED", "0:00"): "....... YYYYYYY ....YYY ....YYY ...YYYY ....YYY ....... ....... .......", + ("STATUS_POSTPONED", ":00"): "....... YYYYYYY ....YYY ....YYY ...YYYY ....YYY ....... ....... .......", ("STATUS_POSTPONED", "0.0"): "....... YYYYYYY ....... ....... ....... ....... ....... ....... .......", ("STATUS_POSTPONED", "-"): "....... YYYYYYY ....... ....... ....... ....... ....... ....... .......", ("STATUS_POSTPONED", "''"): "....... YYYYYYY ....... ....... ....... ....... ....... ....... .......", - ("STATUS_POSTPONED", "None"): "....... YYYYYYY ....... ....... ....... ....... ....... ....... ....YYY", - ("STATUS_POSTPONED", "missing"): "....... YYYYYYY ....... ....... ....... ....... ....... ....... ....YYY", + ("STATUS_POSTPONED", "None"): "....... YYYYYYY ....... ....... ....... ....... ....... ....... .......", + ("STATUS_POSTPONED", "missing"): "....... YYYYYYY ....... ....... ....... ....... ....... ....... .......", } # One cell per plugin, in SPORTS order. EXPECTED_EDGES = { - "period_text None, P4 0:00": ".YYYYY..Y", + "period_text None, P4 0:00": "..YYYY...", "period None, 0:00": ".........", "period 'OT', 0:00": ".........", - "period '4' (str), 0:00": ".YYYYY..Y", - "clock int 0, P4": ".Y......Y", - "clock float 0.0, P4": ".Y......Y", - "tied, end of P3, 0:00": "....Y....", - "tied, end of P4, 0:00": ".YYYYY..Y", + "period '4' (str), 0:00": "..YYYY...", + "clock int 0, P4": ".........", + "clock float 0.0, P4": ".........", + "tied, end of P3, 0:00": ".........", + "tied, end of P4, 0:00": ".........", "period_text 'Final/OT', P5 0:00": "YYYYYYYYY", }