basketball 0.0.8 → 0.0.9

Sign up to get free protection for your applications and to get access to all the features.
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'