Skip to content

Initial plugin registry setup with hello-world and clock-simple plugins - #1

Merged
ChuckBuilds merged 1 commit into
mainfrom
setup/plugin-registry
Oct 9, 2025
Merged

Initial plugin registry setup with hello-world and clock-simple plugins#1
ChuckBuilds merged 1 commit into
mainfrom
setup/plugin-registry

Conversation

@ChuckBuilds

Copy link
Copy Markdown
Owner
  • Created plugins.json registry with 2 official plugins

  • Added comprehensive documentation (README, SUBMISSION, VERIFICATION guides)

  • Included plugin store implementation docs

  • Set up proper .gitignore for Python projects

- Created plugins.json registry with 2 official plugins

- Added comprehensive documentation (README, SUBMISSION, VERIFICATION guides)

- Included plugin store implementation docs

- Set up proper .gitignore for Python projects
@ChuckBuilds
ChuckBuilds merged commit 9b99e3d into main Oct 9, 2025
ChuckBuilds pushed a commit that referenced this pull request Feb 17, 2026
…erboard, and odds-ticker plugins

Basketball Scoreboard (v1.2.1):
- Auto-detect March Madness window (March 10 - April 10) for tournament mode
- Show tournament seeds, rounds, and regions on scorebugs
- Add round-based sorting for recent games (NCG > F4 > E8 > S16 > R32 > R64)
- Cap non-favorite tournament games via tournament_games_limit config
- Add March Madness separator logo in scroll mode
- Fix upcoming games dropping non-favorite tournament games

Leaderboard (v1.0.7):
- Auto-switch from AP rankings to tournament seeds during March Madness
- Show seeds as #1-#16 instead of sequential numbering
- Swap league logo to March Madness logo during tournament
- Add show_ranking config for ncaam_basketball

Odds Ticker (v1.0.4):
- Extract tournament round and seeds from ESPN event data
- Show seeds as (5) DUKE instead of AP ranking 14. DUKE during tournament
- Add show_seeds_in_tournament config option

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ChuckBuilds added a commit that referenced this pull request Feb 21, 2026
* feat(march-madness): add tournament awareness across basketball, leaderboard, and odds-ticker plugins

Basketball Scoreboard (v1.2.1):
- Auto-detect March Madness window (March 10 - April 10) for tournament mode
- Show tournament seeds, rounds, and regions on scorebugs
- Add round-based sorting for recent games (NCG > F4 > E8 > S16 > R32 > R64)
- Cap non-favorite tournament games via tournament_games_limit config
- Add March Madness separator logo in scroll mode
- Fix upcoming games dropping non-favorite tournament games

Leaderboard (v1.0.7):
- Auto-switch from AP rankings to tournament seeds during March Madness
- Show seeds as #1-#16 instead of sequential numbering
- Swap league logo to March Madness logo during tournament
- Add show_ranking config for ncaam_basketball

Odds Ticker (v1.0.4):
- Extract tournament round and seeds from ESPN event data
- Show seeds as (5) DUKE instead of AP ranking 14. DUKE during tournament
- Add show_seeds_in_tournament config option

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address code review findings for march madness features

- Fix critical bug: _fetch_data() and game processing incorrectly nested
  under show_ranking check in SportsLive, preventing data fetch when disabled
- Fix tournament round/region parsing to work for all TRNMNT-type games,
  not just those with "Championship" in headline
- Fix missing "events" key handling in ncaam/ncaaw managers (KeyError when
  data is dict without events)
- Fix seed == 0 exclusion in leaderboard tournament seed fetching
- Add TypeError safety for curatedRank values in odds-ticker
- Add show_seeds_in_tournament re-read in odds-ticker on_config_change
- Make _parse_tournament_region case-insensitive
- Move re-sort inside tourney_extras guard to skip when no extras added
- Add tournament round overflow protection in game_renderer
- Fix Optional[Dict] annotation, remove unnecessary f-string
- Bump versions: basketball 1.3.0, leaderboard 1.1.0, odds-ticker 1.1.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address code review round 2 findings

- Fix ncaam/ncaaw march_madness settings clobber in game_renderer: store
  per-league settings and look up by game['league'] at render time
- Simplify elif→else in ncaam/ncaaw managers tournament merge block
- Remove unused tournament_headline variable from sports.py
- Simplify redundant 'elif data and "events" in data' to 'else' in SportsLive
- Remove phantom versions from manifests (1.2.0/1.2.1, 1.0.7, 1.0.4)
- Extract MARCH_MADNESS_LOGO_PATH class constant in image_renderer
- Use logger.exception for traceback in data_fetcher error handling
- Add forward-compat comment for ncaaw_basketball in odds-ticker seed display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address code review round 3 findings

- Fix SyntaxError: remove orphaned else block in SportsLive.update() caused
  by previous elif→else simplification; merge fetch-failure warning into
  the 'if not data' branch instead
- Fix _get_mm_setting variable shadowing: use next(iter(...), None) instead
  of loop-with-immediate-return for fallback
- Guard tournament metadata extraction to NCAA basketball leagues only in
  odds-ticker (skip for NFL, MLB, etc.)
- Don't cache empty tournament seed results in leaderboard data_fetcher
  to allow re-fetch on next update cycle
- Use parameterized logging in data_fetcher exception handler
- Extract TOURNAMENT_ROUND_ORDER as class constant on SportsCore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address code review round 4 findings

- Fix tournament round overflow in SportsRecent._draw_scorebug_layout:
  add width guard (display_width - 40) before prepending round text
- Remove iteration-order-dependent fallback in _get_mm_setting; return
  default directly when league not found
- Make margin consistent: recent-game status uses same 40px margin as
  live-game status for tournament round prepend
- Add ClassVar annotation to TOURNAMENT_ROUND_ORDER (silences RUF012)
- Update odds-ticker API version string from 1.0.0 to 1.1.0
- Tighten seed validation in odds-ticker to 1 <= seed <= 16 range check
- Restructure data_fetcher try/except/else: move cache save and return
  into else block per TRY300

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address code review round 5 findings

- Tighten seed validation to 1-16 range in sports.py (rejects 0, negatives, and 17+)
- Add safe int coercion for curatedRank in leaderboard data_fetcher
- Extract _get_team_annotation() helper on SportsCore to deduplicate ~140 lines
  of seed/ranking/record selection logic between SportsUpcoming and SportsRecent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
ChuckBuilds pushed a commit that referenced this pull request Aug 29, 2026
The quality filter declares the poll to be the thing worth showing, and then
selection ignored the number entirely. #1 against #2 and #25 against an
unranked side were interchangeable: both passed the gate, and whichever kicked
off sooner took the slot. Rank was fetched, cached, and used for nothing but
the badge painted on the card -- the only sort touching it in the whole file
was `sorted(rankings)[:8]`, for a log line.

The non-favourite pool is now ordered by the better of the two sides' poll
positions before the window slices it, with kickoff as the tie-break. The
rotation still walks the entire pool, so coverage and the measured gaps are
unchanged; it walks DOWN the ladder instead of along the clock. What changes is
which games lead: the first window after a restart or an update holds the best
game available rather than the earliest, and a board is far more often freshly
started than three hours into a lap.

Favourites keep kickoff order. Ordering your own teams by rank would put a
week-8 fixture ahead of Saturday's, and for your own team the next game is the
point -- a test pins that.

A league with no poll keeps the chronological order it had, because there is
nothing to sort on: `_by_importance` returns the list untouched when the
rankings table is empty, which is also what happens on a failed fetch.

Reverting the ordering fails the check that the first window holds the best
game; the fixture puts the top-ranked matchup last chronologically so the two
orderings cannot agree by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
ChuckBuilds pushed a commit that referenced this pull request Aug 29, 2026
Shipping the rank ordering to a real board showed what the chronological order
had been hiding: the upcoming pool is not a week of fixtures. For college
football it is the whole season -- ledpi logged "Found 947 total upcoming games
in data" -- so ordering by rank alone stacked all twelve of the #1 team's games
above the #2 team's first one. The board went straight to KENT@OSU, ILL@OSU,
then OSU@IOWA, MD@OSU: Ohio State's season, in order, before any other matchup.

The pool now keeps one game per team, the soonest, and orders those by rank. It
reads as "what each team has next, best matchup first", which is what an
upcoming board means, and it is inherently near-term without a horizon setting
to tune: a team's next game is by definition its closest one.

Deduping happens on a soonest-first pass rather than on the rank-ordered one.
Taking the first entry per team out of rank order would keep whichever game
sorted first by rank, and for a game between two ranked sides that is not
necessarily the one being played next.

The ablation reproduces the board's symptom exactly -- top0, top1, top2, top3
-- so the test fails for the reason it was written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
ChuckBuilds added a commit that referenced this pull request Aug 30, 2026
…works (#335)

* feat(football): favourites first, ranked others, and a rotation that works

Football-scoreboard only. The same work is prepared for the other eight sports
plugins and is deliberately held back so each can be judged on its own board
rather than nine changing at once.

Selection
- Five settings added in #332 never reached the display code. Managers read a
  translated config and that translation is an explicit whitelist, so every one
  of them was declared in the schema, rendered in the UI, read by sports.py and
  dropped in between: set it, save it, nothing happens.
- Other games are filtered by quality -- ranked by default -- instead of taken
  in kickoff order. On a real board's college slate, 923 non-favourite upcoming
  games, 235 of them involving a ranked team.
- Within that pool the better matchup leads, and each team appears once. The
  upcoming list is a whole SEASON, not a week -- ledpi logs 947 games -- so
  ordering by rank alone put the #1 team's twelve games above the #2 team's
  first one, and the board walked one team's schedule.
- Favourite slots are shared between your teams: each gets its next game before
  any gets a second. Walked across a real 901-game season with two favourites
  at a limit of 2, nine days showed one team twice and the other not at all.
- The FBS/FCS filter needs one side in a checked division, not both. Requiring
  both silently removed five of twenty ranked matchups -- a ranked side hosting
  an FCS school is still a game about a team you asked for.
- ncaa_fb defaults to five games with no favourite team, rather than one.

Data
- College rankings came from the wrong endpoint. /standings was tried first and
  /rankings only on a 404, but college football answers /standings with 200 and
  no rankings key, so the fallback never fired: the rank badge never appeared
  and the ranked filter passed everything, an empty table failing open.
- Each poll is fetched once, not once per configured group.
- Leagues with no poll no longer request one -- roughly 2,900 dead requests a
  day, per league, at a 30s interval.
- The FBS/FCS group lookup runs for college football alone, the one league ESPN
  publishes those rosters for. Everywhere else it 500s or returns an empty list.
- The division cache expires instead of being held for the life of the process.

Pacing
- The rotation now re-cuts the slice on the display path. It only ever ran from
  update(), which returns early until upcoming_update_interval -- an hour, and
  not settable -- so a four-minute rotation produced one jump of fifteen
  windows once an hour. ledpi showed the same two matchups 89 times in six
  hours; it now cycles the ranked slate in about twenty minutes.
- recent_update_interval, upcoming_update_interval and stale_game_timeout are
  declared and translated. They were read by the code and reachable by nobody.

Safety
- The filters fail open as a set, not only per check: if they leave nothing at
  all, the unfiltered list is used rather than blanking the mode. Asking for 0
  other games is still an explicit "favourites only".
- "broadcast" reads coverage off the slate. The payload always carries the key
  and it is simply empty in leagues ESPN publishes no listings for, so the
  usual missing-means-allowed reading never fired and the setting removed every
  non-favourite game there.
- A loaded poll that matches no game on the schedule now warns, throttled, and
  names the abbreviations it holds.
- Config values are clamped to their declared ranges. A string where an integer
  belongs raised inside update()'s own try/except, which surfaces as a mode
  that renders nothing.

Repo guards
- scripts/check_selection_settings.py finds every settings block structurally
  and requires each setting the plugin's own code reads. Deriving the
  requirement from the code is what lets these nine plugins be ported one at a
  time without a red build.
- run_plugin_tests names the checks that failed instead of the last line
  logged, which for a script that warns on stderr was the warning, pass or fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* test(football): build the resolver's kwargs once, not a keyword it may not take

Codacy flagged `DynamicTeamResolver(cache_manager=cache)` as an unexpected
keyword, and it is right about the code as written. Five plugins ship a class
of that name and three of them -- basketball, hockey, lacrosse -- take no
cache_manager at all, so `from dynamic_team_resolver import ...` gives an
analyser a call it can prove wrong for whichever class the bare module name
resolved to. The runtime guard made it safe; it did not make it checkable.

The signature is now inspected once and the argument passed as **kwargs, which
also stops the same condition being written twice -- once for the first
resolver and once for the second, where the two spellings could drift apart.

Verified against both shapes: football's own resolver, and hockey's swapped in,
which takes no cache_manager. The second constructs cleanly and then fails the
three behavioural checks, because hockey has not been ported yet and still keys
its cache per pattern rather than per sport -- which is the bug this PR fixes,
and exactly what that test exists to catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(football): act on the review -- one selection path, and two silent failures

Five of the eight findings held up. The two most serious were mine and recent.

1. The adapter coerced the divisions list before the coercion helper could see
   it: `list(game_limits.get("other_games_divisions", ["fbs"]))`. A hand-edited
   "fbs" became ['f','b','s'] -- already a list, so _normalise_divisions' string
   branch never fired -- and the filter then intersected against three letters
   that name no division and rejected every non-favourite game. A null was
   worse: list(None) raises inside _adapt_config_for_manager, which
   _initialize_managers catches and logs once, leaving all six managers None
   and the plugin enabled but rendering nothing. Passed through raw now.

2. The no-favourites branch had its own selection path -- filter, sort,
   truncate -- and so never built the selection pools. Nothing rotated, nothing
   was ordered by rank. That is the DEFAULT configuration for college football,
   and this PR had just made it the default for five games, so the board most
   in need of the pacing work was the one board not getting it. Both branches
   now call _favorites_first, the no-favourites case with a favourite limit of
   0, which retires _filtered_or_all: one path, one fallback.

3. fetch_standings suppressed every non-HTTP failure. `status not in (404,
   None)` reads None for ConnectionError, Timeout and a body that will not
   parse, so a board that could not reach ESPN said one debug line and ran the
   ranked filter against an empty table. Only 404 is routine now.

4. The division roster asked for datetime.now().year. College football's 2026
   season runs into January 2027, when that returns groups which do not exist,
   the roster comes back empty and division filtering fails open -- through the
   bowls and the playoff. Derived from the season instead.

5. The window advance is a read-modify-write with two writers -- update() and
   the display path -- and neither held a lock at that point. Interleaved, both
   see the interval elapsed and each add a width, skipping a window nobody
   sees. Now under _games_lock, which is an RLock and which the display path
   takes again immediately after.

6. The rotation gated on `others`, but the whole-set fallback slices
   `unfiltered`. In exactly the case that triggers the fallback the pool read
   as empty and the fallback was pinned until the next fetch.

Two findings I did not take:

- Odds are fetched for the selection, so a card rotated in between fetches
  renders without a spread. I widened the fetch to cover the rotation pool and
  scripts/test_odds_fetch_scope.py failed -- correctly. That guard exists
  because a rig once made 946 odds requests to put one game on the panel, and
  widening the scope inside update() is exactly what it forbids. Reverted; the
  gap is real but belongs in a change that revisits that decision deliberately,
  not in this one.
- The README's default for upcoming_games_to_show was stale against the new
  ncaa_fb schema default. Corrected -- that one I did take.

The odds guard needed a change of its own: it located the trim by searching for
the literal slice, which merging the two selection branches moved inside the
helper. It now accepts either shape, and still fails when the fetch is pointed
back at the collected list, which is the bug it was written for.

Two probes built with __new__ needed the lock supplied, which is the failure
mode their own comments warn about: a missing attribute raises inside update()'s
try/except, and the test reads as a wording change rather than an incomplete
object.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(football): stop the due-check recomposing every frame, and tell the truth about game counts

The rotation due-check guessed which pool _compose_selection would slice
as `others or unfiltered`, but the composer only falls back to the
unfiltered list when favourites contribute nothing. With a favourite
playing and the filters rejecting every other game, the guess said
'rotate', the recompose produced an identical favourites-only list, the
rotation clock never advanced, and selection re-ran on every display()
call forever. The check now mirrors the composer's rule; the new test
fails on the old expression and no other check.

The games_to_show schema descriptions still said 'N games per favorite
team', which is only true with show_favorite_teams_only on -- in the
default favourites-first mode they are totals. Reworded, and the
'additive, never a removal' comment scoped to the counts it is true of.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YF7Q48EYCCkU1Vs932uDY1

* fix: address the CodeRabbit review

All six findings verified against the branch before fixing:

- broadcast is coerced to a string at extraction; ESPN's undocumented
  payloads have served it as an object, and a non-string reached
  _note_broadcast_coverage's .strip() inside update()'s try/except.
- _setting_int also catches OverflowError: json parses a bare Infinity
  and int(inf) raises from __init__, outside any fallback.
- check_selection_settings rejects unknown plugin ids up front instead
  of printing OK and then crashing on the block count.
- run_plugin_tests keeps a labelled tail from each output stream;
  concatenating them reported only stderr when both had evidence.
- README: the AP_TOP_10 example now says 'up to 12 favorites' with the
  overlap caveat, and the rotation example fence names a language.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YF7Q48EYCCkU1Vs932uDY1

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chuck <chuck@example.com>
ChuckBuilds added a commit that referenced this pull request Sep 2, 2026
…neages (#353)

* feat(sports): only fill the other slots with games worth watching

Selection was purely chronological, so rotating harder just served more
filler. On a real board's college schedule, 923 non-favourite upcoming games:
235 involve a nationally ranked team and the rest are matchups the viewer has
never heard of. Two settings now decide what fills the slots left over after
favourites, and neither ever touches favourites themselves -- follow a
smaller-division school and its games always show.

other_games_min_quality (default "ranked") uses the rankings table the plugin
already fetches for the rank badge, so it costs no extra requests. That gating
had to move: rankings were only fetched when show_ranking was on, which left
the filter with an empty table and would have emptied the board.

other_games_divisions (default ["fbs"]) needs ESPN's own group rosters -- two
requests a day, cached. conferenceId cannot do this job: cross-division games
put an FBS conference on an FCS slate, so the id sets overlap and Merrimack at
Delaware classifies as FBS. The group rosters are disjoint (148 FBS ids, 130
FCS). Every participant must be in a checked division, so leaving FCS
unchecked also removes a ranked side hosting an FCS school -- which is the
actual complaint.

Every check fails OPEN. A rankings table that did not load, or divisions that
did not resolve, allows the game: a board showing filler is poor, a board
showing nothing is broken.

Three real defects found while testing this, all in the already-pushed commits
of this PR:

- SportsRecent is a SIBLING of SportsUpcoming, not a subclass, so its call to
  _favorites_first hit a method it did not have. AttributeError, swallowed by
  update()'s own try/except, recent games silently blank. The shared helpers
  now live on SportsCore and a test drives the real SportsRecent class rather
  than only Upcoming, which is what hid it.
- nrl matches favourites by ESPN team id on purpose -- its abbreviations are
  not unique, "NEW" is both Newcastle Knights and New Zealand Warriors -- and
  the ported abbreviation-based matcher shadowed that on the upcoming path,
  where it would favourite the wrong club. Removed; nrl keeps its own.
- The custom-league editor is an array-table, and array-table.js stringifies a
  list into "a,b" before submitting, so an array-typed property inside a row
  can never validate. The checkbox group is out of custom_leagues; the enum
  string stays.

Class-level defaults for everything the selection path reads, because that
read happens inside update()'s try/except: a missing attribute does not raise
anywhere visible, it just blanks the board.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW

* fix(sports): make the new settings actually take effect

Two defects found while re-checking the logic with the season a week out.
Both are the same shape: nothing raises, nothing logs, the feature is just
quietly not there.

1. None of the five new settings reached the code. Managers do not read the
   plugin config -- _adapt_config_for_manager translates it, and that
   translation is an explicit whitelist. Every one of the new keys was
   declared in the schema, rendered in the web UI, read by sports.py, and
   dropped in between. A user could set them, save, and nothing would change;
   the code kept its own defaults. All nine plugins, and the lineages disagree
   about where the values live: game_limits, filtering, or the league root,
   with hockey and lacrosse going through resolve_value instead. Each now
   reads from the same place its own schema declares them.

   test_settings_reach_the_manager.py guards it, using values that are NOT the
   defaults -- a fixture built from defaults passes against a translation that
   drops the key entirely.

2. Making "ranked" the default quality made every league fetch rankings, and
   only college leagues have them: NFL's endpoint 404s. _fetch_team_rankings
   only short-circuits on a NON-empty cache, so a failed fetch leaves it empty
   and the next update tries again -- roughly 2,900 dead requests a day per
   non-college league at a 30s interval. Gated on the league actually having a
   poll.

Verified against the live API rather than assumed: the division lookup fetches
148 FBS and 130 FCS team ids, caches them, serves a second instance without
touching the network, and is skipped entirely for NFL. college-football's
rankings endpoint returns 25; nfl's returns 404.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW

* fix(sports): fetch each ranking poll once, not once per group

AP_TOP_5, AP_TOP_10 and AP_TOP_25 all resolve from the same poll and differ
only in how far down it they slice -- and the value cached was the whole list.
The key, though, named the pattern, so configuring two groups fetched the
identical payload twice, stored it twice and expired it twice for no
difference in the result. Seen on a real board:
dynamic_teams_ncaa_fb_AP_TOP_10.json and dynamic_teams_ncaa_fb_AP_TOP_25.json
side by side, both holding the same 25 teams.

Keyed by sport instead. Five plugins carry a resolver with patterns; two key
spellings between them. afl, nrl and soccer ship a stub with no patterns at
all, so there is nothing to key.

The test asserts one fetch PER SPORT rather than one overall: hockey and
lacrosse declare groups across several sports, and those really are separate
polls. It also skips the shared-cache assertion for lineages that pair a
class-level dict with a per-instance freshness stamp, where a new instance
refetches by construction -- pre-existing, and not what this change governs.

Reverting the key in any of the five fails three checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW

* fix(sports): fetch college rankings from the endpoint that has them

Driving the real NCAAFBUpcomingManager end to end -- which nothing had done --
showed the selection working and "rankings loaded: 0". The quality filter was
failing open on every board.

fetch_standings tried /standings first and fell back to /rankings only on a
404. College football answers /standings with HTTP 200 and no "rankings" key,
so the fallback never fired. Verified against the live API today:

    football/college-football/standings  200, no "rankings" key
    football/college-football/rankings   200, 3 ranking blocks
    football/nfl/standings               200
    football/nfl/rankings                404

Nothing ever failed. _fetch_team_rankings parsed a body with no rankings in
it and cached an empty table, so the AP rank badge never appeared however
show_ranking was set -- that part predates this PR -- and the new "ranked"
filter passed every game, because an empty table fails open.

The endpoint is now chosen by league rather than discovered by error code,
and a 200 without the key counts as a miss. After the fix the same end-to-end
run loads 25 rankings and fills the other slots with SJSU@USC (#14),
UTEP@OU (#10) and MIA@STAN (#7) instead of the next three unranked games.

Also verified end to end, both directions: NFL recent returns 2 TB games plus
2 others, newest first, and fetches 0 rankings -- no poll exists, and none is
requested.

Note on the baseball copy: the first attempt replaced from fetch_standings to
the next top-level class, which in that file swallowed fetch_game_summary,
fetch_player_details and _parse_player_details. Its own test_player_card
caught it. Redone bounded to the method, and targeted at ESPNDataSource
specifically -- the abstract declaration above it has the same signature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW

* test(football): drive the real manager classes, not just the base ones

The SportsRecent defect survived every test because they all drove
SportsUpcoming directly. This drives what the plugin actually instantiates --
live, recent and upcoming, for both leagues -- and asserts each carries every
selection helper and setting.

Favourite detection is checked in BOTH directions. Asserting only that a
favourite returns True passes against a matcher that returns True for
everything, which would sweep the whole league into the favourites bucket and
quietly empty the other-games slots.

Reverting the helpers to SportsUpcoming reproduces the original failure
verbatim: 'NFLRecentManager' object has no attribute '_is_favorite_game'.

Verified alongside, by driving the real managers end to end against live ESPN
data rather than fixtures:

- NCAA upcoming: 3 favourites (UGA, AUB) plus 3 ranked others -- SJSU@USC
  (#14), UTEP@OU (#10), MIA@STAN (#7)
- NFL recent: 2 TB games plus 2 others, newest first, 0 rankings requested
- both live managers update cleanly and resolve favourites
- the config the web UI writes from schema defaults validates, a user-edited
  one validates, and a string where an array belongs is rejected

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW

* docs(sports): explain how favourite selection actually works

None of this was written down anywhere a user would look. The settings existed
only as schema descriptions in the web UI, plus code comments and commit
messages -- and the behaviour is not guessable, which is exactly why it kept
being misread.

The section leads with the thing that trips people up: upcoming_games_to_show
is not "how many cards you see", it is the size of a POOL that the panel
cycles, keeping its place between visits. Making it bigger lengthens the lap,
so any one game appears LESS often -- the opposite of what people reach for it
to do.

Then the three modes as a table, because which one you are in depends on two
settings at once, and the useful one (favourites first, then others) is the
combination that until now did nothing.

Facts in it are measured rather than described: ~950 upcoming college games of
which ~250 involve a ranked team; 18 distinct matchups over three hours of
rotation while the pool stays at 6 cards. Every default quoted was checked
against the schema.

The AP_TOP_n warning is in both this section and the Dynamic Team Resolution
section that introduces those patterns, because that is where someone meets
them: expanding a group into the favourites list makes your own teams compete
with it, and on a real schedule UGA's next game was favourite-game #5 and
Auburn's #8, so neither appeared with a limit of 3.

Shorter version in the other eight READMEs, without the college-specific
detail, and noting that the quality and division filters are inert for leagues
with no poll and no divisions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW

* fix(sports): expire the division lookup instead of holding it forever

The in-memory copy had no clock. _load_division_team_ids returned early on
any non-None value, so the first result a process produced was the only one
it ever used, and a board that happened to be offline for that first lookup
ran with division filtering disabled until someone restarted the service --
on a display that stays up for weeks, indefinitely. A roster that changed
between seasons was never picked up either.

The copy now expires like the stored one: a day for a resolved lookup, ten
minutes for one that came back empty, so a blip costs minutes rather than a
day without retrying per frame.

Also lower-cases the league before the "college" test -- the guard that
decides whether to make the two requests at all was case-sensitive on a value
the config supplies.

The probe sets the freshness stamp alongside the pre-loaded ids: a populated
cache with a zero stamp now reads as stale and goes back to the network,
which is not what a test pre-loading divisions means.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* chore(sports): publish the follow-up work as new plugin versions

Nine minor bumps, one per sports plugin. The 1.32.0-generation entry that
1.32.0 already shipped under is restored to the text it was released with --
it had been edited in place while this work was still on the same branch, and
it now described settings that version does not have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(sports): act on the review -- divisions, empty favourites, dead tests

Six of CodeRabbit's thirteen comments were real. Three are behaviour, three
are the tests and docs that let the behaviour hide.

1. FBS/FCS is a college FOOTBALL taxonomy, and the lookup ran for any league
   whose name contains "college". Checked against the live API, groups 80 and
   81 exist for that one league:

       football/college-football     200, 148 FBS + 130 FCS team ids
       baseball/college-baseball     500
       lacrosse/mens-college-lacrosse 500
       basketball/mens-college-basketball  200, 0 items
       basketball/womens-college-basketball 200, 0 items
       hockey/mens-college-hockey    200, 0 items

   An empty roster fails open, so the setting filtered nothing on those
   leagues; it only cost two requests a day and two warnings in the log. The
   group ids are now keyed by league, and the schema and READMEs say plainly
   that the division filter is college football alone rather than implying
   every college league has divisions to pick from.

2. With no favourite teams configured, selection took the next N games
   chronologically and never called _passes_other_filters. Every game in that
   branch is a non-favourite game, so both settings were inert for exactly the
   boards that have nothing else narrowing the list -- ask for ranked games
   only, get the next three kickoffs. Both branches now go through
   _filtered_or_all, which fails open as a whole: a filter matching nothing
   would blank the mode, and there is no favourite left to carry it.

3. afl and nrl declare these keys twice, at the config root and inside
   game_limits, and the web UI renders both. afl's translation read the root,
   nrl's read game_limits, so each plugin had a set of controls that accepted
   input and dropped it. Both now read either, game_limits first, matching how
   nrl already resolved the two older limits.

4. The division-filter assertion was vacuous in all nine test copies. The
   fixture put home ids in the division sets and away ids nowhere, so every
   away side classified as "other", the filter dropped the whole slate, and
   all() over the empty result passed -- it would have passed just as well
   against a filter that rejected everything. The sets now cover both sides,
   one game straddles deliberately (and is the FIRST game, or selection never
   reaches it), and the count is asserted. Ablating _game_divisions to the home
   side only now fails two checks; before, it failed none.

5. test_settings_reach_the_manager now asserts that every location a plugin's
   own schema offers actually reaches the manager -- root, game_limits or
   filtering, whichever that schema declares -- rather than one fixture that
   fills in all three and passes whichever the adapter happens to read.
   Reverting the afl fallback fails five of its checks.

6. E731: the two assigned lambdas in the ranking-fetch tests are functions.

Skipped, with reasons:

- "Read broadcast data from `broadcasts`, not `broadcast`." The scoreboard
  payload carries both, and `broadcast` is the string this code wants:
  college-football "NBC", nfl "NFL Net", college-baseball "ESPN",
  mens-college-lacrosse "ESPN", ufc "Paramount+". Where it is empty
  (soccer/eng.1, nhl) `broadcasts` is an empty list too, so reading the other
  key changes nothing.
- "Namespace the persistent cache keys by plugin." They are already keyed by
  sport and by league, and the copies of the resolver store the same shape, so
  a shared entry is the same data fetched once instead of twice.
- "Load deferred plugin modules under unique module names." Each test script
  runs in its own process and puts only its own plugin directory on sys.path,
  so there is no other plugin's module to collide with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(sports): make the filters fail open as a set, not just per check

Three gaps found reading the selection logic back, all the same shape: a
filter doing exactly what it was asked leaves the board with less than the
user expected, and nothing says why.

1. `_passes_other_filters` fails open per check -- a ranking table that could
   not be fetched allows every game -- but the SET of filters had no such
   guard on the favourites path. Favourites idle inside the schedule window
   plus a quality bar nothing clears meant an empty list, which is a blank
   mode rather than a short one. `_filtered_or_all` already made that
   whole-list fallback for a board with no favourites; the favourites path now
   makes the same one. `other_..._games_to_show` of 0 is an explicit
   "favourites only" and is still honoured, blank or not.

2. "broadcast" was the one check that could not fail open, because the
   scoreboard payload always carries the key -- it is simply empty in leagues
   ESPN publishes no listings for. Measured today:

       college-football "NBC"    nfl "NFL Net"     college-baseball "ESPN"
       mens-college-lacrosse "ESPN"    mma/ufc "Paramount+"
       soccer/eng.1 ""           hockey/nhl ""

   So picking it on a hockey or soccer board removed every non-favourite game.
   Coverage is now read off the slate rather than a hardcoded league list: no
   game carrying a broadcaster means the data is absent, not that nothing is
   on television, and the check allows everything.

3. The ranking table is keyed by the abbreviation the RANKINGS endpoint
   returns and matched against the SCOREBOARD's. Nothing guarantees the two
   agree, and if they stop agreeing the filter silently removes every
   non-favourite game -- the same silence that let "rankings loaded: 0" run on
   real boards until someone went looking. A loaded poll matching no game on
   the schedule now warns, throttled to once an hour, and names the
   abbreviations it holds so the mismatch is visible rather than inferred.

Ablating any one of the three fails its own check and no others.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* feat(sports): one side in a checked division is enough, and guard the schema

The division filter required EVERY participant to be in a checked division, so
the default ["fbs"] read as "FBS versus FBS only". Measured against the real
Week 2 slate, that silently removed five of the twenty ranked matchups --
Missouri, Utah, Tennessee, Texas Tech and BYU, each hosting an FCS school.
Those are games about a team the viewer checked the box for.

One side is now enough. FBS vs FCS is in; FCS vs FCS is still out unless `fcs`
is checked, which is what the setting is actually for. On the same slate, with
the same rotation, coverage of ranked matchups goes 15/20 -> 20/20, and the
longest gap between two showings of one game moves 40 -> 69 minutes because the
pool is five games larger. Favourites remain exempt from every filter, so a
favourite's own FCS tune-up game was showing before this change and still is.

The quality filter keeps this from becoming a flood: with the default "ranked",
an unranked FBS side hosting an FCS school is rejected on quality anyway, so
what the looser rule admits is specifically the ranked matchup.

Also, from an audit of all 31 selection blocks across the nine schemas:

- The five settings are declared in every block, each with a default, matching
  types, and identical ranges (0-20 counts, 0-86400 seconds, the two enums).
  The `other_*` counts default to their own block's limit, so an upgrade keeps
  the games the board was already showing.
- scripts/check_selection_settings.py now enforces that, structurally: it finds
  every properties-dict that declares a game limit, so a league added later is
  covered without editing the checker. Its self-test asserts the repo passes
  AND that five separate kinds of gap are caught, because a guard that cannot
  fail is indistinguishable from one that passes.
- soccer's custom_leagues block is the one place a setting is legitimately
  absent. array-table.js coerceValue() has no array branch: it submits "fbs"
  where the schema wants ["fbs"], and jsonschema then rejects the entire save,
  not just that field. Adding it there broke three cases in
  test_custom_league_config.py, which is how the constraint was found. The
  checker knows about row editors and does not demand arrays inside one.
- The reads are hardened. These land in update()'s own try/except, so a string
  where an integer belongs surfaced as a mode that rendered nothing rather than
  as an error. Counts and the interval clamp to their declared range,
  other_games_min_quality is case-normalised, and a bare "fbs" becomes one
  division rather than list("fbs") == ['f','b','s'] -- three names matching
  nothing, which rejected every non-favourite game.

Ablating the division rule back fails two checks; ablating any of the schema
guarantees fails the guard's self-test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(sports): rotate on the display path, and make three settings reachable

Two findings from auditing the configuration surface, and they compound: the
rotation interval was a setting that could not take effect, gated behind an
interval that could not be changed.

1. `_other_games_window` only ever ran from update(), which returns early until
   `upcoming_update_interval` has passed -- an hour. So
   `other_rotation_interval_seconds: 240` did not produce fifteen slices an
   hour; it produced one jump of fifteen windows, once an hour. On a real board
   that reads as the same two matchups for six hours, which is exactly what
   ledpi's journal showed: 89 visits to ncaa_fb_upcoming, UNC@TCU and SJSU@USC
   every single time.

   Which games exist, and which are worth a slot, is a fetch concern. WHICH of
   them is on screen is a display concern. The composition is now split out of
   _favorites_first, and the two display paths re-cut the slice when the
   interval passes -- one list slice and a sort of a few games, no network.
   Same lesson `_advance_live_game_if_due` already carries a comment about, for
   the same reason: gating a display decision on the fetch quantises it to the
   refresh rate.

   The card on screen keeps its place if it survived the cut, so rotating
   changes what comes next rather than interrupting what someone is reading.

2. Five settings sports.py reads were unreachable: no schema declaration, no
   translation, permanently at their built-in defaults. Three are worth
   exposing and now are -- recent_update_interval, upcoming_update_interval and
   stale_game_timeout. The odds intervals are left internal: odds are already
   fetched per selected game, so the knob would be a second lever on the same
   behaviour.

   The shape of the gap differed per plugin, which is why one audit found all
   of it: afl, nrl and soccer DECLARED the two intervals and dropped them in
   translation -- controls the form rendered and the board ignored, the same
   bug this PR opened with. baseball, basketball, football and ufc had neither.
   hockey and lacrosse were already complete, under `update_intervals.recent`
   and `.upcoming`; only the staleness guard was missing there.

   `update_interval_seconds` stays undeclared deliberately: all three managers
   overwrite it with their own per-mode interval, so a control for it would do
   nothing in the place a user would expect it to.

The propagation test now covers all eight settings rather than the five
selection ones, and asserts each still arrives from every location its own
schema offers. Removing any single adapter entry fails two of its checks.

Also updates the empty-mode stand-in in three plugins, which builds a bare
object carrying only what display() touches -- it binds the two real rotation
methods rather than stubbing them, so a regression that made them raise is
caught rather than hidden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* feat(football): default college football to five games, no favorite team

`favorite_teams` was already empty by default; the counts were 1. With no
favorites configured every card is a non-favorite card, so a fresh college
install showed exactly one game and repeated it until the schedule moved on --
which is the state ledpi was in this morning, two games shown 89 times each
over six hours.

Five in each of the four counts. The `other_*` pair mirrors its own limit, as
in every other block, so adding a favorite team later adds to what is there
rather than replacing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* feat(sports): order the other-games pool by the matchup, not the clock

The quality filter declares the poll to be the thing worth showing, and then
selection ignored the number entirely. #1 against #2 and #25 against an
unranked side were interchangeable: both passed the gate, and whichever kicked
off sooner took the slot. Rank was fetched, cached, and used for nothing but
the badge painted on the card -- the only sort touching it in the whole file
was `sorted(rankings)[:8]`, for a log line.

The non-favourite pool is now ordered by the better of the two sides' poll
positions before the window slices it, with kickoff as the tie-break. The
rotation still walks the entire pool, so coverage and the measured gaps are
unchanged; it walks DOWN the ladder instead of along the clock. What changes is
which games lead: the first window after a restart or an update holds the best
game available rather than the earliest, and a board is far more often freshly
started than three hours into a lap.

Favourites keep kickoff order. Ordering your own teams by rank would put a
week-8 fixture ahead of Saturday's, and for your own team the next game is the
point -- a test pins that.

A league with no poll keeps the chronological order it had, because there is
nothing to sort on: `_by_importance` returns the list untouched when the
rankings table is empty, which is also what happens on a failed fetch.

Reverting the ordering fails the check that the first window holds the best
game; the fixture puts the top-ranked matchup last chronologically so the two
orderings cannot agree by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(sports): one game per team in the ordered pool

Shipping the rank ordering to a real board showed what the chronological order
had been hiding: the upcoming pool is not a week of fixtures. For college
football it is the whole season -- ledpi logged "Found 947 total upcoming games
in data" -- so ordering by rank alone stacked all twelve of the #1 team's games
above the #2 team's first one. The board went straight to KENT@OSU, ILL@OSU,
then OSU@IOWA, MD@OSU: Ohio State's season, in order, before any other matchup.

The pool now keeps one game per team, the soonest, and orders those by rank. It
reads as "what each team has next, best matchup first", which is what an
upcoming board means, and it is inherently near-term without a horizon setting
to tune: a team's next game is by definition its closest one.

Deduping happens on a soonest-first pass rather than on the rank-ordered one.
Taking the first entry per team out of rank order would keep whichever game
sorted first by rank, and for a game between two ranked sides that is not
necessarily the one being played next.

The ablation reproduces the board's symptom exactly -- top0, top1, top2, top3
-- so the test fails for the reason it was written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(sports): share the favourite slots between your teams

The other-games pool refuses to spend its slots on one team; favourites were
still doing exactly that. `favorites[:limit]` takes the soonest N, and the
upcoming list is a season, so a team that plays either side of another's bye
takes both slots. Walked across a real 901-game season with UGA and AUB at a
limit of 2: nine days showed Auburn twice and Georgia not at all.

Round-robin instead -- each favourite team's next game before any team's
second. Depth survives where there is room: one favourite with three slots
still gets its next three games, because a team's second game only comes up
once every team has had a first. A game between two favourites is picked once
and counts for both.

Which side of a game belongs to which favourite turned out to be a per-lineage
question. NRL matches on ESPN team ids because its abbreviations are not unique
-- "NEW" is both Newcastle and New Zealand -- while the other eight match on
abbreviation. The first version assumed abbreviations and silently grouped
nothing there: every queue empty, every favourite slot empty, and the nine
plugins would have disagreed about what the setting does. It now asks for the
lineage's own matcher.

The fixture gives each team both spellings, as the existing one does, so a
single test covers both styles -- and the ablation fails on the id-matching
lineage too, which is what proves that path is really exercised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* test(harness): name the check that failed, not the last line logged

Nine plugins failed in CI with the same reason -- "ignoring unusable
other_upcoming_games_to_show='not a number', using 3" -- which is a log line
from a test that PASSES, emitted on stderr near the end of the script. The
runner reported the last line of stdout+stderr, and for any script that warns
on stderr that is the warning, whether the run passed or failed. The reason
was therefore identical for every failure and named nothing.

It now reports the checks that actually failed, up to three, and falls back to
the exit code plus the last few lines when a script died without naming one --
a traceback, or an exit from somewhere unexpected. That case has to stay
visible: it is the one where there is no check name to report and the tail is
all the evidence there is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(sports): a fresh boot must not swallow the first coverage warning

CI failed all nine plugins on "the mismatch is reported" and no machine here
could reproduce it. The reason is uptime: monotonic() counts from an arbitrary
origin, a few hundred seconds on a runner that just booted, and the throttle
compared it against a stamp of 0. So "never logged" read as "logged at the
epoch", and the first warning was suppressed for the first hour of uptime --
precisely when a misconfigured board is being watched. This machine has days of
uptime, so monotonic() dwarfs the hour and it always passed.

Zero now means never logged, as it already does for the rotation clock a few
methods up. The test drives it at 120 seconds of uptime rather than trusting
the host's, so the case is pinned on any machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* feat(hockey): sunset the bundled scroll fallback, floor at 3.2.0 (#346)

B6, piloted on one plugin. B5 moved this scoreboard onto the core's
src.common.sports_scroll behind a guarded import, keeping the frozen
pre-adoption implementation as scroll_display_legacy.py so it still ran on a
core that predated the module. That copy is now deleted and the import is
plain.

The guard goes with the copy, deliberately. Keeping try/except with nothing
behind it would name the missing scroll_display_legacy rather than the core
module that is actually absent -- and that name is the whole user-visible
contract here, because PluginManager catches the ModuleNotFoundError and parks
the plugin in ERROR with one log line. Get it wrong and the user is told the
wrong thing about why their scoreboard vanished.

Hockey first, alone, because B5 shipped four of eight plugins with scroll mode
broken at once and every gate was green at the time. It is also the only
adoption besides baseball exercised on real hardware.

The floor rises to 3.2.0 -- the release that ships the module, not a later one:
a floor describes what the plugin needs. compatible_versions moves to >=3.2.0
with it, since compatibility.check evaluates the range before the floor and
leaving >=2.0.0 beside a 3.2.0 floor would be self-contradictory even though it
changes no verdict.

Behaviour is unchanged. The frozen copy and the live path were method-for-method
identical for every content method, all 16 safety-harness renders pass across
eight panel sizes, and the class-level separator-icon constants -- the exact
thing B5 lost -- are byte-identical after the de-indent. 703 lines removed.

test_core_fallback.py -> test_core_scroll.py, rewritten rather than deleted:
its machinery has already caught two shipped bugs, and both were load-time
bugs the harness cannot see because the core base catches exceptions out of
prepare_scroll_content. It now asserts the sunset instead of the fallback --
the import is top-level and unguarded, no copy exists or is imported, the base
is the core class by identity, an old core fails naming exactly
src.common.sports_scroll, and the manifest floors at 3.2.0 or above.

That last one is load-bearing: nothing else in either repo checks the floor's
VALUE. The harness runs against core main, which has the module whatever the
manifest says; check_manifest_version_fields checks the field's spelling, not
its number; the registry carries no floor at all. A sunset shipping with a
2.0.0 floor would be green everywhere while the store handed it to a 3.1.0
core.

The cross-path attribute diff that caught afl's unset _game_renderer has no
second path left to diff against, so it is replaced by a static self-attribute
audit: attributes a method reads that nothing in the class assigns and the
built object does not carry. Same defect class, one path.

check_scroll_adoption.py gains sunset_violations and a SUNSET_PLUGINS set.
offending_classes is left byte-identical so its eleven pinned cases keep
meaning what they mean. The existing check asks whether the fallback was
INLINED and structurally cannot ask whether it still EXISTS -- it opens
scroll_display.py and nothing else -- so a resurrected file or a returned guard
would both pass. The set is listed rather than inferred: a plugin that never
adopted legitimately has neither guard nor copy, so adding an id is the
deliberate act of stating the sunset holds, in the same PR as the deletion.

The self-test's most important new case is the negative one: an unrelated
try/except must not read as the guard returning. This file already has two
(ScrollHelper, the Pillow resample constant) and basketball has three, so a
check that fired on any try/except would have failed all eight on day one.

Also fixes the runner prefix: these scripts printed "FAIL name:", which
run_plugin_tests.py does not match (it greps [FAIL] or FAILED), so CI reported
the stream tail instead of naming the failing check. Now "[FAIL] name:".

Verified both regressions are caught, by both the test and the gate: restoring
the guard fails test_the_core_import_is_unguarded and trips the gate;
restoring the copy fails test_the_bundled_copy_is_gone and trips it too.
hockey 20/20; fleet 222 passed, 2 skipped, 1 failed -- unchanged, that failure
being football's pre-existing test_favorite_live_boost.py, which reproduces on
a clean origin/main.


Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

Co-authored-by: Claude <noreply@anthropic.com>

* fix(hockey-scoreboard): keep this PR to selection, not the scroll sunset

The merged branch carried two feature versions. 1.21.0 is the selection
work this PR is for; 1.22.0 is the B6 scroll-fallback sunset, which has
its own PR (sunset/hockey-scroll-fallback) and raises the core floor to
3.2.0. Shipping them together would have floored every user at 3.2.0 to
get a selection fix, and advertised a sunset in the manifest while the
bundled copy was still present.

Hockey drops to 1.21.0 at >=2.0.0; scroll_display.py,
scroll_display_legacy.py and test_core_fallback.py return to main's
copies; the sunset-only test_core_scroll.py and the SUNSET_PLUGINS
edition of check_scroll_adoption.py go back to main's versions too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style(scoreboards): drop the redundant `or {}` in the ranked-quality check

`(getattr(self, "_team_rankings_cache", None) or {}) and not ...` is
truth-identical to `getattr(...) and not ...` in all three cases -- None,
an empty dict, and a populated one -- since `{}` is falsy either way.
Codacy flagged it once per lineage, which is the whole of its "8 new
issues" on this PR.

Football expresses the same guard as `self._rankings_loaded()`; the
siblings have no such helper, so this keeps the inline form rather than
porting a method to eight copies for a one-line simplification.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant