basketball 0.0.10 → 0.0.12
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +28 -21
- data/basketball.gemspec +14 -8
- data/exe/basketball +91 -0
- data/lib/basketball/app/coordinator_cli.rb +42 -65
- data/lib/basketball/app/coordinator_repository.rb +9 -8
- data/lib/basketball/app/league_serializable.rb +69 -24
- data/lib/basketball/app/room_cli.rb +19 -32
- data/lib/basketball/app/room_repository.rb +6 -2
- data/lib/basketball/draft/pick.rb +0 -7
- data/lib/basketball/draft/room.rb +38 -35
- data/lib/basketball/org/conference.rb +47 -0
- data/lib/basketball/org/division.rb +43 -0
- data/lib/basketball/org/has_divisions.rb +25 -0
- data/lib/basketball/org/has_players.rb +20 -0
- data/lib/basketball/org/has_teams.rb +24 -0
- data/lib/basketball/org/league.rb +59 -27
- data/lib/basketball/org.rb +12 -1
- data/lib/basketball/season/arena.rb +5 -5
- data/lib/basketball/season/calendar.rb +52 -22
- data/lib/basketball/season/coordinator.rb +22 -14
- data/lib/basketball/season/detail.rb +47 -0
- data/lib/basketball/season/exhibition.rb +1 -1
- data/lib/basketball/season/opponent.rb +6 -0
- data/lib/basketball/season/record.rb +92 -0
- data/lib/basketball/season/scheduler.rb +223 -0
- data/lib/basketball/season/standings.rb +56 -0
- data/lib/basketball/season.rb +6 -0
- data/lib/basketball/version.rb +1 -1
- metadata +17 -6
- /data/exe/{basketball-room → basketball-draft-room} +0 -0
- /data/exe/{basketball-coordinator → basketball-season-coordinator} +0 -0
@@ -2,35 +2,35 @@
|
|
2
2
|
|
3
3
|
module Basketball
|
4
4
|
module Season
|
5
|
-
# Sets boundaries for
|
5
|
+
# Sets boundaries for exhibition and regular season play. Add games as long as they are
|
6
6
|
# within the correct dated boundaries
|
7
7
|
class Calendar
|
8
8
|
class OutOfBoundsError < StandardError; end
|
9
9
|
class TeamAlreadyBookedError < StandardError; end
|
10
10
|
|
11
|
-
attr_reader :
|
12
|
-
:
|
13
|
-
:
|
14
|
-
:
|
11
|
+
attr_reader :exhibition_start_date,
|
12
|
+
:exhibition_end_date,
|
13
|
+
:regular_start_date,
|
14
|
+
:regular_end_date,
|
15
15
|
:games
|
16
16
|
|
17
17
|
def initialize(
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
18
|
+
exhibition_start_date:,
|
19
|
+
exhibition_end_date:,
|
20
|
+
regular_start_date:,
|
21
|
+
regular_end_date:,
|
22
22
|
games: []
|
23
23
|
)
|
24
|
-
raise ArgumentError, '
|
25
|
-
raise ArgumentError, '
|
26
|
-
raise ArgumentError, '
|
27
|
-
raise ArgumentError, '
|
24
|
+
raise ArgumentError, 'exhibition_start_date is required' if exhibition_start_date.to_s.empty?
|
25
|
+
raise ArgumentError, 'exhibition_end_date is required' if exhibition_end_date.to_s.empty?
|
26
|
+
raise ArgumentError, 'regular_start_date is required' if regular_start_date.to_s.empty?
|
27
|
+
raise ArgumentError, 'regular_end_date is required' if regular_end_date.to_s.empty?
|
28
28
|
|
29
|
-
@
|
30
|
-
@
|
31
|
-
@
|
32
|
-
@
|
33
|
-
@games
|
29
|
+
@exhibition_start_date = exhibition_start_date
|
30
|
+
@exhibition_end_date = exhibition_end_date
|
31
|
+
@regular_start_date = regular_start_date
|
32
|
+
@regular_end_date = regular_end_date
|
33
|
+
@games = []
|
34
34
|
|
35
35
|
games.each { |game| add!(game) }
|
36
36
|
|
@@ -60,8 +60,38 @@ module Basketball
|
|
60
60
|
end
|
61
61
|
end
|
62
62
|
|
63
|
+
def available_exhibition_dates_for(opponent)
|
64
|
+
all_exhibition_dates - exhibitions_for(opponent:).map(&:date)
|
65
|
+
end
|
66
|
+
|
67
|
+
def available_regular_dates_for(opponent)
|
68
|
+
all_season_dates - regulars_for(opponent:).map(&:date)
|
69
|
+
end
|
70
|
+
|
71
|
+
def available_exhibition_matchup_dates(opponent1, opponent2)
|
72
|
+
available_opponent_dates = available_exhibition_dates_for(opponent1)
|
73
|
+
available_other_opponent_dates = available_exhibition_dates_for(opponent2)
|
74
|
+
|
75
|
+
available_opponent_dates & available_other_opponent_dates
|
76
|
+
end
|
77
|
+
|
78
|
+
def available_regular_matchup_dates(opponent1, opponent2)
|
79
|
+
available_opponent_dates = available_regular_dates_for(opponent1)
|
80
|
+
available_other_opponent_dates = available_regular_dates_for(opponent2)
|
81
|
+
|
82
|
+
available_opponent_dates & available_other_opponent_dates
|
83
|
+
end
|
84
|
+
|
63
85
|
private
|
64
86
|
|
87
|
+
def all_exhibition_dates
|
88
|
+
(exhibition_start_date..exhibition_end_date).to_a
|
89
|
+
end
|
90
|
+
|
91
|
+
def all_season_dates
|
92
|
+
(regular_start_date..regular_end_date).to_a
|
93
|
+
end
|
94
|
+
|
65
95
|
def assert_free_date(game)
|
66
96
|
if games_for(date: game.date, opponent: game.home_opponent).any?
|
67
97
|
raise TeamAlreadyBookedError, "#{game.home_opponent} already playing on #{game.date}"
|
@@ -76,11 +106,11 @@ module Basketball
|
|
76
106
|
date = game.date
|
77
107
|
|
78
108
|
if game.is_a?(Exhibition)
|
79
|
-
raise OutOfBoundsError, "#{date} is before
|
80
|
-
raise OutOfBoundsError, "#{date} is after
|
109
|
+
raise OutOfBoundsError, "#{date} is before exhibition begins" if date < exhibition_start_date
|
110
|
+
raise OutOfBoundsError, "#{date} is after exhibition ends" if date > exhibition_end_date
|
81
111
|
elsif game.is_a?(Regular)
|
82
|
-
raise OutOfBoundsError, "#{date} is before season begins" if date <
|
83
|
-
raise OutOfBoundsError, "#{date} is after season ends" if date >
|
112
|
+
raise OutOfBoundsError, "#{date} is before season begins" if date < regular_start_date
|
113
|
+
raise OutOfBoundsError, "#{date} is after season ends" if date > regular_end_date
|
84
114
|
else
|
85
115
|
raise ArgumentError, "Dont know what this game type is: #{game.class.name}"
|
86
116
|
end
|
@@ -21,10 +21,10 @@ module Basketball
|
|
21
21
|
:league
|
22
22
|
|
23
23
|
def_delegators :calendar,
|
24
|
-
:
|
25
|
-
:
|
26
|
-
:
|
27
|
-
:
|
24
|
+
:exhibition_start_date,
|
25
|
+
:exhibition_end_date,
|
26
|
+
:regular_start_date,
|
27
|
+
:regular_end_date,
|
28
28
|
:games,
|
29
29
|
:exhibitions_for,
|
30
30
|
:regulars_for,
|
@@ -69,13 +69,13 @@ module Basketball
|
|
69
69
|
end
|
70
70
|
|
71
71
|
def assert_current_date
|
72
|
-
if current_date <
|
73
|
-
raise OutOfBoundsError, "current date #{current_date} should be on or after #{
|
72
|
+
if current_date < exhibition_start_date
|
73
|
+
raise OutOfBoundsError, "current date #{current_date} should be on or after #{exhibition_start_date}"
|
74
74
|
end
|
75
75
|
|
76
|
-
return unless current_date >
|
76
|
+
return unless current_date > regular_end_date
|
77
77
|
|
78
|
-
raise OutOfBoundsError, "current date #{current_date} should be on or after #{
|
78
|
+
raise OutOfBoundsError, "current date #{current_date} should be on or after #{exhibition_start_date}"
|
79
79
|
end
|
80
80
|
|
81
81
|
def sim!
|
@@ -103,11 +103,11 @@ module Basketball
|
|
103
103
|
end
|
104
104
|
|
105
105
|
def total_days
|
106
|
-
(
|
106
|
+
(regular_end_date - exhibition_start_date).to_i
|
107
107
|
end
|
108
108
|
|
109
109
|
def days_left
|
110
|
-
(
|
110
|
+
(regular_end_date - current_date).to_i
|
111
111
|
end
|
112
112
|
|
113
113
|
def total_exhibitions
|
@@ -131,7 +131,7 @@ module Basketball
|
|
131
131
|
end
|
132
132
|
|
133
133
|
def done?
|
134
|
-
current_date ==
|
134
|
+
current_date == regular_end_date && games.length == results.length
|
135
135
|
end
|
136
136
|
|
137
137
|
def not_done?
|
@@ -153,6 +153,14 @@ module Basketball
|
|
153
153
|
end
|
154
154
|
end
|
155
155
|
|
156
|
+
def regular_results
|
157
|
+
results.select { |result| result.game.is_a?(Regular) }
|
158
|
+
end
|
159
|
+
|
160
|
+
def exhibition_results
|
161
|
+
results.select { |result| result.game.is_a?(Exhibition) }
|
162
|
+
end
|
163
|
+
|
156
164
|
private
|
157
165
|
|
158
166
|
attr_writer :arena
|
@@ -170,7 +178,7 @@ module Basketball
|
|
170
178
|
end
|
171
179
|
|
172
180
|
def increment_current_date!
|
173
|
-
return self if current_date >=
|
181
|
+
return self if current_date >= regular_end_date
|
174
182
|
|
175
183
|
@current_date = current_date + 1
|
176
184
|
|
@@ -184,9 +192,9 @@ module Basketball
|
|
184
192
|
end
|
185
193
|
|
186
194
|
def assert_known_teams(game)
|
187
|
-
raise UnknownTeamError, "unknown opponent: #{game.home_opponent}"
|
195
|
+
raise UnknownTeamError, "unknown opponent: #{game.home_opponent}" unless league.team?(game.home_opponent)
|
188
196
|
|
189
|
-
return
|
197
|
+
return if league.team?(game.away_opponent)
|
190
198
|
|
191
199
|
raise UnknownTeamError, "unknown opponent: #{game.away_opponent}"
|
192
200
|
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Basketball
|
4
|
+
module Season
|
5
|
+
# Describes a result from the perspective of a team.
|
6
|
+
class Detail < ValueObject
|
7
|
+
value_reader :date,
|
8
|
+
:home,
|
9
|
+
:opponent_score,
|
10
|
+
:opponent,
|
11
|
+
:score
|
12
|
+
|
13
|
+
alias home? home
|
14
|
+
|
15
|
+
def initialize(date:, home:, opponent:, opponent_score:, score:)
|
16
|
+
super()
|
17
|
+
|
18
|
+
raise ArgumentError, 'date is required' unless date
|
19
|
+
raise ArgumentError, 'opponent is required' unless opponent
|
20
|
+
raise ArgumentError, 'score is required' unless score
|
21
|
+
raise ArgumentError, 'opponent_score is required' unless opponent_score
|
22
|
+
raise ArgumentError, 'home is required' if home.nil?
|
23
|
+
raise CannotTieError, 'scores cannot be equal' if score == opponent_score
|
24
|
+
|
25
|
+
@date = date
|
26
|
+
@opponent = opponent
|
27
|
+
@score = score
|
28
|
+
@opponent_score = opponent_score
|
29
|
+
@home = home
|
30
|
+
|
31
|
+
freeze
|
32
|
+
end
|
33
|
+
|
34
|
+
def win?
|
35
|
+
score > opponent_score
|
36
|
+
end
|
37
|
+
|
38
|
+
def loss?
|
39
|
+
score < opponent_score
|
40
|
+
end
|
41
|
+
|
42
|
+
def to_s
|
43
|
+
"[#{date}] #{win? ? 'Win' : 'Loss'} #{home? ? 'vs' : 'at'} #{opponent} (#{score}-#{opponent_score})"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -5,6 +5,12 @@ module Basketball
|
|
5
5
|
# Represents a team without a roster. Equal to a team by identity.
|
6
6
|
# A team's roster will not be known until the last minute (when it is game time).
|
7
7
|
class Opponent < Entity
|
8
|
+
class << self
|
9
|
+
def from(team)
|
10
|
+
new(id: team.id)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
8
14
|
def initialize(id:)
|
9
15
|
super(id)
|
10
16
|
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Basketball
|
4
|
+
module Season
|
5
|
+
# Represents a team within a Standings object. Each Record is comprised of Detail instances
|
6
|
+
# which are the game results in the perspective of a single Team.
|
7
|
+
class Record < Entity
|
8
|
+
class DetailAlreadyAddedError < StandardError; end
|
9
|
+
class OpponentNotFoundError < StandardError; end
|
10
|
+
|
11
|
+
def initialize(id:)
|
12
|
+
super(id)
|
13
|
+
|
14
|
+
@details_by_date = {}
|
15
|
+
|
16
|
+
details.each { |detail| add!(detail) }
|
17
|
+
|
18
|
+
freeze
|
19
|
+
end
|
20
|
+
|
21
|
+
def accept!(result)
|
22
|
+
if result.home_opponent == self
|
23
|
+
detail = Detail.new(
|
24
|
+
date: result.date,
|
25
|
+
opponent: result.away_opponent,
|
26
|
+
score: result.home_score,
|
27
|
+
opponent_score: result.away_score,
|
28
|
+
home: true
|
29
|
+
)
|
30
|
+
|
31
|
+
add!(detail)
|
32
|
+
elsif result.away_opponent == self
|
33
|
+
detail = Detail.new(
|
34
|
+
date: result.date,
|
35
|
+
opponent: result.home_opponent,
|
36
|
+
score: result.away_score,
|
37
|
+
opponent_score: result.home_score,
|
38
|
+
home: false
|
39
|
+
)
|
40
|
+
|
41
|
+
add!(detail)
|
42
|
+
else
|
43
|
+
raise OpponentNotFoundError, "#{result} has no opponent for #{self}"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def detail_for(date)
|
48
|
+
details_by_date[date]
|
49
|
+
end
|
50
|
+
|
51
|
+
def details
|
52
|
+
details_by_date.values
|
53
|
+
end
|
54
|
+
|
55
|
+
def win_percentage
|
56
|
+
(win_count.to_f / game_count).round(3)
|
57
|
+
end
|
58
|
+
|
59
|
+
def game_count
|
60
|
+
details.length
|
61
|
+
end
|
62
|
+
|
63
|
+
def win_count
|
64
|
+
details.count(&:win?)
|
65
|
+
end
|
66
|
+
|
67
|
+
def loss_count
|
68
|
+
details.count(&:loss?)
|
69
|
+
end
|
70
|
+
|
71
|
+
def to_s
|
72
|
+
"[#{super}] #{win_count}-#{loss_count} (#{win_percentage})"
|
73
|
+
end
|
74
|
+
|
75
|
+
def <=>(other)
|
76
|
+
[win_count, win_percentage] <=> [other.win_count, other.win_percentage]
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
attr_reader :details_by_date
|
82
|
+
|
83
|
+
def add!(detail)
|
84
|
+
raise DetailAlreadyAddedError, "#{detail} already added for date" if detail_for(detail.date)
|
85
|
+
|
86
|
+
details_by_date[detail.date] = detail
|
87
|
+
|
88
|
+
self
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,223 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Basketball
|
4
|
+
module Season
|
5
|
+
# This is the service class responsible for actually picking out free dates and pairing up teams to
|
6
|
+
# play each other. This is a reasonable naive first pass at some underlying match-making algorithms
|
7
|
+
# but could definitely use some help with the complexity/runtime/etc.
|
8
|
+
class Scheduler
|
9
|
+
class BadConferencesSizeError < StandardError; end
|
10
|
+
class BadDivisionsSizeError < StandardError; end
|
11
|
+
class BadTeamsSizeError < StandardError; end
|
12
|
+
|
13
|
+
MIN_PRESEASON_GAMES_PER_TEAM = 3
|
14
|
+
MAX_PRESEASON_GAMES_PER_TEAM = 6
|
15
|
+
CONFERENCES_SIZE = 2
|
16
|
+
DIVISIONS_SIZE = 3
|
17
|
+
TEAMS_SIZE = 5
|
18
|
+
|
19
|
+
private_constant :MIN_PRESEASON_GAMES_PER_TEAM,
|
20
|
+
:MAX_PRESEASON_GAMES_PER_TEAM,
|
21
|
+
:CONFERENCES_SIZE,
|
22
|
+
:DIVISIONS_SIZE,
|
23
|
+
:TEAMS_SIZE
|
24
|
+
|
25
|
+
def schedule(league:, year: Time.now.year)
|
26
|
+
assert_properly_sized_league(league)
|
27
|
+
|
28
|
+
Calendar.new(**make_dates(year)).tap do |calendar|
|
29
|
+
schedule_exhibition!(calendar:, league:)
|
30
|
+
schedule_season!(calendar:, league:)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def make_dates(year)
|
37
|
+
{
|
38
|
+
exhibition_start_date: Date.new(year, 9, 30),
|
39
|
+
exhibition_end_date: Date.new(year, 10, 14),
|
40
|
+
regular_start_date: Date.new(year, 10, 18),
|
41
|
+
regular_end_date: Date.new(year + 1, 4, 29)
|
42
|
+
}
|
43
|
+
end
|
44
|
+
|
45
|
+
def assert_properly_sized_league(league)
|
46
|
+
if league.conferences.length != CONFERENCES_SIZE
|
47
|
+
raise BadConferencesSizeError, "there has to be #{CONFERENCES_SIZE} conferences"
|
48
|
+
end
|
49
|
+
|
50
|
+
league.conferences.each do |conference|
|
51
|
+
if conference.divisions.length != DIVISIONS_SIZE
|
52
|
+
raise BadDivisionsSizeError, "#{conference.id} should have exactly #{DIVISIONS_SIZE} divisions"
|
53
|
+
end
|
54
|
+
|
55
|
+
conference.divisions.each do |division|
|
56
|
+
if division.teams.length != TEAMS_SIZE
|
57
|
+
raise BadTeamsSizeError, "#{division.id} should have exactly #{TEAMS_SIZE} teams"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def base_matchup_count(league, team1, team2)
|
64
|
+
# Same Conference, Same Division
|
65
|
+
if league.division_for(team1) == league.division_for(team2)
|
66
|
+
4
|
67
|
+
# Same Conference, Different Division and one of 4/10 that play 3 times
|
68
|
+
elsif league.conference_for(team1) == league.conference_for(team2)
|
69
|
+
3
|
70
|
+
# Different Conference
|
71
|
+
else
|
72
|
+
2
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
77
|
+
# This method derives the plan for which a schedule can be generated from.
|
78
|
+
def matchup_plan(league)
|
79
|
+
matchups = {}
|
80
|
+
game_counts = league.teams.to_h { |t| [t, 0] }
|
81
|
+
teams = game_counts.keys
|
82
|
+
|
83
|
+
(0...teams.length).each do |i|
|
84
|
+
team1 = teams[i]
|
85
|
+
|
86
|
+
(i + 1...teams.length).each do |j|
|
87
|
+
team2 = teams[j]
|
88
|
+
key = [team1, team2].sort
|
89
|
+
count = base_matchup_count(league, team1, team2)
|
90
|
+
matchups[key] = count
|
91
|
+
game_counts[team1] += count
|
92
|
+
game_counts[team2] += count
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# Each team will play 6 games against conference opponents in other divisions.
|
97
|
+
# The fours hash will be that plan.
|
98
|
+
find_fours(league).each do |team, opponents|
|
99
|
+
next if game_counts[team] == 82
|
100
|
+
|
101
|
+
opponents.each do |opponent|
|
102
|
+
next if game_counts[team] == 82
|
103
|
+
next if game_counts[opponent] == 82
|
104
|
+
|
105
|
+
game_counts[team] += 1
|
106
|
+
game_counts[opponent] += 1
|
107
|
+
|
108
|
+
key = [team, opponent].sort
|
109
|
+
|
110
|
+
matchups[key] += 1
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
matchups
|
115
|
+
end
|
116
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
117
|
+
|
118
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
119
|
+
# I am not liking this algorithm implementation at all but it will seemingly produce a valid
|
120
|
+
# result about 1 out of every 1000 cycles. I have yet to spot the assignment pattern to make
|
121
|
+
# this way more deterministic.
|
122
|
+
def find_fours(league)
|
123
|
+
balanced = false
|
124
|
+
count = 0
|
125
|
+
four_tracker = {}
|
126
|
+
|
127
|
+
until balanced
|
128
|
+
# Let's not completely thrash our CPUs in case this algorithm hits an infinite loop.
|
129
|
+
# Instead, lets hard-fail against a hard boundary.
|
130
|
+
raise ArgumentError, 'we spent too much CPU time and didnt resolve fours' if count > 100_000
|
131
|
+
|
132
|
+
four_tracker = league.teams.to_h { |team| [team, []] }
|
133
|
+
|
134
|
+
league.teams.each do |team|
|
135
|
+
opponents = league.cross_division_opponents_for(team).shuffle
|
136
|
+
|
137
|
+
opponents.each do |opponent|
|
138
|
+
if four_tracker[team].length < 6 && four_tracker[opponent].length < 6
|
139
|
+
four_tracker[opponent] << team
|
140
|
+
four_tracker[team] << opponent
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
good = true
|
146
|
+
|
147
|
+
# trip-wire: if one team isnt balanced then we are not balanced
|
148
|
+
four_tracker.each { |_k, v| good = false if v.length < 6 }
|
149
|
+
|
150
|
+
balanced = good
|
151
|
+
|
152
|
+
count += 1
|
153
|
+
end
|
154
|
+
|
155
|
+
four_tracker
|
156
|
+
end
|
157
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
158
|
+
|
159
|
+
def schedule_season!(calendar:, league:)
|
160
|
+
matchups = matchup_plan(league)
|
161
|
+
|
162
|
+
matchups.each do |(team1, team2), count|
|
163
|
+
candidates = calendar.available_regular_matchup_dates(team1, team2)
|
164
|
+
dates = candidates.sample(count)
|
165
|
+
games = balanced_games(dates, team1, team2)
|
166
|
+
|
167
|
+
games.each { |game| calendar.add!(game) }
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def balanced_games(dates, team1, team2)
|
172
|
+
dates.map.with_index(1) do |date, index|
|
173
|
+
home_opponent, away_opponent =
|
174
|
+
if index.even?
|
175
|
+
[Opponent.from(team1), Opponent.from(team2)]
|
176
|
+
else
|
177
|
+
[Opponent.from(team2), Opponent.from(team1)]
|
178
|
+
end
|
179
|
+
|
180
|
+
Regular.new(date:, home_opponent:, away_opponent:)
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def schedule_exhibition!(calendar:, league:)
|
185
|
+
league.teams.each do |team|
|
186
|
+
current_games = calendar.exhibitions_for(opponent: team)
|
187
|
+
count = current_games.length
|
188
|
+
|
189
|
+
next if count >= MIN_PRESEASON_GAMES_PER_TEAM
|
190
|
+
|
191
|
+
other_teams = (league.teams - [team]).shuffle
|
192
|
+
|
193
|
+
other_teams.each do |other_team|
|
194
|
+
break if count > MIN_PRESEASON_GAMES_PER_TEAM
|
195
|
+
next if calendar.exhibitions_for(opponent: other_team).length >= MAX_PRESEASON_GAMES_PER_TEAM
|
196
|
+
|
197
|
+
candidates = calendar.available_exhibition_matchup_dates(team, other_team)
|
198
|
+
|
199
|
+
next if candidates.empty?
|
200
|
+
|
201
|
+
date = candidates.sample
|
202
|
+
game = random_exhibition_game(date, team, other_team)
|
203
|
+
|
204
|
+
calendar.add!(game)
|
205
|
+
|
206
|
+
count += 1
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
def random_exhibition_game(date, team1, team2)
|
212
|
+
home_opponent, away_opponent =
|
213
|
+
if rand(1..2) == 1
|
214
|
+
[Opponent.from(team1), Opponent.from(team2)]
|
215
|
+
else
|
216
|
+
[Opponent.from(team2), Opponent.from(team1)]
|
217
|
+
end
|
218
|
+
|
219
|
+
Exhibition.new(date:, home_opponent:, away_opponent:)
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
223
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Basketball
|
4
|
+
module Season
|
5
|
+
# Represents a League with each team's win/loss details.
|
6
|
+
class Standings
|
7
|
+
class TeamAlreadyRegisteredError < StandardError; end
|
8
|
+
class TeamNotRegisteredError < StandardError; end
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@records_by_id = {}
|
12
|
+
end
|
13
|
+
|
14
|
+
def register!(team)
|
15
|
+
raise TeamAlreadyRegisteredError, "#{team} already registered!" if team?(team)
|
16
|
+
|
17
|
+
records_by_id[team.id] = Record.new(id: team.id)
|
18
|
+
|
19
|
+
self
|
20
|
+
end
|
21
|
+
|
22
|
+
def records
|
23
|
+
records_by_id.values
|
24
|
+
end
|
25
|
+
|
26
|
+
def record_for(team)
|
27
|
+
raise TeamNotRegisteredError, "#{team} not registered" unless team?(team)
|
28
|
+
|
29
|
+
records_by_id.fetch(team.id)
|
30
|
+
end
|
31
|
+
|
32
|
+
def accept!(result)
|
33
|
+
[
|
34
|
+
record_for(result.home_opponent),
|
35
|
+
record_for(result.away_opponent)
|
36
|
+
].each do |record|
|
37
|
+
record.accept!(result)
|
38
|
+
end
|
39
|
+
|
40
|
+
self
|
41
|
+
end
|
42
|
+
|
43
|
+
def team?(team)
|
44
|
+
records_by_id.key?(team.id)
|
45
|
+
end
|
46
|
+
|
47
|
+
def to_s
|
48
|
+
records.sort.reverse.map.with_index(1) { |r, i| "##{i} #{r}" }.join("\n")
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
attr_reader :records_by_id
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
data/lib/basketball/season.rb
CHANGED
@@ -12,5 +12,11 @@ require_relative 'season/opponent'
|
|
12
12
|
require_relative 'season/exhibition'
|
13
13
|
require_relative 'season/regular'
|
14
14
|
|
15
|
+
# Standings
|
16
|
+
require_relative 'season/detail'
|
17
|
+
require_relative 'season/record'
|
18
|
+
require_relative 'season/standings'
|
19
|
+
|
15
20
|
# Specific
|
16
21
|
require_relative 'season/coordinator'
|
22
|
+
require_relative 'season/scheduler'
|
data/lib/basketball/version.rb
CHANGED