basketball 0.0.5 → 0.0.6

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.
@@ -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