basketball 0.0.7 → 0.0.9

Sign up to get free protection for your applications and to get access to all the features.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +9 -19
  3. data/CHANGELOG.md +1 -31
  4. data/README.md +72 -91
  5. data/basketball.gemspec +3 -6
  6. data/exe/{basketball-schedule → basketball-coordinator} +1 -1
  7. data/exe/{basketball-draft → basketball-room} +1 -1
  8. data/lib/basketball/app/coordinator_cli.rb +243 -0
  9. data/lib/basketball/app/coordinator_repository.rb +191 -0
  10. data/lib/basketball/app/file_store.rb +22 -0
  11. data/lib/basketball/app/room_cli.rb +212 -0
  12. data/lib/basketball/app/room_repository.rb +189 -0
  13. data/lib/basketball/app.rb +12 -0
  14. data/lib/basketball/draft/assessment.rb +31 -0
  15. data/lib/basketball/{drafting → draft}/event.rb +4 -3
  16. data/lib/basketball/draft/front_office.rb +99 -0
  17. data/lib/basketball/draft/pick.rb +32 -0
  18. data/lib/basketball/draft/room.rb +221 -0
  19. data/lib/basketball/{drafting/player_search.rb → draft/scout.rb} +5 -10
  20. data/lib/basketball/draft/skip.rb +12 -0
  21. data/lib/basketball/draft.rb +16 -0
  22. data/lib/basketball/entity.rb +10 -4
  23. data/lib/basketball/org/league.rb +73 -0
  24. data/lib/basketball/org/player.rb +26 -0
  25. data/lib/basketball/{drafting → org}/position.rb +3 -2
  26. data/lib/basketball/org/team.rb +38 -0
  27. data/lib/basketball/org.rb +12 -0
  28. data/lib/basketball/season/arena.rb +112 -0
  29. data/lib/basketball/season/calendar.rb +90 -0
  30. data/lib/basketball/season/coordinator.rb +239 -0
  31. data/lib/basketball/{scheduling/preseason_game.rb → season/exhibition.rb} +3 -2
  32. data/lib/basketball/season/game.rb +37 -0
  33. data/lib/basketball/season/matchup.rb +27 -0
  34. data/lib/basketball/season/opponent.rb +15 -0
  35. data/lib/basketball/season/regular.rb +9 -0
  36. data/lib/basketball/season/result.rb +37 -0
  37. data/lib/basketball/season.rb +16 -0
  38. data/lib/basketball/value_object.rb +4 -1
  39. data/lib/basketball/version.rb +1 -1
  40. data/lib/basketball.rb +11 -6
  41. metadata +40 -52
  42. data/lib/basketball/drafting/cli.rb +0 -235
  43. data/lib/basketball/drafting/engine.rb +0 -221
  44. data/lib/basketball/drafting/engine_serializer.rb +0 -186
  45. data/lib/basketball/drafting/front_office.rb +0 -92
  46. data/lib/basketball/drafting/league.rb +0 -70
  47. data/lib/basketball/drafting/pick_event.rb +0 -25
  48. data/lib/basketball/drafting/player.rb +0 -43
  49. data/lib/basketball/drafting/roster.rb +0 -37
  50. data/lib/basketball/drafting/sim_event.rb +0 -23
  51. data/lib/basketball/drafting/skip_event.rb +0 -13
  52. data/lib/basketball/drafting.rb +0 -9
  53. data/lib/basketball/scheduling/calendar.rb +0 -121
  54. data/lib/basketball/scheduling/calendar_serializer.rb +0 -94
  55. data/lib/basketball/scheduling/cli.rb +0 -198
  56. data/lib/basketball/scheduling/conference.rb +0 -57
  57. data/lib/basketball/scheduling/coordinator.rb +0 -180
  58. data/lib/basketball/scheduling/division.rb +0 -43
  59. data/lib/basketball/scheduling/game.rb +0 -32
  60. data/lib/basketball/scheduling/league.rb +0 -114
  61. data/lib/basketball/scheduling/league_serializer.rb +0 -99
  62. data/lib/basketball/scheduling/season_game.rb +0 -8
  63. data/lib/basketball/scheduling/team.rb +0 -21
  64. data/lib/basketball/scheduling.rb +0 -17
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Draft
5
+ # A team will send their front office to a draft room to make draft selections.
6
+ class FrontOffice < Entity
7
+ # The higher the number the more the front office will make more top-player-based decisions
8
+ # over position-based decisions.
9
+ DEFAULT_MAX_STAR_LEVEL = 5
10
+
11
+ # Riskier front offices may choose to not draft the top player. The higher the number the more
12
+ # they will not select the top player available.
13
+ DEFAULT_MAX_RISK_LEVEL = 5
14
+
15
+ MAX_POSITIONS = 12
16
+
17
+ private_constant :DEFAULT_MAX_STAR_LEVEL, :DEFAULT_MAX_RISK_LEVEL, :MAX_POSITIONS
18
+
19
+ attr_reader :prioritized_positions, :risk_level, :star_level, :scout
20
+
21
+ def initialize(
22
+ id:,
23
+ prioritized_positions: [],
24
+ risk_level: rand(0..DEFAULT_MAX_RISK_LEVEL),
25
+ star_level: rand(0..DEFAULT_MAX_STAR_LEVEL)
26
+ )
27
+ super(id)
28
+
29
+ @risk_level = risk_level.to_i
30
+ @star_level = star_level.to_i
31
+ @prioritized_positions = prioritized_positions
32
+ @scout = Scout.new
33
+
34
+ # fill in the rest of the queue here
35
+ need_count = MAX_POSITIONS - @prioritized_positions.length
36
+
37
+ @prioritized_positions += random_positions_queue[0...need_count]
38
+
39
+ freeze
40
+ end
41
+
42
+ def pick(assessment)
43
+ players = []
44
+ players = adaptive_search(assessment) if star_level >= assessment.round
45
+ players = balanced_search(assessment) if players.empty?
46
+ players = top_players(assessment) if players.empty?
47
+
48
+ players[0..risk_level].sample
49
+ end
50
+
51
+ private
52
+
53
+ def adaptive_search(assessment)
54
+ drafted_positions = assessment.drafted_players.map(&:position)
55
+
56
+ scout.top_for(players: assessment.undrafted_players, exclude_positions: drafted_positions)
57
+ end
58
+
59
+ def balanced_search(assessment)
60
+ players = []
61
+
62
+ # Try to find best pick for exact desired position.
63
+ # If you cant find one, then move to the next desired position until the end of the queue
64
+ available_prioritized_positions(assessment.drafted_players).each do |position|
65
+ players = scout.top_for(players: assessment.undrafted_players, position:)
66
+
67
+ break if players.any?
68
+ end
69
+
70
+ players = players.any? ? players : scout.top_for
71
+ end
72
+
73
+ def all_random_positions
74
+ Org::Position::ALL_VALUES.to_a.shuffle.map { |v| Org::Position.new(v) }
75
+ end
76
+
77
+ def random_positions_queue
78
+ all_random_positions + all_random_positions + [Org::Position.random] + [Org::Position.random]
79
+ end
80
+
81
+ def available_prioritized_positions(drafted_players)
82
+ drafted_positions = drafted_players.map(&:position)
83
+ queue = prioritized_positions.dup
84
+
85
+ drafted_positions.each do |drafted_position|
86
+ index = queue.index(drafted_position)
87
+
88
+ next unless index
89
+
90
+ queue.delete_at(index)
91
+
92
+ queue << drafted_position
93
+ end
94
+
95
+ queue
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Draft
5
+ # Room event where a player is selected.
6
+ class Pick < Event
7
+ value_reader :player, :auto
8
+
9
+ def initialize(front_office:, player:, pick:, round:, round_pick:, auto: false)
10
+ super(front_office:, pick:, round:, round_pick:)
11
+
12
+ raise ArgumentError, 'player required' unless player
13
+
14
+ @player = player
15
+ @auto = auto
16
+
17
+ freeze
18
+ end
19
+
20
+ def to_s
21
+ "#{super} #{auto ? 'auto-' : ''}picked #{player}"
22
+ end
23
+
24
+ def ==(other)
25
+ super &&
26
+ player == other.player &&
27
+ auto == other.auto
28
+ end
29
+ alias eql? ==
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Draft
5
+ # Main pick-by-pick iterator object which will round-robin rotate team selections.
6
+ class Room
7
+ class AlreadyPickedError < StandardError; end
8
+ class EndOfDraftError < StandardError; end
9
+ class EventOutOfOrderError < StandardError; end
10
+ class FrontOfficeAlreadyRegisteredError < StandardError; end
11
+ class PlayerAlreadyAddedError < StandardError; end
12
+ class UnknownFrontOfficeError < StandardError; end
13
+ class UnknownPlayerError < StandardError; end
14
+
15
+ attr_reader :rounds, :players, :front_offices, :events, :id
16
+
17
+ def initialize(front_offices:, rounds:, players: [], events: [])
18
+ raise InvalidRoundsError, "#{rounds} should be a positive number" unless rounds.positive?
19
+
20
+ @rounds = rounds
21
+ @players = []
22
+ @front_offices = []
23
+ @events = []
24
+
25
+ front_offices.each { |front_office| register!(front_office) }
26
+ players.each { |player| add_player!(player) }
27
+ events.each { |event| add_event!(event) }
28
+ end
29
+
30
+ # This method will return a materialized list of teams and their selections.
31
+ def league
32
+ Org::League.new.tap do |league|
33
+ front_offices.each do |front_office|
34
+ team = Org::Team.new(id: front_office.id)
35
+
36
+ league.register!(team)
37
+
38
+ drafted_players(front_office).each do |player|
39
+ league.sign!(player:, team:)
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ ### Peek Methods
46
+
47
+ def assessment
48
+ Assessment.new(
49
+ drafted_players: drafted_players(front_office),
50
+ undrafted_players:,
51
+ pick:,
52
+ round:,
53
+ round_pick:
54
+ )
55
+ end
56
+
57
+ def total_picks
58
+ rounds * front_offices.length
59
+ end
60
+
61
+ def round
62
+ return if done?
63
+
64
+ (pick / front_offices.length.to_f).ceil
65
+ end
66
+
67
+ def round_pick
68
+ return if done?
69
+
70
+ mod = pick % front_offices.length
71
+
72
+ mod.positive? ? mod : front_offices.length
73
+ end
74
+
75
+ def front_office
76
+ return if done?
77
+
78
+ front_offices[round_pick - 1]
79
+ end
80
+
81
+ def pick
82
+ return if done?
83
+
84
+ internal_pick
85
+ end
86
+
87
+ def remaining_picks
88
+ total_picks - internal_pick + 1
89
+ end
90
+
91
+ def done?
92
+ internal_pick > total_picks
93
+ end
94
+
95
+ def not_done?
96
+ !done?
97
+ end
98
+
99
+ def drafted_players(front_office = nil)
100
+ raise UnknownFrontOfficeError, "#{front_office} doesnt exist" if front_office && !registered?(front_office)
101
+
102
+ player_events.each_with_object([]) do |e, memo|
103
+ next unless front_office.nil? || e.front_office == front_office
104
+
105
+ memo << e.player
106
+ end
107
+ end
108
+
109
+ def undrafted_players
110
+ players - drafted_players
111
+ end
112
+
113
+ ### Event Methods
114
+
115
+ def skip!
116
+ return if done?
117
+
118
+ event = Skip.new(front_office:, pick:, round:, round_pick:)
119
+
120
+ add_event!(event)
121
+
122
+ event
123
+ end
124
+
125
+ def sim!
126
+ return if done?
127
+
128
+ player = front_office.pick(assessment)
129
+ event = Pick.new(front_office:, pick:, round:, round_pick:, player:, auto: true)
130
+
131
+ add_event!(event)
132
+
133
+ event
134
+ end
135
+
136
+ def sim_rest!
137
+ events = []
138
+
139
+ while not_done?
140
+ event = sim!
141
+
142
+ yield event if block_given?
143
+
144
+ events << event
145
+ end
146
+
147
+ events
148
+ end
149
+
150
+ def pick!(player)
151
+ return nil if done?
152
+
153
+ event = Pick.new(front_office:, pick:, round:, round_pick:, player:)
154
+
155
+ add_event!(event)
156
+ end
157
+
158
+ private
159
+
160
+ attr_writer :id
161
+
162
+ def player_events
163
+ events.select { |e| e.respond_to?(:player) }
164
+ end
165
+
166
+ # rubocop:disable Metrics/AbcSize
167
+ def add_event!(event)
168
+ raise EndOfDraftError, "#{total_picks} pick limit reached" if done?
169
+ raise UnknownFrontOfficeError, "#{front_office} doesnt exist" unless front_offices.include?(event.front_office)
170
+ raise EventOutOfOrderError, "#{event.front_office} cant pick right now" if event.front_office != front_office
171
+ raise EventOutOfOrderError, "#{event} has wrong pick" if event.pick != pick
172
+ raise EventOutOfOrderError, "#{event} has wrong round" if event.round != round
173
+ raise EventOutOfOrderError, "#{event} has wrong round_pick" if event.round_pick != round_pick
174
+
175
+ assert_player(event)
176
+
177
+ events << event
178
+
179
+ event
180
+ end
181
+ # rubocop:enable Metrics/AbcSize
182
+
183
+ def assert_player(event)
184
+ return unless event.respond_to?(:player)
185
+
186
+ raise AlreadyPickedError, "#{event.player} was already picked" if drafted_players.include?(event.player)
187
+ raise UnknownPlayerError, "#{event.player} doesnt exist" unless players.include?(event.player)
188
+ end
189
+
190
+ def internal_pick
191
+ events.length + 1
192
+ end
193
+
194
+ def registered?(front_office)
195
+ front_offices.include?(front_office)
196
+ end
197
+
198
+ def register!(front_office)
199
+ raise ArgumentError, 'front_office required' unless front_office
200
+ raise FrontOfficeAlreadyRegisteredError, "#{front_office} already registered" if registered?(front_office)
201
+
202
+ front_offices << front_office
203
+
204
+ self
205
+ end
206
+
207
+ def player?(player)
208
+ players.include?(player)
209
+ end
210
+
211
+ def add_player!(player)
212
+ raise ArgumentError, 'player required' unless player
213
+ raise PlayerAlreadyAddedError, "#{player} already added" if player?(player)
214
+
215
+ players << player
216
+
217
+ self
218
+ end
219
+ end
220
+ end
221
+ end
@@ -1,15 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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: [])
4
+ module Draft
5
+ # A Scout knows how to process a set of players and figure out who the top prospects are.
6
+ class Scout
7
+ def top_for(players: [], position: nil, exclude_positions: [])
13
8
  filtered_players = players
14
9
 
15
10
  if position
@@ -24,7 +19,7 @@ module Basketball
24
19
  end
25
20
  end
26
21
 
27
- filtered_players.sort_by(&:overall).reverse
22
+ filtered_players.sort_by { |p| [p.overall, p.id] }.reverse
28
23
  end
29
24
  end
30
25
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Draft
5
+ # Room event which indicates a front office has intentionally skipped a selection.
6
+ class Skip < Event
7
+ def to_s
8
+ "#{super} skipped"
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Services
4
+ require_relative 'draft/scout'
5
+
6
+ # Common
7
+ require_relative 'draft/assessment'
8
+ require_relative 'draft/event'
9
+ require_relative 'draft/front_office'
10
+
11
+ # Event Subclasses
12
+ require_relative 'draft/pick'
13
+ require_relative 'draft/skip'
14
+
15
+ # Specific
16
+ require_relative 'draft/room'
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basketball
4
+ # Base class for uniquely identifiable classes. Subclasses are simply based on a string-based ID
5
+ # and comparison/sorting/equality will be done in a case-insensitive manner.
4
6
  class Entity
5
7
  extend Forwardable
6
8
  include Comparable
@@ -12,20 +14,24 @@ module Basketball
12
14
  def initialize(id)
13
15
  raise ArgumentError, 'id is required' if id.to_s.empty?
14
16
 
15
- @id = id.to_s.upcase
17
+ @id = id
16
18
  end
17
19
 
18
20
  def <=>(other)
19
- id <=> other.id
21
+ comparable_id <=> other.comparable_id
20
22
  end
21
23
 
22
24
  def ==(other)
23
- id == other.id
25
+ comparable_id == other.comparable_id
24
26
  end
25
27
  alias eql? ==
26
28
 
27
29
  def hash
28
- id.hash
30
+ comparable_id.hash
31
+ end
32
+
33
+ def comparable_id
34
+ id.to_s.upcase
29
35
  end
30
36
  end
31
37
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Org
5
+ # Describes a collection of teams and players. Holds the rules which support
6
+ # adding teams and players to ensure the all the teams are cohesive, such as:
7
+ # - preventing duplicate teams
8
+ # - preventing double-signing players across teams
9
+ class League
10
+ class TeamAlreadyRegisteredError < StandardError; end
11
+ class UnregisteredTeamError < StandardError; end
12
+
13
+ attr_reader :teams
14
+
15
+ def initialize(teams: [])
16
+ @teams = []
17
+
18
+ teams.each { |team| register!(team) }
19
+
20
+ freeze
21
+ end
22
+
23
+ def to_s
24
+ teams.map(&:to_s).join("\n")
25
+ end
26
+
27
+ def sign!(player:, team:)
28
+ raise ArgumentError, 'player is required' unless player
29
+ raise ArgumentError, 'team is required' unless team
30
+ raise UnregisteredTeamError, "#{team} is not registered" unless registered?(team)
31
+ raise PlayerAlreadySignedError, "#{player} is already signed" if signed?(player)
32
+
33
+ team.sign!(player)
34
+
35
+ self
36
+ end
37
+
38
+ def signed?(player)
39
+ players.include?(player)
40
+ end
41
+
42
+ def players
43
+ teams.flat_map(&:players)
44
+ end
45
+
46
+ def not_registered?(team)
47
+ !registered?(team)
48
+ end
49
+
50
+ def registered?(team)
51
+ teams.include?(team)
52
+ end
53
+
54
+ def register!(team)
55
+ raise ArgumentError, 'team is required' unless team
56
+ raise TeamAlreadyRegisteredError, "#{team} already registered" if registered?(team)
57
+
58
+ team.players.each do |player|
59
+ raise PlayerAlreadySignedError, "#{player} already signed" if signed?(player)
60
+ end
61
+
62
+ teams << team
63
+
64
+ self
65
+ end
66
+
67
+ def ==(other)
68
+ teams == other.teams
69
+ end
70
+ alias eql? ==
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Org
5
+ # Base class describing a player.
6
+ # A consumer application should extend these specific to their specific sports traits.
7
+ class Player < Entity
8
+ attr_reader :overall, :position
9
+
10
+ def initialize(id:, overall: 0, position: nil)
11
+ super(id)
12
+
13
+ raise ArgumentError, 'position is required' unless position
14
+
15
+ @overall = overall
16
+ @position = position
17
+
18
+ freeze
19
+ end
20
+
21
+ def to_s
22
+ "[#{super}] (#{position}) #{overall}".strip
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basketball
4
- module Drafting
4
+ module Org
5
+ # Describes a player position.
5
6
  class Position < ValueObject
6
7
  extend Forwardable
7
8
 
@@ -17,7 +18,7 @@ module Basketball
17
18
  FRONT_COURT_VALUES = %w[PF C].to_set.freeze
18
19
  ALL_VALUES = (BACK_COURT_VALUES.to_a + FRONT_COURT_VALUES.to_a).to_set.freeze
19
20
 
20
- attr_reader_value :code
21
+ value_reader :code
21
22
 
22
23
  def_delegators :code, :to_s
23
24
 
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Org
5
+ # Base class describing a team. A team here is bare metal and is just described by an ID
6
+ # and a collection of Player objects.
7
+ class Team < Entity
8
+ attr_reader :players
9
+
10
+ def initialize(id:, players: [])
11
+ super(id)
12
+
13
+ @players = []
14
+
15
+ players.each { |p| sign!(p) }
16
+
17
+ freeze
18
+ end
19
+
20
+ def to_s
21
+ ([super.to_s] + players.map(&:to_s)).join("\n")
22
+ end
23
+
24
+ def signed?(player)
25
+ players.include?(player)
26
+ end
27
+
28
+ def sign!(player)
29
+ raise ArgumentError, 'player is required' unless player
30
+ raise PlayerAlreadySignedError, "#{player} already signed by #{self}" if signed?(player)
31
+
32
+ players << player
33
+
34
+ self
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'org/player'
4
+ require_relative 'org/position'
5
+ require_relative 'org/team'
6
+ require_relative 'org/league'
7
+
8
+ module Basketball
9
+ module Org
10
+ class PlayerAlreadySignedError < StandardError; end
11
+ end
12
+ end