Files
LEDMatrix/docs/SPORTS_UNIFICATION.md
T
Claude e591ceca48 refactor(sports): convert sports.py into a package (pure move)
Phase B1a of docs/SPORTS_UNIFICATION.md. src/base_classes/sports.py
becomes a package so the upcoming capability modules have a home and
diffs show their blast radius:

  sports/__init__.py   re-exports the public API
  sports/core.py       SportsCore
  sports/modes.py      SportsUpcoming / SportsRecent / SportsLive

No logic change: the 1515 class-body lines are byte-identical to the
original (verified by concatenating the two modules and diffing against
HEAD). Only module docstrings and the redistributed import blocks are
new. MRO and __abstractmethods__ are unchanged, and every existing
import site — including 'from src.base_classes.sports import SportsCore'
in the sport subclasses, the skin tests, and the characterization
suite — resolves through the package __init__.

One test edit was required: the characterization suite monkeypatched
'src.base_classes.sports.get_background_service', which is no longer a
module attribute on a package. Retargeted to
'src.base_classes.sports.core.get_background_service' — the module whose
globals SportsCore.__init__ actually resolves, so the patch is effective
exactly as before. No test logic or assertion changed.

Also adds docs/SPORTS_UNIFICATION.md: the architecture for the whole
B1-B5 sequence — how upgradability (guarded imports, capability probing,
frozen view-model keys, the sunset rule), reusability (promote only what
all nine copies share), and modularity (capabilities as opt-in mixins
rather than config branches, variants as named strategies, sport-unique
code as declared override points) are kept as three separate mechanisms.

Verified: characterization + skin 94 passed; the 10-file unit suite 338
passed; test/plugins 60 passed — all identical to pre-change counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
2026-08-01 16:07:46 +00:00

6.9 KiB

Sports Code Unification — Architecture

How the nine sports scoreboard plugins converge onto shared core code without becoming nine clients of a god class.

The problem

Nine plugins (afl, baseball, basketball, football, hockey, lacrosse, nrl, soccer, ufc) each ship a ~3,000-line sports.py descended from this repo's src/base_classes/sports.py. They have drifted into three lineages, and only 28 of the 66 methods appearing across them are present in all nine. One logical fix (the UTC start-time bug) cost 75 files.

Merging everything into one base class would fix the duplication and create a worse problem: a single 2,500-line class that all nine plugins inherit, where any change has a nine-plugin blast radius and per-sport behavior survives only as if self.sport == "hockey" branches.

Three properties, three mechanisms

These are independent concerns. Conflating them is what produces god classes.

Upgradability — a plugin keeps working across core versions

Rule Mechanism
Plugin loads on a core that predates a module Guarded import with a bundled fallback (try: from src.X import Y / except ModuleNotFoundError: from y import Y)
Plugin loads on a core that predates a method Capability probing — hasattr(SportsCore, "_detect_stale_games") — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection.
Core changes never break a plugin's rendering The view-model contract: _extract_game_details_common returns a dict whose GUARANTEED_KEYS are frozen by test/test_skin_system.py::TestViewModelContract. Keys may be added, never renamed or removed.
A plugin can drop its bundled copy safely The sunset rule: only when its manifest floors ledmatrix_min_version at the first core release shipping the module (recorded in CHANGELOG.md).

The core API is additive-only. A method the plugins call is never removed or given a new required parameter; new behavior arrives as new methods with defaults, or as capabilities they opt into.

Reusability — write once, nine plugins benefit

Only code that is identical in intent across all nine moves into the base class. That set is small and knowable — it is exactly the methods present in every copy today (phase B1 below). Everything else stays where it is until it earns promotion.

Modularity — a change to one feature cannot reach a plugin that doesn't use it

This is the property the naive merge destroys, and it is enforced structurally:

  1. Capabilities are separate modules composed by inheritance, not config branches inside the base class. Hockey has no celebrations, so HockeyLive does not inherit CelebrationMixin — the celebration code is not merely disabled for hockey, it is not in hockey's MRO at all. No shared state, no dead branches, no risk. Contrast with if self.celebrations_enabled: inside SportsLive, where a bug in celebration code can still crash a plugin that never wanted the feature.

  2. Variant behavior is a strategy object chosen by name, not a branch. Live rotation exists in three dialects across the lineages; core ships all three behind rotation_strategy: "swrr" | "weighted" | "simple" and a plugin may register its own. Core never learns sport names.

  3. Sport-specific behavior is a documented override point. The base class declares the seam; the plugin fills it. Basketball's tournament-round parsing and baseball's BDF sizing stay in their plugins forever — they are not candidates for promotion, and core must never grow a branch for them.

  4. Files bound the blast radius. Capabilities live in their own modules so a diff shows at a glance which plugins a change can reach.

Layering

src/base_classes/sports/
  __init__.py            re-exports the public API (import path unchanged)
  core.py                SportsCore — fetch, cache, config, logos, fonts, odds,
                         view-model extraction, the skin seam
  modes.py               SportsUpcoming / SportsRecent / SportsLive
  capabilities/
    celebrations.py      CelebrationMixin        (opt-in: 4 of 9 plugins)
    rotation.py          RotationStrategy + registry

from src.base_classes.sports import SportsCore keeps working — the package __init__ re-exports, so the conversion is invisible to every existing importer.

Override points (the plugin-facing seam)

The base class calls these; plugins implement or override them. This table is the contract — additions require a default implementation, removals require a deprecation cycle.

Hook Purpose Default
_fetch_data() Sport's schedule source abstract
_extract_game_details(event) Sport-specific view-model fields on top of the common ones delegates to _extract_game_details_common
_draw_scorebug_layout(game, force_clear) Sport's card rendering base layout
_custom_scorebug_layout(game, draw) Per-sport overlay on the base layout no-op
render_skin_card(game, size) Skin-system entry point built-in fallback
score_phrase(game) Celebration wording ("GOAL" vs "TOUCHDOWN") "SCORE" — only consulted when CelebrationMixin is present

Phases

Phase Scope Risk control
B0 Characterization tests, CI unit job, element_style, font cwd fix, CHANGELOG discipline
B1 Promote the nine universal methods; convert sports.py → package Characterization suite must stay green; no behavior change intended
B2 CelebrationMixin + rotation strategies as opt-in capabilities Plugins that don't opt in have zero new code in their MRO
B3 Upstream ScrollDisplay as src/common/sports_scroll.py, reading global_config['target_fps'] natively Plugin copies remain until sunset
B4 Bump to 3.2.0, record modules in CHANGELOG, migrate ledmatrix_minledmatrix_min_version Gives plugins a version to floor on
B5 Pilot one plugin per lineage (hockey, soccer, football) on guarded core imports; then the remaining six; then delete bundled copies Pilot soaks before rollout; harness + golden suites gate each

Rules for contributors

  • Promote on evidence, not intuition. A method moves to core when every copy has it and they agree on intent. Otherwise it stays in the plugins.
  • Never add a sport name to core. If core needs to know which sport it is, the design is wrong — add an override point instead.
  • A capability that is not opted into must not execute. If you find yourself writing if self.<capability>_enabled inside a base class, it belongs in a mixin.
  • Touch the view-model keys only additively. Published skins depend on them.
  • Every promotion lands with the characterization suite green, and every pilot adoption lands with that plugin's harness and golden suites green.