basketball 0.0.5 → 0.0.7
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/CHANGELOG.md +10 -0
- data/Gemfile +11 -0
- data/README.md +95 -14
- data/basketball.gemspec +1 -12
- data/exe/basketball-schedule +7 -0
- data/lib/basketball/drafting/cli.rb +2 -2
- data/lib/basketball/drafting/engine.rb +5 -8
- data/lib/basketball/drafting/engine_serializer.rb +62 -51
- data/lib/basketball/drafting/event.rb +4 -4
- data/lib/basketball/drafting/league.rb +0 -1
- data/lib/basketball/drafting/pick_event.rb +3 -3
- data/lib/basketball/drafting/roster.rb +0 -1
- data/lib/basketball/drafting/sim_event.rb +3 -3
- data/lib/basketball/drafting.rb +6 -0
- data/lib/basketball/scheduling/calendar.rb +121 -0
- data/lib/basketball/scheduling/calendar_serializer.rb +94 -0
- data/lib/basketball/scheduling/cli.rb +198 -0
- data/lib/basketball/scheduling/conference.rb +57 -0
- data/lib/basketball/scheduling/coordinator.rb +180 -0
- data/lib/basketball/scheduling/division.rb +43 -0
- data/lib/basketball/scheduling/game.rb +32 -0
- data/lib/basketball/scheduling/league.rb +114 -0
- data/lib/basketball/scheduling/league_serializer.rb +99 -0
- data/lib/basketball/scheduling/preseason_game.rb +11 -0
- data/lib/basketball/scheduling/season_game.rb +8 -0
- data/lib/basketball/scheduling/team.rb +21 -0
- data/lib/basketball/scheduling.rb +17 -0
- data/lib/basketball/value_object.rb +16 -7
- data/lib/basketball/version.rb +1 -1
- data/lib/basketball.rb +1 -0
- metadata +17 -142
@@ -0,0 +1,121 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Basketball
|
4
|
+
module Scheduling
|
5
|
+
class Calendar < ValueObject
|
6
|
+
class TeamAlreadyBookedError < StandardError; end
|
7
|
+
class InvalidGameOrderError < StandardError; end
|
8
|
+
class OutOfBoundsError < StandardError; end
|
9
|
+
|
10
|
+
attr_reader :preseason_start_date,
|
11
|
+
:preseason_end_date,
|
12
|
+
:season_start_date,
|
13
|
+
:season_end_date
|
14
|
+
|
15
|
+
attr_reader_value :year, :games
|
16
|
+
|
17
|
+
def initialize(year:, games: [])
|
18
|
+
super()
|
19
|
+
|
20
|
+
raise ArgumentError, 'year is required' unless year
|
21
|
+
|
22
|
+
@year = year
|
23
|
+
@preseason_start_date = Date.new(year, 9, 30)
|
24
|
+
@preseason_end_date = Date.new(year, 10, 14)
|
25
|
+
@season_start_date = Date.new(year, 10, 18)
|
26
|
+
@season_end_date = Date.new(year + 1, 4, 29)
|
27
|
+
@games = []
|
28
|
+
|
29
|
+
games.each { |game| add!(game) }
|
30
|
+
|
31
|
+
freeze
|
32
|
+
end
|
33
|
+
|
34
|
+
def add!(game)
|
35
|
+
assert_in_bounds(game)
|
36
|
+
assert_free_date(game)
|
37
|
+
|
38
|
+
@games << game
|
39
|
+
|
40
|
+
self
|
41
|
+
end
|
42
|
+
|
43
|
+
def preseason_games_for(date: nil, team: nil)
|
44
|
+
games_for(date:, team:).select { |game| game.is_a?(PreseasonGame) }
|
45
|
+
end
|
46
|
+
|
47
|
+
def season_games_for(date: nil, team: nil)
|
48
|
+
games_for(date:, team:).select { |game| game.is_a?(SeasonGame) }
|
49
|
+
end
|
50
|
+
|
51
|
+
def games_for(date: nil, team: nil)
|
52
|
+
games.select do |game|
|
53
|
+
(date.nil? || game.date == date) &&
|
54
|
+
(team.nil? || (game.home_team == team || game.away_team == team))
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def available_preseason_dates_for(team)
|
59
|
+
all_preseason_dates - preseason_games_for(team:).map(&:date)
|
60
|
+
end
|
61
|
+
|
62
|
+
def available_season_dates_for(team)
|
63
|
+
all_season_dates - season_games_for(team:).map(&:date)
|
64
|
+
end
|
65
|
+
|
66
|
+
def available_preseason_matchup_dates(team1, team2)
|
67
|
+
available_team_dates = available_preseason_dates_for(team1)
|
68
|
+
available_other_team_dates = available_preseason_dates_for(team2)
|
69
|
+
|
70
|
+
available_team_dates & available_other_team_dates
|
71
|
+
end
|
72
|
+
|
73
|
+
def available_season_matchup_dates(team1, team2)
|
74
|
+
available_team_dates = available_season_dates_for(team1)
|
75
|
+
available_other_team_dates = available_season_dates_for(team2)
|
76
|
+
|
77
|
+
available_team_dates & available_other_team_dates
|
78
|
+
end
|
79
|
+
|
80
|
+
def teams
|
81
|
+
games.flat_map(&:teams)
|
82
|
+
end
|
83
|
+
|
84
|
+
def team(id)
|
85
|
+
teams.find { |t| t == Team.new(id:) }
|
86
|
+
end
|
87
|
+
|
88
|
+
private
|
89
|
+
|
90
|
+
def all_preseason_dates
|
91
|
+
(preseason_start_date..preseason_end_date).to_a
|
92
|
+
end
|
93
|
+
|
94
|
+
def all_season_dates
|
95
|
+
(season_start_date..season_end_date).to_a
|
96
|
+
end
|
97
|
+
|
98
|
+
def assert_free_date(game)
|
99
|
+
if games_for(date: game.date, team: game.home_team).any?
|
100
|
+
raise TeamAlreadyBookedError, "#{game.home_team} already playing on #{game.date}"
|
101
|
+
end
|
102
|
+
|
103
|
+
return unless games_for(date: game.date, team: game.away_team).any?
|
104
|
+
|
105
|
+
raise TeamAlreadyBookedError, "#{game.away_team} already playing on #{game.date}"
|
106
|
+
end
|
107
|
+
|
108
|
+
def assert_in_bounds(game)
|
109
|
+
if game.is_a?(PreseasonGame)
|
110
|
+
raise OutOfBoundsError, "#{game.date} is before preseason begins" if game.date < preseason_start_date
|
111
|
+
raise OutOfBoundsError, "#{game.date} is after preseason ends" if game.date > preseason_end_date
|
112
|
+
elsif game.is_a?(SeasonGame)
|
113
|
+
raise OutOfBoundsError, "#{game.date} is before season begins" if game.date < season_start_date
|
114
|
+
raise OutOfBoundsError, "#{game.date} is after season ends" if game.date > season_end_date
|
115
|
+
else
|
116
|
+
raise ArgumentError, "Dont know what this game type is: #{game.class.name}"
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'game'
|
4
|
+
require_relative 'preseason_game'
|
5
|
+
require_relative 'season_game'
|
6
|
+
|
7
|
+
module Basketball
|
8
|
+
module Scheduling
|
9
|
+
class CalendarSerializer
|
10
|
+
GAME_CLASSES = {
|
11
|
+
'PreseasonGame' => PreseasonGame,
|
12
|
+
'SeasonGame' => SeasonGame
|
13
|
+
}.freeze
|
14
|
+
|
15
|
+
def to_hash(calendar)
|
16
|
+
teams = calendar.games.flat_map(&:teams).uniq
|
17
|
+
|
18
|
+
{
|
19
|
+
'year' => calendar.preseason_start_date.year,
|
20
|
+
'teams' => serialize_teams(teams),
|
21
|
+
'games' => serialize_games(calendar.games)
|
22
|
+
}
|
23
|
+
end
|
24
|
+
|
25
|
+
def from_hash(json)
|
26
|
+
Calendar.new(
|
27
|
+
year: json['year'].to_i,
|
28
|
+
games: deserialize_games(json)
|
29
|
+
)
|
30
|
+
end
|
31
|
+
|
32
|
+
def deserialize(string)
|
33
|
+
json = JSON.parse(string)
|
34
|
+
|
35
|
+
from_hash(json)
|
36
|
+
end
|
37
|
+
|
38
|
+
def serialize(calendar)
|
39
|
+
to_hash(calendar).to_json
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
## Deserialization
|
45
|
+
|
46
|
+
def deserialize_games(json)
|
47
|
+
teams = deserialize_teams(json['teams'])
|
48
|
+
|
49
|
+
(json['games'] || []).map do |game_hash|
|
50
|
+
GAME_CLASSES.fetch(game_hash['type']).new(
|
51
|
+
date: Date.parse(game_hash['date']),
|
52
|
+
home_team: teams.fetch(game_hash['home_team']),
|
53
|
+
away_team: teams.fetch(game_hash['away_team'])
|
54
|
+
)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def deserialize_teams(teams)
|
59
|
+
(teams || []).to_h do |id, team_hash|
|
60
|
+
team = Team.new(id:, name: team_hash['name'])
|
61
|
+
|
62
|
+
[
|
63
|
+
team.id,
|
64
|
+
team
|
65
|
+
]
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
## Serialization
|
70
|
+
|
71
|
+
def serialize_teams(teams)
|
72
|
+
teams.to_h do |team|
|
73
|
+
[
|
74
|
+
team.id,
|
75
|
+
{
|
76
|
+
'name' => team.name
|
77
|
+
}
|
78
|
+
]
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def serialize_games(games)
|
83
|
+
games.sort_by(&:date).map do |game|
|
84
|
+
{
|
85
|
+
'type' => game.class.name.split('::').last,
|
86
|
+
'date' => game.date.to_s,
|
87
|
+
'home_team' => game.home_team.id,
|
88
|
+
'away_team' => game.away_team.id
|
89
|
+
}
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,198 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'calendar_serializer'
|
4
|
+
require_relative 'conference'
|
5
|
+
require_relative 'coordinator'
|
6
|
+
require_relative 'division'
|
7
|
+
require_relative 'league'
|
8
|
+
require_relative 'league_serializer'
|
9
|
+
require_relative 'team'
|
10
|
+
|
11
|
+
module Basketball
|
12
|
+
module Scheduling
|
13
|
+
# Examples:
|
14
|
+
# exe/basketball-schedule -o tmp/league.json
|
15
|
+
# exe/basketball-schedule -i tmp/league.json -o tmp/calendar.json
|
16
|
+
# exe/basketball-schedule -i tmp/league.json -o tmp/calendar.json -y 2005
|
17
|
+
# exe/basketball-schedule -c tmp/calendar.json
|
18
|
+
# exe/basketball-schedule -c tmp/calendar.json -t C0-D0-T0
|
19
|
+
# exe/basketball-schedule -c tmp/calendar.json -d 2005-02-03
|
20
|
+
# exe/basketball-schedule -c tmp/calendar.json -d 2005-02-03 -t C0-D0-T0
|
21
|
+
class CLI
|
22
|
+
attr_reader :opts,
|
23
|
+
:league_serializer,
|
24
|
+
:calendar_serializer,
|
25
|
+
:io,
|
26
|
+
:coordinator
|
27
|
+
|
28
|
+
def initialize(args:, io: $stdout)
|
29
|
+
@io = io
|
30
|
+
@opts = slop_parse(args)
|
31
|
+
@league_serializer = LeagueSerializer.new
|
32
|
+
@calendar_serializer = CalendarSerializer.new
|
33
|
+
@coordinator = Coordinator.new
|
34
|
+
|
35
|
+
freeze
|
36
|
+
end
|
37
|
+
|
38
|
+
def invoke!
|
39
|
+
if output?
|
40
|
+
out_dir = File.dirname(output)
|
41
|
+
FileUtils.mkdir_p(out_dir)
|
42
|
+
end
|
43
|
+
|
44
|
+
if output? && no_input?
|
45
|
+
execute_with_no_input
|
46
|
+
elsif output?
|
47
|
+
execute_with_input
|
48
|
+
end
|
49
|
+
|
50
|
+
output_cal_query if cal
|
51
|
+
|
52
|
+
self
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def output_cal_query
|
58
|
+
contents = File.read(cal)
|
59
|
+
calendar = calendar_serializer.deserialize(contents)
|
60
|
+
team_instance = team ? calendar.team(team) : nil
|
61
|
+
games = calendar.games_for(date:, team: team_instance).sort_by(&:date)
|
62
|
+
pre_counter = 1
|
63
|
+
counter = 1
|
64
|
+
|
65
|
+
io.puts("Games for [team: #{team}, date: #{date}]")
|
66
|
+
games.each do |game|
|
67
|
+
if game.is_a?(PreseasonGame)
|
68
|
+
io.puts("##{pre_counter} - #{game}")
|
69
|
+
pre_counter += 1
|
70
|
+
else
|
71
|
+
io.puts("##{counter} - #{game}")
|
72
|
+
counter += 1
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def execute_with_input
|
78
|
+
io.puts("Loading league from: #{input}")
|
79
|
+
|
80
|
+
contents = File.read(input)
|
81
|
+
league = league_serializer.deserialize(contents)
|
82
|
+
|
83
|
+
io.puts("Generating calendar for the year #{year}...")
|
84
|
+
|
85
|
+
calendar = coordinator.schedule(league:, year:)
|
86
|
+
contents = calendar_serializer.serialize(calendar)
|
87
|
+
|
88
|
+
File.write(output, contents)
|
89
|
+
|
90
|
+
io.puts("Calendar written to: #{output}")
|
91
|
+
end
|
92
|
+
|
93
|
+
def execute_with_no_input
|
94
|
+
league = generate_league
|
95
|
+
contents = league_serializer.serialize(league)
|
96
|
+
|
97
|
+
File.write(output, contents)
|
98
|
+
|
99
|
+
io.puts("League written to: #{output}")
|
100
|
+
end
|
101
|
+
|
102
|
+
def cal
|
103
|
+
opts[:cal].to_s.empty? ? nil : opts[:cal]
|
104
|
+
end
|
105
|
+
|
106
|
+
def team
|
107
|
+
opts[:team].to_s.empty? ? nil : opts[:team]
|
108
|
+
end
|
109
|
+
|
110
|
+
def date
|
111
|
+
opts[:date].to_s.empty? ? nil : Date.parse(opts[:date])
|
112
|
+
end
|
113
|
+
|
114
|
+
def year
|
115
|
+
opts[:year].to_s.empty? ? Date.today.year : opts[:year]
|
116
|
+
end
|
117
|
+
|
118
|
+
def no_input?
|
119
|
+
input.to_s.empty?
|
120
|
+
end
|
121
|
+
|
122
|
+
def input
|
123
|
+
opts[:input]
|
124
|
+
end
|
125
|
+
|
126
|
+
def output?
|
127
|
+
!output.to_s.empty?
|
128
|
+
end
|
129
|
+
|
130
|
+
def output
|
131
|
+
opts[:output]
|
132
|
+
end
|
133
|
+
|
134
|
+
def generate_conferences
|
135
|
+
2.times.map do |i|
|
136
|
+
id = "C#{i}"
|
137
|
+
|
138
|
+
Conference.new(
|
139
|
+
id:,
|
140
|
+
name: Faker::Esport.league,
|
141
|
+
divisions: generate_divisions("#{id}-")
|
142
|
+
)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
def generate_divisions(id_prefix)
|
147
|
+
3.times.map do |j|
|
148
|
+
id = "#{id_prefix}D#{j}"
|
149
|
+
|
150
|
+
Division.new(
|
151
|
+
id:,
|
152
|
+
name: Faker::Address.community,
|
153
|
+
teams: generate_teams("#{id}-")
|
154
|
+
)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def generate_teams(id_prefix)
|
159
|
+
5.times.map do |k|
|
160
|
+
Team.new(
|
161
|
+
id: "#{id_prefix}T#{k}",
|
162
|
+
name: Faker::Team.name
|
163
|
+
)
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def generate_league
|
168
|
+
League.new(conferences: generate_conferences)
|
169
|
+
end
|
170
|
+
|
171
|
+
def slop_parse(args)
|
172
|
+
Slop.parse(args) do |o|
|
173
|
+
o.banner = 'Usage: basketball-schedule [options] ...'
|
174
|
+
|
175
|
+
output_description = <<~DESC
|
176
|
+
If input path is omitted then a new league will be written to this path.
|
177
|
+
If an input path is specified then a Calendar will be written to the output path.
|
178
|
+
DESC
|
179
|
+
|
180
|
+
# League and Calendar Generation Interface
|
181
|
+
o.string '-i', '--input', 'Path to load the League from. If omitted then a new league will be generated.'
|
182
|
+
o.string '-o', '--output', output_description
|
183
|
+
o.integer '-y', '--year', 'Year to use to generate a calendar for (defaults to current year).'
|
184
|
+
|
185
|
+
# Calendar Query Interface
|
186
|
+
o.string '-c', '--cal', 'Path to load a Calendar from. If omitted then no matchups will be outputted.'
|
187
|
+
o.string '-d', '--date', 'Filter matchups to just the date specified (requires --cal option).'
|
188
|
+
o.string '-t', '--team', 'Filter matchups to just the team ID specified (requires --cal option).'
|
189
|
+
|
190
|
+
o.on '-h', '--help', 'Print out help, like this is doing right now.' do
|
191
|
+
io.puts(o)
|
192
|
+
exit
|
193
|
+
end
|
194
|
+
end.to_h
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Basketball
|
4
|
+
module Scheduling
|
5
|
+
class Conference < Entity
|
6
|
+
DIVISIONS_SIZE = 3
|
7
|
+
|
8
|
+
attr_reader :name, :divisions
|
9
|
+
|
10
|
+
def initialize(id:, name: '', divisions: [])
|
11
|
+
super(id)
|
12
|
+
|
13
|
+
@name = name.to_s
|
14
|
+
@divisions = []
|
15
|
+
|
16
|
+
divisions.each { |d| register_division!(d) }
|
17
|
+
|
18
|
+
if divisions.length != DIVISIONS_SIZE
|
19
|
+
raise BadDivisionsSizeError, "#{id} should have exactly #{DIVISIONS_SIZE} divisions"
|
20
|
+
end
|
21
|
+
|
22
|
+
freeze
|
23
|
+
end
|
24
|
+
|
25
|
+
def to_s
|
26
|
+
(["[#{super}] #{name}"] + divisions.map(&:to_s)).join("\n")
|
27
|
+
end
|
28
|
+
|
29
|
+
def division?(division)
|
30
|
+
divisions.include?(division)
|
31
|
+
end
|
32
|
+
|
33
|
+
def teams
|
34
|
+
divisions.flat_map(&:teams)
|
35
|
+
end
|
36
|
+
|
37
|
+
def team?(team)
|
38
|
+
teams.include?(team)
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def register_division!(division)
|
44
|
+
raise ArgumentError, 'division is required' unless division
|
45
|
+
raise DivisionAlreadyRegisteredError, "#{division} already registered" if division?(division)
|
46
|
+
|
47
|
+
division.teams.each do |team|
|
48
|
+
raise TeamAlreadyRegisteredError, "#{team} already registered" if team?(team)
|
49
|
+
end
|
50
|
+
|
51
|
+
divisions << division
|
52
|
+
|
53
|
+
self
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,180 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'calendar'
|
4
|
+
|
5
|
+
module Basketball
|
6
|
+
module Scheduling
|
7
|
+
# This is the service class responsible for actually picking out free dates ane pairing up teams to
|
8
|
+
# play each other. This is a reasonable naive first pass at some underlying match-making algorithms
|
9
|
+
# but could definitely use some help with the complexity/runtime/etc.
|
10
|
+
class Coordinator
|
11
|
+
MIN_PRESEASON_GAMES_PER_TEAM = 4
|
12
|
+
MAX_PRESEASON_GAMES_PER_TEAM = 6
|
13
|
+
|
14
|
+
private_constant :MIN_PRESEASON_GAMES_PER_TEAM,
|
15
|
+
:MAX_PRESEASON_GAMES_PER_TEAM
|
16
|
+
|
17
|
+
def schedule(year:, league:)
|
18
|
+
Calendar.new(year:).tap do |calendar|
|
19
|
+
schedule_preseason!(calendar:, league:)
|
20
|
+
schedule_season!(calendar:, league:)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def base_matchup_count(league, team1, team2)
|
27
|
+
# Same Conference, Same Division
|
28
|
+
if league.division_for(team1) == league.division_for(team2)
|
29
|
+
4
|
30
|
+
# Same Conference, Different Division and one of 4/10 that play 3 times
|
31
|
+
elsif league.conference_for(team1) == league.conference_for(team2)
|
32
|
+
3
|
33
|
+
# Different Conference
|
34
|
+
else
|
35
|
+
2
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# rubocop:disable Metrics/AbcSize
|
40
|
+
# This method derives the plan for which a schedule can be generated from.
|
41
|
+
def matchup_plan(league)
|
42
|
+
matchups = {}
|
43
|
+
game_counts = league.teams.to_h { |t| [t, 0] }
|
44
|
+
teams = game_counts.keys
|
45
|
+
|
46
|
+
(0...teams.length).each do |i|
|
47
|
+
team1 = teams[i]
|
48
|
+
|
49
|
+
(i + 1...teams.length).each do |j|
|
50
|
+
team2 = teams[j]
|
51
|
+
key = [team1, team2].sort
|
52
|
+
count = base_matchup_count(league, team1, team2)
|
53
|
+
matchups[key] = count
|
54
|
+
game_counts[team1] += count
|
55
|
+
game_counts[team2] += count
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# Each team will play 6 games against conference opponents in other divisions.
|
60
|
+
# The fours hash will be that plan.
|
61
|
+
find_fours(league).each do |team, opponents|
|
62
|
+
next if game_counts[team] == 82
|
63
|
+
|
64
|
+
opponents.each do |opponent|
|
65
|
+
next if game_counts[team] == 82
|
66
|
+
next if game_counts[opponent] == 82
|
67
|
+
|
68
|
+
game_counts[team] += 1
|
69
|
+
game_counts[opponent] += 1
|
70
|
+
|
71
|
+
key = [team, opponent].sort
|
72
|
+
|
73
|
+
matchups[key] += 1
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
matchups
|
78
|
+
end
|
79
|
+
# rubocop:enable Metrics/AbcSize
|
80
|
+
|
81
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
82
|
+
# I am not liking this algorithm implementation at all but it will seemingly produce a valid
|
83
|
+
# result about 1 out of every 1000 cycles. I have yet to spot the assignment pattern to make
|
84
|
+
# this way more deterministic.
|
85
|
+
def find_fours(league)
|
86
|
+
balanced = false
|
87
|
+
count = 0
|
88
|
+
four_tracker = {}
|
89
|
+
|
90
|
+
until balanced
|
91
|
+
# Let's not completely thrash our CPUs in case this algorithm hits an infinite loop.
|
92
|
+
# Instead, lets hard-fail against a hard boundary.
|
93
|
+
raise ArgumentError, 'we spent too much CPU time and didnt resolve fours' if count > 100_000
|
94
|
+
|
95
|
+
four_tracker = league.teams.to_h { |team| [team, []] }
|
96
|
+
|
97
|
+
league.teams.each do |team|
|
98
|
+
opponents = league.cross_division_opponents_for(team).shuffle
|
99
|
+
|
100
|
+
opponents.each do |opponent|
|
101
|
+
if four_tracker[team].length < 6 && four_tracker[opponent].length < 6
|
102
|
+
four_tracker[opponent] << team
|
103
|
+
four_tracker[team] << opponent
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
good = true
|
109
|
+
|
110
|
+
# trip-wire: if one team isnt balanced then we are not balanced
|
111
|
+
four_tracker.each { |_k, v| good = false if v.length < 6 }
|
112
|
+
|
113
|
+
balanced = good
|
114
|
+
|
115
|
+
count += 1
|
116
|
+
end
|
117
|
+
|
118
|
+
four_tracker
|
119
|
+
end
|
120
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
121
|
+
|
122
|
+
def schedule_season!(calendar:, league:)
|
123
|
+
matchups = matchup_plan(league)
|
124
|
+
|
125
|
+
matchups.each do |(team1, team2), count|
|
126
|
+
candidates = calendar.available_season_matchup_dates(team1, team2)
|
127
|
+
dates = candidates.sample(count)
|
128
|
+
games = balanced_games(dates, team1, team2)
|
129
|
+
|
130
|
+
games.each { |game| calendar.add!(game) }
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def balanced_games(dates, team1, team2)
|
135
|
+
dates.map.with_index(1) do |date, index|
|
136
|
+
if index.even?
|
137
|
+
SeasonGame.new(date:, home_team: team1, away_team: team2)
|
138
|
+
else
|
139
|
+
SeasonGame.new(date:, home_team: team2, away_team: team1)
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def schedule_preseason!(calendar:, league:)
|
145
|
+
league.teams.each do |team|
|
146
|
+
current_games = calendar.preseason_games_for(team:)
|
147
|
+
count = current_games.length
|
148
|
+
|
149
|
+
next if count >= MIN_PRESEASON_GAMES_PER_TEAM
|
150
|
+
|
151
|
+
other_teams = (league.teams - [team]).shuffle
|
152
|
+
|
153
|
+
other_teams.each do |other_team|
|
154
|
+
break if count > MIN_PRESEASON_GAMES_PER_TEAM
|
155
|
+
next if calendar.preseason_games_for(team: other_team).length >= MAX_PRESEASON_GAMES_PER_TEAM
|
156
|
+
|
157
|
+
candidates = calendar.available_preseason_matchup_dates(team, other_team)
|
158
|
+
|
159
|
+
next if candidates.empty?
|
160
|
+
|
161
|
+
date = candidates.sample
|
162
|
+
game = random_preseason_game(date, team, other_team)
|
163
|
+
|
164
|
+
calendar.add!(game)
|
165
|
+
|
166
|
+
count += 1
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def random_preseason_game(date, team1, team2)
|
172
|
+
if rand(1..2) == 1
|
173
|
+
PreseasonGame.new(date:, home_team: team1, away_team: team2)
|
174
|
+
else
|
175
|
+
PreseasonGame.new(date:, home_team: team2, away_team: team1)
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Basketball
|
4
|
+
module Scheduling
|
5
|
+
class Division < Entity
|
6
|
+
TEAMS_SIZE = 5
|
7
|
+
|
8
|
+
attr_reader :name, :teams
|
9
|
+
|
10
|
+
def initialize(id:, name: '', teams: [])
|
11
|
+
super(id)
|
12
|
+
|
13
|
+
@name = name.to_s
|
14
|
+
@teams = []
|
15
|
+
|
16
|
+
teams.each { |t| register_team!(t) }
|
17
|
+
|
18
|
+
raise BadTeamsSizeError, "#{id} should have exactly #{TEAMS_SIZE} teams" if teams.length != TEAMS_SIZE
|
19
|
+
|
20
|
+
freeze
|
21
|
+
end
|
22
|
+
|
23
|
+
def to_s
|
24
|
+
(["[#{super}] #{name}"] + teams.map(&:to_s)).join("\n")
|
25
|
+
end
|
26
|
+
|
27
|
+
def team?(team)
|
28
|
+
teams.include?(team)
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def register_team!(team)
|
34
|
+
raise ArgumentError, 'team is required' unless team
|
35
|
+
raise TeamAlreadyRegisteredError, "#{team} already registered" if team?(team)
|
36
|
+
|
37
|
+
teams << team
|
38
|
+
|
39
|
+
self
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|