Source code for sr.comp.knockout_scheduler.base_scheduler

"""Base for knockout scheduling."""

from __future__ import annotations

import collections
import difflib
from collections.abc import Collection, Iterable, Mapping, Sequence
from typing import final, Generic, TypedDict, TypeVar

from .. import text_utils
from ..match_period import KnockoutMatch, Match, MatchPeriod, MatchType
from ..scores import Scores
from ..teams import Team
from ..types import ArenaName, KnockoutBracketData, MatchId, MatchNumber, TLA
from .types import (
    KnockoutBracket,
    KnockoutPeriodData,
    KnockoutRound,
    ScheduleHost,
)

# Use '???' as the "we don't know yet" marker
UNKNOWABLE_TEAM = TLA('???')


class BaseKnockoutScheduleData(TypedDict):
    match_periods: KnockoutPeriodData
    brackets: Sequence[KnockoutBracketData]


TConfig = TypeVar('TConfig', bound=BaseKnockoutScheduleData)

DEFAULT_KNOCKOUT_BRACKET_NAME = 'default'


class InvalidKnockoutBracketsError(Exception):
    def __init__(
        self,
        missing_brackets: Mapping[str, Collection[KnockoutMatch]],
        extra_brackets: Collection[str],
        known_brackets: Collection[str],
    ) -> None:
        super().__init__(missing_brackets, extra_brackets, known_brackets)
        self.missing_brackets = missing_brackets
        self.extra_brackets = extra_brackets
        self.known_brackets = known_brackets

    def __str__(self) -> str:
        messages = ["Invalid knockout brackets configuration."]

        if self.missing_brackets:
            missing_str = ", ".join(
                f"{bracket!r} (in {text_utils.join_and(x.display_name for x in matches)}; "
                f"close matches: {difflib.get_close_matches(bracket, self.known_brackets)})"
                for bracket, matches in self.missing_brackets.items()
            )
            messages.append(
                f"The following brackets are referenced but not defined: {missing_str}.",
            )

        if self.extra_brackets:
            extra_str = text_utils.join_and(repr(x) for x in self.extra_brackets)
            messages.append(
                f"The following brackets are defined but not used: {extra_str}.",
            )

        return " ".join(messages)


[docs] class BaseKnockoutScheduler(Generic[TConfig]): """ Base class for knockout schedulers offering common functionality. :param schedule: The league schedule. :param scores: The scores. :param dict arenas: The arenas. :param dict teams: The teams. :param config: Custom configuration for the knockout scheduler. """ @staticmethod def _build_brackets(brackets: Sequence[KnockoutBracketData]) -> list[KnockoutBracket]: if not brackets: return [ KnockoutBracket( DEFAULT_KNOCKOUT_BRACKET_NAME, display_name="Knockouts", ), ] return [KnockoutBracket(**x) for x in brackets] def __init__( self, schedule: ScheduleHost, scores: Scores, arenas: Iterable[ArenaName], num_teams_per_arena: int, teams: Mapping[TLA, Team], config: TConfig, ) -> None: self.schedule = schedule self.scores = scores self.arenas = arenas self.teams = teams self.config = config self.num_teams_per_arena = num_teams_per_arena """ The number of spaces for teams in an arena. This is used in building matches where we don't yet know which teams will actually be playing, and for filling in when there aren't enough teams to fill the arena. """ self.knockout_brackets = self._build_brackets(self.config['brackets']) """ Brackets which make up the knockout. This currently has no bearing on the actual matches and is purely a display consideration. """ # The knockout matches appear in the normal matches list # but this list provides them in groups of rounds. # e.g. self.knockout_rounds[-2] gives the semi-final matches # and self.knockout_rounds[-1] gives the final match (in a list) # Note that the ordering of the matches within the rounds # in this list is important (e.g. self.knockout_rounds[0][0] is # will involve the top seed, whilst self.knockout_rounds[0][-1] will # involve the second seed). self.knockout_rounds: list[KnockoutRound] = [] period_config = self.config['match_periods']['knockout'][0] self.period = MatchPeriod( period_config['start_time'], period_config['end_time'], # The knockouts *must* end on time, so we don't specify a # different max_end_time. period_config['end_time'], period_config['description'], [], MatchType.knockout, ) def _played_all_league_matches(self) -> bool: """ Check if all league matches have been played. :return: :py:bool:`True` if we've played all league matches. """ for arena_matches in self.schedule.matches: for match in arena_matches.values(): if match.type != MatchType.league: continue if (match.arena, match.num) not in self.scores.league.game_points: return False return True
[docs] @staticmethod def get_match_display_name( rounds_remaining: int, num_within_round: int, global_num: MatchNumber, ) -> str: """ Get a human-readable match display name. :param rounds_remaining: The number of knockout rounds remaining. :param num_within_round: The match number within the knockout round. :param global_num: The global match number. """ if rounds_remaining == 0: display_name = "Final (#{global_num})" elif rounds_remaining == 1: display_name = "Semi {num_within_round} (#{global_num})" elif rounds_remaining == 2: display_name = "Quarter {num_within_round} (#{global_num})" else: display_name = "Match {global_num}" return display_name.format( num_within_round=num_within_round + 1, global_num=global_num, )
[docs] @staticmethod def get_round_display_name( round_number: int, rounds_remaining: int, ) -> str: """ Get a human-readable knockout round display name. :param round_number: The round number within the knockouts. This should be a 0-indexed number. :param rounds_remaining: The number of knockout rounds remaining. """ if rounds_remaining == 0: return "Finals" elif rounds_remaining == 1: return "Semi Finals" elif rounds_remaining == 2: return "Quarter Finals" return f"Round {round_number}"
[docs] def get_ranking(self, game: Match) -> list[TLA]: """ Get a ranking of the given match's teams. :param game: A game. """ match_id: MatchId = (game.arena, game.num) # Get the resolved positions if present (will be a tla -> position map) positions = self.scores.knockout.resolved_positions.get(match_id, None) if positions is None: # Given match hasn't been scored yet return [ UNKNOWABLE_TEAM for x in game.teams if x is not None ] # Extract just TLAs return list(positions.keys())
def _get_seeds(self) -> list[TLA]: """ Get a list of TLAs ordered by league position, for use in building a knockout schedule. This will not include any teams who have dropped out prior to the start of the league. The top seed will be first in the list. If the league has not completed (and thus a seeding is not yet available) the list will contain an explicit placeholder TLA, though the length of the list will be as correct based on the dropouts so far. """ first_knockout_match_num = MatchNumber(self.schedule.n_league_matches) teams = list(self.scores.league.positions.keys()) teams = [ tla for tla in teams if self.teams[tla].is_still_around(first_knockout_match_num) ] if not self._played_all_league_matches(): teams = [UNKNOWABLE_TEAM] * len(teams) return teams def _append_knockout_round( self, rounds_remaining: int, name: str | None = None, ) -> KnockoutRound: if name is None: name = self.get_round_display_name( round_number=len(self.knockout_rounds), rounds_remaining=rounds_remaining, ) knockout_round = KnockoutRound(name=name) self.knockout_rounds.append(knockout_round) return knockout_round
[docs] @final def validate_brackets(self) -> None: valid_bracket_ids = set(x.name for x in self.knockout_brackets) matches_by_bracket: collections.defaultdict[str, list[KnockoutMatch]] matches_by_bracket = collections.defaultdict(list) for knockout_round in self.knockout_rounds: for match in knockout_round: matches_by_bracket[match.knockout_bracket].append(match) missing_brackets = { x: y for x, y in matches_by_bracket.items() if x not in valid_bracket_ids } extra_brackets = valid_bracket_ids - matches_by_bracket.keys() if missing_brackets or extra_brackets: raise InvalidKnockoutBracketsError( missing_brackets, extra_brackets, valid_bracket_ids, )
[docs] def add_knockouts(self) -> None: """ Add the knockouts to the schedule. Derived classes must override this method. """ raise NotImplementedError()