basketball 0.0.10 → 0.0.11

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