basketball 0.0.9 → 0.0.11

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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +31 -21
  3. data/basketball.gemspec +14 -8
  4. data/exe/basketball +91 -0
  5. data/lib/basketball/app/coordinator_cli.rb +56 -72
  6. data/lib/basketball/app/coordinator_repository.rb +12 -88
  7. data/lib/basketball/app/document_repository.rb +67 -0
  8. data/lib/basketball/app/file_store.rb +16 -0
  9. data/lib/basketball/app/in_memory_store.rb +42 -0
  10. data/lib/basketball/app/league_repository.rb +20 -0
  11. data/lib/basketball/app/league_serializable.rb +99 -0
  12. data/lib/basketball/app/room_cli.rb +30 -26
  13. data/lib/basketball/app/room_repository.rb +1 -41
  14. data/lib/basketball/app.rb +10 -2
  15. data/lib/basketball/draft/pick.rb +0 -7
  16. data/lib/basketball/draft/room.rb +11 -13
  17. data/lib/basketball/entity.rb +9 -6
  18. data/lib/basketball/org/conference.rb +47 -0
  19. data/lib/basketball/org/division.rb +43 -0
  20. data/lib/basketball/org/has_divisions.rb +25 -0
  21. data/lib/basketball/org/has_players.rb +20 -0
  22. data/lib/basketball/org/has_teams.rb +24 -0
  23. data/lib/basketball/org/league.rb +59 -32
  24. data/lib/basketball/org.rb +12 -1
  25. data/lib/basketball/season/arena.rb +26 -25
  26. data/lib/basketball/season/calendar.rb +52 -22
  27. data/lib/basketball/season/coordinator.rb +25 -18
  28. data/lib/basketball/season/detail.rb +47 -0
  29. data/lib/basketball/season/exhibition.rb +1 -1
  30. data/lib/basketball/season/opponent.rb +6 -0
  31. data/lib/basketball/season/record.rb +92 -0
  32. data/lib/basketball/season/scheduler.rb +223 -0
  33. data/lib/basketball/season/standings.rb +56 -0
  34. data/lib/basketball/season.rb +6 -0
  35. data/lib/basketball/value_object.rb +6 -28
  36. data/lib/basketball/value_object_dsl.rb +30 -0
  37. data/lib/basketball/version.rb +1 -1
  38. metadata +22 -6
  39. /data/exe/{basketball-room → basketball-draft-room} +0 -0
  40. /data/exe/{basketball-coordinator → basketball-season-coordinator} +0 -0
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basketball
4
+ module Org
5
+ # Helper methods for objects that can be composed of teams which are also made up of players.
6
+ module HasTeams
7
+ include HasPlayers
8
+
9
+ def team?(team)
10
+ teams.include?(team)
11
+ end
12
+
13
+ private
14
+
15
+ def assert_teams_are_not_already_registered(teams)
16
+ teams.each do |team|
17
+ raise TeamAlreadyRegisteredError, "#{team} already registered" if team?(team)
18
+
19
+ assert_players_are_not_already_signed(team.players)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -2,72 +2,99 @@
2
2
 
3
3
  module Basketball
4
4
  module Org
5
- # Describes a collection of teams and players. Holds the rules which support
6
- # adding teams and players to ensure the all the teams are cohesive, such as:
5
+ # Describes a collection of conferences, divisions, teams, and players.
6
+ # Holds the rules which support adding teams and players to ensure the all the
7
+ # teams are cohesive, such as:
8
+ # - preventing duplicate conferences
9
+ # - preventing duplicate divisions
7
10
  # - preventing duplicate teams
8
11
  # - preventing double-signing players across teams
9
- class League
10
- class TeamAlreadyRegisteredError < StandardError; end
11
- class UnregisteredTeamError < StandardError; end
12
+ class League < Entity
13
+ include HasDivisions
12
14
 
13
- attr_reader :teams
15
+ class ConferenceAlreadyRegisteredError < StandardError; end
14
16
 
15
- def initialize(teams: [])
16
- @teams = []
17
+ alias signed? player?
17
18
 
18
- teams.each { |team| register!(team) }
19
+ attr_reader :conferences
19
20
 
20
- freeze
21
+ def initialize(conferences: [])
22
+ super()
23
+
24
+ @conferences = []
25
+
26
+ conferences.each { |c| register!(c) }
21
27
  end
22
28
 
23
29
  def to_s
24
- teams.map(&:to_s).join("\n")
30
+ conferences.map(&:to_s).join("\n")
25
31
  end
26
32
 
27
33
  def sign!(player:, team:)
28
34
  raise ArgumentError, 'player is required' unless player
29
35
  raise ArgumentError, 'team is required' unless team
30
- raise UnregisteredTeamError, "#{team} is not registered" unless registered?(team)
31
- raise PlayerAlreadySignedError, "#{player} is already signed" if signed?(player)
36
+ raise UnregisteredTeamError, "#{team} not registered" unless team?(team)
37
+ raise PlayerAlreadySignedError, "#{player} already registered" if player?(player)
38
+
39
+ # It is OK to pass in a detached team as long as its equivalent resides in this
40
+ # League's object graph.
41
+ team_for(team.id).sign!(player)
42
+
43
+ self
44
+ end
45
+
46
+ def register!(conference)
47
+ raise ArgumentError, 'conference is required' unless conference
48
+ raise ConferenceAlreadyRegisteredError, "#{conference} already registered" if conference?(conference)
49
+
50
+ assert_divisions_are_not_already_registered(conference.divisions)
32
51
 
33
- team.sign!(player)
52
+ conferences << conference
34
53
 
35
54
  self
36
55
  end
37
56
 
38
- def signed?(player)
39
- players.include?(player)
57
+ def conference?(conference)
58
+ conferences.include?(conference)
59
+ end
60
+
61
+ def divisions
62
+ conferences.flat_map(&:divisions)
63
+ end
64
+
65
+ def teams
66
+ conferences.flat_map(&:teams)
40
67
  end
41
68
 
42
69
  def players
43
- teams.flat_map(&:players)
70
+ conferences.flat_map(&:players)
44
71
  end
45
72
 
46
- def not_registered?(team)
47
- !registered?(team)
73
+ def conference_for(team)
74
+ conferences.find { |c| c.divisions.find { |d| d.teams.include?(team) } }
48
75
  end
49
76
 
50
- def registered?(team)
51
- teams.include?(team)
77
+ def division_for(team)
78
+ conference_for(team)&.divisions&.find { |d| d.teams.include?(team) }
52
79
  end
53
80
 
54
- def register!(team)
55
- raise ArgumentError, 'team is required' unless team
56
- raise TeamAlreadyRegisteredError, "#{team} already registered" if registered?(team)
81
+ # Same conference, different division
82
+ def cross_division_opponents_for(team)
83
+ conference = conference_for(team)
84
+ division = division_for(team)
57
85
 
58
- team.players.each do |player|
59
- raise PlayerAlreadySignedError, "#{player} already signed" if signed?(player)
60
- end
86
+ return nil unless conference && division
61
87
 
62
- teams << team
88
+ other_divisions = conference.divisions - [division]
63
89
 
64
- self
90
+ other_divisions.flat_map(&:teams)
65
91
  end
66
92
 
67
- def ==(other)
68
- teams == other.teams
93
+ private
94
+
95
+ def team_for(id)
96
+ teams.find { |team| team.id == id }
69
97
  end
70
- alias eql? ==
71
98
  end
72
99
  end
73
100
  end
@@ -1,12 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Cross-cutting Concerns
4
+ require_relative 'org/has_players'
5
+ require_relative 'org/has_teams'
6
+ require_relative 'org/has_divisions'
7
+
8
+ # Domain Models
9
+ require_relative 'org/conference'
10
+ require_relative 'org/division'
11
+ require_relative 'org/league'
3
12
  require_relative 'org/player'
4
13
  require_relative 'org/position'
5
14
  require_relative 'org/team'
6
- require_relative 'org/league'
7
15
 
8
16
  module Basketball
9
17
  module Org
18
+ class DivisionAlreadyRegisteredError < StandardError; end
10
19
  class PlayerAlreadySignedError < StandardError; end
20
+ class TeamAlreadyRegisteredError < StandardError; end
21
+ class UnregisteredTeamError < StandardError; end
11
22
  end
12
23
  end
@@ -4,32 +4,29 @@ module Basketball
4
4
  module Season
5
5
  # A very, very, very basic starting point for a "semi-randomized" game simulator.
6
6
  class Arena
7
- RANDOM = :random
8
- TOP_ONE = :top_one
9
- TOP_TWO = :top_two
10
- TOP_THREE = :top_three
11
- TOP_SIX = :top_six
12
- MAX_HOME_ADVANTAGE = 5
13
-
14
- STRATEGY_FREQUENCIES = {
15
- RANDOM => 10,
16
- TOP_ONE => 5,
17
- TOP_TWO => 10,
18
- TOP_THREE => 20,
19
- TOP_SIX => 30
7
+ RANDOM = :random
8
+ TOP_ONE = :top_one
9
+ TOP_TWO = :top_two
10
+ TOP_THREE = :top_three
11
+ TOP_SIX = :top_six
12
+ DEFAULT_MAX_HOME_ADVANTAGE = 5
13
+
14
+ DEFAULT_STRATEGY_FREQUENCIES = {
15
+ RANDOM => 3,
16
+ TOP_ONE => 1,
17
+ TOP_TWO => 1,
18
+ TOP_THREE => 1,
19
+ TOP_SIX => 1
20
20
  }.freeze
21
21
 
22
- private_constant :STRATEGY_FREQUENCIES,
23
- :RANDOM,
24
- :TOP_ONE,
25
- :TOP_TWO,
26
- :TOP_SIX,
27
- :MAX_HOME_ADVANTAGE
22
+ attr_reader :lotto, :max_home_advantage
28
23
 
29
- def initialize
30
- @lotto = STRATEGY_FREQUENCIES.inject([]) do |memo, (name, frequency)|
31
- memo + ([name] * frequency)
32
- end.shuffle
24
+ def initialize(
25
+ strategy_frquencies: DEFAULT_STRATEGY_FREQUENCIES,
26
+ max_home_advantage: DEFAULT_MAX_HOME_ADVANTAGE
27
+ )
28
+ @max_home_advantage = max_home_advantage
29
+ @lotto = make_lotto(strategy_frquencies)
33
30
 
34
31
  freeze
35
32
  end
@@ -57,7 +54,11 @@ module Basketball
57
54
 
58
55
  private
59
56
 
60
- attr_reader :lotto
57
+ def make_lotto(strategy_frquencies)
58
+ strategy_frquencies.inject([]) do |memo, (name, frequency)|
59
+ memo + ([name] * frequency)
60
+ end.shuffle
61
+ end
61
62
 
62
63
  def pick_strategy
63
64
  lotto.sample
@@ -89,7 +90,7 @@ module Basketball
89
90
  end
90
91
 
91
92
  def random_home_advantage
92
- rand(0..MAX_HOME_ADVANTAGE)
93
+ rand(0..max_home_advantage)
93
94
  end
94
95
 
95
96
  def top_one_strategy(matchup)
@@ -2,35 +2,35 @@
2
2
 
3
3
  module Basketball
4
4
  module Season
5
- # Sets boundaries for preseason and regular season play. Add games as long as they are
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 :preseason_start_date,
12
- :preseason_end_date,
13
- :season_start_date,
14
- :season_end_date,
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
- preseason_start_date:,
19
- preseason_end_date:,
20
- season_start_date:,
21
- season_end_date:,
18
+ exhibition_start_date:,
19
+ exhibition_end_date:,
20
+ regular_start_date:,
21
+ regular_end_date:,
22
22
  games: []
23
23
  )
24
- raise ArgumentError, 'preseason_start_date is required' if preseason_start_date.to_s.empty?
25
- raise ArgumentError, 'preseason_end_date is required' if preseason_end_date.to_s.empty?
26
- raise ArgumentError, 'season_start_date is required' if season_start_date.to_s.empty?
27
- raise ArgumentError, 'season_end_date is required' if season_end_date.to_s.empty?
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
- @preseason_start_date = preseason_start_date
30
- @preseason_end_date = preseason_end_date
31
- @season_start_date = season_start_date
32
- @season_end_date = season_end_date
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 preseason begins" if date < preseason_start_date
80
- raise OutOfBoundsError, "#{date} is after preseason ends" if date > preseason_end_date
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 < season_start_date
83
- raise OutOfBoundsError, "#{date} is after season ends" if date > season_end_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
@@ -3,7 +3,7 @@
3
3
  module Basketball
4
4
  module Season
5
5
  # Main iterator-based object that knows how to manage a calendar and simulate games per day.
6
- class Coordinator
6
+ class Coordinator < Entity
7
7
  extend Forwardable
8
8
 
9
9
  class AlreadyPlayedGameError < StandardError; end
@@ -18,14 +18,13 @@ module Basketball
18
18
  :current_date,
19
19
  :arena,
20
20
  :results,
21
- :league,
22
- :id
21
+ :league
23
22
 
24
23
  def_delegators :calendar,
25
- :preseason_start_date,
26
- :preseason_end_date,
27
- :season_start_date,
28
- :season_end_date,
24
+ :exhibition_start_date,
25
+ :exhibition_end_date,
26
+ :regular_start_date,
27
+ :regular_end_date,
29
28
  :games,
30
29
  :exhibitions_for,
31
30
  :regulars_for,
@@ -70,13 +69,13 @@ module Basketball
70
69
  end
71
70
 
72
71
  def assert_current_date
73
- if current_date < preseason_start_date
74
- raise OutOfBoundsError, "current date #{current_date} should be on or after #{preseason_start_date}"
72
+ if current_date < exhibition_start_date
73
+ raise OutOfBoundsError, "current date #{current_date} should be on or after #{exhibition_start_date}"
75
74
  end
76
75
 
77
- return unless current_date > season_end_date
76
+ return unless current_date > regular_end_date
78
77
 
79
- raise OutOfBoundsError, "current date #{current_date} should be on or after #{preseason_start_date}"
78
+ raise OutOfBoundsError, "current date #{current_date} should be on or after #{exhibition_start_date}"
80
79
  end
81
80
 
82
81
  def sim!
@@ -104,11 +103,11 @@ module Basketball
104
103
  end
105
104
 
106
105
  def total_days
107
- (season_end_date - preseason_start_date).to_i
106
+ (regular_end_date - exhibition_start_date).to_i
108
107
  end
109
108
 
110
109
  def days_left
111
- (season_end_date - current_date).to_i
110
+ (regular_end_date - current_date).to_i
112
111
  end
113
112
 
114
113
  def total_exhibitions
@@ -132,7 +131,7 @@ module Basketball
132
131
  end
133
132
 
134
133
  def done?
135
- current_date == season_end_date && games.length == results.length
134
+ current_date == regular_end_date && games.length == results.length
136
135
  end
137
136
 
138
137
  def not_done?
@@ -154,9 +153,17 @@ module Basketball
154
153
  end
155
154
  end
156
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
+
157
164
  private
158
165
 
159
- attr_writer :id, :arena
166
+ attr_writer :arena
160
167
 
161
168
  def opponent_team(opponent)
162
169
  league.teams.find { |t| t == opponent }
@@ -171,7 +178,7 @@ module Basketball
171
178
  end
172
179
 
173
180
  def increment_current_date!
174
- return self if current_date >= season_end_date
181
+ return self if current_date >= regular_end_date
175
182
 
176
183
  @current_date = current_date + 1
177
184
 
@@ -185,9 +192,9 @@ module Basketball
185
192
  end
186
193
 
187
194
  def assert_known_teams(game)
188
- raise UnknownTeamError, "unknown opponent: #{game.home_opponent}" if league.not_registered?(game.home_opponent)
195
+ raise UnknownTeamError, "unknown opponent: #{game.home_opponent}" unless league.team?(game.home_opponent)
189
196
 
190
- return unless league.not_registered?(game.away_opponent)
197
+ return if league.team?(game.away_opponent)
191
198
 
192
199
  raise UnknownTeamError, "unknown opponent: #{game.away_opponent}"
193
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,7 +5,7 @@ module Basketball
5
5
  # An exhibition game.
6
6
  class Exhibition < Game
7
7
  def to_s
8
- "#{super} (preseason)"
8
+ "#{super} (exhibition)"
9
9
  end
10
10
  end
11
11
  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