basketball 0.0.9 → 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.
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,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,37 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'value_object_dsl'
4
+
3
5
  module Basketball
4
6
  # A Value Object is something that has no specific identity, instead its identity is the sum of
5
7
  # 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.
8
+ # Comes with a very simple DSL provided by ValueObjectDSL for specifying properties along with
9
+ # base equality and sorting methods.
7
10
  class ValueObject
8
11
  include Comparable
9
-
10
- class << self
11
- def all_value_keys
12
- @all_value_keys ||= ancestors.flat_map do |ancestor|
13
- if ancestor < Basketball::ValueObject
14
- ancestor.value_keys
15
- else
16
- []
17
- end
18
- end.uniq.sort
19
- end
20
-
21
- def all_sorted_value_keys
22
- all_value_keys.sort
23
- end
24
-
25
- def value_keys
26
- @value_keys ||= []
27
- end
28
-
29
- def value_reader(*keys)
30
- keys.each { |k| value_keys << k.to_sym }
31
-
32
- attr_reader(*keys)
33
- end
34
- end
12
+ extend ValueObjectDSL
35
13
 
36
14
  def to_s
37
15
  to_h.map { |k, v| "#{k}: #{v}" }.join(', ')
@@ -55,7 +33,7 @@ module Basketball
55
33
  alias eql? ==
56
34
 
57
35
  def hash
58
- all_sorted_values.map(&:hash).hash
36
+ all_sorted_values.hash
59
37
  end
60
38
 
61
39
  def all_sorted_values
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ # Class-level methods that extend core Ruby attr_* methods.
5
+ module ValueObjectDSL
6
+ def all_value_keys
7
+ @all_value_keys ||= ancestors.flat_map do |ancestor|
8
+ if ancestor < Basketball::ValueObject
9
+ ancestor.value_keys
10
+ else
11
+ []
12
+ end
13
+ end.uniq.sort
14
+ end
15
+
16
+ def all_sorted_value_keys
17
+ all_value_keys.sort
18
+ end
19
+
20
+ def value_keys
21
+ @value_keys ||= []
22
+ end
23
+
24
+ def value_reader(*keys)
25
+ keys.each { |k| value_keys << k.to_sym }
26
+
27
+ attr_reader(*keys)
28
+ end
29
+ end
30
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basketball
4
- VERSION = '0.0.9'
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.9
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-05-30 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,13 +49,18 @@ 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
56
58
  - lib/basketball/app/coordinator_repository.rb
59
+ - lib/basketball/app/document_repository.rb
57
60
  - lib/basketball/app/file_store.rb
61
+ - lib/basketball/app/in_memory_store.rb
62
+ - lib/basketball/app/league_repository.rb
63
+ - lib/basketball/app/league_serializable.rb
58
64
  - lib/basketball/app/room_cli.rb
59
65
  - lib/basketball/app/room_repository.rb
60
66
  - lib/basketball/draft.rb
@@ -67,6 +73,11 @@ files:
67
73
  - lib/basketball/draft/skip.rb
68
74
  - lib/basketball/entity.rb
69
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
70
81
  - lib/basketball/org/league.rb
71
82
  - lib/basketball/org/player.rb
72
83
  - lib/basketball/org/position.rb
@@ -75,13 +86,18 @@ files:
75
86
  - lib/basketball/season/arena.rb
76
87
  - lib/basketball/season/calendar.rb
77
88
  - lib/basketball/season/coordinator.rb
89
+ - lib/basketball/season/detail.rb
78
90
  - lib/basketball/season/exhibition.rb
79
91
  - lib/basketball/season/game.rb
80
92
  - lib/basketball/season/matchup.rb
81
93
  - lib/basketball/season/opponent.rb
94
+ - lib/basketball/season/record.rb
82
95
  - lib/basketball/season/regular.rb
83
96
  - lib/basketball/season/result.rb
97
+ - lib/basketball/season/scheduler.rb
98
+ - lib/basketball/season/standings.rb
84
99
  - lib/basketball/value_object.rb
100
+ - lib/basketball/value_object_dsl.rb
85
101
  - lib/basketball/version.rb
86
102
  homepage: https://github.com/mattruggio/basketball
87
103
  licenses:
File without changes