basketball 0.0.1
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 +7 -0
- data/.editorconfig +8 -0
- data/.gitignore +8 -0
- data/.rubocop.yml +41 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +3 -0
- data/CODE_OF_CONDUCT.md +73 -0
- data/Gemfile +5 -0
- data/Guardfile +16 -0
- data/LICENSE +5 -0
- data/README.md +144 -0
- data/Rakefile +10 -0
- data/basketball.gemspec +47 -0
- data/exe/basketball-draft +7 -0
- data/lib/basketball/drafting/cli.rb +203 -0
- data/lib/basketball/drafting/engine.rb +186 -0
- data/lib/basketball/drafting/engine_serializer.rb +170 -0
- data/lib/basketball/drafting/entity.rb +33 -0
- data/lib/basketball/drafting/event.rb +24 -0
- data/lib/basketball/drafting/front_office.rb +93 -0
- data/lib/basketball/drafting/pick_event.rb +23 -0
- data/lib/basketball/drafting/player.rb +43 -0
- data/lib/basketball/drafting/player_search.rb +31 -0
- data/lib/basketball/drafting/position.rb +42 -0
- data/lib/basketball/drafting/roster.rb +37 -0
- data/lib/basketball/drafting/sim_event.rb +23 -0
- data/lib/basketball/drafting/team.rb +28 -0
- data/lib/basketball/drafting.rb +19 -0
- data/lib/basketball/version.rb +5 -0
- data/lib/basketball.rb +12 -0
- metadata +250 -0
| @@ -0,0 +1,186 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Basketball
         | 
| 4 | 
            +
              module Drafting
         | 
| 5 | 
            +
                class Engine
         | 
| 6 | 
            +
                  class AlreadyPickedError < StandardError; end
         | 
| 7 | 
            +
                  class DupeEventError < StandardError; end
         | 
| 8 | 
            +
                  class EventOutOfOrderError < StandardError; end
         | 
| 9 | 
            +
                  class UnknownPlayerError < StandardError; end
         | 
| 10 | 
            +
                  class UnknownTeamError < StandardError; end
         | 
| 11 | 
            +
                  class EndOfDraftError < StandardError; end
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                  DEFAULT_ROUNDS = 12
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                  private_constant :DEFAULT_ROUNDS
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                  attr_reader :events, :rounds
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                  def initialize(players: [], teams: [], events: [], rounds: DEFAULT_ROUNDS)
         | 
| 20 | 
            +
                    @players_by_id = players.to_h { |p| [p.id, p] }
         | 
| 21 | 
            +
                    @teams_by_id   = teams.to_h { |t| [t.id, t] }
         | 
| 22 | 
            +
                    @events        = []
         | 
| 23 | 
            +
                    @rounds        = rounds.to_i
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                    # Each one will be validated for correctness.
         | 
| 26 | 
            +
                    events.each { |e| play!(e) }
         | 
| 27 | 
            +
             | 
| 28 | 
            +
                    freeze
         | 
| 29 | 
            +
                  end
         | 
| 30 | 
            +
             | 
| 31 | 
            +
                  def rosters
         | 
| 32 | 
            +
                    events_by_team = teams.to_h { |t| [t, []] }
         | 
| 33 | 
            +
             | 
| 34 | 
            +
                    events.each do |event|
         | 
| 35 | 
            +
                      events_by_team.fetch(event.team) << event
         | 
| 36 | 
            +
                    end
         | 
| 37 | 
            +
             | 
| 38 | 
            +
                    events_by_team.map do |team, events|
         | 
| 39 | 
            +
                      Roster.new(team:, events:)
         | 
| 40 | 
            +
                    end
         | 
| 41 | 
            +
                  end
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                  def to_s
         | 
| 44 | 
            +
                    events.join("\n")
         | 
| 45 | 
            +
                  end
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                  def teams
         | 
| 48 | 
            +
                    teams_by_id.values
         | 
| 49 | 
            +
                  end
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                  def players
         | 
| 52 | 
            +
                    players_by_id.values
         | 
| 53 | 
            +
                  end
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                  def total_picks
         | 
| 56 | 
            +
                    rounds * teams.length
         | 
| 57 | 
            +
                  end
         | 
| 58 | 
            +
             | 
| 59 | 
            +
                  def current_round
         | 
| 60 | 
            +
                    return if done?
         | 
| 61 | 
            +
             | 
| 62 | 
            +
                    (current_pick / teams.length.to_f).ceil
         | 
| 63 | 
            +
                  end
         | 
| 64 | 
            +
             | 
| 65 | 
            +
                  def current_round_pick
         | 
| 66 | 
            +
                    return if done?
         | 
| 67 | 
            +
             | 
| 68 | 
            +
                    mod = current_pick % teams.length
         | 
| 69 | 
            +
             | 
| 70 | 
            +
                    mod.positive? ? mod : teams.length
         | 
| 71 | 
            +
                  end
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                  def current_team
         | 
| 74 | 
            +
                    return if done?
         | 
| 75 | 
            +
             | 
| 76 | 
            +
                    teams[current_round_pick - 1]
         | 
| 77 | 
            +
                  end
         | 
| 78 | 
            +
             | 
| 79 | 
            +
                  def current_pick
         | 
| 80 | 
            +
                    return if done?
         | 
| 81 | 
            +
             | 
| 82 | 
            +
                    internal_current_pick
         | 
| 83 | 
            +
                  end
         | 
| 84 | 
            +
             | 
| 85 | 
            +
                  def remaining_picks
         | 
| 86 | 
            +
                    total_picks - internal_current_pick + 1
         | 
| 87 | 
            +
                  end
         | 
| 88 | 
            +
             | 
| 89 | 
            +
                  def done?
         | 
| 90 | 
            +
                    internal_current_pick > total_picks
         | 
| 91 | 
            +
                  end
         | 
| 92 | 
            +
             | 
| 93 | 
            +
                  def not_done?
         | 
| 94 | 
            +
                    !done?
         | 
| 95 | 
            +
                  end
         | 
| 96 | 
            +
             | 
| 97 | 
            +
                  def sim!(times = nil)
         | 
| 98 | 
            +
                    counter = 0
         | 
| 99 | 
            +
             | 
| 100 | 
            +
                    until done? || (times && counter >= times)
         | 
| 101 | 
            +
                      team = current_team
         | 
| 102 | 
            +
             | 
| 103 | 
            +
                      player = team.pick(
         | 
| 104 | 
            +
                        undrafted_players:,
         | 
| 105 | 
            +
                        drafted_players: drafted_players(team),
         | 
| 106 | 
            +
                        round: current_round
         | 
| 107 | 
            +
                      )
         | 
| 108 | 
            +
             | 
| 109 | 
            +
                      event = SimEvent.new(
         | 
| 110 | 
            +
                        id: SecureRandom.uuid,
         | 
| 111 | 
            +
                        team:,
         | 
| 112 | 
            +
                        player:,
         | 
| 113 | 
            +
                        pick: current_pick,
         | 
| 114 | 
            +
                        round: current_round,
         | 
| 115 | 
            +
                        round_pick: current_round_pick
         | 
| 116 | 
            +
                      )
         | 
| 117 | 
            +
             | 
| 118 | 
            +
                      play!(event)
         | 
| 119 | 
            +
             | 
| 120 | 
            +
                      yield(event) if block_given?
         | 
| 121 | 
            +
             | 
| 122 | 
            +
                      counter += 1
         | 
| 123 | 
            +
                    end
         | 
| 124 | 
            +
             | 
| 125 | 
            +
                    self
         | 
| 126 | 
            +
                  end
         | 
| 127 | 
            +
             | 
| 128 | 
            +
                  def pick!(player)
         | 
| 129 | 
            +
                    return nil if done?
         | 
| 130 | 
            +
             | 
| 131 | 
            +
                    event = PickEvent.new(
         | 
| 132 | 
            +
                      id: SecureRandom.uuid,
         | 
| 133 | 
            +
                      team: current_team,
         | 
| 134 | 
            +
                      player:,
         | 
| 135 | 
            +
                      pick: current_pick,
         | 
| 136 | 
            +
                      round: current_round,
         | 
| 137 | 
            +
                      round_pick: current_round_pick
         | 
| 138 | 
            +
                    )
         | 
| 139 | 
            +
             | 
| 140 | 
            +
                    play!(event)
         | 
| 141 | 
            +
                  end
         | 
| 142 | 
            +
             | 
| 143 | 
            +
                  def undrafted_players
         | 
| 144 | 
            +
                    players - drafted_players
         | 
| 145 | 
            +
                  end
         | 
| 146 | 
            +
             | 
| 147 | 
            +
                  private
         | 
| 148 | 
            +
             | 
| 149 | 
            +
                  attr_reader :players_by_id, :teams_by_id
         | 
| 150 | 
            +
             | 
| 151 | 
            +
                  def internal_current_pick
         | 
| 152 | 
            +
                    events.length + 1
         | 
| 153 | 
            +
                  end
         | 
| 154 | 
            +
             | 
| 155 | 
            +
                  def drafted_players(team = nil)
         | 
| 156 | 
            +
                    events.each_with_object([]) do |e, memo|
         | 
| 157 | 
            +
                      next unless team.nil? || e.team == team
         | 
| 158 | 
            +
             | 
| 159 | 
            +
                      memo << e.player
         | 
| 160 | 
            +
                    end
         | 
| 161 | 
            +
                  end
         | 
| 162 | 
            +
             | 
| 163 | 
            +
                  # rubocop:disable Metrics/AbcSize
         | 
| 164 | 
            +
                  # rubocop:disable Metrics/CyclomaticComplexity
         | 
| 165 | 
            +
                  # rubocop:disable Metrics/PerceivedComplexity
         | 
| 166 | 
            +
                  def play!(event)
         | 
| 167 | 
            +
                    raise AlreadyPickedError, "#{player} was already picked" if drafted_players.include?(event.player)
         | 
| 168 | 
            +
                    raise DupeEventError,     "#{event} is a dupe"                if events.include?(event)
         | 
| 169 | 
            +
                    raise EventOutOfOrder,    "#{event} team cant pick right now" if event.team != current_team
         | 
| 170 | 
            +
                    raise EventOutOfOrder,    "#{event} has wrong pick"           if event.pick != current_pick
         | 
| 171 | 
            +
                    raise EventOutOfOrder,    "#{event} has wrong round"          if event.round != current_round
         | 
| 172 | 
            +
                    raise EventOutOfOrder,    "#{event} has wrong round_pick" if event.round_pick != current_round_pick
         | 
| 173 | 
            +
                    raise UnknownTeamError,   "#{team} doesnt exist"                   unless teams.include?(event.team)
         | 
| 174 | 
            +
                    raise UnknownPlayerError, "#{player} doesnt exist"                 unless players.include?(event.player)
         | 
| 175 | 
            +
                    raise EndOfDraftError,    "#{total_picks} pick limit reached"      if events.length > total_picks + 1
         | 
| 176 | 
            +
             | 
| 177 | 
            +
                    events << event
         | 
| 178 | 
            +
             | 
| 179 | 
            +
                    event
         | 
| 180 | 
            +
                  end
         | 
| 181 | 
            +
                  # rubocop:enable Metrics/AbcSize
         | 
| 182 | 
            +
                  # rubocop:enable Metrics/CyclomaticComplexity
         | 
| 183 | 
            +
                  # rubocop:enable Metrics/PerceivedComplexity
         | 
| 184 | 
            +
                end
         | 
| 185 | 
            +
              end
         | 
| 186 | 
            +
            end
         | 
| @@ -0,0 +1,170 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Basketball
         | 
| 4 | 
            +
              module Drafting
         | 
| 5 | 
            +
                class EngineSerializer
         | 
| 6 | 
            +
                  EVENT_CLASSES = {
         | 
| 7 | 
            +
                    'PickEvent' => PickEvent,
         | 
| 8 | 
            +
                    'SimEvent' => SimEvent
         | 
| 9 | 
            +
                  }.freeze
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                  private_constant :EVENT_CLASSES
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                  def deserialize(string)
         | 
| 14 | 
            +
                    json        = JSON.parse(string, symbolize_names: true)
         | 
| 15 | 
            +
                    teams       = deserialize_teams(json)
         | 
| 16 | 
            +
                    players     = deserialize_players(json)
         | 
| 17 | 
            +
                    events = deserialize_events(json, players, teams)
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                    engine_opts = {
         | 
| 20 | 
            +
                      players:,
         | 
| 21 | 
            +
                      teams:,
         | 
| 22 | 
            +
                      events:
         | 
| 23 | 
            +
                    }
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                    engine_opts[:rounds] = json.dig(:engine, :rounds) if json.dig(:engine, :rounds)
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                    Engine.new(**engine_opts)
         | 
| 28 | 
            +
                  end
         | 
| 29 | 
            +
             | 
| 30 | 
            +
                  def serialize(engine)
         | 
| 31 | 
            +
                    {
         | 
| 32 | 
            +
                      info: serialize_info(engine),
         | 
| 33 | 
            +
                      engine: serialize_engine(engine),
         | 
| 34 | 
            +
                      rosters: serialize_rosters(engine)
         | 
| 35 | 
            +
                    }.to_json
         | 
| 36 | 
            +
                  end
         | 
| 37 | 
            +
             | 
| 38 | 
            +
                  private
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                  def serialize_engine(engine)
         | 
| 41 | 
            +
                    {
         | 
| 42 | 
            +
                      rounds: engine.rounds,
         | 
| 43 | 
            +
                      teams: serialize_teams(engine),
         | 
| 44 | 
            +
                      players: serialize_players(engine),
         | 
| 45 | 
            +
                      events: serialize_events(engine.events)
         | 
| 46 | 
            +
                    }
         | 
| 47 | 
            +
                  end
         | 
| 48 | 
            +
             | 
| 49 | 
            +
                  def serialize_info(engine)
         | 
| 50 | 
            +
                    {
         | 
| 51 | 
            +
                      total_picks: engine.total_picks,
         | 
| 52 | 
            +
                      current_round: engine.current_round,
         | 
| 53 | 
            +
                      current_round_pick: engine.current_round_pick,
         | 
| 54 | 
            +
                      current_team: engine.current_team&.id,
         | 
| 55 | 
            +
                      current_pick: engine.current_pick,
         | 
| 56 | 
            +
                      remaining_picks: engine.remaining_picks,
         | 
| 57 | 
            +
                      done: engine.done?,
         | 
| 58 | 
            +
                      undrafted_players: engine.undrafted_players.map(&:id)
         | 
| 59 | 
            +
                    }
         | 
| 60 | 
            +
                  end
         | 
| 61 | 
            +
             | 
| 62 | 
            +
                  def serialize_rosters(engine)
         | 
| 63 | 
            +
                    engine.rosters.to_h do |roster|
         | 
| 64 | 
            +
                      [
         | 
| 65 | 
            +
                        roster.id,
         | 
| 66 | 
            +
                        {
         | 
| 67 | 
            +
                          events: roster.events.map(&:id),
         | 
| 68 | 
            +
                          players: roster.events.map { |event| event.player.id }
         | 
| 69 | 
            +
                        }
         | 
| 70 | 
            +
                      ]
         | 
| 71 | 
            +
                    end
         | 
| 72 | 
            +
                  end
         | 
| 73 | 
            +
             | 
| 74 | 
            +
                  def serialize_teams(engine)
         | 
| 75 | 
            +
                    engine.teams.to_h do |team|
         | 
| 76 | 
            +
                      [
         | 
| 77 | 
            +
                        team.id,
         | 
| 78 | 
            +
                        {
         | 
| 79 | 
            +
                          name: team.name,
         | 
| 80 | 
            +
                          front_office: {
         | 
| 81 | 
            +
                            fuzz: team.front_office.fuzz,
         | 
| 82 | 
            +
                            depth: team.front_office.depth,
         | 
| 83 | 
            +
                            prioritized_positions: team.front_office.prioritized_positions
         | 
| 84 | 
            +
                          }
         | 
| 85 | 
            +
                        }
         | 
| 86 | 
            +
                      ]
         | 
| 87 | 
            +
                    end
         | 
| 88 | 
            +
                  end
         | 
| 89 | 
            +
             | 
| 90 | 
            +
                  def serialize_players(engine)
         | 
| 91 | 
            +
                    engine.players.to_h do |player|
         | 
| 92 | 
            +
                      [
         | 
| 93 | 
            +
                        player.id,
         | 
| 94 | 
            +
                        {
         | 
| 95 | 
            +
                          first_name: player.first_name,
         | 
| 96 | 
            +
                          last_name: player.last_name,
         | 
| 97 | 
            +
                          overall: player.overall,
         | 
| 98 | 
            +
                          position: player.position.value
         | 
| 99 | 
            +
                        }
         | 
| 100 | 
            +
                      ]
         | 
| 101 | 
            +
                    end
         | 
| 102 | 
            +
                  end
         | 
| 103 | 
            +
             | 
| 104 | 
            +
                  def serialize_events(events)
         | 
| 105 | 
            +
                    events.map do |event|
         | 
| 106 | 
            +
                      {
         | 
| 107 | 
            +
                        type: event.class.name.split('::').last,
         | 
| 108 | 
            +
                        id: event.id,
         | 
| 109 | 
            +
                        player: event.player.id,
         | 
| 110 | 
            +
                        team: event.team.id,
         | 
| 111 | 
            +
                        pick: event.pick,
         | 
| 112 | 
            +
                        round: event.round,
         | 
| 113 | 
            +
                        round_pick: event.round_pick
         | 
| 114 | 
            +
                      }
         | 
| 115 | 
            +
                    end
         | 
| 116 | 
            +
                  end
         | 
| 117 | 
            +
             | 
| 118 | 
            +
                  def deserialize_teams(json)
         | 
| 119 | 
            +
                    (json.dig(:engine, :teams) || []).map do |id, team_hash|
         | 
| 120 | 
            +
                      team_opts = {
         | 
| 121 | 
            +
                        id:,
         | 
| 122 | 
            +
                        name: team_hash[:name]
         | 
| 123 | 
            +
                      }
         | 
| 124 | 
            +
             | 
| 125 | 
            +
                      if team_hash.key?(:front_office)
         | 
| 126 | 
            +
                        front_office_hash = team_hash[:front_office] || {}
         | 
| 127 | 
            +
             | 
| 128 | 
            +
                        prioritized_positions = (front_office_hash[:prioritized_positions] || []).map do |v|
         | 
| 129 | 
            +
                          Position.new(v)
         | 
| 130 | 
            +
                        end
         | 
| 131 | 
            +
             | 
| 132 | 
            +
                        front_office_opts = {
         | 
| 133 | 
            +
                          prioritized_positions:,
         | 
| 134 | 
            +
                          fuzz: front_office_hash[:fuzz],
         | 
| 135 | 
            +
                          depth: front_office_hash[:depth]
         | 
| 136 | 
            +
                        }
         | 
| 137 | 
            +
             | 
| 138 | 
            +
                        team_opts[:front_office] = FrontOffice.new(**front_office_opts)
         | 
| 139 | 
            +
                      end
         | 
| 140 | 
            +
             | 
| 141 | 
            +
                      Team.new(**team_opts)
         | 
| 142 | 
            +
                    end
         | 
| 143 | 
            +
                  end
         | 
| 144 | 
            +
             | 
| 145 | 
            +
                  def deserialize_players(json)
         | 
| 146 | 
            +
                    (json.dig(:engine, :players) || []).map do |id, player_hash|
         | 
| 147 | 
            +
                      player_opts = player_hash.merge(
         | 
| 148 | 
            +
                        id:,
         | 
| 149 | 
            +
                        position: Position.new(player_hash[:position])
         | 
| 150 | 
            +
                      )
         | 
| 151 | 
            +
             | 
| 152 | 
            +
                      Player.new(**player_opts)
         | 
| 153 | 
            +
                    end
         | 
| 154 | 
            +
                  end
         | 
| 155 | 
            +
             | 
| 156 | 
            +
                  def deserialize_events(json, players, teams)
         | 
| 157 | 
            +
                    (json.dig(:engine, :events) || []).map do |event_hash|
         | 
| 158 | 
            +
                      event_opts = event_hash.slice(:id, :pick, :round, :round_pick).merge(
         | 
| 159 | 
            +
                        player: players.find { |p| p.id == event_hash[:player] },
         | 
| 160 | 
            +
                        team: teams.find { |t| t.id == event_hash[:team] }
         | 
| 161 | 
            +
                      )
         | 
| 162 | 
            +
             | 
| 163 | 
            +
                      class_constant = EVENT_CLASSES.fetch(event_hash[:type])
         | 
| 164 | 
            +
             | 
| 165 | 
            +
                      class_constant.new(**event_opts)
         | 
| 166 | 
            +
                    end
         | 
| 167 | 
            +
                  end
         | 
| 168 | 
            +
                end
         | 
| 169 | 
            +
              end
         | 
| 170 | 
            +
            end
         | 
| @@ -0,0 +1,33 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Basketball
         | 
| 4 | 
            +
              module Drafting
         | 
| 5 | 
            +
                class Entity
         | 
| 6 | 
            +
                  extend Forwardable
         | 
| 7 | 
            +
                  include Comparable
         | 
| 8 | 
            +
             | 
| 9 | 
            +
                  attr_reader :id
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                  def_delegators :id, :to_s
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                  def initialize(id)
         | 
| 14 | 
            +
                    raise ArgumentError, 'id is required' if id.to_s.empty?
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                    @id = id.to_s.upcase
         | 
| 17 | 
            +
                  end
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                  def <=>(other)
         | 
| 20 | 
            +
                    id <=> other.id
         | 
| 21 | 
            +
                  end
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                  def ==(other)
         | 
| 24 | 
            +
                    id == other.id
         | 
| 25 | 
            +
                  end
         | 
| 26 | 
            +
                  alias eql? ==
         | 
| 27 | 
            +
             | 
| 28 | 
            +
                  def hash
         | 
| 29 | 
            +
                    id.hash
         | 
| 30 | 
            +
                  end
         | 
| 31 | 
            +
                end
         | 
| 32 | 
            +
              end
         | 
| 33 | 
            +
            end
         | 
| @@ -0,0 +1,24 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Basketball
         | 
| 4 | 
            +
              module Drafting
         | 
| 5 | 
            +
                class Event < Entity
         | 
| 6 | 
            +
                  attr_reader :pick, :round, :round_pick, :team
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                  def initialize(id:, team:, pick:, round:, round_pick:)
         | 
| 9 | 
            +
                    super(id)
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                    raise ArgumentError, 'team required' unless team
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                    @team       = team
         | 
| 14 | 
            +
                    @pick       = pick.to_i
         | 
| 15 | 
            +
                    @round      = round.to_i
         | 
| 16 | 
            +
                    @round_pick = round_pick.to_i
         | 
| 17 | 
            +
                  end
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                  def to_s
         | 
| 20 | 
            +
                    "##{pick} overall (R#{round}:P#{round_pick}) by #{team}"
         | 
| 21 | 
            +
                  end
         | 
| 22 | 
            +
                end
         | 
| 23 | 
            +
              end
         | 
| 24 | 
            +
            end
         | 
| @@ -0,0 +1,93 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Basketball
         | 
| 4 | 
            +
              module Drafting
         | 
| 5 | 
            +
                class FrontOffice
         | 
| 6 | 
            +
                  MAX_DEPTH     = 3
         | 
| 7 | 
            +
                  MAX_FUZZ      = 2
         | 
| 8 | 
            +
                  MAX_POSITIONS = 12
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                  private_constant :MAX_DEPTH, :MAX_FUZZ, :MAX_POSITIONS
         | 
| 11 | 
            +
             | 
| 12 | 
            +
                  attr_reader :prioritized_positions, :fuzz, :depth
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                  def initialize(prioritized_positions: [], fuzz: rand(0..MAX_FUZZ), depth: rand(0..MAX_DEPTH))
         | 
| 15 | 
            +
                    @fuzz                  = fuzz.to_i
         | 
| 16 | 
            +
                    @depth                 = depth.to_i
         | 
| 17 | 
            +
                    @prioritized_positions = prioritized_positions
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                    # fill in the rest of the queue here
         | 
| 20 | 
            +
                    need_count = MAX_POSITIONS - @prioritized_positions.length
         | 
| 21 | 
            +
             | 
| 22 | 
            +
                    @prioritized_positions += random_positions_queue[0...need_count]
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                    freeze
         | 
| 25 | 
            +
                  end
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                  def to_s
         | 
| 28 | 
            +
                    "#{prioritized_positions.map(&:to_s).join(',')} (F:#{fuzz} D:#{depth})"
         | 
| 29 | 
            +
                  end
         | 
| 30 | 
            +
             | 
| 31 | 
            +
                  def pick(undrafted_players:, drafted_players:, round:)
         | 
| 32 | 
            +
                    players = []
         | 
| 33 | 
            +
             | 
| 34 | 
            +
                    players = adaptive_search(undrafted_players:, drafted_players:) if depth >= round
         | 
| 35 | 
            +
                    players = balanced_search(undrafted_players:, drafted_players:) if players.empty?
         | 
| 36 | 
            +
                    players = top_players(undrafted_players:)                       if players.empty?
         | 
| 37 | 
            +
             | 
| 38 | 
            +
                    players[0..fuzz].sample
         | 
| 39 | 
            +
                  end
         | 
| 40 | 
            +
             | 
| 41 | 
            +
                  private
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                  def adaptive_search(undrafted_players:, drafted_players:)
         | 
| 44 | 
            +
                    search = PlayerSearch.new(undrafted_players)
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                    drafted_positions = drafted_players.map(&:position)
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                    search.query(exclude_positions: drafted_positions)
         | 
| 49 | 
            +
                  end
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                  def balanced_search(undrafted_players:, drafted_players:)
         | 
| 52 | 
            +
                    search = PlayerSearch.new(undrafted_players)
         | 
| 53 | 
            +
             | 
| 54 | 
            +
                    players = []
         | 
| 55 | 
            +
             | 
| 56 | 
            +
                    # Try to find best pick for exact desired position.
         | 
| 57 | 
            +
                    # If you cant find one, then move to the next desired position until the end of the queue
         | 
| 58 | 
            +
                    available_prioritized_positions(drafted_players:).each do |position|
         | 
| 59 | 
            +
                      players = search.query(position:)
         | 
| 60 | 
            +
             | 
| 61 | 
            +
                      break if players.any?
         | 
| 62 | 
            +
                    end
         | 
| 63 | 
            +
             | 
| 64 | 
            +
                    players = players.any? ? players : search.query
         | 
| 65 | 
            +
                  end
         | 
| 66 | 
            +
             | 
| 67 | 
            +
                  def all_random_positions
         | 
| 68 | 
            +
                    Position::ALL_VALUES.to_a.shuffle.map { |v| Position.new(v) }
         | 
| 69 | 
            +
                  end
         | 
| 70 | 
            +
             | 
| 71 | 
            +
                  def random_positions_queue
         | 
| 72 | 
            +
                    all_random_positions + all_random_positions + [Position.random] + [Position.random]
         | 
| 73 | 
            +
                  end
         | 
| 74 | 
            +
             | 
| 75 | 
            +
                  def available_prioritized_positions(drafted_players:)
         | 
| 76 | 
            +
                    drafted_positions = drafted_players.map(&:position)
         | 
| 77 | 
            +
                    queue             = prioritized_positions.dup
         | 
| 78 | 
            +
             | 
| 79 | 
            +
                    drafted_positions.each do |drafted_position|
         | 
| 80 | 
            +
                      index = queue.index(drafted_position)
         | 
| 81 | 
            +
             | 
| 82 | 
            +
                      next unless index
         | 
| 83 | 
            +
             | 
| 84 | 
            +
                      queue.delete_at(index)
         | 
| 85 | 
            +
             | 
| 86 | 
            +
                      queue << drafted_position
         | 
| 87 | 
            +
                    end
         | 
| 88 | 
            +
             | 
| 89 | 
            +
                    queue
         | 
| 90 | 
            +
                  end
         | 
| 91 | 
            +
                end
         | 
| 92 | 
            +
              end
         | 
| 93 | 
            +
            end
         | 
| @@ -0,0 +1,23 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Basketball
         | 
| 4 | 
            +
              module Drafting
         | 
| 5 | 
            +
                class PickEvent < Event
         | 
| 6 | 
            +
                  attr_reader :player
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                  def initialize(id:, team:, player:, pick:, round:, round_pick:)
         | 
| 9 | 
            +
                    super(id:, team:, pick:, round:, round_pick:)
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                    raise ArgumentError, 'player required' unless player
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                    @player = player
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                    freeze
         | 
| 16 | 
            +
                  end
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                  def to_s
         | 
| 19 | 
            +
                    "#{player} picked #{super}"
         | 
| 20 | 
            +
                  end
         | 
| 21 | 
            +
                end
         | 
| 22 | 
            +
              end
         | 
| 23 | 
            +
            end
         | 
| @@ -0,0 +1,43 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Basketball
         | 
| 4 | 
            +
              module Drafting
         | 
| 5 | 
            +
                class Player < Entity
         | 
| 6 | 
            +
                  STAR_THRESHOLD         = 75
         | 
| 7 | 
            +
                  OVERALL_STAR_INDICATOR = '⭐'
         | 
| 8 | 
            +
             | 
| 9 | 
            +
                  private_constant :STAR_THRESHOLD, :OVERALL_STAR_INDICATOR
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                  attr_reader :first_name, :last_name, :position, :overall
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                  def initialize(id:, position:, first_name: '', last_name: '', overall: 0)
         | 
| 14 | 
            +
                    super(id)
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                    raise ArgumentError, 'position is required' unless position
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                    @first_name = first_name.to_s
         | 
| 19 | 
            +
                    @last_name  = last_name.to_s
         | 
| 20 | 
            +
                    @position   = position
         | 
| 21 | 
            +
                    @overall    = overall.to_i
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                    freeze
         | 
| 24 | 
            +
                  end
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                  def full_name
         | 
| 27 | 
            +
                    "#{first_name.strip} #{last_name.strip}".strip
         | 
| 28 | 
            +
                  end
         | 
| 29 | 
            +
             | 
| 30 | 
            +
                  def to_s
         | 
| 31 | 
            +
                    "[#{super}] #{full_name} (#{position}) #{overall} #{star_indicators.join(', ')}".strip
         | 
| 32 | 
            +
                  end
         | 
| 33 | 
            +
             | 
| 34 | 
            +
                  private
         | 
| 35 | 
            +
             | 
| 36 | 
            +
                  def star_indicators
         | 
| 37 | 
            +
                    [].tap do |indicators|
         | 
| 38 | 
            +
                      indicators << OVERALL_STAR_INDICATOR if overall >= STAR_THRESHOLD
         | 
| 39 | 
            +
                    end
         | 
| 40 | 
            +
                  end
         | 
| 41 | 
            +
                end
         | 
| 42 | 
            +
              end
         | 
| 43 | 
            +
            end
         | 
| @@ -0,0 +1,31 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Basketball
         | 
| 4 | 
            +
              module Drafting
         | 
| 5 | 
            +
                class PlayerSearch
         | 
| 6 | 
            +
                  attr_reader :players
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                  def initialize(players = [])
         | 
| 9 | 
            +
                    @players = players
         | 
| 10 | 
            +
                  end
         | 
| 11 | 
            +
             | 
| 12 | 
            +
                  def query(position: nil, exclude_positions: [])
         | 
| 13 | 
            +
                    filtered_players = players
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                    if position
         | 
| 16 | 
            +
                      filtered_players = filtered_players.select do |player|
         | 
| 17 | 
            +
                        player.position == position
         | 
| 18 | 
            +
                      end
         | 
| 19 | 
            +
                    end
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                    if exclude_positions.any?
         | 
| 22 | 
            +
                      filtered_players = filtered_players.reject do |player|
         | 
| 23 | 
            +
                        exclude_positions.include?(player.position)
         | 
| 24 | 
            +
                      end
         | 
| 25 | 
            +
                    end
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                    filtered_players.sort_by(&:overall).reverse
         | 
| 28 | 
            +
                  end
         | 
| 29 | 
            +
                end
         | 
| 30 | 
            +
              end
         | 
| 31 | 
            +
            end
         | 
| @@ -0,0 +1,42 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Basketball
         | 
| 4 | 
            +
              module Drafting
         | 
| 5 | 
            +
                class Position
         | 
| 6 | 
            +
                  extend Forwardable
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                  class << self
         | 
| 9 | 
            +
                    def random
         | 
| 10 | 
            +
                      new(ALL_VALUES.to_a.sample)
         | 
| 11 | 
            +
                    end
         | 
| 12 | 
            +
                  end
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                  class InvalidPositionError < StandardError; end
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                  BACK_COURT_VALUES  = %w[PG SG SF].to_set.freeze
         | 
| 17 | 
            +
                  FRONT_COURT_VALUES = %w[PF C].to_set.freeze
         | 
| 18 | 
            +
                  ALL_VALUES         = (BACK_COURT_VALUES.to_a + FRONT_COURT_VALUES.to_a).to_set.freeze
         | 
| 19 | 
            +
             | 
| 20 | 
            +
                  attr_reader :value
         | 
| 21 | 
            +
             | 
| 22 | 
            +
                  def_delegators :value, :to_s
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                  def initialize(value)
         | 
| 25 | 
            +
                    @value = value.to_s.upcase
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                    raise InvalidPositionError, "Unknown position value: #{@value}" unless ALL_VALUES.include?(@value)
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                    freeze
         | 
| 30 | 
            +
                  end
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                  def ==(other)
         | 
| 33 | 
            +
                    value == other.value
         | 
| 34 | 
            +
                  end
         | 
| 35 | 
            +
                  alias eql? ==
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                  def hash
         | 
| 38 | 
            +
                    value.hash
         | 
| 39 | 
            +
                  end
         | 
| 40 | 
            +
                end
         | 
| 41 | 
            +
              end
         | 
| 42 | 
            +
            end
         | 
| @@ -0,0 +1,37 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Basketball
         | 
| 4 | 
            +
              module Drafting
         | 
| 5 | 
            +
                class Roster
         | 
| 6 | 
            +
                  extend Forwardable
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                  class WrongTeamEventError < StandardError; end
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                  attr_reader :team, :events
         | 
| 11 | 
            +
             | 
| 12 | 
            +
                  def_delegators :team, :id
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                  def initialize(team:, events: [])
         | 
| 15 | 
            +
                    raise ArgumentError, 'team is required' unless team
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                    other_teams_pick_event_ids = events.reject { |e| e.team == team }.map(&:id)
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                    if other_teams_pick_event_ids.any?
         | 
| 20 | 
            +
                      raise WrongTeamEventError,
         | 
| 21 | 
            +
                            "Event(s): #{other_teams_pick_event_ids.join(',')} has wrong team"
         | 
| 22 | 
            +
                    end
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                    @team   = team
         | 
| 25 | 
            +
                    @events = events
         | 
| 26 | 
            +
                  end
         | 
| 27 | 
            +
             | 
| 28 | 
            +
                  def players
         | 
| 29 | 
            +
                    events.map(&:player)
         | 
| 30 | 
            +
                  end
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                  def to_s
         | 
| 33 | 
            +
                    ([team.to_s] + players.map(&:to_s)).join("\n")
         | 
| 34 | 
            +
                  end
         | 
| 35 | 
            +
                end
         | 
| 36 | 
            +
              end
         | 
| 37 | 
            +
            end
         | 
| @@ -0,0 +1,23 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Basketball
         | 
| 4 | 
            +
              module Drafting
         | 
| 5 | 
            +
                class SimEvent < Event
         | 
| 6 | 
            +
                  attr_reader :player
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                  def initialize(id:, team:, player:, pick:, round:, round_pick:)
         | 
| 9 | 
            +
                    super(id:, team:, pick:, round:, round_pick:)
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                    raise ArgumentError, 'player required' unless player
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                    @player = player
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                    freeze
         | 
| 16 | 
            +
                  end
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                  def to_s
         | 
| 19 | 
            +
                    "#{player} auto-picked #{super}"
         | 
| 20 | 
            +
                  end
         | 
| 21 | 
            +
                end
         | 
| 22 | 
            +
              end
         | 
| 23 | 
            +
            end
         |