mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
Phase B2 of the sports unification. Both features exist in only some of
the nine scoreboards, so they ship as capabilities the plugin composes,
never as `if self.<feature>_enabled` branches inside the base classes: a
sport that does not opt in has none of this code in its MRO.
CelebrationMixin (afl, nrl, soccer, football)
The two lineages spelled this differently -- _check_for_goal /
celebrate_opponent_goals vs _check_for_score / celebrate_opponent_scores
-- but the bodies were identical apart from three things, each now a
seam rather than a branch:
- wording -> score_phrase() / win_phrase() hooks
- follow-up suppression -> COALESCE_SCORING_SEQUENCE, on for football
where a touchdown lands as +6 then +1, off where two increments are
two real goals
- team identity -> _favorite_key, so nrl matches on team id without
core learning why its abbreviations are ambiguous
Both config spellings are read, so a plugin adopting the mixin keeps
working with the keys already in its published schema.
Rotation strategies
The three "dialects" turned out to be one algorithm (SWRR) in two
shapes: an incremental picker holding state across calls, and a
precomputed per-cycle list. They agree within a cycle and differ only at
the boundary, so core ships both behind a name registry rather than
declaring a winner. weight_for is supplied by the host, so rotation.py
never learns what a favorite is; an unknown name degrades to "simple"
because it arrives from user config.
Each strategy is checked against a verbatim transcription of the plugin
code it replaces, over every live-game shape up to four games -- the
differential B5 will delete the bundled copies on the strength of.
185 new tests. Gates: 648 core unit, 60 plugin safety.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
197 lines
11 KiB
Markdown
197 lines
11 KiB
Markdown
# 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(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `"<abbr> SCORES!"` — only consulted when `CelebrationMixin` is present |
|
|
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only |
|
|
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching | `game["<side>_abbr"]` |
|
|
| `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read |
|
|
| `_font_root()` | Directory to resolve `assets/fonts` against | core install root |
|
|
|
|
Two class attributes serve the same purpose for values that are per-sport
|
|
constants rather than behavior:
|
|
|
|
| Attribute | Meaning | Default |
|
|
|---|---|---|
|
|
| `FINAL_PERIOD` | Period at/after which a zero clock can mean "over" | `4` (hockey overrides to `3`) |
|
|
| `CLOCK_COUNTS_DOWN` | Whether `0:00` means "expired" | `True` (soccer/afl/nrl override to `False` — their clocks count up, so `0:00` is kickoff) |
|
|
| `COALESCE_SCORING_SEQUENCE` | Fold score increments arriving during an active celebration into that one celebration | `False` (football overrides to `True` — a touchdown lands as +6, then +1 for the extra point) |
|
|
|
|
### Why these are seams and not branches
|
|
|
|
`_favorite_key` exists because NRL abbreviations are **not unique** — "NEW" is both
|
|
Newcastle Knights and New Zealand Warriors, "CAN" both Canberra and Canterbury —
|
|
so NRL matches favorites on team ID. Flattening every plugin to abbreviations
|
|
would silently select the wrong club for NRL users. The base declares the seam,
|
|
NRL fills it, and core never learns the string `"nrl"`.
|
|
|
|
`CLOCK_COUNTS_DOWN` exists for the same reason in the opposite direction: a
|
|
soccer clock reading `0:00` means the match has not kicked off, so running the
|
|
clock-expiry branch there would evict live games.
|
|
|
|
`COALESCE_SCORING_SEQUENCE` is the third of the same kind. In football one
|
|
scoring play arrives as two score updates, so the follow-up must be folded into
|
|
the first celebration; in soccer two increments a few seconds apart are two real
|
|
goals, and folding them would swallow one. Neither default is "right" — which is
|
|
precisely why it is a declared per-sport constant rather than a hidden
|
|
assumption baked into the shared body.
|
|
|
|
## Capabilities
|
|
|
|
```
|
|
capabilities/
|
|
celebrations.py CelebrationMixin opt-in: afl, nrl, soccer, football
|
|
rotation.py RotationStrategy + registry
|
|
```
|
|
|
|
**`CelebrationMixin`** merges the two dialects the lineages grew
|
|
(`_check_for_goal`/`celebrate_opponent_goals` vs
|
|
`_check_for_score`/`celebrate_opponent_scores`). Their bodies were identical
|
|
apart from three things, each now a seam: wording (`score_phrase`), follow-up
|
|
suppression (`COALESCE_SCORING_SEQUENCE`), and team identity (`_favorite_key`,
|
|
so NRL matches on id). Both config spellings are read, so a plugin adopting the
|
|
mixin keeps working with the keys already in its published schema.
|
|
|
|
Mix it in **before** the mode class — `class SoccerLive(CelebrationMixin,
|
|
SportsLive)` — so the celebration `display()` runs first and falls through to
|
|
the scorebug via `super()`.
|
|
|
|
**Rotation strategies.** The three "dialects" turned out to be one algorithm
|
|
(Smooth Weighted Round-Robin) in two shapes: an incremental picker holding state
|
|
across calls (afl/nrl/soccer) and a precomputed per-cycle list
|
|
(football/baseball/basketball, and hockey with a different loop shape). They
|
|
agree within a cycle and differ only at the boundary — the incremental form has
|
|
no restart seam — so core ships both rather than declaring a winner:
|
|
|
|
```python
|
|
self.rotation = get_rotation_strategy("swrr", weight_for=self._live_weight)
|
|
```
|
|
|
|
`weight_for` is supplied by the host, so the *favorites* policy stays with the
|
|
plugin and `rotation.py` never learns what a favorite is. An unknown strategy
|
|
name degrades to `simple` rather than raising: the name comes from user config,
|
|
and a typo should cost the boost, not the scoreboard. A plugin needing an
|
|
ordering core does not ship calls `register_rotation_strategy` instead of core
|
|
growing a branch.
|
|
|
|
`test_sports_capabilities.py` checks each strategy against a **verbatim
|
|
transcription** of the plugin code it replaces, over every live-game shape up to
|
|
four games. That differential is what B5 deletes the bundled copies on the
|
|
strength of.
|
|
|
|
## 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; strategies checked against verbatim plugin transcriptions |
|
|
| **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_min` → `ledmatrix_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.
|