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,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