"""Competition and season registry for extensible football passports. The World Cup 2026 passport is the first product namespace, but the backend must be able to host future competitions without rewriting gamification logic. This module keeps that boundary explicit: every piece of content, leaderboard, challenge, collection, and scoring rule can be scoped to a competition season. The registry is intentionally persistence-agnostic. It can be hydrated from JSON configuration, an admin CMS table, or code-defined defaults. Runtime code can use the stable namespace helpers here to avoid hard-coding "world cup" in future Premier League, Champions League, or club-passport experiences. """ from __future__ import annotations from collections.abc import Iterable as IterableABC from dataclasses import dataclass, field from datetime import date from enum import Enum import json from pathlib import Path import re from typing import Any, Iterable, Mapping _SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,63}$") _SEASON_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{1,31}$") _NAMESPACE_RE = re.compile(r"^(?P[a-z0-9][a-z0-9-]{1,63}):(?P[a-z0-9][a-z0-9._-]{1,31})$") class RegistryError(ValueError): """Raised when a competition definition or registry lookup is invalid.""" class CompetitionType(str, Enum): """Broad competition classes used by scoring and content extensions.""" NATIONAL_TEAMS_TOURNAMENT = "national_teams_tournament" CLUB_LEAGUE = "club_league" CLUB_CUP = "club_cup" INTERNATIONAL_CLUB_TOURNAMENT = "international_club_tournament" FRIENDLY_SERIES = "friendly_series" class LeaderboardScope(str, Enum): """Standard leaderboard scopes supported by the product.""" GLOBAL = "global" COUNTRY = "country" FRIENDS = "friends" COMPETITION = "competition" CLUB = "club" @dataclass(frozen=True) class SeasonWindow: """Calendar window for a competition season.""" starts_on: date ends_on: date def __post_init__(self) -> None: if self.ends_on < self.starts_on: raise RegistryError("season window ends_on must be on or after starts_on") def contains(self, day: date) -> bool: """Return True when *day* falls inside the season window, inclusive.""" return self.starts_on <= day <= self.ends_on def to_dict(self) -> dict[str, str]: return { "starts_on": self.starts_on.isoformat(), "ends_on": self.ends_on.isoformat(), } @classmethod def from_mapping(cls, value: Mapping[str, Any]) -> "SeasonWindow": try: starts_on = date.fromisoformat(str(value["starts_on"])) ends_on = date.fromisoformat(str(value["ends_on"])) except KeyError as exc: raise RegistryError(f"season window missing required field: {exc.args[0]}") from exc except ValueError as exc: raise RegistryError(f"season window date must be ISO yyyy-mm-dd: {exc}") from exc return cls(starts_on=starts_on, ends_on=ends_on) @dataclass(frozen=True) class ContentNamespace: """Stable namespace used to partition content and user progress. The namespace string is deliberately compact because it appears in badge keys, challenge IDs, leaderboard cache keys, analytics events, and provider import records. """ competition_slug: str season_label: str def __post_init__(self) -> None: _validate_slug(self.competition_slug, "competition slug") _validate_season_label(self.season_label) @property def value(self) -> str: return f"{self.competition_slug}:{self.season_label}" def key_for(self, entity_kind: str, stable_id: str) -> str: """Build a stable content key inside this namespace. Example: ``world-cup:2026:challenge:watch-england``. """ if not entity_kind or ":" in entity_kind: raise RegistryError("entity_kind must be non-empty and must not contain ':'") if not stable_id or ":" in stable_id: raise RegistryError("stable_id must be non-empty and must not contain ':'") return f"{self.value}:{entity_kind}:{stable_id}" @classmethod def parse(cls, raw: str) -> "ContentNamespace": match = _NAMESPACE_RE.match(raw) if not match: raise RegistryError( "namespace must use ':' with lowercase slug-safe values" ) return cls(competition_slug=match.group("slug"), season_label=match.group("season")) @dataclass(frozen=True) class CompetitionDefinition: """Configuration for one competition season.""" slug: str name: str competition_type: CompetitionType season_label: str governing_body: str region: str window: SeasonWindow scoring_profile: str = "standard" content_version: str = "1" leaderboard_scopes: tuple[LeaderboardScope, ...] = ( LeaderboardScope.GLOBAL, LeaderboardScope.FRIENDS, LeaderboardScope.COMPETITION, ) metadata: Mapping[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: _validate_slug(self.slug, "competition slug") _validate_season_label(self.season_label) if not self.name.strip(): raise RegistryError("competition name must be non-empty") if not self.governing_body.strip(): raise RegistryError("governing_body must be non-empty") if not self.region.strip(): raise RegistryError("region must be non-empty") if not self.scoring_profile.strip(): raise RegistryError("scoring_profile must be non-empty") if not self.content_version.strip(): raise RegistryError("content_version must be non-empty") coerced_type = _coerce_enum(CompetitionType, self.competition_type, "competition_type") object.__setattr__(self, "competition_type", coerced_type) scopes = tuple(_coerce_enum(LeaderboardScope, scope, "leaderboard_scope") for scope in self.leaderboard_scopes) if not scopes: raise RegistryError("at least one leaderboard scope is required") object.__setattr__(self, "leaderboard_scopes", scopes) object.__setattr__(self, "metadata", dict(self.metadata)) @property def namespace(self) -> ContentNamespace: return ContentNamespace(self.slug, self.season_label) @property def registry_key(self) -> str: return self.namespace.value def is_active_on(self, day: date) -> bool: return self.window.contains(day) def content_key(self, entity_kind: str, stable_id: str) -> str: return self.namespace.key_for(entity_kind, stable_id) def leaderboard_key(self, scope: LeaderboardScope | str, suffix: str = "overall") -> str: scope_value = _coerce_enum(LeaderboardScope, scope, "leaderboard scope").value if not suffix or ":" in suffix: raise RegistryError("leaderboard suffix must be non-empty and must not contain ':'") return f"leaderboard:{self.registry_key}:{scope_value}:{suffix}" def to_dict(self) -> dict[str, Any]: return { "slug": self.slug, "name": self.name, "competition_type": self.competition_type.value, "season_label": self.season_label, "governing_body": self.governing_body, "region": self.region, "window": self.window.to_dict(), "scoring_profile": self.scoring_profile, "content_version": self.content_version, "leaderboard_scopes": [scope.value for scope in self.leaderboard_scopes], "metadata": dict(self.metadata), } @classmethod def from_mapping(cls, value: Mapping[str, Any]) -> "CompetitionDefinition": try: window_value = value["window"] window = window_value if isinstance(window_value, SeasonWindow) else SeasonWindow.from_mapping(window_value) default_scopes = ( LeaderboardScope.GLOBAL.value, LeaderboardScope.FRIENDS.value, LeaderboardScope.COMPETITION.value, ) scopes = tuple(value.get("leaderboard_scopes", default_scopes)) return cls( slug=str(value["slug"]), name=str(value["name"]), competition_type=_coerce_enum(CompetitionType, value["competition_type"], "competition_type"), season_label=str(value["season_label"]), governing_body=str(value["governing_body"]), region=str(value["region"]), window=window, scoring_profile=str(value.get("scoring_profile", "standard")), content_version=str(value.get("content_version", "1")), leaderboard_scopes=tuple(_coerce_enum(LeaderboardScope, scope, "leaderboard_scope") for scope in scopes), metadata=dict(value.get("metadata", {})), ) except KeyError as exc: raise RegistryError(f"competition definition missing required field: {exc.args[0]}") from exc class CompetitionRegistry: """Lookup and validation container for competition definitions.""" def __init__(self, competitions: Iterable[CompetitionDefinition] = ()) -> None: self._competitions: dict[str, CompetitionDefinition] = {} for competition in competitions: self.register(competition) def register(self, competition: CompetitionDefinition) -> None: if not isinstance(competition, CompetitionDefinition): competition = CompetitionDefinition.from_mapping(competition) # type: ignore[arg-type] if competition.registry_key in self._competitions: raise RegistryError(f"duplicate competition season registered: {competition.registry_key}") self._competitions[competition.registry_key] = competition def get(self, slug: str, season_label: str | None = None) -> CompetitionDefinition: _validate_slug(slug, "competition slug") if season_label is not None: _validate_season_label(season_label) key = ContentNamespace(slug, season_label).value try: return self._competitions[key] except KeyError as exc: raise RegistryError(f"unknown competition season: {key}") from exc matches = [competition for competition in self._competitions.values() if competition.slug == slug] if not matches: raise RegistryError(f"unknown competition: {slug}") if len(matches) > 1: seasons = ", ".join(sorted(competition.season_label for competition in matches)) raise RegistryError(f"competition '{slug}' has multiple seasons; specify one of: {seasons}") return matches[0] def get_namespace(self, namespace: str | ContentNamespace) -> CompetitionDefinition: parsed = namespace if isinstance(namespace, ContentNamespace) else ContentNamespace.parse(namespace) return self.get(parsed.competition_slug, parsed.season_label) def all(self) -> tuple[CompetitionDefinition, ...]: return tuple(sorted(self._competitions.values(), key=lambda item: item.registry_key)) def active_on(self, day: date) -> tuple[CompetitionDefinition, ...]: return tuple(competition for competition in self.all() if competition.is_active_on(day)) def namespaces(self) -> tuple[str, ...]: return tuple(competition.registry_key for competition in self.all()) def to_dict(self) -> dict[str, Any]: return {"competitions": [competition.to_dict() for competition in self.all()]} @classmethod def from_mapping(cls, value: Mapping[str, Any]) -> "CompetitionRegistry": competitions = value.get("competitions") if competitions is None: raise RegistryError("registry mapping must contain a 'competitions' list") if not isinstance(competitions, IterableABC) or isinstance(competitions, (str, bytes)): raise RegistryError("'competitions' must be a list") return cls(CompetitionDefinition.from_mapping(item) for item in competitions) def build_default_registry() -> CompetitionRegistry: """Return the product's built-in competition roadmap. Dates for post-World-Cup competitions are intentionally broad planning windows; production deployments can override them through JSON/admin data. """ return CompetitionRegistry( [ CompetitionDefinition( slug="world-cup", name="FIFA World Cup", competition_type=CompetitionType.NATIONAL_TEAMS_TOURNAMENT, season_label="2026", governing_body="FIFA", region="Global", window=SeasonWindow(date(2026, 6, 11), date(2026, 7, 19)), scoring_profile="world_cup_2026", content_version="mvp", leaderboard_scopes=( LeaderboardScope.GLOBAL, LeaderboardScope.COUNTRY, LeaderboardScope.FRIENDS, LeaderboardScope.COMPETITION, ), metadata={ "hosts": ["Canada", "Mexico", "United States"], "team_count": 48, "match_count": 104, }, ), CompetitionDefinition( slug="premier-league", name="Premier League", competition_type=CompetitionType.CLUB_LEAGUE, season_label="2026-27", governing_body="The Football Association", region="England", window=SeasonWindow(date(2026, 8, 1), date(2027, 5, 31)), scoring_profile="domestic_league_standard", content_version="planning", leaderboard_scopes=( LeaderboardScope.GLOBAL, LeaderboardScope.FRIENDS, LeaderboardScope.CLUB, LeaderboardScope.COMPETITION, ), metadata={"extension_status": "post_world_cup_candidate"}, ), CompetitionDefinition( slug="champions-league", name="UEFA Champions League", competition_type=CompetitionType.CLUB_CUP, season_label="2026-27", governing_body="UEFA", region="Europe", window=SeasonWindow(date(2026, 7, 1), date(2027, 6, 15)), scoring_profile="continental_cup_standard", content_version="planning", leaderboard_scopes=( LeaderboardScope.GLOBAL, LeaderboardScope.FRIENDS, LeaderboardScope.CLUB, LeaderboardScope.COMPETITION, ), metadata={"extension_status": "post_world_cup_candidate"}, ), ] ) def load_registry(path: str | Path) -> CompetitionRegistry: """Load a registry JSON file.""" with Path(path).open("r", encoding="utf-8") as handle: payload = json.load(handle) return CompetitionRegistry.from_mapping(payload) def dump_registry(registry: CompetitionRegistry, path: str | Path) -> None: """Write a registry JSON file using deterministic formatting.""" with Path(path).open("w", encoding="utf-8") as handle: json.dump(registry.to_dict(), handle, indent=2, sort_keys=True) handle.write("\n") def _validate_slug(value: str, label: str) -> None: if not _SLUG_RE.match(value): raise RegistryError(f"{label} must match {_SLUG_RE.pattern}") def _validate_season_label(value: str) -> None: if not _SEASON_RE.match(value): raise RegistryError(f"season_label must match {_SEASON_RE.pattern}") def _coerce_enum(enum_type: type[Enum], value: Enum | str, label: str) -> Any: if isinstance(value, enum_type): return value try: return enum_type(str(value)) except ValueError as exc: allowed = ", ".join(item.value for item in enum_type) raise RegistryError(f"invalid {label}: {value!r}; allowed values: {allowed}") from exc __all__ = [ "CompetitionDefinition", "CompetitionRegistry", "CompetitionType", "ContentNamespace", "LeaderboardScope", "RegistryError", "SeasonWindow", "build_default_registry", "dump_registry", "load_registry", ]