basketball 0.0.10 → 0.0.11

Sign up to get free protection for your applications and to get access to all the features.
@@ -21,10 +21,10 @@ module Basketball
21
21
  :league
22
22
 
23
23
  def_delegators :calendar,
24
- :preseason_start_date,
25
- :preseason_end_date,
26
- :season_start_date,
27
- :season_end_date,
24
+ :exhibition_start_date,
25
+ :exhibition_end_date,
26
+ :regular_start_date,
27
+ :regular_end_date,
28
28
  :games,
29
29
  :exhibitions_for,
30
30
  :regulars_for,
@@ -69,13 +69,13 @@ module Basketball
69
69
  end
70
70
 
71
71
  def assert_current_date
72
- if current_date < preseason_start_date
73
- 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}"
74
74
  end
75
75
 
76
- return unless current_date > season_end_date
76
+ return unless current_date > regular_end_date
77
77
 
78
- 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}"
79
79
  end
80
80
 
81
81
  def sim!
@@ -103,11 +103,11 @@ module Basketball
103
103
  end
104
104
 
105
105
  def total_days
106
- (season_end_date - preseason_start_date).to_i
106
+ (regular_end_date - exhibition_start_date).to_i
107
107
  end
108
108
 
109
109
  def days_left
110
- (season_end_date - current_date).to_i
110
+ (regular_end_date - current_date).to_i
111
111
  end
112
112
 
113
113
  def total_exhibitions
@@ -131,7 +131,7 @@ module Basketball
131
131
  end
132
132
 
133
133
  def done?
134
- current_date == season_end_date && games.length == results.length
134
+ current_date == regular_end_date && games.length == results.length
135
135
  end
136
136
 
137
137
  def not_done?
@@ -153,6 +153,14 @@ module Basketball
153
153
  end
154
154
  end
155
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
+
156
164
  private
157
165
 
158
166
  attr_writer :arena
@@ -170,7 +178,7 @@ module Basketball
170
178
  end
171
179
 
172
180
  def increment_current_date!
173
- return self if current_date >= season_end_date
181
+ return self if current_date >= regular_end_date
174
182
 
175
183
  @current_date = current_date + 1
176
184
 
@@ -184,9 +192,9 @@ module Basketball
184
192
  end
185
193
 
186
194
  def assert_known_teams(game)
187
- 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)
188
196
 
189
- return unless league.not_registered?(game.away_opponent)
197
+ return if league.team?(game.away_opponent)
190
198
 
191
199
  raise UnknownTeamError, "unknown opponent: #{game.away_opponent}"
192
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
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Season
5
+ # This is the service class responsible for actually picking out free dates and pairing up teams to
6
+ # play each other. This is a reasonable naive first pass at some underlying match-making algorithms
7
+ # but could definitely use some help with the complexity/runtime/etc.
8
+ class Scheduler
9
+ class BadConferencesSizeError < StandardError; end
10
+ class BadDivisionsSizeError < StandardError; end
11
+ class BadTeamsSizeError < StandardError; end
12
+
13
+ MIN_PRESEASON_GAMES_PER_TEAM = 3
14
+ MAX_PRESEASON_GAMES_PER_TEAM = 6
15
+ CONFERENCES_SIZE = 2
16
+ DIVISIONS_SIZE = 3
17
+ TEAMS_SIZE = 5
18
+
19
+ private_constant :MIN_PRESEASON_GAMES_PER_TEAM,
20
+ :MAX_PRESEASON_GAMES_PER_TEAM,
21
+ :CONFERENCES_SIZE,
22
+ :DIVISIONS_SIZE,
23
+ :TEAMS_SIZE
24
+
25
+ def schedule(league:, year: Time.now.year)
26
+ assert_properly_sized_league(league)
27
+
28
+ Calendar.new(**make_dates(year)).tap do |calendar|
29
+ schedule_exhibition!(calendar:, league:)
30
+ schedule_season!(calendar:, league:)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def make_dates(year)
37
+ {
38
+ exhibition_start_date: Date.new(year, 9, 30),
39
+ exhibition_end_date: Date.new(year, 10, 14),
40
+ regular_start_date: Date.new(year, 10, 18),
41
+ regular_end_date: Date.new(year + 1, 4, 29)
42
+ }
43
+ end
44
+
45
+ def assert_properly_sized_league(league)
46
+ if league.conferences.length != CONFERENCES_SIZE
47
+ raise BadConferencesSizeError, "there has to be #{CONFERENCES_SIZE} conferences"
48
+ end
49
+
50
+ league.conferences.each do |conference|
51
+ if conference.divisions.length != DIVISIONS_SIZE
52
+ raise BadDivisionsSizeError, "#{conference.id} should have exactly #{DIVISIONS_SIZE} divisions"
53
+ end
54
+
55
+ conference.divisions.each do |division|
56
+ if division.teams.length != TEAMS_SIZE
57
+ raise BadTeamsSizeError, "#{division.id} should have exactly #{TEAMS_SIZE} teams"
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ def base_matchup_count(league, team1, team2)
64
+ # Same Conference, Same Division
65
+ if league.division_for(team1) == league.division_for(team2)
66
+ 4
67
+ # Same Conference, Different Division and one of 4/10 that play 3 times
68
+ elsif league.conference_for(team1) == league.conference_for(team2)
69
+ 3
70
+ # Different Conference
71
+ else
72
+ 2
73
+ end
74
+ end
75
+
76
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
77
+ # This method derives the plan for which a schedule can be generated from.
78
+ def matchup_plan(league)
79
+ matchups = {}
80
+ game_counts = league.teams.to_h { |t| [t, 0] }
81
+ teams = game_counts.keys
82
+
83
+ (0...teams.length).each do |i|
84
+ team1 = teams[i]
85
+
86
+ (i + 1...teams.length).each do |j|
87
+ team2 = teams[j]
88
+ key = [team1, team2].sort
89
+ count = base_matchup_count(league, team1, team2)
90
+ matchups[key] = count
91
+ game_counts[team1] += count
92
+ game_counts[team2] += count
93
+ end
94
+ end
95
+
96
+ # Each team will play 6 games against conference opponents in other divisions.
97
+ # The fours hash will be that plan.
98
+ find_fours(league).each do |team, opponents|
99
+ next if game_counts[team] == 82
100
+
101
+ opponents.each do |opponent|
102
+ next if game_counts[team] == 82
103
+ next if game_counts[opponent] == 82
104
+
105
+ game_counts[team] += 1
106
+ game_counts[opponent] += 1
107
+
108
+ key = [team, opponent].sort
109
+
110
+ matchups[key] += 1
111
+ end
112
+ end
113
+
114
+ matchups
115
+ end
116
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
117
+
118
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
119
+ # I am not liking this algorithm implementation at all but it will seemingly produce a valid
120
+ # result about 1 out of every 1000 cycles. I have yet to spot the assignment pattern to make
121
+ # this way more deterministic.
122
+ def find_fours(league)
123
+ balanced = false
124
+ count = 0
125
+ four_tracker = {}
126
+
127
+ until balanced
128
+ # Let's not completely thrash our CPUs in case this algorithm hits an infinite loop.
129
+ # Instead, lets hard-fail against a hard boundary.
130
+ raise ArgumentError, 'we spent too much CPU time and didnt resolve fours' if count > 100_000
131
+
132
+ four_tracker = league.teams.to_h { |team| [team, []] }
133
+
134
+ league.teams.each do |team|
135
+ opponents = league.cross_division_opponents_for(team).shuffle
136
+
137
+ opponents.each do |opponent|
138
+ if four_tracker[team].length < 6 && four_tracker[opponent].length < 6
139
+ four_tracker[opponent] << team
140
+ four_tracker[team] << opponent
141
+ end
142
+ end
143
+ end
144
+
145
+ good = true
146
+
147
+ # trip-wire: if one team isnt balanced then we are not balanced
148
+ four_tracker.each { |_k, v| good = false if v.length < 6 }
149
+
150
+ balanced = good
151
+
152
+ count += 1
153
+ end
154
+
155
+ four_tracker
156
+ end
157
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
158
+
159
+ def schedule_season!(calendar:, league:)
160
+ matchups = matchup_plan(league)
161
+
162
+ matchups.each do |(team1, team2), count|
163
+ candidates = calendar.available_regular_matchup_dates(team1, team2)
164
+ dates = candidates.sample(count)
165
+ games = balanced_games(dates, team1, team2)
166
+
167
+ games.each { |game| calendar.add!(game) }
168
+ end
169
+ end
170
+
171
+ def balanced_games(dates, team1, team2)
172
+ dates.map.with_index(1) do |date, index|
173
+ home_opponent, away_opponent =
174
+ if index.even?
175
+ [Opponent.from(team1), Opponent.from(team2)]
176
+ else
177
+ [Opponent.from(team2), Opponent.from(team1)]
178
+ end
179
+
180
+ Regular.new(date:, home_opponent:, away_opponent:)
181
+ end
182
+ end
183
+
184
+ def schedule_exhibition!(calendar:, league:)
185
+ league.teams.each do |team|
186
+ current_games = calendar.exhibitions_for(opponent: team)
187
+ count = current_games.length
188
+
189
+ next if count >= MIN_PRESEASON_GAMES_PER_TEAM
190
+
191
+ other_teams = (league.teams - [team]).shuffle
192
+
193
+ other_teams.each do |other_team|
194
+ break if count > MIN_PRESEASON_GAMES_PER_TEAM
195
+ next if calendar.exhibitions_for(opponent: other_team).length >= MAX_PRESEASON_GAMES_PER_TEAM
196
+
197
+ candidates = calendar.available_exhibition_matchup_dates(team, other_team)
198
+
199
+ next if candidates.empty?
200
+
201
+ date = candidates.sample
202
+ game = random_exhibition_game(date, team, other_team)
203
+
204
+ calendar.add!(game)
205
+
206
+ count += 1
207
+ end
208
+ end
209
+ end
210
+
211
+ def random_exhibition_game(date, team1, team2)
212
+ home_opponent, away_opponent =
213
+ if rand(1..2) == 1
214
+ [Opponent.from(team1), Opponent.from(team2)]
215
+ else
216
+ [Opponent.from(team2), Opponent.from(team1)]
217
+ end
218
+
219
+ Exhibition.new(date:, home_opponent:, away_opponent:)
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Season
5
+ # Represents a League with each team's win/loss details.
6
+ class Standings
7
+ class TeamAlreadyRegisteredError < StandardError; end
8
+ class TeamNotRegisteredError < StandardError; end
9
+
10
+ def initialize
11
+ @records_by_id = {}
12
+ end
13
+
14
+ def register!(team)
15
+ raise TeamAlreadyRegisteredError, "#{team} already registered!" if team?(team)
16
+
17
+ records_by_id[team.id] = Record.new(id: team.id)
18
+
19
+ self
20
+ end
21
+
22
+ def records
23
+ records_by_id.values
24
+ end
25
+
26
+ def record_for(team)
27
+ raise TeamNotRegisteredError, "#{team} not registered" unless team?(team)
28
+
29
+ records_by_id.fetch(team.id)
30
+ end
31
+
32
+ def accept!(result)
33
+ [
34
+ record_for(result.home_opponent),
35
+ record_for(result.away_opponent)
36
+ ].each do |record|
37
+ record.accept!(result)
38
+ end
39
+
40
+ self
41
+ end
42
+
43
+ def team?(team)
44
+ records_by_id.key?(team.id)
45
+ end
46
+
47
+ def to_s
48
+ records.sort.reverse.map.with_index(1) { |r, i| "##{i} #{r}" }.join("\n")
49
+ end
50
+
51
+ private
52
+
53
+ attr_reader :records_by_id
54
+ end
55
+ end
56
+ end
@@ -12,5 +12,11 @@ require_relative 'season/opponent'
12
12
  require_relative 'season/exhibition'
13
13
  require_relative 'season/regular'
14
14
 
15
+ # Standings
16
+ require_relative 'season/detail'
17
+ require_relative 'season/record'
18
+ require_relative 'season/standings'
19
+
15
20
  # Specific
16
21
  require_relative 'season/coordinator'
22
+ require_relative 'season/scheduler'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basketball
4
- VERSION = '0.0.10'
4
+ VERSION = '0.0.11'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: basketball
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.10
4
+ version: 0.0.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthew Ruggio
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-06-01 00:00:00.000000000 Z
11
+ date: 2023-06-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: slop
@@ -31,8 +31,9 @@ description: " This library is meant to serve as the domain for a basketball
31
31
  email:
32
32
  - mattruggio@icloud.com
33
33
  executables:
34
- - basketball-coordinator
35
- - basketball-room
34
+ - basketball
35
+ - basketball-season-coordinator
36
+ - basketball-draft-room
36
37
  extensions: []
37
38
  extra_rdoc_files: []
38
39
  files:
@@ -48,8 +49,9 @@ files:
48
49
  - README.md
49
50
  - Rakefile
50
51
  - basketball.gemspec
51
- - exe/basketball-coordinator
52
- - exe/basketball-room
52
+ - exe/basketball
53
+ - exe/basketball-draft-room
54
+ - exe/basketball-season-coordinator
53
55
  - lib/basketball.rb
54
56
  - lib/basketball/app.rb
55
57
  - lib/basketball/app/coordinator_cli.rb
@@ -71,6 +73,11 @@ files:
71
73
  - lib/basketball/draft/skip.rb
72
74
  - lib/basketball/entity.rb
73
75
  - lib/basketball/org.rb
76
+ - lib/basketball/org/conference.rb
77
+ - lib/basketball/org/division.rb
78
+ - lib/basketball/org/has_divisions.rb
79
+ - lib/basketball/org/has_players.rb
80
+ - lib/basketball/org/has_teams.rb
74
81
  - lib/basketball/org/league.rb
75
82
  - lib/basketball/org/player.rb
76
83
  - lib/basketball/org/position.rb
@@ -79,12 +86,16 @@ files:
79
86
  - lib/basketball/season/arena.rb
80
87
  - lib/basketball/season/calendar.rb
81
88
  - lib/basketball/season/coordinator.rb
89
+ - lib/basketball/season/detail.rb
82
90
  - lib/basketball/season/exhibition.rb
83
91
  - lib/basketball/season/game.rb
84
92
  - lib/basketball/season/matchup.rb
85
93
  - lib/basketball/season/opponent.rb
94
+ - lib/basketball/season/record.rb
86
95
  - lib/basketball/season/regular.rb
87
96
  - lib/basketball/season/result.rb
97
+ - lib/basketball/season/scheduler.rb
98
+ - lib/basketball/season/standings.rb
88
99
  - lib/basketball/value_object.rb
89
100
  - lib/basketball/value_object_dsl.rb
90
101
  - lib/basketball/version.rb
File without changes