feat(sports): live Vegas cards for the scoreboards (shared layer)

One live element per game, drawn only when what the card shows changes, so
a score changes on a card already crossing the panel. The shared part, so
each scoreboard adopts it in a few lines:

- src/common/sports_vegas.py: game_key, game_fingerprint (the whole game
  dict, frozen: no drawn field can be missed), dedupe_games, VegasCardCache,
  StickyOdds (odds a live poll left out stay drawn), finished_games /
  with_finished_games (a game that just went final keeps its card, after its
  league's live games; one a heuristic only judged over keeps its live
  state, so a tied end of regulation never shows FINAL early).
- SportsScrollDisplay.make_vegas_renderer() is the override point;
  build_vegas_elements() and SportsScrollDisplayManager
  .get_vegas_elements_for() do the rest. A card's version includes its
  teams' ranks, which the renderer draws from the rankings cache.
- SportsLiveSharedMixin._record_finished_game() / finished_games_snapshot():
  held for FINISHED_GAME_TTL after it leaves the live list.

A sport that does not implement make_vegas_renderer keeps its ordinary Vegas
content, so no scoreboard changes until it opts in.

scripts/render_plugin.py --vegas renders a plugin's Vegas block as the
ticker lays it out, and --timeline stacks it at successive moments as
the ticker would update it in place; the join is now
render_pipeline.join_plugin_rows().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-30 18:36:39 -04:00
co-authored by Claude Opus 5.5
parent 876130e93f
commit 8cce532ea9
11 changed files with 1126 additions and 37 deletions
+49
View File
@@ -1348,6 +1348,55 @@ class SportsLiveSharedMixin:
or candidate < current):
self._next_scheduled_start_ts = candidate
#: How long a game that finished live is still reported by
#: finished_games_snapshot(): long enough for the recent-games list, which
#: refreshes about hourly, to take it over well before most slates would.
FINISHED_GAME_TTL = 900.0
def _record_finished_game(self, details: Dict) -> None:
"""Remember a game that was live and has just gone final (or looks over).
A finished game leaves ``live_games`` at the next poll, and the recent
list that will show it refreshes about hourly, so in between nothing
holds the game's final score -- and a live Vegas card for it would keep
its last live score. Call this wherever a poll drops a game as final
or over. Only a game this manager had as live is taken; one already
held takes the newer details (a game dropped by an "is it over"
heuristic, then marked final by the feed) but keeps its expiry, so a
feed that lists finals all day cannot keep one here all day.
"""
game_id = details.get("id") if isinstance(details, dict) else None
if not game_id:
return
finished = self.__dict__.setdefault("_finished_games", {})
held = finished.get(game_id)
if held is not None:
finished[game_id] = (held[0], dict(details))
return
if not any(g.get("id") == game_id for g in getattr(self, "live_games", ()) or ()):
return
finished[game_id] = (time.monotonic(), dict(details))
def finished_games_snapshot(self) -> List[Dict]:
"""Games that went final here within FINISHED_GAME_TTL, newest data first.
Copies, safe to decorate. The caller dedupes them against its other
lists (src/common/sports_vegas.dedupe_games keeps the liveliest copy,
and a final beats nothing but a live one).
"""
finished = self.__dict__.get("_finished_games")
if not finished:
return []
now = time.monotonic()
# A copy first: a manager finishing its update in the background (off
# the plugin's lock) may record a game while the ticker reads these.
held = list(finished.items())
for game_id, (seen, _game) in held:
if now - seen > self.FINISHED_GAME_TTL:
finished.pop(game_id, None)
return [dict(game) for _id, (seen, game) in held
if now - seen <= self.FINISHED_GAME_TTL]
def _note_live_fetch(self, found_live: bool) -> None:
"""Record whether a look for live games found any."""
if found_live: