basketball 0.0.5 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +88 -13
- data/basketball.gemspec +1 -1
- data/exe/basketball-schedule +7 -0
- data/lib/basketball/drafting/cli.rb +2 -2
- data/lib/basketball/drafting/engine.rb +5 -8
- data/lib/basketball/drafting/engine_serializer.rb +1 -2
- data/lib/basketball/drafting/event.rb +4 -4
- data/lib/basketball/drafting/league.rb +0 -1
- data/lib/basketball/drafting/pick_event.rb +3 -3
- data/lib/basketball/drafting/roster.rb +0 -1
- data/lib/basketball/drafting/sim_event.rb +3 -3
- data/lib/basketball/drafting.rb +6 -0
- data/lib/basketball/scheduling/calendar.rb +121 -0
- data/lib/basketball/scheduling/calendar_serializer.rb +84 -0
- data/lib/basketball/scheduling/cli.rb +198 -0
- data/lib/basketball/scheduling/conference.rb +57 -0
- data/lib/basketball/scheduling/coordinator.rb +180 -0
- data/lib/basketball/scheduling/division.rb +43 -0
- data/lib/basketball/scheduling/game.rb +32 -0
- data/lib/basketball/scheduling/league.rb +114 -0
- data/lib/basketball/scheduling/league_serializer.rb +90 -0
- data/lib/basketball/scheduling/preseason_game.rb +11 -0
- data/lib/basketball/scheduling/season_game.rb +8 -0
- data/lib/basketball/scheduling/team.rb +21 -0
- data/lib/basketball/scheduling.rb +17 -0
- data/lib/basketball/value_object.rb +16 -7
- data/lib/basketball/version.rb +1 -1
- data/lib/basketball.rb +1 -0
- metadata +17 -2
| @@ -0,0 +1,198 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require_relative 'calendar_serializer'
         | 
| 4 | 
            +
            require_relative 'conference'
         | 
| 5 | 
            +
            require_relative 'coordinator'
         | 
| 6 | 
            +
            require_relative 'division'
         | 
| 7 | 
            +
            require_relative 'league'
         | 
| 8 | 
            +
            require_relative 'league_serializer'
         | 
| 9 | 
            +
            require_relative 'team'
         | 
| 10 | 
            +
             | 
| 11 | 
            +
            module Basketball
         | 
| 12 | 
            +
              module Scheduling
         | 
| 13 | 
            +
                # Examples:
         | 
| 14 | 
            +
                #   exe/basketball-schedule -o tmp/league.json
         | 
| 15 | 
            +
                #   exe/basketball-schedule -i tmp/league.json -o tmp/calendar.json
         | 
| 16 | 
            +
                #   exe/basketball-schedule -i tmp/league.json -o tmp/calendar.json -y 2005
         | 
| 17 | 
            +
                #   exe/basketball-schedule -c tmp/calendar.json
         | 
| 18 | 
            +
                #   exe/basketball-schedule -c tmp/calendar.json -t C0-D0-T0
         | 
| 19 | 
            +
                #   exe/basketball-schedule -c tmp/calendar.json -d 2005-02-03
         | 
| 20 | 
            +
                #   exe/basketball-schedule -c tmp/calendar.json -d 2005-02-03 -t C0-D0-T0
         | 
| 21 | 
            +
                class CLI
         | 
| 22 | 
            +
                  attr_reader :opts,
         | 
| 23 | 
            +
                              :league_serializer,
         | 
| 24 | 
            +
                              :calendar_serializer,
         | 
| 25 | 
            +
                              :io,
         | 
| 26 | 
            +
                              :coordinator
         | 
| 27 | 
            +
             | 
| 28 | 
            +
                  def initialize(args:, io: $stdout)
         | 
| 29 | 
            +
                    @io                  = io
         | 
| 30 | 
            +
                    @opts                = slop_parse(args)
         | 
| 31 | 
            +
                    @league_serializer   = LeagueSerializer.new
         | 
| 32 | 
            +
                    @calendar_serializer = CalendarSerializer.new
         | 
| 33 | 
            +
                    @coordinator         = Coordinator.new
         | 
| 34 | 
            +
             | 
| 35 | 
            +
                    freeze
         | 
| 36 | 
            +
                  end
         | 
| 37 | 
            +
             | 
| 38 | 
            +
                  def invoke!
         | 
| 39 | 
            +
                    if output?
         | 
| 40 | 
            +
                      out_dir = File.dirname(output)
         | 
| 41 | 
            +
                      FileUtils.mkdir_p(out_dir)
         | 
| 42 | 
            +
                    end
         | 
| 43 | 
            +
             | 
| 44 | 
            +
                    if output? && no_input?
         | 
| 45 | 
            +
                      execute_with_no_input
         | 
| 46 | 
            +
                    elsif output?
         | 
| 47 | 
            +
                      execute_with_input
         | 
| 48 | 
            +
                    end
         | 
| 49 | 
            +
             | 
| 50 | 
            +
                    output_cal_query if cal
         | 
| 51 | 
            +
             | 
| 52 | 
            +
                    self
         | 
| 53 | 
            +
                  end
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                  private
         | 
| 56 | 
            +
             | 
| 57 | 
            +
                  def output_cal_query
         | 
| 58 | 
            +
                    contents      = File.read(cal)
         | 
| 59 | 
            +
                    calendar      = calendar_serializer.deserialize(contents)
         | 
| 60 | 
            +
                    team_instance = team ? calendar.team(team) : nil
         | 
| 61 | 
            +
                    games         = calendar.games_for(date:, team: team_instance).sort_by(&:date)
         | 
| 62 | 
            +
                    pre_counter   = 1
         | 
| 63 | 
            +
                    counter       = 1
         | 
| 64 | 
            +
             | 
| 65 | 
            +
                    io.puts("Games for [team: #{team}, date: #{date}]")
         | 
| 66 | 
            +
                    games.each do |game|
         | 
| 67 | 
            +
                      if game.is_a?(PreseasonGame)
         | 
| 68 | 
            +
                        io.puts("##{pre_counter} - #{game}")
         | 
| 69 | 
            +
                        pre_counter += 1
         | 
| 70 | 
            +
                      else
         | 
| 71 | 
            +
                        io.puts("##{counter} - #{game}")
         | 
| 72 | 
            +
                        counter += 1
         | 
| 73 | 
            +
                      end
         | 
| 74 | 
            +
                    end
         | 
| 75 | 
            +
                  end
         | 
| 76 | 
            +
             | 
| 77 | 
            +
                  def execute_with_input
         | 
| 78 | 
            +
                    io.puts("Loading league from: #{input}")
         | 
| 79 | 
            +
             | 
| 80 | 
            +
                    contents = File.read(input)
         | 
| 81 | 
            +
                    league   = league_serializer.deserialize(contents)
         | 
| 82 | 
            +
             | 
| 83 | 
            +
                    io.puts("Generating calendar for the year #{year}...")
         | 
| 84 | 
            +
             | 
| 85 | 
            +
                    calendar = coordinator.schedule(league:, year:)
         | 
| 86 | 
            +
                    contents = calendar_serializer.serialize(calendar)
         | 
| 87 | 
            +
             | 
| 88 | 
            +
                    File.write(output, contents)
         | 
| 89 | 
            +
             | 
| 90 | 
            +
                    io.puts("Calendar written to: #{output}")
         | 
| 91 | 
            +
                  end
         | 
| 92 | 
            +
             | 
| 93 | 
            +
                  def execute_with_no_input
         | 
| 94 | 
            +
                    league   = generate_league
         | 
| 95 | 
            +
                    contents = league_serializer.serialize(league)
         | 
| 96 | 
            +
             | 
| 97 | 
            +
                    File.write(output, contents)
         | 
| 98 | 
            +
             | 
| 99 | 
            +
                    io.puts("League written to: #{output}")
         | 
| 100 | 
            +
                  end
         | 
| 101 | 
            +
             | 
| 102 | 
            +
                  def cal
         | 
| 103 | 
            +
                    opts[:cal].to_s.empty? ? nil : opts[:cal]
         | 
| 104 | 
            +
                  end
         | 
| 105 | 
            +
             | 
| 106 | 
            +
                  def team
         | 
| 107 | 
            +
                    opts[:team].to_s.empty? ? nil : opts[:team]
         | 
| 108 | 
            +
                  end
         | 
| 109 | 
            +
             | 
| 110 | 
            +
                  def date
         | 
| 111 | 
            +
                    opts[:date].to_s.empty? ? nil : Date.parse(opts[:date])
         | 
| 112 | 
            +
                  end
         | 
| 113 | 
            +
             | 
| 114 | 
            +
                  def year
         | 
| 115 | 
            +
                    opts[:year].to_s.empty? ? Date.today.year : opts[:year]
         | 
| 116 | 
            +
                  end
         | 
| 117 | 
            +
             | 
| 118 | 
            +
                  def no_input?
         | 
| 119 | 
            +
                    input.to_s.empty?
         | 
| 120 | 
            +
                  end
         | 
| 121 | 
            +
             | 
| 122 | 
            +
                  def input
         | 
| 123 | 
            +
                    opts[:input]
         | 
| 124 | 
            +
                  end
         | 
| 125 | 
            +
             | 
| 126 | 
            +
                  def output?
         | 
| 127 | 
            +
                    !output.to_s.empty?
         | 
| 128 | 
            +
                  end
         | 
| 129 | 
            +
             | 
| 130 | 
            +
                  def output
         | 
| 131 | 
            +
                    opts[:output]
         | 
| 132 | 
            +
                  end
         | 
| 133 | 
            +
             | 
| 134 | 
            +
                  def generate_conferences
         | 
| 135 | 
            +
                    2.times.map do |i|
         | 
| 136 | 
            +
                      id = "C#{i}"
         | 
| 137 | 
            +
             | 
| 138 | 
            +
                      Conference.new(
         | 
| 139 | 
            +
                        id:,
         | 
| 140 | 
            +
                        name: Faker::Esport.league,
         | 
| 141 | 
            +
                        divisions: generate_divisions("#{id}-")
         | 
| 142 | 
            +
                      )
         | 
| 143 | 
            +
                    end
         | 
| 144 | 
            +
                  end
         | 
| 145 | 
            +
             | 
| 146 | 
            +
                  def generate_divisions(id_prefix)
         | 
| 147 | 
            +
                    3.times.map do |j|
         | 
| 148 | 
            +
                      id = "#{id_prefix}D#{j}"
         | 
| 149 | 
            +
             | 
| 150 | 
            +
                      Division.new(
         | 
| 151 | 
            +
                        id:,
         | 
| 152 | 
            +
                        name: Faker::Address.community,
         | 
| 153 | 
            +
                        teams: generate_teams("#{id}-")
         | 
| 154 | 
            +
                      )
         | 
| 155 | 
            +
                    end
         | 
| 156 | 
            +
                  end
         | 
| 157 | 
            +
             | 
| 158 | 
            +
                  def generate_teams(id_prefix)
         | 
| 159 | 
            +
                    5.times.map do |k|
         | 
| 160 | 
            +
                      Team.new(
         | 
| 161 | 
            +
                        id: "#{id_prefix}T#{k}",
         | 
| 162 | 
            +
                        name: Faker::Team.name
         | 
| 163 | 
            +
                      )
         | 
| 164 | 
            +
                    end
         | 
| 165 | 
            +
                  end
         | 
| 166 | 
            +
             | 
| 167 | 
            +
                  def generate_league
         | 
| 168 | 
            +
                    League.new(conferences: generate_conferences)
         | 
| 169 | 
            +
                  end
         | 
| 170 | 
            +
             | 
| 171 | 
            +
                  def slop_parse(args)
         | 
| 172 | 
            +
                    Slop.parse(args) do |o|
         | 
| 173 | 
            +
                      o.banner = 'Usage: basketball-schedule [options] ...'
         | 
| 174 | 
            +
             | 
| 175 | 
            +
                      output_description = <<~DESC
         | 
| 176 | 
            +
                        If input path is omitted then a new league will be written to this path.
         | 
| 177 | 
            +
                        If an input path is specified then a Calendar will be written to the output path.
         | 
| 178 | 
            +
                      DESC
         | 
| 179 | 
            +
             | 
| 180 | 
            +
                      # League and Calendar Generation Interface
         | 
| 181 | 
            +
                      o.string  '-i', '--input',  'Path to load the League from. If omitted then a new league will be generated.'
         | 
| 182 | 
            +
                      o.string  '-o', '--output', output_description
         | 
| 183 | 
            +
                      o.integer '-y', '--year',   'Year to use to generate a calendar for (defaults to current year).'
         | 
| 184 | 
            +
             | 
| 185 | 
            +
                      # Calendar Query Interface
         | 
| 186 | 
            +
                      o.string  '-c', '--cal',  'Path to load a Calendar from. If omitted then no matchups will be outputted.'
         | 
| 187 | 
            +
                      o.string  '-d', '--date', 'Filter matchups to just the date specified (requires --cal option).'
         | 
| 188 | 
            +
                      o.string  '-t', '--team', 'Filter matchups to just the team ID specified (requires --cal option).'
         | 
| 189 | 
            +
             | 
| 190 | 
            +
                      o.on '-h', '--help', 'Print out help, like this is doing right now.' do
         | 
| 191 | 
            +
                        io.puts(o)
         | 
| 192 | 
            +
                        exit
         | 
| 193 | 
            +
                      end
         | 
| 194 | 
            +
                    end.to_h
         | 
| 195 | 
            +
                  end
         | 
| 196 | 
            +
                end
         | 
| 197 | 
            +
              end
         | 
| 198 | 
            +
            end
         | 
| @@ -0,0 +1,57 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Basketball
         | 
| 4 | 
            +
              module Scheduling
         | 
| 5 | 
            +
                class Conference < Entity
         | 
| 6 | 
            +
                  DIVISIONS_SIZE = 3
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                  attr_reader :name, :divisions
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                  def initialize(id:, name: '', divisions: [])
         | 
| 11 | 
            +
                    super(id)
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                    @name      = name.to_s
         | 
| 14 | 
            +
                    @divisions = []
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                    divisions.each { |d| register_division!(d) }
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                    if divisions.length != DIVISIONS_SIZE
         | 
| 19 | 
            +
                      raise BadDivisionsSizeError, "#{id} should have exactly #{DIVISIONS_SIZE} divisions"
         | 
| 20 | 
            +
                    end
         | 
| 21 | 
            +
             | 
| 22 | 
            +
                    freeze
         | 
| 23 | 
            +
                  end
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                  def to_s
         | 
| 26 | 
            +
                    (["[#{super}] #{name}"] + divisions.map(&:to_s)).join("\n")
         | 
| 27 | 
            +
                  end
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                  def division?(division)
         | 
| 30 | 
            +
                    divisions.include?(division)
         | 
| 31 | 
            +
                  end
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                  def teams
         | 
| 34 | 
            +
                    divisions.flat_map(&:teams)
         | 
| 35 | 
            +
                  end
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                  def team?(team)
         | 
| 38 | 
            +
                    teams.include?(team)
         | 
| 39 | 
            +
                  end
         | 
| 40 | 
            +
             | 
| 41 | 
            +
                  private
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                  def register_division!(division)
         | 
| 44 | 
            +
                    raise ArgumentError, 'division is required' unless division
         | 
| 45 | 
            +
                    raise DivisionAlreadyRegisteredError, "#{division} already registered" if division?(division)
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                    division.teams.each do |team|
         | 
| 48 | 
            +
                      raise TeamAlreadyRegisteredError, "#{team} already registered" if team?(team)
         | 
| 49 | 
            +
                    end
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                    divisions << division
         | 
| 52 | 
            +
             | 
| 53 | 
            +
                    self
         | 
| 54 | 
            +
                  end
         | 
| 55 | 
            +
                end
         | 
| 56 | 
            +
              end
         | 
| 57 | 
            +
            end
         | 
| @@ -0,0 +1,180 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require_relative 'calendar'
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            module Basketball
         | 
| 6 | 
            +
              module Scheduling
         | 
| 7 | 
            +
                # This is the service class responsible for actually picking out free dates ane pairing up teams to
         | 
| 8 | 
            +
                # play each other.  This is a reasonable naive first pass at some underlying match-making algorithms
         | 
| 9 | 
            +
                # but could definitely use some help with the complexity/runtime/etc.
         | 
| 10 | 
            +
                class Coordinator
         | 
| 11 | 
            +
                  MIN_PRESEASON_GAMES_PER_TEAM = 4
         | 
| 12 | 
            +
                  MAX_PRESEASON_GAMES_PER_TEAM = 6
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                  private_constant :MIN_PRESEASON_GAMES_PER_TEAM,
         | 
| 15 | 
            +
                                   :MAX_PRESEASON_GAMES_PER_TEAM
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                  def schedule(year:, league:)
         | 
| 18 | 
            +
                    Calendar.new(year:).tap do |calendar|
         | 
| 19 | 
            +
                      schedule_preseason!(calendar:, league:)
         | 
| 20 | 
            +
                      schedule_season!(calendar:, league:)
         | 
| 21 | 
            +
                    end
         | 
| 22 | 
            +
                  end
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                  private
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                  def base_matchup_count(league, team1, team2)
         | 
| 27 | 
            +
                    # Same Conference, Same Division
         | 
| 28 | 
            +
                    if league.division_for(team1) == league.division_for(team2)
         | 
| 29 | 
            +
                      4
         | 
| 30 | 
            +
                    # Same Conference, Different Division  and one of 4/10 that play 3 times
         | 
| 31 | 
            +
                    elsif league.conference_for(team1) == league.conference_for(team2)
         | 
| 32 | 
            +
                      3
         | 
| 33 | 
            +
                    # Different Conference
         | 
| 34 | 
            +
                    else
         | 
| 35 | 
            +
                      2
         | 
| 36 | 
            +
                    end
         | 
| 37 | 
            +
                  end
         | 
| 38 | 
            +
             | 
| 39 | 
            +
                  # rubocop:disable Metrics/AbcSize
         | 
| 40 | 
            +
                  # This method derives the plan for which a schedule can be generated from.
         | 
| 41 | 
            +
                  def matchup_plan(league)
         | 
| 42 | 
            +
                    matchups    = {}
         | 
| 43 | 
            +
                    game_counts = league.teams.to_h { |t| [t, 0] }
         | 
| 44 | 
            +
                    teams       = game_counts.keys
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                    (0...teams.length).each do |i|
         | 
| 47 | 
            +
                      team1 = teams[i]
         | 
| 48 | 
            +
             | 
| 49 | 
            +
                      (i + 1...teams.length).each do |j|
         | 
| 50 | 
            +
                        team2              = teams[j]
         | 
| 51 | 
            +
                        key                = [team1, team2].sort
         | 
| 52 | 
            +
                        count              = base_matchup_count(league, team1, team2)
         | 
| 53 | 
            +
                        matchups[key]      = count
         | 
| 54 | 
            +
                        game_counts[team1] += count
         | 
| 55 | 
            +
                        game_counts[team2] += count
         | 
| 56 | 
            +
                      end
         | 
| 57 | 
            +
                    end
         | 
| 58 | 
            +
             | 
| 59 | 
            +
                    # Each team will play 6 games against conference opponents in other divisions.
         | 
| 60 | 
            +
                    # The fours hash will be that plan.
         | 
| 61 | 
            +
                    find_fours(league).each do |team, opponents|
         | 
| 62 | 
            +
                      next if game_counts[team] == 82
         | 
| 63 | 
            +
             | 
| 64 | 
            +
                      opponents.each do |opponent|
         | 
| 65 | 
            +
                        next if game_counts[team] == 82
         | 
| 66 | 
            +
                        next if game_counts[opponent] == 82
         | 
| 67 | 
            +
             | 
| 68 | 
            +
                        game_counts[team] += 1
         | 
| 69 | 
            +
                        game_counts[opponent] += 1
         | 
| 70 | 
            +
             | 
| 71 | 
            +
                        key = [team, opponent].sort
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                        matchups[key] += 1
         | 
| 74 | 
            +
                      end
         | 
| 75 | 
            +
                    end
         | 
| 76 | 
            +
             | 
| 77 | 
            +
                    matchups
         | 
| 78 | 
            +
                  end
         | 
| 79 | 
            +
                  # rubocop:enable Metrics/AbcSize
         | 
| 80 | 
            +
             | 
| 81 | 
            +
                  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
         | 
| 82 | 
            +
                  # I am not liking this algorithm implementation at all but it will seemingly produce a valid
         | 
| 83 | 
            +
                  # result about 1 out of every 1000 cycles. I have yet to spot the assignment pattern to make
         | 
| 84 | 
            +
                  # this way more deterministic.
         | 
| 85 | 
            +
                  def find_fours(league)
         | 
| 86 | 
            +
                    balanced     = false
         | 
| 87 | 
            +
                    count        = 0
         | 
| 88 | 
            +
                    four_tracker = {}
         | 
| 89 | 
            +
             | 
| 90 | 
            +
                    until balanced
         | 
| 91 | 
            +
                      # Let's not completely thrash our CPUs in case this algorithm hits an infinite loop.
         | 
| 92 | 
            +
                      # Instead, lets hard-fail against a hard boundary.
         | 
| 93 | 
            +
                      raise ArgumentError, 'we spent too much CPU time and didnt resolve fours' if count > 100_000
         | 
| 94 | 
            +
             | 
| 95 | 
            +
                      four_tracker = league.teams.to_h { |team| [team, []] }
         | 
| 96 | 
            +
             | 
| 97 | 
            +
                      league.teams.each do |team|
         | 
| 98 | 
            +
                        opponents = league.cross_division_opponents_for(team).shuffle
         | 
| 99 | 
            +
             | 
| 100 | 
            +
                        opponents.each do |opponent|
         | 
| 101 | 
            +
                          if four_tracker[team].length < 6 && four_tracker[opponent].length < 6
         | 
| 102 | 
            +
                            four_tracker[opponent] << team
         | 
| 103 | 
            +
                            four_tracker[team] << opponent
         | 
| 104 | 
            +
                          end
         | 
| 105 | 
            +
                        end
         | 
| 106 | 
            +
                      end
         | 
| 107 | 
            +
             | 
| 108 | 
            +
                      good = true
         | 
| 109 | 
            +
             | 
| 110 | 
            +
                      # trip-wire: if one team isnt balanced then we are not balanced
         | 
| 111 | 
            +
                      four_tracker.each { |_k, v| good = false if v.length < 6 }
         | 
| 112 | 
            +
             | 
| 113 | 
            +
                      balanced = good
         | 
| 114 | 
            +
             | 
| 115 | 
            +
                      count += 1
         | 
| 116 | 
            +
                    end
         | 
| 117 | 
            +
             | 
| 118 | 
            +
                    four_tracker
         | 
| 119 | 
            +
                  end
         | 
| 120 | 
            +
                  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
         | 
| 121 | 
            +
             | 
| 122 | 
            +
                  def schedule_season!(calendar:, league:)
         | 
| 123 | 
            +
                    matchups = matchup_plan(league)
         | 
| 124 | 
            +
             | 
| 125 | 
            +
                    matchups.each do |(team1, team2), count|
         | 
| 126 | 
            +
                      candidates = calendar.available_season_matchup_dates(team1, team2)
         | 
| 127 | 
            +
                      dates      = candidates.sample(count)
         | 
| 128 | 
            +
                      games      = balanced_games(dates, team1, team2)
         | 
| 129 | 
            +
             | 
| 130 | 
            +
                      games.each { |game| calendar.add!(game) }
         | 
| 131 | 
            +
                    end
         | 
| 132 | 
            +
                  end
         | 
| 133 | 
            +
             | 
| 134 | 
            +
                  def balanced_games(dates, team1, team2)
         | 
| 135 | 
            +
                    dates.map.with_index(1) do |date, index|
         | 
| 136 | 
            +
                      if index.even?
         | 
| 137 | 
            +
                        SeasonGame.new(date:, home_team: team1, away_team: team2)
         | 
| 138 | 
            +
                      else
         | 
| 139 | 
            +
                        SeasonGame.new(date:, home_team: team2, away_team: team1)
         | 
| 140 | 
            +
                      end
         | 
| 141 | 
            +
                    end
         | 
| 142 | 
            +
                  end
         | 
| 143 | 
            +
             | 
| 144 | 
            +
                  def schedule_preseason!(calendar:, league:)
         | 
| 145 | 
            +
                    league.teams.each do |team|
         | 
| 146 | 
            +
                      current_games = calendar.preseason_games_for(team:)
         | 
| 147 | 
            +
                      count         = current_games.length
         | 
| 148 | 
            +
             | 
| 149 | 
            +
                      next if count >= MIN_PRESEASON_GAMES_PER_TEAM
         | 
| 150 | 
            +
             | 
| 151 | 
            +
                      other_teams = (league.teams - [team]).shuffle
         | 
| 152 | 
            +
             | 
| 153 | 
            +
                      other_teams.each do |other_team|
         | 
| 154 | 
            +
                        break if count > MIN_PRESEASON_GAMES_PER_TEAM
         | 
| 155 | 
            +
                        next  if calendar.preseason_games_for(team: other_team).length >= MAX_PRESEASON_GAMES_PER_TEAM
         | 
| 156 | 
            +
             | 
| 157 | 
            +
                        candidates = calendar.available_preseason_matchup_dates(team, other_team)
         | 
| 158 | 
            +
             | 
| 159 | 
            +
                        next if candidates.empty?
         | 
| 160 | 
            +
             | 
| 161 | 
            +
                        date = candidates.sample
         | 
| 162 | 
            +
                        game = random_preseason_game(date, team, other_team)
         | 
| 163 | 
            +
             | 
| 164 | 
            +
                        calendar.add!(game)
         | 
| 165 | 
            +
             | 
| 166 | 
            +
                        count += 1
         | 
| 167 | 
            +
                      end
         | 
| 168 | 
            +
                    end
         | 
| 169 | 
            +
                  end
         | 
| 170 | 
            +
             | 
| 171 | 
            +
                  def random_preseason_game(date, team1, team2)
         | 
| 172 | 
            +
                    if rand(1..2) == 1
         | 
| 173 | 
            +
                      PreseasonGame.new(date:, home_team: team1, away_team: team2)
         | 
| 174 | 
            +
                    else
         | 
| 175 | 
            +
                      PreseasonGame.new(date:, home_team: team2, away_team: team1)
         | 
| 176 | 
            +
                    end
         | 
| 177 | 
            +
                  end
         | 
| 178 | 
            +
                end
         | 
| 179 | 
            +
              end
         | 
| 180 | 
            +
            end
         | 
| @@ -0,0 +1,43 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Basketball
         | 
| 4 | 
            +
              module Scheduling
         | 
| 5 | 
            +
                class Division < Entity
         | 
| 6 | 
            +
                  TEAMS_SIZE = 5
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                  attr_reader :name, :teams
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                  def initialize(id:, name: '', teams: [])
         | 
| 11 | 
            +
                    super(id)
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                    @name  = name.to_s
         | 
| 14 | 
            +
                    @teams = []
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                    teams.each { |t| register_team!(t) }
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                    raise BadTeamsSizeError, "#{id} should have exactly #{TEAMS_SIZE} teams" if teams.length != TEAMS_SIZE
         | 
| 19 | 
            +
             | 
| 20 | 
            +
                    freeze
         | 
| 21 | 
            +
                  end
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                  def to_s
         | 
| 24 | 
            +
                    (["[#{super}] #{name}"] + teams.map(&:to_s)).join("\n")
         | 
| 25 | 
            +
                  end
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                  def team?(team)
         | 
| 28 | 
            +
                    teams.include?(team)
         | 
| 29 | 
            +
                  end
         | 
| 30 | 
            +
             | 
| 31 | 
            +
                  private
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                  def register_team!(team)
         | 
| 34 | 
            +
                    raise ArgumentError, 'team is required' unless team
         | 
| 35 | 
            +
                    raise TeamAlreadyRegisteredError, "#{team} already registered" if team?(team)
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                    teams << team
         | 
| 38 | 
            +
             | 
| 39 | 
            +
                    self
         | 
| 40 | 
            +
                  end
         | 
| 41 | 
            +
                end
         | 
| 42 | 
            +
              end
         | 
| 43 | 
            +
            end
         | 
| @@ -0,0 +1,32 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Basketball
         | 
| 4 | 
            +
              module Scheduling
         | 
| 5 | 
            +
                class Game < ValueObject
         | 
| 6 | 
            +
                  attr_reader_value :date, :home_team, :away_team
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                  def initialize(date:, home_team:, away_team:)
         | 
| 9 | 
            +
                    super()
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                    raise ArgumentError, 'date is required'             unless date
         | 
| 12 | 
            +
                    raise ArgumentError, 'home_team is required'        unless home_team
         | 
| 13 | 
            +
                    raise ArgumentError, 'away_team is required'        unless away_team
         | 
| 14 | 
            +
                    raise ArgumentError, 'teams cannot play themselves' if home_team == away_team
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                    @date      = date
         | 
| 17 | 
            +
                    @home_team = home_team
         | 
| 18 | 
            +
                    @away_team = away_team
         | 
| 19 | 
            +
             | 
| 20 | 
            +
                    freeze
         | 
| 21 | 
            +
                  end
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                  def teams
         | 
| 24 | 
            +
                    [home_team, away_team]
         | 
| 25 | 
            +
                  end
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                  def to_s
         | 
| 28 | 
            +
                    "#{date} - #{away_team} at #{home_team}"
         | 
| 29 | 
            +
                  end
         | 
| 30 | 
            +
                end
         | 
| 31 | 
            +
              end
         | 
| 32 | 
            +
            end
         | 
| @@ -0,0 +1,114 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Basketball
         | 
| 4 | 
            +
              module Scheduling
         | 
| 5 | 
            +
                class League
         | 
| 6 | 
            +
                  class UnknownTeamError < StandardError; end
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                  class << self
         | 
| 9 | 
            +
                    def generate_random; end
         | 
| 10 | 
            +
                  end
         | 
| 11 | 
            +
             | 
| 12 | 
            +
                  CONFERENCES_SIZE = 2
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                  attr_reader :conferences
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                  def initialize(conferences: [])
         | 
| 17 | 
            +
                    @conferences = []
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                    conferences.each { |c| register_conference!(c) }
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                    if conferences.length != CONFERENCES_SIZE
         | 
| 22 | 
            +
                      raise BadConferencesSizeError, "there has to be #{CONFERENCES_SIZE} conferences"
         | 
| 23 | 
            +
                    end
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                    freeze
         | 
| 26 | 
            +
                  end
         | 
| 27 | 
            +
             | 
| 28 | 
            +
                  def to_s
         | 
| 29 | 
            +
                    (['League'] + conferences.map(&:to_s)).join("\n")
         | 
| 30 | 
            +
                  end
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                  def divisions
         | 
| 33 | 
            +
                    conferences.flat_map(&:divisions)
         | 
| 34 | 
            +
                  end
         | 
| 35 | 
            +
             | 
| 36 | 
            +
                  def conference?(conference)
         | 
| 37 | 
            +
                    conferences.include?(conference)
         | 
| 38 | 
            +
                  end
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                  def division?(division)
         | 
| 41 | 
            +
                    divisions.include?(division)
         | 
| 42 | 
            +
                  end
         | 
| 43 | 
            +
             | 
| 44 | 
            +
                  def team?(team)
         | 
| 45 | 
            +
                    teams.include?(team)
         | 
| 46 | 
            +
                  end
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                  def teams
         | 
| 49 | 
            +
                    conferences.flat_map do |conference|
         | 
| 50 | 
            +
                      conference.divisions.flat_map(&:teams)
         | 
| 51 | 
            +
                    end
         | 
| 52 | 
            +
                  end
         | 
| 53 | 
            +
             | 
| 54 | 
            +
                  def conference_for(team)
         | 
| 55 | 
            +
                    conferences.find { |c| c.divisions.find { |d| d.teams.include?(team) } }
         | 
| 56 | 
            +
                  end
         | 
| 57 | 
            +
             | 
| 58 | 
            +
                  def division_for(team)
         | 
| 59 | 
            +
                    conference_for(team)&.divisions&.find { |d| d.teams.include?(team) }
         | 
| 60 | 
            +
                  end
         | 
| 61 | 
            +
             | 
| 62 | 
            +
                  # Same conference, same division
         | 
| 63 | 
            +
                  def division_opponents_for(team)
         | 
| 64 | 
            +
                    division = division_for(team)
         | 
| 65 | 
            +
             | 
| 66 | 
            +
                    return nil unless division
         | 
| 67 | 
            +
             | 
| 68 | 
            +
                    division.teams - [team]
         | 
| 69 | 
            +
                  end
         | 
| 70 | 
            +
             | 
| 71 | 
            +
                  # Same conference, different division
         | 
| 72 | 
            +
                  def cross_division_opponents_for(team)
         | 
| 73 | 
            +
                    conference = conference_for(team)
         | 
| 74 | 
            +
                    division   = division_for(team)
         | 
| 75 | 
            +
             | 
| 76 | 
            +
                    return nil unless conference && division
         | 
| 77 | 
            +
             | 
| 78 | 
            +
                    other_divisions = conference.divisions - [division]
         | 
| 79 | 
            +
             | 
| 80 | 
            +
                    other_divisions.flat_map(&:teams)
         | 
| 81 | 
            +
                  end
         | 
| 82 | 
            +
             | 
| 83 | 
            +
                  # Different conference
         | 
| 84 | 
            +
                  def cross_conference_opponents_for(team)
         | 
| 85 | 
            +
                    conference = conference_for(team)
         | 
| 86 | 
            +
             | 
| 87 | 
            +
                    return nil unless conference
         | 
| 88 | 
            +
             | 
| 89 | 
            +
                    other_conferences = conferences - [conference]
         | 
| 90 | 
            +
             | 
| 91 | 
            +
                    other_conferences.flat_map { |c| c.divisions.flat_map(&:teams) }
         | 
| 92 | 
            +
                  end
         | 
| 93 | 
            +
             | 
| 94 | 
            +
                  private
         | 
| 95 | 
            +
             | 
| 96 | 
            +
                  def register_conference!(conference)
         | 
| 97 | 
            +
                    raise ArgumentError, 'conference is required' unless conference
         | 
| 98 | 
            +
                    raise ConferenceAlreadyRegisteredError, "#{conference} already registered" if conference?(conference)
         | 
| 99 | 
            +
             | 
| 100 | 
            +
                    conference.divisions.each do |division|
         | 
| 101 | 
            +
                      raise DivisionAlreadyRegisteredError, "#{division} already registered" if division?(division)
         | 
| 102 | 
            +
             | 
| 103 | 
            +
                      division.teams.each do |team|
         | 
| 104 | 
            +
                        raise TeamAlreadyRegisteredError, "#{team} already registered" if team?(team)
         | 
| 105 | 
            +
                      end
         | 
| 106 | 
            +
                    end
         | 
| 107 | 
            +
             | 
| 108 | 
            +
                    conferences << conference
         | 
| 109 | 
            +
             | 
| 110 | 
            +
                    self
         | 
| 111 | 
            +
                  end
         | 
| 112 | 
            +
                end
         | 
| 113 | 
            +
              end
         | 
| 114 | 
            +
            end
         | 
| @@ -0,0 +1,90 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Basketball
         | 
| 4 | 
            +
              module Scheduling
         | 
| 5 | 
            +
                class LeagueSerializer
         | 
| 6 | 
            +
                  def deserialize(string)
         | 
| 7 | 
            +
                    json        = JSON.parse(string, symbolize_names: true)
         | 
| 8 | 
            +
                    conferences = deserialize_conferences(json[:conferences])
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                    League.new(conferences:)
         | 
| 11 | 
            +
                  end
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                  def serialize(league)
         | 
| 14 | 
            +
                    {
         | 
| 15 | 
            +
                      conferences: serialize_conferences(league.conferences)
         | 
| 16 | 
            +
                    }.to_json
         | 
| 17 | 
            +
                  end
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                  private
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                  ## Deserialization
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                  def deserialize_conferences(conferences)
         | 
| 24 | 
            +
                    (conferences || []).map do |conference_id, conference_hash|
         | 
| 25 | 
            +
                      Conference.new(
         | 
| 26 | 
            +
                        id: conference_id,
         | 
| 27 | 
            +
                        name: conference_hash[:name],
         | 
| 28 | 
            +
                        divisions: deserialize_divisions(conference_hash[:divisions])
         | 
| 29 | 
            +
                      )
         | 
| 30 | 
            +
                    end
         | 
| 31 | 
            +
                  end
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                  def deserialize_divisions(divisions)
         | 
| 34 | 
            +
                    (divisions || []).map do |division_id, division_hash|
         | 
| 35 | 
            +
                      Division.new(
         | 
| 36 | 
            +
                        id: division_id,
         | 
| 37 | 
            +
                        name: division_hash[:name],
         | 
| 38 | 
            +
                        teams: deserialize_teams(division_hash[:teams])
         | 
| 39 | 
            +
                      )
         | 
| 40 | 
            +
                    end
         | 
| 41 | 
            +
                  end
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                  def deserialize_teams(teams)
         | 
| 44 | 
            +
                    (teams || []).map do |team_id, team_hash|
         | 
| 45 | 
            +
                      Team.new(
         | 
| 46 | 
            +
                        id: team_id,
         | 
| 47 | 
            +
                        name: team_hash[:name]
         | 
| 48 | 
            +
                      )
         | 
| 49 | 
            +
                    end
         | 
| 50 | 
            +
                  end
         | 
| 51 | 
            +
             | 
| 52 | 
            +
                  ## Serialization
         | 
| 53 | 
            +
             | 
| 54 | 
            +
                  def serialize_conferences(conferences)
         | 
| 55 | 
            +
                    conferences.to_h do |conference|
         | 
| 56 | 
            +
                      [
         | 
| 57 | 
            +
                        conference.id,
         | 
| 58 | 
            +
                        {
         | 
| 59 | 
            +
                          name: conference.name,
         | 
| 60 | 
            +
                          divisions: serialize_divisions(conference.divisions)
         | 
| 61 | 
            +
                        }
         | 
| 62 | 
            +
                      ]
         | 
| 63 | 
            +
                    end
         | 
| 64 | 
            +
                  end
         | 
| 65 | 
            +
             | 
| 66 | 
            +
                  def serialize_divisions(divisions)
         | 
| 67 | 
            +
                    divisions.to_h do |division|
         | 
| 68 | 
            +
                      [
         | 
| 69 | 
            +
                        division.id,
         | 
| 70 | 
            +
                        {
         | 
| 71 | 
            +
                          name: division.name,
         | 
| 72 | 
            +
                          teams: serialize_teams(division.teams)
         | 
| 73 | 
            +
                        }
         | 
| 74 | 
            +
                      ]
         | 
| 75 | 
            +
                    end
         | 
| 76 | 
            +
                  end
         | 
| 77 | 
            +
             | 
| 78 | 
            +
                  def serialize_teams(teams)
         | 
| 79 | 
            +
                    teams.to_h do |team|
         | 
| 80 | 
            +
                      [
         | 
| 81 | 
            +
                        team.id,
         | 
| 82 | 
            +
                        {
         | 
| 83 | 
            +
                          name: team.name
         | 
| 84 | 
            +
                        }
         | 
| 85 | 
            +
                      ]
         | 
| 86 | 
            +
                    end
         | 
| 87 | 
            +
                  end
         | 
| 88 | 
            +
                end
         | 
| 89 | 
            +
              end
         | 
| 90 | 
            +
            end
         |