basketball 0.0.7 → 0.0.9

Sign up to get free protection for your applications and to get access to all the features.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +9 -19
  3. data/CHANGELOG.md +1 -31
  4. data/README.md +72 -91
  5. data/basketball.gemspec +3 -6
  6. data/exe/{basketball-schedule → basketball-coordinator} +1 -1
  7. data/exe/{basketball-draft → basketball-room} +1 -1
  8. data/lib/basketball/app/coordinator_cli.rb +243 -0
  9. data/lib/basketball/app/coordinator_repository.rb +191 -0
  10. data/lib/basketball/app/file_store.rb +22 -0
  11. data/lib/basketball/app/room_cli.rb +212 -0
  12. data/lib/basketball/app/room_repository.rb +189 -0
  13. data/lib/basketball/app.rb +12 -0
  14. data/lib/basketball/draft/assessment.rb +31 -0
  15. data/lib/basketball/{drafting → draft}/event.rb +4 -3
  16. data/lib/basketball/draft/front_office.rb +99 -0
  17. data/lib/basketball/draft/pick.rb +32 -0
  18. data/lib/basketball/draft/room.rb +221 -0
  19. data/lib/basketball/{drafting/player_search.rb → draft/scout.rb} +5 -10
  20. data/lib/basketball/draft/skip.rb +12 -0
  21. data/lib/basketball/draft.rb +16 -0
  22. data/lib/basketball/entity.rb +10 -4
  23. data/lib/basketball/org/league.rb +73 -0
  24. data/lib/basketball/org/player.rb +26 -0
  25. data/lib/basketball/{drafting → org}/position.rb +3 -2
  26. data/lib/basketball/org/team.rb +38 -0
  27. data/lib/basketball/org.rb +12 -0
  28. data/lib/basketball/season/arena.rb +112 -0
  29. data/lib/basketball/season/calendar.rb +90 -0
  30. data/lib/basketball/season/coordinator.rb +239 -0
  31. data/lib/basketball/{scheduling/preseason_game.rb → season/exhibition.rb} +3 -2
  32. data/lib/basketball/season/game.rb +37 -0
  33. data/lib/basketball/season/matchup.rb +27 -0
  34. data/lib/basketball/season/opponent.rb +15 -0
  35. data/lib/basketball/season/regular.rb +9 -0
  36. data/lib/basketball/season/result.rb +37 -0
  37. data/lib/basketball/season.rb +16 -0
  38. data/lib/basketball/value_object.rb +4 -1
  39. data/lib/basketball/version.rb +1 -1
  40. data/lib/basketball.rb +11 -6
  41. metadata +40 -52
  42. data/lib/basketball/drafting/cli.rb +0 -235
  43. data/lib/basketball/drafting/engine.rb +0 -221
  44. data/lib/basketball/drafting/engine_serializer.rb +0 -186
  45. data/lib/basketball/drafting/front_office.rb +0 -92
  46. data/lib/basketball/drafting/league.rb +0 -70
  47. data/lib/basketball/drafting/pick_event.rb +0 -25
  48. data/lib/basketball/drafting/player.rb +0 -43
  49. data/lib/basketball/drafting/roster.rb +0 -37
  50. data/lib/basketball/drafting/sim_event.rb +0 -23
  51. data/lib/basketball/drafting/skip_event.rb +0 -13
  52. data/lib/basketball/drafting.rb +0 -9
  53. data/lib/basketball/scheduling/calendar.rb +0 -121
  54. data/lib/basketball/scheduling/calendar_serializer.rb +0 -94
  55. data/lib/basketball/scheduling/cli.rb +0 -198
  56. data/lib/basketball/scheduling/conference.rb +0 -57
  57. data/lib/basketball/scheduling/coordinator.rb +0 -180
  58. data/lib/basketball/scheduling/division.rb +0 -43
  59. data/lib/basketball/scheduling/game.rb +0 -32
  60. data/lib/basketball/scheduling/league.rb +0 -114
  61. data/lib/basketball/scheduling/league_serializer.rb +0 -99
  62. data/lib/basketball/scheduling/season_game.rb +0 -8
  63. data/lib/basketball/scheduling/team.rb +0 -21
  64. data/lib/basketball/scheduling.rb +0 -17
@@ -1,94 +0,0 @@
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
@@ -1,198 +0,0 @@
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
@@ -1,57 +0,0 @@
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
@@ -1,180 +0,0 @@
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
@@ -1,43 +0,0 @@
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
@@ -1,32 +0,0 @@
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