basketball 0.0.4 → 0.0.6
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 -2
- data/CODE_OF_CONDUCT.md +1 -1
- data/README.md +120 -18
- data/basketball.gemspec +1 -1
- data/exe/basketball-schedule +7 -0
- data/lib/basketball/drafting/cli.rb +15 -15
- data/lib/basketball/drafting/engine.rb +43 -42
- data/lib/basketball/drafting/engine_serializer.rb +41 -49
- data/lib/basketball/drafting/event.rb +10 -10
- data/lib/basketball/drafting/front_office.rb +9 -4
- data/lib/basketball/drafting/league.rb +70 -0
- data/lib/basketball/drafting/pick_event.rb +3 -3
- data/lib/basketball/drafting/roster.rb +18 -24
- 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 +84 -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 +90 -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 +18 -3
- data/lib/basketball/drafting/team.rb +0 -28
@@ -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
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Basketball
|
4
|
+
module Scheduling
|
5
|
+
class Game < ValueObject
|
6
|
+
attr_reader_value :date, :home_team, :away_team
|
7
|
+
|
8
|
+
def initialize(date:, home_team:, away_team:)
|
9
|
+
super()
|
10
|
+
|
11
|
+
raise ArgumentError, 'date is required' unless date
|
12
|
+
raise ArgumentError, 'home_team is required' unless home_team
|
13
|
+
raise ArgumentError, 'away_team is required' unless away_team
|
14
|
+
raise ArgumentError, 'teams cannot play themselves' if home_team == away_team
|
15
|
+
|
16
|
+
@date = date
|
17
|
+
@home_team = home_team
|
18
|
+
@away_team = away_team
|
19
|
+
|
20
|
+
freeze
|
21
|
+
end
|
22
|
+
|
23
|
+
def teams
|
24
|
+
[home_team, away_team]
|
25
|
+
end
|
26
|
+
|
27
|
+
def to_s
|
28
|
+
"#{date} - #{away_team} at #{home_team}"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Basketball
|
4
|
+
module Scheduling
|
5
|
+
class League
|
6
|
+
class UnknownTeamError < StandardError; end
|
7
|
+
|
8
|
+
class << self
|
9
|
+
def generate_random; end
|
10
|
+
end
|
11
|
+
|
12
|
+
CONFERENCES_SIZE = 2
|
13
|
+
|
14
|
+
attr_reader :conferences
|
15
|
+
|
16
|
+
def initialize(conferences: [])
|
17
|
+
@conferences = []
|
18
|
+
|
19
|
+
conferences.each { |c| register_conference!(c) }
|
20
|
+
|
21
|
+
if conferences.length != CONFERENCES_SIZE
|
22
|
+
raise BadConferencesSizeError, "there has to be #{CONFERENCES_SIZE} conferences"
|
23
|
+
end
|
24
|
+
|
25
|
+
freeze
|
26
|
+
end
|
27
|
+
|
28
|
+
def to_s
|
29
|
+
(['League'] + conferences.map(&:to_s)).join("\n")
|
30
|
+
end
|
31
|
+
|
32
|
+
def divisions
|
33
|
+
conferences.flat_map(&:divisions)
|
34
|
+
end
|
35
|
+
|
36
|
+
def conference?(conference)
|
37
|
+
conferences.include?(conference)
|
38
|
+
end
|
39
|
+
|
40
|
+
def division?(division)
|
41
|
+
divisions.include?(division)
|
42
|
+
end
|
43
|
+
|
44
|
+
def team?(team)
|
45
|
+
teams.include?(team)
|
46
|
+
end
|
47
|
+
|
48
|
+
def teams
|
49
|
+
conferences.flat_map do |conference|
|
50
|
+
conference.divisions.flat_map(&:teams)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def conference_for(team)
|
55
|
+
conferences.find { |c| c.divisions.find { |d| d.teams.include?(team) } }
|
56
|
+
end
|
57
|
+
|
58
|
+
def division_for(team)
|
59
|
+
conference_for(team)&.divisions&.find { |d| d.teams.include?(team) }
|
60
|
+
end
|
61
|
+
|
62
|
+
# Same conference, same division
|
63
|
+
def division_opponents_for(team)
|
64
|
+
division = division_for(team)
|
65
|
+
|
66
|
+
return nil unless division
|
67
|
+
|
68
|
+
division.teams - [team]
|
69
|
+
end
|
70
|
+
|
71
|
+
# Same conference, different division
|
72
|
+
def cross_division_opponents_for(team)
|
73
|
+
conference = conference_for(team)
|
74
|
+
division = division_for(team)
|
75
|
+
|
76
|
+
return nil unless conference && division
|
77
|
+
|
78
|
+
other_divisions = conference.divisions - [division]
|
79
|
+
|
80
|
+
other_divisions.flat_map(&:teams)
|
81
|
+
end
|
82
|
+
|
83
|
+
# Different conference
|
84
|
+
def cross_conference_opponents_for(team)
|
85
|
+
conference = conference_for(team)
|
86
|
+
|
87
|
+
return nil unless conference
|
88
|
+
|
89
|
+
other_conferences = conferences - [conference]
|
90
|
+
|
91
|
+
other_conferences.flat_map { |c| c.divisions.flat_map(&:teams) }
|
92
|
+
end
|
93
|
+
|
94
|
+
private
|
95
|
+
|
96
|
+
def register_conference!(conference)
|
97
|
+
raise ArgumentError, 'conference is required' unless conference
|
98
|
+
raise ConferenceAlreadyRegisteredError, "#{conference} already registered" if conference?(conference)
|
99
|
+
|
100
|
+
conference.divisions.each do |division|
|
101
|
+
raise DivisionAlreadyRegisteredError, "#{division} already registered" if division?(division)
|
102
|
+
|
103
|
+
division.teams.each do |team|
|
104
|
+
raise TeamAlreadyRegisteredError, "#{team} already registered" if team?(team)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
conferences << conference
|
109
|
+
|
110
|
+
self
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Basketball
|
4
|
+
module Scheduling
|
5
|
+
class LeagueSerializer
|
6
|
+
def deserialize(string)
|
7
|
+
json = JSON.parse(string, symbolize_names: true)
|
8
|
+
conferences = deserialize_conferences(json[:conferences])
|
9
|
+
|
10
|
+
League.new(conferences:)
|
11
|
+
end
|
12
|
+
|
13
|
+
def serialize(league)
|
14
|
+
{
|
15
|
+
conferences: serialize_conferences(league.conferences)
|
16
|
+
}.to_json
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
## Deserialization
|
22
|
+
|
23
|
+
def deserialize_conferences(conferences)
|
24
|
+
(conferences || []).map do |conference_id, conference_hash|
|
25
|
+
Conference.new(
|
26
|
+
id: conference_id,
|
27
|
+
name: conference_hash[:name],
|
28
|
+
divisions: deserialize_divisions(conference_hash[:divisions])
|
29
|
+
)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def deserialize_divisions(divisions)
|
34
|
+
(divisions || []).map do |division_id, division_hash|
|
35
|
+
Division.new(
|
36
|
+
id: division_id,
|
37
|
+
name: division_hash[:name],
|
38
|
+
teams: deserialize_teams(division_hash[:teams])
|
39
|
+
)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def deserialize_teams(teams)
|
44
|
+
(teams || []).map do |team_id, team_hash|
|
45
|
+
Team.new(
|
46
|
+
id: team_id,
|
47
|
+
name: team_hash[:name]
|
48
|
+
)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
## Serialization
|
53
|
+
|
54
|
+
def serialize_conferences(conferences)
|
55
|
+
conferences.to_h do |conference|
|
56
|
+
[
|
57
|
+
conference.id,
|
58
|
+
{
|
59
|
+
name: conference.name,
|
60
|
+
divisions: serialize_divisions(conference.divisions)
|
61
|
+
}
|
62
|
+
]
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def serialize_divisions(divisions)
|
67
|
+
divisions.to_h do |division|
|
68
|
+
[
|
69
|
+
division.id,
|
70
|
+
{
|
71
|
+
name: division.name,
|
72
|
+
teams: serialize_teams(division.teams)
|
73
|
+
}
|
74
|
+
]
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def serialize_teams(teams)
|
79
|
+
teams.to_h do |team|
|
80
|
+
[
|
81
|
+
team.id,
|
82
|
+
{
|
83
|
+
name: team.name
|
84
|
+
}
|
85
|
+
]
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|