basketball 0.0.5 → 0.0.6

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