basketball 0.0.7 → 0.0.9

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.
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