basketball 0.0.9 → 0.0.11

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +31 -21
  3. data/basketball.gemspec +14 -8
  4. data/exe/basketball +91 -0
  5. data/lib/basketball/app/coordinator_cli.rb +56 -72
  6. data/lib/basketball/app/coordinator_repository.rb +12 -88
  7. data/lib/basketball/app/document_repository.rb +67 -0
  8. data/lib/basketball/app/file_store.rb +16 -0
  9. data/lib/basketball/app/in_memory_store.rb +42 -0
  10. data/lib/basketball/app/league_repository.rb +20 -0
  11. data/lib/basketball/app/league_serializable.rb +99 -0
  12. data/lib/basketball/app/room_cli.rb +30 -26
  13. data/lib/basketball/app/room_repository.rb +1 -41
  14. data/lib/basketball/app.rb +10 -2
  15. data/lib/basketball/draft/pick.rb +0 -7
  16. data/lib/basketball/draft/room.rb +11 -13
  17. data/lib/basketball/entity.rb +9 -6
  18. data/lib/basketball/org/conference.rb +47 -0
  19. data/lib/basketball/org/division.rb +43 -0
  20. data/lib/basketball/org/has_divisions.rb +25 -0
  21. data/lib/basketball/org/has_players.rb +20 -0
  22. data/lib/basketball/org/has_teams.rb +24 -0
  23. data/lib/basketball/org/league.rb +59 -32
  24. data/lib/basketball/org.rb +12 -1
  25. data/lib/basketball/season/arena.rb +26 -25
  26. data/lib/basketball/season/calendar.rb +52 -22
  27. data/lib/basketball/season/coordinator.rb +25 -18
  28. data/lib/basketball/season/detail.rb +47 -0
  29. data/lib/basketball/season/exhibition.rb +1 -1
  30. data/lib/basketball/season/opponent.rb +6 -0
  31. data/lib/basketball/season/record.rb +92 -0
  32. data/lib/basketball/season/scheduler.rb +223 -0
  33. data/lib/basketball/season/standings.rb +56 -0
  34. data/lib/basketball/season.rb +6 -0
  35. data/lib/basketball/value_object.rb +6 -28
  36. data/lib/basketball/value_object_dsl.rb +30 -0
  37. data/lib/basketball/version.rb +1 -1
  38. metadata +22 -6
  39. /data/exe/{basketball-room → basketball-draft-room} +0 -0
  40. /data/exe/{basketball-coordinator → basketball-season-coordinator} +0 -0
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Org
5
+ # Helper methods for objects that can be composed of teams which are also made up of players.
6
+ module HasTeams
7
+ include HasPlayers
8
+
9
+ def team?(team)
10
+ teams.include?(team)
11
+ end
12
+
13
+ private
14
+
15
+ def assert_teams_are_not_already_registered(teams)
16
+ teams.each do |team|
17
+ raise TeamAlreadyRegisteredError, "#{team} already registered" if team?(team)
18
+
19
+ assert_players_are_not_already_signed(team.players)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -2,72 +2,99 @@
2
2
 
3
3
  module Basketball
4
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:
5
+ # Describes a collection of conferences, divisions, teams, and players.
6
+ # Holds the rules which support adding teams and players to ensure the all the
7
+ # teams are cohesive, such as:
8
+ # - preventing duplicate conferences
9
+ # - preventing duplicate divisions
7
10
  # - preventing duplicate teams
8
11
  # - preventing double-signing players across teams
9
- class League
10
- class TeamAlreadyRegisteredError < StandardError; end
11
- class UnregisteredTeamError < StandardError; end
12
+ class League < Entity
13
+ include HasDivisions
12
14
 
13
- attr_reader :teams
15
+ class ConferenceAlreadyRegisteredError < StandardError; end
14
16
 
15
- def initialize(teams: [])
16
- @teams = []
17
+ alias signed? player?
17
18
 
18
- teams.each { |team| register!(team) }
19
+ attr_reader :conferences
19
20
 
20
- freeze
21
+ def initialize(conferences: [])
22
+ super()
23
+
24
+ @conferences = []
25
+
26
+ conferences.each { |c| register!(c) }
21
27
  end
22
28
 
23
29
  def to_s
24
- teams.map(&:to_s).join("\n")
30
+ conferences.map(&:to_s).join("\n")
25
31
  end
26
32
 
27
33
  def sign!(player:, team:)
28
34
  raise ArgumentError, 'player is required' unless player
29
35
  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)
36
+ raise UnregisteredTeamError, "#{team} not registered" unless team?(team)
37
+ raise PlayerAlreadySignedError, "#{player} already registered" if player?(player)
38
+
39
+ # It is OK to pass in a detached team as long as its equivalent resides in this
40
+ # League's object graph.
41
+ team_for(team.id).sign!(player)
42
+
43
+ self
44
+ end
45
+
46
+ def register!(conference)
47
+ raise ArgumentError, 'conference is required' unless conference
48
+ raise ConferenceAlreadyRegisteredError, "#{conference} already registered" if conference?(conference)
49
+
50
+ assert_divisions_are_not_already_registered(conference.divisions)
32
51
 
33
- team.sign!(player)
52
+ conferences << conference
34
53
 
35
54
  self
36
55
  end
37
56
 
38
- def signed?(player)
39
- players.include?(player)
57
+ def conference?(conference)
58
+ conferences.include?(conference)
59
+ end
60
+
61
+ def divisions
62
+ conferences.flat_map(&:divisions)
63
+ end
64
+
65
+ def teams
66
+ conferences.flat_map(&:teams)
40
67
  end
41
68
 
42
69
  def players
43
- teams.flat_map(&:players)
70
+ conferences.flat_map(&:players)
44
71
  end
45
72
 
46
- def not_registered?(team)
47
- !registered?(team)
73
+ def conference_for(team)
74
+ conferences.find { |c| c.divisions.find { |d| d.teams.include?(team) } }
48
75
  end
49
76
 
50
- def registered?(team)
51
- teams.include?(team)
77
+ def division_for(team)
78
+ conference_for(team)&.divisions&.find { |d| d.teams.include?(team) }
52
79
  end
53
80
 
54
- def register!(team)
55
- raise ArgumentError, 'team is required' unless team
56
- raise TeamAlreadyRegisteredError, "#{team} already registered" if registered?(team)
81
+ # Same conference, different division
82
+ def cross_division_opponents_for(team)
83
+ conference = conference_for(team)
84
+ division = division_for(team)
57
85
 
58
- team.players.each do |player|
59
- raise PlayerAlreadySignedError, "#{player} already signed" if signed?(player)
60
- end
86
+ return nil unless conference && division
61
87
 
62
- teams << team
88
+ other_divisions = conference.divisions - [division]
63
89
 
64
- self
90
+ other_divisions.flat_map(&:teams)
65
91
  end
66
92
 
67
- def ==(other)
68
- teams == other.teams
93
+ private
94
+
95
+ def team_for(id)
96
+ teams.find { |team| team.id == id }
69
97
  end
70
- alias eql? ==
71
98
  end
72
99
  end
73
100
  end
@@ -1,12 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Cross-cutting Concerns
4
+ require_relative 'org/has_players'
5
+ require_relative 'org/has_teams'
6
+ require_relative 'org/has_divisions'
7
+
8
+ # Domain Models
9
+ require_relative 'org/conference'
10
+ require_relative 'org/division'
11
+ require_relative 'org/league'
3
12
  require_relative 'org/player'
4
13
  require_relative 'org/position'
5
14
  require_relative 'org/team'
6
- require_relative 'org/league'
7
15
 
8
16
  module Basketball
9
17
  module Org
18
+ class DivisionAlreadyRegisteredError < StandardError; end
10
19
  class PlayerAlreadySignedError < StandardError; end
20
+ class TeamAlreadyRegisteredError < StandardError; end
21
+ class UnregisteredTeamError < StandardError; end
11
22
  end
12
23
  end
@@ -4,32 +4,29 @@ module Basketball
4
4
  module Season
5
5
  # A very, very, very basic starting point for a "semi-randomized" game simulator.
6
6
  class Arena
7
- RANDOM = :random
8
- TOP_ONE = :top_one
9
- TOP_TWO = :top_two
10
- TOP_THREE = :top_three
11
- TOP_SIX = :top_six
12
- MAX_HOME_ADVANTAGE = 5
13
-
14
- STRATEGY_FREQUENCIES = {
15
- RANDOM => 10,
16
- TOP_ONE => 5,
17
- TOP_TWO => 10,
18
- TOP_THREE => 20,
19
- TOP_SIX => 30
7
+ RANDOM = :random
8
+ TOP_ONE = :top_one
9
+ TOP_TWO = :top_two
10
+ TOP_THREE = :top_three
11
+ TOP_SIX = :top_six
12
+ DEFAULT_MAX_HOME_ADVANTAGE = 5
13
+
14
+ DEFAULT_STRATEGY_FREQUENCIES = {
15
+ RANDOM => 3,
16
+ TOP_ONE => 1,
17
+ TOP_TWO => 1,
18
+ TOP_THREE => 1,
19
+ TOP_SIX => 1
20
20
  }.freeze
21
21
 
22
- private_constant :STRATEGY_FREQUENCIES,
23
- :RANDOM,
24
- :TOP_ONE,
25
- :TOP_TWO,
26
- :TOP_SIX,
27
- :MAX_HOME_ADVANTAGE
22
+ attr_reader :lotto, :max_home_advantage
28
23
 
29
- def initialize
30
- @lotto = STRATEGY_FREQUENCIES.inject([]) do |memo, (name, frequency)|
31
- memo + ([name] * frequency)
32
- end.shuffle
24
+ def initialize(
25
+ strategy_frquencies: DEFAULT_STRATEGY_FREQUENCIES,
26
+ max_home_advantage: DEFAULT_MAX_HOME_ADVANTAGE
27
+ )
28
+ @max_home_advantage = max_home_advantage
29
+ @lotto = make_lotto(strategy_frquencies)
33
30
 
34
31
  freeze
35
32
  end
@@ -57,7 +54,11 @@ module Basketball
57
54
 
58
55
  private
59
56
 
60
- attr_reader :lotto
57
+ def make_lotto(strategy_frquencies)
58
+ strategy_frquencies.inject([]) do |memo, (name, frequency)|
59
+ memo + ([name] * frequency)
60
+ end.shuffle
61
+ end
61
62
 
62
63
  def pick_strategy
63
64
  lotto.sample
@@ -89,7 +90,7 @@ module Basketball
89
90
  end
90
91
 
91
92
  def random_home_advantage
92
- rand(0..MAX_HOME_ADVANTAGE)
93
+ rand(0..max_home_advantage)
93
94
  end
94
95
 
95
96
  def top_one_strategy(matchup)
@@ -2,35 +2,35 @@
2
2
 
3
3
  module Basketball
4
4
  module Season
5
- # Sets boundaries for preseason and regular season play. Add games as long as they are
5
+ # Sets boundaries for exhibition and regular season play. Add games as long as they are
6
6
  # within the correct dated boundaries
7
7
  class Calendar
8
8
  class OutOfBoundsError < StandardError; end
9
9
  class TeamAlreadyBookedError < StandardError; end
10
10
 
11
- attr_reader :preseason_start_date,
12
- :preseason_end_date,
13
- :season_start_date,
14
- :season_end_date,
11
+ attr_reader :exhibition_start_date,
12
+ :exhibition_end_date,
13
+ :regular_start_date,
14
+ :regular_end_date,
15
15
  :games
16
16
 
17
17
  def initialize(
18
- preseason_start_date:,
19
- preseason_end_date:,
20
- season_start_date:,
21
- season_end_date:,
18
+ exhibition_start_date:,
19
+ exhibition_end_date:,
20
+ regular_start_date:,
21
+ regular_end_date:,
22
22
  games: []
23
23
  )
24
- raise ArgumentError, 'preseason_start_date is required' if preseason_start_date.to_s.empty?
25
- raise ArgumentError, 'preseason_end_date is required' if preseason_end_date.to_s.empty?
26
- raise ArgumentError, 'season_start_date is required' if season_start_date.to_s.empty?
27
- raise ArgumentError, 'season_end_date is required' if season_end_date.to_s.empty?
24
+ raise ArgumentError, 'exhibition_start_date is required' if exhibition_start_date.to_s.empty?
25
+ raise ArgumentError, 'exhibition_end_date is required' if exhibition_end_date.to_s.empty?
26
+ raise ArgumentError, 'regular_start_date is required' if regular_start_date.to_s.empty?
27
+ raise ArgumentError, 'regular_end_date is required' if regular_end_date.to_s.empty?
28
28
 
29
- @preseason_start_date = preseason_start_date
30
- @preseason_end_date = preseason_end_date
31
- @season_start_date = season_start_date
32
- @season_end_date = season_end_date
33
- @games = []
29
+ @exhibition_start_date = exhibition_start_date
30
+ @exhibition_end_date = exhibition_end_date
31
+ @regular_start_date = regular_start_date
32
+ @regular_end_date = regular_end_date
33
+ @games = []
34
34
 
35
35
  games.each { |game| add!(game) }
36
36
 
@@ -60,8 +60,38 @@ module Basketball
60
60
  end
61
61
  end
62
62
 
63
+ def available_exhibition_dates_for(opponent)
64
+ all_exhibition_dates - exhibitions_for(opponent:).map(&:date)
65
+ end
66
+
67
+ def available_regular_dates_for(opponent)
68
+ all_season_dates - regulars_for(opponent:).map(&:date)
69
+ end
70
+
71
+ def available_exhibition_matchup_dates(opponent1, opponent2)
72
+ available_opponent_dates = available_exhibition_dates_for(opponent1)
73
+ available_other_opponent_dates = available_exhibition_dates_for(opponent2)
74
+
75
+ available_opponent_dates & available_other_opponent_dates
76
+ end
77
+
78
+ def available_regular_matchup_dates(opponent1, opponent2)
79
+ available_opponent_dates = available_regular_dates_for(opponent1)
80
+ available_other_opponent_dates = available_regular_dates_for(opponent2)
81
+
82
+ available_opponent_dates & available_other_opponent_dates
83
+ end
84
+
63
85
  private
64
86
 
87
+ def all_exhibition_dates
88
+ (exhibition_start_date..exhibition_end_date).to_a
89
+ end
90
+
91
+ def all_season_dates
92
+ (regular_start_date..regular_end_date).to_a
93
+ end
94
+
65
95
  def assert_free_date(game)
66
96
  if games_for(date: game.date, opponent: game.home_opponent).any?
67
97
  raise TeamAlreadyBookedError, "#{game.home_opponent} already playing on #{game.date}"
@@ -76,11 +106,11 @@ module Basketball
76
106
  date = game.date
77
107
 
78
108
  if game.is_a?(Exhibition)
79
- raise OutOfBoundsError, "#{date} is before preseason begins" if date < preseason_start_date
80
- raise OutOfBoundsError, "#{date} is after preseason ends" if date > preseason_end_date
109
+ raise OutOfBoundsError, "#{date} is before exhibition begins" if date < exhibition_start_date
110
+ raise OutOfBoundsError, "#{date} is after exhibition ends" if date > exhibition_end_date
81
111
  elsif game.is_a?(Regular)
82
- raise OutOfBoundsError, "#{date} is before season begins" if date < season_start_date
83
- raise OutOfBoundsError, "#{date} is after season ends" if date > season_end_date
112
+ raise OutOfBoundsError, "#{date} is before season begins" if date < regular_start_date
113
+ raise OutOfBoundsError, "#{date} is after season ends" if date > regular_end_date
84
114
  else
85
115
  raise ArgumentError, "Dont know what this game type is: #{game.class.name}"
86
116
  end
@@ -3,7 +3,7 @@
3
3
  module Basketball
4
4
  module Season
5
5
  # Main iterator-based object that knows how to manage a calendar and simulate games per day.
6
- class Coordinator
6
+ class Coordinator < Entity
7
7
  extend Forwardable
8
8
 
9
9
  class AlreadyPlayedGameError < StandardError; end
@@ -18,14 +18,13 @@ module Basketball
18
18
  :current_date,
19
19
  :arena,
20
20
  :results,
21
- :league,
22
- :id
21
+ :league
23
22
 
24
23
  def_delegators :calendar,
25
- :preseason_start_date,
26
- :preseason_end_date,
27
- :season_start_date,
28
- :season_end_date,
24
+ :exhibition_start_date,
25
+ :exhibition_end_date,
26
+ :regular_start_date,
27
+ :regular_end_date,
29
28
  :games,
30
29
  :exhibitions_for,
31
30
  :regulars_for,
@@ -70,13 +69,13 @@ module Basketball
70
69
  end
71
70
 
72
71
  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}"
72
+ if current_date < exhibition_start_date
73
+ raise OutOfBoundsError, "current date #{current_date} should be on or after #{exhibition_start_date}"
75
74
  end
76
75
 
77
- return unless current_date > season_end_date
76
+ return unless current_date > regular_end_date
78
77
 
79
- raise OutOfBoundsError, "current date #{current_date} should be on or after #{preseason_start_date}"
78
+ raise OutOfBoundsError, "current date #{current_date} should be on or after #{exhibition_start_date}"
80
79
  end
81
80
 
82
81
  def sim!
@@ -104,11 +103,11 @@ module Basketball
104
103
  end
105
104
 
106
105
  def total_days
107
- (season_end_date - preseason_start_date).to_i
106
+ (regular_end_date - exhibition_start_date).to_i
108
107
  end
109
108
 
110
109
  def days_left
111
- (season_end_date - current_date).to_i
110
+ (regular_end_date - current_date).to_i
112
111
  end
113
112
 
114
113
  def total_exhibitions
@@ -132,7 +131,7 @@ module Basketball
132
131
  end
133
132
 
134
133
  def done?
135
- current_date == season_end_date && games.length == results.length
134
+ current_date == regular_end_date && games.length == results.length
136
135
  end
137
136
 
138
137
  def not_done?
@@ -154,9 +153,17 @@ module Basketball
154
153
  end
155
154
  end
156
155
 
156
+ def regular_results
157
+ results.select { |result| result.game.is_a?(Regular) }
158
+ end
159
+
160
+ def exhibition_results
161
+ results.select { |result| result.game.is_a?(Exhibition) }
162
+ end
163
+
157
164
  private
158
165
 
159
- attr_writer :id, :arena
166
+ attr_writer :arena
160
167
 
161
168
  def opponent_team(opponent)
162
169
  league.teams.find { |t| t == opponent }
@@ -171,7 +178,7 @@ module Basketball
171
178
  end
172
179
 
173
180
  def increment_current_date!
174
- return self if current_date >= season_end_date
181
+ return self if current_date >= regular_end_date
175
182
 
176
183
  @current_date = current_date + 1
177
184
 
@@ -185,9 +192,9 @@ module Basketball
185
192
  end
186
193
 
187
194
  def assert_known_teams(game)
188
- raise UnknownTeamError, "unknown opponent: #{game.home_opponent}" if league.not_registered?(game.home_opponent)
195
+ raise UnknownTeamError, "unknown opponent: #{game.home_opponent}" unless league.team?(game.home_opponent)
189
196
 
190
- return unless league.not_registered?(game.away_opponent)
197
+ return if league.team?(game.away_opponent)
191
198
 
192
199
  raise UnknownTeamError, "unknown opponent: #{game.away_opponent}"
193
200
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Season
5
+ # Describes a result from the perspective of a team.
6
+ class Detail < ValueObject
7
+ value_reader :date,
8
+ :home,
9
+ :opponent_score,
10
+ :opponent,
11
+ :score
12
+
13
+ alias home? home
14
+
15
+ def initialize(date:, home:, opponent:, opponent_score:, score:)
16
+ super()
17
+
18
+ raise ArgumentError, 'date is required' unless date
19
+ raise ArgumentError, 'opponent is required' unless opponent
20
+ raise ArgumentError, 'score is required' unless score
21
+ raise ArgumentError, 'opponent_score is required' unless opponent_score
22
+ raise ArgumentError, 'home is required' if home.nil?
23
+ raise CannotTieError, 'scores cannot be equal' if score == opponent_score
24
+
25
+ @date = date
26
+ @opponent = opponent
27
+ @score = score
28
+ @opponent_score = opponent_score
29
+ @home = home
30
+
31
+ freeze
32
+ end
33
+
34
+ def win?
35
+ score > opponent_score
36
+ end
37
+
38
+ def loss?
39
+ score < opponent_score
40
+ end
41
+
42
+ def to_s
43
+ "[#{date}] #{win? ? 'Win' : 'Loss'} #{home? ? 'vs' : 'at'} #{opponent} (#{score}-#{opponent_score})"
44
+ end
45
+ end
46
+ end
47
+ end
@@ -5,7 +5,7 @@ module Basketball
5
5
  # An exhibition game.
6
6
  class Exhibition < Game
7
7
  def to_s
8
- "#{super} (preseason)"
8
+ "#{super} (exhibition)"
9
9
  end
10
10
  end
11
11
  end
@@ -5,6 +5,12 @@ module Basketball
5
5
  # Represents a team without a roster. Equal to a team by identity.
6
6
  # A team's roster will not be known until the last minute (when it is game time).
7
7
  class Opponent < Entity
8
+ class << self
9
+ def from(team)
10
+ new(id: team.id)
11
+ end
12
+ end
13
+
8
14
  def initialize(id:)
9
15
  super(id)
10
16
 
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Season
5
+ # Represents a team within a Standings object. Each Record is comprised of Detail instances
6
+ # which are the game results in the perspective of a single Team.
7
+ class Record < Entity
8
+ class DetailAlreadyAddedError < StandardError; end
9
+ class OpponentNotFoundError < StandardError; end
10
+
11
+ def initialize(id:)
12
+ super(id)
13
+
14
+ @details_by_date = {}
15
+
16
+ details.each { |detail| add!(detail) }
17
+
18
+ freeze
19
+ end
20
+
21
+ def accept!(result)
22
+ if result.home_opponent == self
23
+ detail = Detail.new(
24
+ date: result.date,
25
+ opponent: result.away_opponent,
26
+ score: result.home_score,
27
+ opponent_score: result.away_score,
28
+ home: true
29
+ )
30
+
31
+ add!(detail)
32
+ elsif result.away_opponent == self
33
+ detail = Detail.new(
34
+ date: result.date,
35
+ opponent: result.home_opponent,
36
+ score: result.away_score,
37
+ opponent_score: result.home_score,
38
+ home: false
39
+ )
40
+
41
+ add!(detail)
42
+ else
43
+ raise OpponentNotFoundError, "#{result} has no opponent for #{self}"
44
+ end
45
+ end
46
+
47
+ def detail_for(date)
48
+ details_by_date[date]
49
+ end
50
+
51
+ def details
52
+ details_by_date.values
53
+ end
54
+
55
+ def win_percentage
56
+ (win_count.to_f / game_count).round(3)
57
+ end
58
+
59
+ def game_count
60
+ details.length
61
+ end
62
+
63
+ def win_count
64
+ details.count(&:win?)
65
+ end
66
+
67
+ def loss_count
68
+ details.count(&:loss?)
69
+ end
70
+
71
+ def to_s
72
+ "[#{super}] #{win_count}-#{loss_count} (#{win_percentage})"
73
+ end
74
+
75
+ def <=>(other)
76
+ [win_count, win_percentage] <=> [other.win_count, other.win_percentage]
77
+ end
78
+
79
+ private
80
+
81
+ attr_reader :details_by_date
82
+
83
+ def add!(detail)
84
+ raise DetailAlreadyAddedError, "#{detail} already added for date" if detail_for(detail.date)
85
+
86
+ details_by_date[detail.date] = detail
87
+
88
+ self
89
+ end
90
+ end
91
+ end
92
+ end