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.
- checksums.yaml +4 -4
- data/README.md +31 -21
- data/basketball.gemspec +14 -8
- data/exe/basketball +91 -0
- data/lib/basketball/app/coordinator_cli.rb +56 -72
- data/lib/basketball/app/coordinator_repository.rb +12 -88
- data/lib/basketball/app/document_repository.rb +67 -0
- data/lib/basketball/app/file_store.rb +16 -0
- data/lib/basketball/app/in_memory_store.rb +42 -0
- data/lib/basketball/app/league_repository.rb +20 -0
- data/lib/basketball/app/league_serializable.rb +99 -0
- data/lib/basketball/app/room_cli.rb +30 -26
- data/lib/basketball/app/room_repository.rb +1 -41
- data/lib/basketball/app.rb +10 -2
- data/lib/basketball/draft/pick.rb +0 -7
- data/lib/basketball/draft/room.rb +11 -13
- data/lib/basketball/entity.rb +9 -6
- data/lib/basketball/org/conference.rb +47 -0
- data/lib/basketball/org/division.rb +43 -0
- data/lib/basketball/org/has_divisions.rb +25 -0
- data/lib/basketball/org/has_players.rb +20 -0
- data/lib/basketball/org/has_teams.rb +24 -0
- data/lib/basketball/org/league.rb +59 -32
- data/lib/basketball/org.rb +12 -1
- data/lib/basketball/season/arena.rb +26 -25
- data/lib/basketball/season/calendar.rb +52 -22
- data/lib/basketball/season/coordinator.rb +25 -18
- data/lib/basketball/season/detail.rb +47 -0
- data/lib/basketball/season/exhibition.rb +1 -1
- data/lib/basketball/season/opponent.rb +6 -0
- data/lib/basketball/season/record.rb +92 -0
- data/lib/basketball/season/scheduler.rb +223 -0
- data/lib/basketball/season/standings.rb +56 -0
- data/lib/basketball/season.rb +6 -0
- data/lib/basketball/value_object.rb +6 -28
- data/lib/basketball/value_object_dsl.rb +30 -0
- data/lib/basketball/version.rb +1 -1
- metadata +22 -6
- /data/exe/{basketball-room → basketball-draft-room} +0 -0
- /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
|
data/lib/basketball/season.rb
CHANGED
@@ -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
|
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.
|
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
|
data/lib/basketball/version.rb
CHANGED
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.
|
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-
|
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
|
35
|
-
- basketball-
|
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
|
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
|
File without changes
|