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