basketball 0.0.5 → 0.0.7

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