basketball 0.0.8 → 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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +9 -19
  3. data/CHANGELOG.md +1 -39
  4. data/README.md +72 -93
  5. data/basketball.gemspec +3 -6
  6. data/exe/{basketball-season-scheduling → 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/{draft/cli.rb → app/room_cli.rb} +53 -76
  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/draft/event.rb +3 -2
  16. data/lib/basketball/draft/front_office.rb +35 -28
  17. data/lib/basketball/draft/{pick_event.rb → pick.rb} +13 -6
  18. data/lib/basketball/draft/room.rb +119 -119
  19. data/lib/basketball/draft/{player_search.rb → scout.rb} +4 -9
  20. data/lib/basketball/draft/skip.rb +12 -0
  21. data/lib/basketball/draft.rb +13 -6
  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/{draft → 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 +41 -72
  30. data/lib/basketball/season/coordinator.rb +185 -126
  31. data/lib/basketball/season/{preseason_game.rb → exhibition.rb} +2 -1
  32. data/lib/basketball/season/game.rb +15 -10
  33. data/lib/basketball/season/matchup.rb +27 -0
  34. data/lib/basketball/season/opponent.rb +15 -0
  35. data/lib/basketball/season/{season_game.rb → regular.rb} +2 -1
  36. data/lib/basketball/season/result.rb +37 -0
  37. data/lib/basketball/season.rb +12 -13
  38. data/lib/basketball/value_object.rb +4 -1
  39. data/lib/basketball/version.rb +1 -1
  40. data/lib/basketball.rb +9 -4
  41. metadata +32 -44
  42. data/lib/basketball/draft/league.rb +0 -70
  43. data/lib/basketball/draft/player.rb +0 -43
  44. data/lib/basketball/draft/room_serializer.rb +0 -186
  45. data/lib/basketball/draft/roster.rb +0 -37
  46. data/lib/basketball/draft/sim_event.rb +0 -23
  47. data/lib/basketball/draft/skip_event.rb +0 -13
  48. data/lib/basketball/season/calendar_serializer.rb +0 -94
  49. data/lib/basketball/season/conference.rb +0 -57
  50. data/lib/basketball/season/division.rb +0 -43
  51. data/lib/basketball/season/league.rb +0 -114
  52. data/lib/basketball/season/league_serializer.rb +0 -99
  53. data/lib/basketball/season/scheduling_cli.rb +0 -198
  54. data/lib/basketball/season/team.rb +0 -21
@@ -1,179 +1,238 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'calendar'
4
-
5
3
  module Basketball
6
4
  module Season
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.
5
+ # Main iterator-based object that knows how to manage a calendar and simulate games per day.
10
6
  class Coordinator
11
- MIN_PRESEASON_GAMES_PER_TEAM = 3
12
- MAX_PRESEASON_GAMES_PER_TEAM = 6
7
+ extend Forwardable
8
+
9
+ class AlreadyPlayedGameError < StandardError; end
10
+ class GameNotCurrentError < StandardError; end
11
+ class OutOfBoundsError < StandardError; end
12
+ class PlayedGamesError < StandardError; end
13
+ class UnknownGameError < StandardError; end
14
+ class UnknownTeamError < StandardError; end
15
+ class UnplayedGamesError < StandardError; end
16
+
17
+ attr_reader :calendar,
18
+ :current_date,
19
+ :arena,
20
+ :results,
21
+ :league,
22
+ :id
23
+
24
+ def_delegators :calendar,
25
+ :preseason_start_date,
26
+ :preseason_end_date,
27
+ :season_start_date,
28
+ :season_end_date,
29
+ :games,
30
+ :exhibitions_for,
31
+ :regulars_for,
32
+ :games_for
33
+
34
+ def initialize(
35
+ calendar:,
36
+ current_date:,
37
+ results: [],
38
+ league: Org::League.new
39
+ )
40
+ super()
41
+
42
+ raise ArgumentError, 'calendar is required' unless calendar
43
+ raise ArgumentError, 'current_date is required' if current_date.to_s.empty?
44
+ raise ArgumentError, 'league is required' unless league
45
+
46
+ @calendar = calendar
47
+ @current_date = current_date
48
+ @arena = Arena.new
49
+ @results = []
50
+ @league = league
51
+
52
+ results.each { |result| replay!(result) }
53
+
54
+ assert_current_date
55
+ assert_all_past_dates_are_played
56
+ assert_all_future_dates_arent_played
57
+ assert_all_known_teams
58
+ end
13
59
 
14
- private_constant :MIN_PRESEASON_GAMES_PER_TEAM,
15
- :MAX_PRESEASON_GAMES_PER_TEAM
60
+ def sim_rest!(&)
61
+ events = []
16
62
 
17
- def schedule(year:, league:)
18
- Calendar.new(year:).tap do |calendar|
19
- schedule_preseason!(calendar:, league:)
20
- schedule_season!(calendar:, league:)
63
+ while not_done?
64
+ new_events = sim!(&)
65
+
66
+ events += new_events
21
67
  end
22
- end
23
68
 
24
- private
69
+ events
70
+ end
25
71
 
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
72
+ def assert_current_date
73
+ if current_date < preseason_start_date
74
+ raise OutOfBoundsError, "current date #{current_date} should be on or after #{preseason_start_date}"
36
75
  end
76
+
77
+ return unless current_date > season_end_date
78
+
79
+ raise OutOfBoundsError, "current date #{current_date} should be on or after #{preseason_start_date}"
37
80
  end
38
81
 
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
82
+ def sim!
83
+ return [] if done?
58
84
 
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
85
+ events = []
86
+ games = games_for(date: current_date)
63
87
 
64
- opponents.each do |opponent|
65
- next if game_counts[team] == 82
66
- next if game_counts[opponent] == 82
88
+ games.each do |game|
89
+ home_players = opponent_team(game.home_opponent).players
90
+ away_players = opponent_team(game.away_opponent).players
91
+ matchup = Matchup.new(game:, home_players:, away_players:)
92
+ event = arena.play(matchup)
67
93
 
68
- game_counts[team] += 1
69
- game_counts[opponent] += 1
94
+ play!(event)
70
95
 
71
- key = [team, opponent].sort
96
+ yield(event) if block_given?
72
97
 
73
- matchups[key] += 1
74
- end
98
+ events << event
75
99
  end
76
100
 
77
- matchups
78
- end
79
- # rubocop:enable Metrics/AbcSize
101
+ increment_current_date!
80
102
 
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 = {}
103
+ events
104
+ end
89
105
 
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
106
+ def total_days
107
+ (season_end_date - preseason_start_date).to_i
108
+ end
94
109
 
95
- four_tracker = league.teams.to_h { |team| [team, []] }
110
+ def days_left
111
+ (season_end_date - current_date).to_i
112
+ end
96
113
 
97
- league.teams.each do |team|
98
- opponents = league.cross_division_opponents_for(team).shuffle
114
+ def total_exhibitions
115
+ exhibitions_for.length
116
+ end
99
117
 
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
118
+ def exhibitions_left
119
+ total_exhibitions - exhibition_result_events.length
120
+ end
107
121
 
108
- good = true
122
+ def total_regulars
123
+ regulars_for.length
124
+ end
109
125
 
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 }
126
+ def regulars_left
127
+ total_regulars - regular_result_events.length
128
+ end
112
129
 
113
- balanced = good
130
+ def current_games
131
+ games_for(date: current_date) - results.map(&:game)
132
+ end
114
133
 
115
- count += 1
116
- end
134
+ def done?
135
+ current_date == season_end_date && games.length == results.length
136
+ end
117
137
 
118
- four_tracker
138
+ def not_done?
139
+ !done?
119
140
  end
120
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
121
141
 
122
- def schedule_season!(calendar:, league:)
123
- matchups = matchup_plan(league)
142
+ def add!(game)
143
+ assert_today_or_in_future(game)
144
+ assert_known_teams(game)
124
145
 
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)
146
+ calendar.add!(game)
129
147
 
130
- games.each { |game| calendar.add!(game) }
131
- end
148
+ self
132
149
  end
133
150
 
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
151
+ def result_for(game)
152
+ results.find do |result|
153
+ result.game == game
141
154
  end
142
155
  end
143
156
 
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
157
+ private
158
+
159
+ attr_writer :id, :arena
160
+
161
+ def opponent_team(opponent)
162
+ league.teams.find { |t| t == opponent }
163
+ end
164
+
165
+ def exhibition_result_events
166
+ results.select { |e| e.game.is_a?(Exhibition) }
167
+ end
148
168
 
149
- next if count >= MIN_PRESEASON_GAMES_PER_TEAM
169
+ def regular_result_events
170
+ results.select { |e| e.game.is_a?(Regular) }
171
+ end
150
172
 
151
- other_teams = (league.teams - [team]).shuffle
173
+ def increment_current_date!
174
+ return self if current_date >= season_end_date
152
175
 
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
176
+ @current_date = current_date + 1
156
177
 
157
- candidates = calendar.available_preseason_matchup_dates(team, other_team)
178
+ self
179
+ end
158
180
 
159
- next if candidates.empty?
181
+ def assert_today_or_in_future(game)
182
+ return unless game.date <= current_date
160
183
 
161
- date = candidates.sample
162
- game = random_preseason_game(date, team, other_team)
184
+ raise OutOfBoundsError, "#{game.date} is on or before the current date (#{current_date})"
185
+ end
163
186
 
164
- calendar.add!(game)
187
+ def assert_known_teams(game)
188
+ raise UnknownTeamError, "unknown opponent: #{game.home_opponent}" if league.not_registered?(game.home_opponent)
165
189
 
166
- count += 1
167
- end
168
- end
190
+ return unless league.not_registered?(game.away_opponent)
191
+
192
+ raise UnknownTeamError, "unknown opponent: #{game.away_opponent}"
169
193
  end
170
194
 
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)
195
+ def assert_all_past_dates_are_played
196
+ games_that_should_be_played = games.select { |game| game.date < current_date }
197
+
198
+ games_played = results.map(&:game)
199
+ unplayed_games = games_that_should_be_played - games_played
200
+
201
+ return if unplayed_games.empty?
202
+
203
+ raise UnplayedGamesError, "#{unplayed_games.length} game(s) not played before #{current_date}"
204
+ end
205
+
206
+ def assert_all_future_dates_arent_played
207
+ games_that_shouldnt_be_played = results.select do |result|
208
+ result.date > current_date
176
209
  end
210
+
211
+ count = games_that_shouldnt_be_played.length
212
+
213
+ return unless games_that_shouldnt_be_played.any?
214
+
215
+ raise PlayedGamesError, "#{count} game(s) played after #{current_date}"
216
+ end
217
+
218
+ def play!(result)
219
+ raise GameNotCurrentError, "#{result} is not for #{current_date}" if result.date != current_date
220
+
221
+ replay!(result)
222
+ end
223
+
224
+ def replay!(result)
225
+ raise AlreadyPlayedGameError, "#{result.game} already played!" if result_for(result.game)
226
+
227
+ raise UnknownGameError, "game not added: #{result.game}" unless games.include?(result.game)
228
+
229
+ results << result
230
+
231
+ result
232
+ end
233
+
234
+ def assert_all_known_teams
235
+ calendar.games.each { |game| assert_known_teams(game) }
177
236
  end
178
237
  end
179
238
  end
@@ -2,7 +2,8 @@
2
2
 
3
3
  module Basketball
4
4
  module Season
5
- class PreseasonGame < Game
5
+ # An exhibition game.
6
+ class Exhibition < Game
6
7
  def to_s
7
8
  "#{super} (preseason)"
8
9
  end
@@ -2,30 +2,35 @@
2
2
 
3
3
  module Basketball
4
4
  module Season
5
+ # Base class describing what all games have in common.
5
6
  class Game < ValueObject
6
- attr_reader_value :date, :home_team, :away_team
7
+ value_reader :date, :home_opponent, :away_opponent
7
8
 
8
- def initialize(date:, home_team:, away_team:)
9
+ def initialize(date:, home_opponent:, away_opponent:)
9
10
  super()
10
11
 
11
12
  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
13
+ raise ArgumentError, 'home_opponent is required' unless home_opponent
14
+ raise ArgumentError, 'away_opponent is required' unless away_opponent
15
+ raise ArgumentError, 'teams cannot play themselves' if home_opponent == away_opponent
15
16
 
16
- @date = date
17
- @home_team = home_team
18
- @away_team = away_team
17
+ @date = date
18
+ @home_opponent = home_opponent
19
+ @away_opponent = away_opponent
19
20
 
20
21
  freeze
21
22
  end
22
23
 
24
+ def for?(team)
25
+ teams.include?(team)
26
+ end
27
+
23
28
  def teams
24
- [home_team, away_team]
29
+ [home_opponent, away_opponent]
25
30
  end
26
31
 
27
32
  def to_s
28
- "#{date} - #{away_team} at #{home_team}"
33
+ "#{date} - #{away_opponent} at #{home_opponent}"
29
34
  end
30
35
  end
31
36
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Season
5
+ # A Matchup is a late materialization of a game. While a game is a skeleton for a future
6
+ # matchup, it does not materialize until game time (rosters could change right up until game time).
7
+ class Matchup
8
+ class PlayersOnBothTeamsError < StandardError; end
9
+
10
+ attr_reader :game, :home_players, :away_players
11
+
12
+ def initialize(game:, home_players: [], away_players: [])
13
+ raise ArgumentError, 'game is required' unless game
14
+
15
+ @game = game
16
+ @home_players = home_players.uniq
17
+ @away_players = away_players.uniq
18
+
19
+ if home_players.intersect?(away_players)
20
+ raise PlayersOnBothTeamsError, 'players cannot be on both home and away team'
21
+ end
22
+
23
+ freeze
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Season
5
+ # Represents a team without a roster. Equal to a team by identity.
6
+ # A team's roster will not be known until the last minute (when it is game time).
7
+ class Opponent < Entity
8
+ def initialize(id:)
9
+ super(id)
10
+
11
+ freeze
12
+ end
13
+ end
14
+ end
15
+ end
@@ -2,7 +2,8 @@
2
2
 
3
3
  module Basketball
4
4
  module Season
5
- class SeasonGame < Game
5
+ # A regular season game.
6
+ class Regular < Game
6
7
  end
7
8
  end
8
9
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Season
5
+ # Base class describing the end result of a game. This should/could be sub-classed to include
6
+ # more sport-specific information.
7
+ class Result
8
+ extend Forwardable
9
+
10
+ class CannotTieError < StandardError; end
11
+
12
+ attr_reader :game, :home_score, :away_score
13
+
14
+ def_delegators :game, :date, :home_opponent, :away_opponent, :teams
15
+
16
+ def initialize(game:, home_score:, away_score:)
17
+ raise ArgumentError, 'game is required' unless game
18
+ raise CannotTieError, "#{game} ended in a tie" if home_score == away_score
19
+
20
+ @game = game
21
+ @home_score = home_score.to_i
22
+ @away_score = away_score.to_i
23
+ end
24
+
25
+ def to_s
26
+ "#{game.date} - #{away_opponent} (#{away_score}) at #{home_opponent} (#{home_score})"
27
+ end
28
+
29
+ def ==(other)
30
+ game == other.game &&
31
+ home_score == other.home_score &&
32
+ away_score == other.away_score
33
+ end
34
+ alias eql? ==
35
+ end
36
+ end
37
+ end
@@ -1,17 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'season/scheduling_cli'
3
+ # Common
4
+ require_relative 'season/arena'
5
+ require_relative 'season/calendar'
6
+ require_relative 'season/result'
7
+ require_relative 'season/game'
8
+ require_relative 'season/matchup'
9
+ require_relative 'season/opponent'
4
10
 
5
- module Basketball
6
- module Season
7
- class ConferenceAlreadyRegisteredError < StandardError; end
8
- class DivisionAlreadyRegisteredError < StandardError; end
9
- class TeamAlreadyRegisteredError < StandardError; end
11
+ # Game Subclasses
12
+ require_relative 'season/exhibition'
13
+ require_relative 'season/regular'
10
14
 
11
- class BadConferencesSizeError < StandardError; end
12
- class BadDivisionsSizeError < StandardError; end
13
- class BadTeamsSizeError < StandardError; end
14
-
15
- class UnknownTeamError < StandardError; end
16
- end
17
- end
15
+ # Specific
16
+ require_relative 'season/coordinator'
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basketball
4
+ # A Value Object is something that has no specific identity, instead its identity is the sum of
5
+ # the attribute values. Changing one will change the entire object's identity.
6
+ # Comes with a very simple DSL for specifying properties along with base equality and sorting methods.
4
7
  class ValueObject
5
8
  include Comparable
6
9
 
@@ -23,7 +26,7 @@ module Basketball
23
26
  @value_keys ||= []
24
27
  end
25
28
 
26
- def attr_reader_value(*keys)
29
+ def value_reader(*keys)
27
30
  keys.each { |k| value_keys << k.to_sym }
28
31
 
29
32
  attr_reader(*keys)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basketball
4
- VERSION = '0.0.8'
4
+ VERSION = '0.0.9'
5
5
  end
data/lib/basketball.rb CHANGED
@@ -1,16 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'faker'
3
+ require 'date'
4
4
  require 'fileutils'
5
5
  require 'forwardable'
6
6
  require 'json'
7
- require 'securerandom'
8
7
  require 'slop'
9
8
 
10
- # Top-level
9
+ # Generic
11
10
  require_relative 'basketball/entity'
12
11
  require_relative 'basketball/value_object'
13
12
 
14
- # Submodules
13
+ # Dependent on Generic
14
+ require_relative 'basketball/org'
15
+
16
+ # Dependent on Org
15
17
  require_relative 'basketball/draft'
16
18
  require_relative 'basketball/season'
19
+
20
+ # Dependent on All
21
+ require_relative 'basketball/app'