basketball 0.0.5 → 0.0.7
Sign up to get free protection for your applications and to get access to all the features.
- 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
|