elo_rankable 0.2.0 → 0.2.1

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.
data/elo_rankable.gemspec CHANGED
@@ -1,36 +1,36 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'lib/elo_rankable/version'
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = 'elo_rankable'
7
- spec.version = EloRankable::VERSION
8
- spec.authors = ['Aberen']
9
- spec.email = ['nijoergensen@gmail.com']
10
-
11
- spec.summary = 'Add ELO rating capabilities to any ActiveRecord model'
12
- spec.description = 'Adds ELO rating to any ActiveRecord model via has_elo_ranking. It stores ratings in a separate EloRanking model to keep your host model clean, and provides domain-style methods for updating rankings after matches.'
13
- spec.homepage = 'https://github.com/Aberen/elo_rankable'
14
- spec.license = 'MIT'
15
- spec.required_ruby_version = '>= 2.7.0'
16
-
17
- spec.metadata['homepage_uri'] = spec.homepage
18
- spec.metadata['source_code_uri'] = spec.homepage
19
- spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
- spec.metadata['rubygems_mfa_required'] = 'true'
21
-
22
- # Specify which files should be added to the gem when it is released.
23
- spec.files = Dir.chdir(__dir__) do
24
- `git ls-files -z`.split("\x0").reject do |f|
25
- (File.expand_path(f) == __FILE__) ||
26
- f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
27
- end
28
- end
29
- spec.bindir = 'exe'
30
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
31
- spec.require_paths = ['lib']
32
-
33
- # Dependencies
34
- spec.add_dependency 'activerecord', '>= 6.0', '< 8.0'
35
- spec.add_dependency 'activesupport', '>= 6.0', '< 8.0'
36
- end
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/elo_rankable/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'elo_rankable'
7
+ spec.version = EloRankable::VERSION
8
+ spec.authors = ['Aberen']
9
+ spec.email = ['nijoergensen@gmail.com']
10
+
11
+ spec.summary = 'Add ELO rating capabilities to any ActiveRecord model'
12
+ spec.description = 'Adds ELO rating to any ActiveRecord model via has_elo_ranking. It stores ratings in a separate EloRanking model to keep your host model clean, and provides domain-style methods for updating rankings after matches.'
13
+ spec.homepage = 'https://github.com/Aberen/elo_rankable'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 2.7.0'
16
+
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = spec.homepage
19
+ spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
+ spec.metadata['rubygems_mfa_required'] = 'true'
21
+
22
+ # Specify which files should be added to the gem when it is released.
23
+ spec.files = Dir.chdir(__dir__) do
24
+ `git ls-files -z`.split("\x0").reject do |f|
25
+ (File.expand_path(f) == __FILE__) ||
26
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
27
+ end
28
+ end
29
+ spec.bindir = 'exe'
30
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ['lib']
32
+
33
+ # Dependencies
34
+ spec.add_dependency 'activerecord', '>= 6.0', '< 9.0'
35
+ spec.add_dependency 'activesupport', '>= 6.0', '< 9.0'
36
+ end
@@ -1,67 +1,67 @@
1
- # frozen_string_literal: true
2
-
3
- module EloRankable
4
- class Calculator
5
- class << self
6
- # Calculate expected score for player A against player B
7
- def expected_score(rating_a, rating_b)
8
- 1.0 / (1.0 + (10.0**((rating_b - rating_a) / 400.0)))
9
- end
10
-
11
- # Update ratings after a match where player1 beats player2
12
- def update_ratings_for_win(winner, loser)
13
- winner_rating = winner.elo_ranking
14
- loser_rating = loser.elo_ranking
15
-
16
- # Defensive validation - these should be caught earlier but just in case
17
- raise ArgumentError, "Winner's elo_ranking is nil" if winner_rating.nil?
18
- raise ArgumentError, "Loser's elo_ranking is nil" if loser_rating.nil?
19
-
20
- winner_expected = expected_score(winner_rating.rating, loser_rating.rating)
21
- loser_expected = expected_score(loser_rating.rating, winner_rating.rating)
22
-
23
- winner_k = winner_rating.k_factor
24
- loser_k = loser_rating.k_factor
25
-
26
- # Winner gets 1 point, loser gets 0 points
27
- winner_new_rating = winner_rating.rating + (winner_k * (1 - winner_expected))
28
- loser_new_rating = loser_rating.rating + (loser_k * (0 - loser_expected))
29
-
30
- update_ranking(winner_rating, winner_new_rating)
31
- update_ranking(loser_rating, loser_new_rating)
32
- end
33
-
34
- # Update ratings after a draw
35
- def update_ratings_for_draw(player1, player2)
36
- player1_rating = player1.elo_ranking
37
- player2_rating = player2.elo_ranking
38
-
39
- # Defensive validation - these should be caught earlier but just in case
40
- raise ArgumentError, "Player1's elo_ranking is nil" if player1_rating.nil?
41
- raise ArgumentError, "Player2's elo_ranking is nil" if player2_rating.nil?
42
-
43
- player1_expected = expected_score(player1_rating.rating, player2_rating.rating)
44
- player2_expected = expected_score(player2_rating.rating, player1_rating.rating)
45
-
46
- player1_k = player1_rating.k_factor
47
- player2_k = player2_rating.k_factor
48
-
49
- # Both players get 0.5 points in a draw
50
- player1_new_rating = player1_rating.rating + (player1_k * (0.5 - player1_expected))
51
- player2_new_rating = player2_rating.rating + (player2_k * (0.5 - player2_expected))
52
-
53
- update_ranking(player1_rating, player1_new_rating)
54
- update_ranking(player2_rating, player2_new_rating)
55
- end
56
-
57
- private
58
-
59
- def update_ranking(elo_ranking, new_rating)
60
- elo_ranking.update!(
61
- rating: new_rating.round,
62
- games_played: elo_ranking.games_played + 1
63
- )
64
- end
65
- end
66
- end
67
- end
1
+ # frozen_string_literal: true
2
+
3
+ module EloRankable
4
+ class Calculator
5
+ class << self
6
+ # Calculate expected score for player A against player B
7
+ def expected_score(rating_a, rating_b)
8
+ 1.0 / (1.0 + (10.0**((rating_b - rating_a) / 400.0)))
9
+ end
10
+
11
+ # Update ratings after a match where player1 beats player2
12
+ def update_ratings_for_win(winner, loser)
13
+ winner_rating = winner.elo_ranking
14
+ loser_rating = loser.elo_ranking
15
+
16
+ # Defensive validation - these should be caught earlier but just in case
17
+ raise ArgumentError, "Winner's elo_ranking is nil" if winner_rating.nil?
18
+ raise ArgumentError, "Loser's elo_ranking is nil" if loser_rating.nil?
19
+
20
+ winner_expected = expected_score(winner_rating.rating, loser_rating.rating)
21
+ loser_expected = expected_score(loser_rating.rating, winner_rating.rating)
22
+
23
+ winner_k = winner_rating.k_factor
24
+ loser_k = loser_rating.k_factor
25
+
26
+ # Winner gets 1 point, loser gets 0 points
27
+ winner_new_rating = winner_rating.rating + (winner_k * (1 - winner_expected))
28
+ loser_new_rating = loser_rating.rating + (loser_k * (0 - loser_expected))
29
+
30
+ update_ranking(winner_rating, winner_new_rating)
31
+ update_ranking(loser_rating, loser_new_rating)
32
+ end
33
+
34
+ # Update ratings after a draw
35
+ def update_ratings_for_draw(player1, player2)
36
+ player1_rating = player1.elo_ranking
37
+ player2_rating = player2.elo_ranking
38
+
39
+ # Defensive validation - these should be caught earlier but just in case
40
+ raise ArgumentError, "Player1's elo_ranking is nil" if player1_rating.nil?
41
+ raise ArgumentError, "Player2's elo_ranking is nil" if player2_rating.nil?
42
+
43
+ player1_expected = expected_score(player1_rating.rating, player2_rating.rating)
44
+ player2_expected = expected_score(player2_rating.rating, player1_rating.rating)
45
+
46
+ player1_k = player1_rating.k_factor
47
+ player2_k = player2_rating.k_factor
48
+
49
+ # Both players get 0.5 points in a draw
50
+ player1_new_rating = player1_rating.rating + (player1_k * (0.5 - player1_expected))
51
+ player2_new_rating = player2_rating.rating + (player2_k * (0.5 - player2_expected))
52
+
53
+ update_ranking(player1_rating, player1_new_rating)
54
+ update_ranking(player2_rating, player2_new_rating)
55
+ end
56
+
57
+ private
58
+
59
+ def update_ranking(elo_ranking, new_rating)
60
+ elo_ranking.update!(
61
+ rating: new_rating.round,
62
+ games_played: elo_ranking.games_played + 1
63
+ )
64
+ end
65
+ end
66
+ end
67
+ end
@@ -1,42 +1,42 @@
1
- # frozen_string_literal: true
2
-
3
- module EloRankable
4
- class Configuration
5
- attr_accessor :base_rating
6
- attr_reader :k_factor_strategy
7
-
8
- def initialize
9
- @base_rating = 1200
10
- @k_factor_strategy = default_k_factor_strategy
11
- end
12
-
13
- def k_factor_for=(strategy)
14
- @k_factor_strategy = strategy
15
- end
16
-
17
- def k_factor_for(rating)
18
- case @k_factor_strategy
19
- when Proc
20
- @k_factor_strategy.call(rating)
21
- when Numeric
22
- @k_factor_strategy
23
- else
24
- raise ArgumentError, 'K-factor strategy must be a Proc or Numeric'
25
- end
26
- end
27
-
28
- private
29
-
30
- def default_k_factor_strategy
31
- lambda do |rating|
32
- if rating > 2400
33
- 10
34
- elsif rating > 2000
35
- 20
36
- else
37
- 32
38
- end
39
- end
40
- end
41
- end
42
- end
1
+ # frozen_string_literal: true
2
+
3
+ module EloRankable
4
+ class Configuration
5
+ attr_accessor :base_rating
6
+ attr_reader :k_factor_strategy
7
+
8
+ def initialize
9
+ @base_rating = 1200
10
+ @k_factor_strategy = default_k_factor_strategy
11
+ end
12
+
13
+ def k_factor_for=(strategy)
14
+ @k_factor_strategy = strategy
15
+ end
16
+
17
+ def k_factor_for(rating)
18
+ case @k_factor_strategy
19
+ when Proc
20
+ @k_factor_strategy.call(rating)
21
+ when Numeric
22
+ @k_factor_strategy
23
+ else
24
+ raise ArgumentError, 'K-factor strategy must be a Proc or Numeric'
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def default_k_factor_strategy
31
+ lambda do |rating|
32
+ if rating > 2400
33
+ 10
34
+ elsif rating > 2000
35
+ 20
36
+ else
37
+ 32
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -1,23 +1,23 @@
1
- # frozen_string_literal: true
2
-
3
- module EloRankable
4
- class EloRanking < ActiveRecord::Base
5
- belongs_to :rankable, polymorphic: true
6
-
7
- validates :rating, presence: true, numericality: { greater_than: 0 }
8
- validates :games_played, presence: true, numericality: { greater_than_or_equal_to: 0 }
9
-
10
- scope :by_rating, -> { order(rating: :desc) }
11
- scope :top, ->(limit = 10) { by_rating.limit(limit) }
12
-
13
- def initialize(attributes = nil)
14
- super
15
- self.rating ||= EloRankable.config.base_rating
16
- self.games_played ||= 0
17
- end
18
-
19
- def k_factor
20
- EloRankable.config.k_factor_for(rating)
21
- end
22
- end
23
- end
1
+ # frozen_string_literal: true
2
+
3
+ module EloRankable
4
+ class EloRanking < ActiveRecord::Base
5
+ belongs_to :rankable, polymorphic: true
6
+
7
+ validates :rating, presence: true, numericality: { greater_than: 0 }
8
+ validates :games_played, presence: true, numericality: { greater_than_or_equal_to: 0 }
9
+
10
+ scope :by_rating, -> { order(rating: :desc) }
11
+ scope :top, ->(limit = 10) { by_rating.limit(limit) }
12
+
13
+ def initialize(attributes = nil)
14
+ super
15
+ self.rating ||= EloRankable.config.base_rating
16
+ self.games_played ||= 0
17
+ end
18
+
19
+ def k_factor
20
+ EloRankable.config.k_factor_for(rating)
21
+ end
22
+ end
23
+ end
@@ -1,71 +1,71 @@
1
- # frozen_string_literal: true
2
-
3
- module EloRankable
4
- module HasEloRanking
5
- def has_elo_ranking
6
- # Set up the polymorphic association
7
- has_one :elo_ranking, as: :rankable, class_name: 'EloRankable::EloRanking', dependent: :destroy
8
-
9
- # Include instance methods
10
- include InstanceMethods
11
-
12
- # Add scopes for leaderboards
13
- scope :by_elo_rating, -> { joins(:elo_ranking).order('elo_rankings.rating DESC') }
14
- scope :top_rated, ->(limit = 10) { by_elo_rating.limit(limit) }
15
- end
16
-
17
- module InstanceMethods
18
- def elo_ranking
19
- super || create_elo_ranking!(
20
- rating: EloRankable.config.base_rating,
21
- games_played: 0
22
- )
23
- end
24
-
25
- def elo_rating
26
- elo_ranking.rating
27
- end
28
-
29
- def games_played
30
- elo_ranking.games_played
31
- end
32
-
33
- # Domain-style DSL methods
34
- def beat!(other_player)
35
- validate_opponent!(other_player)
36
- EloRankable::Calculator.update_ratings_for_win(self, other_player)
37
- end
38
-
39
- def lost_to!(other_player)
40
- validate_opponent!(other_player)
41
- EloRankable::Calculator.update_ratings_for_win(other_player, self)
42
- end
43
-
44
- def draw_with!(other_player)
45
- validate_opponent!(other_player)
46
- EloRankable::Calculator.update_ratings_for_draw(self, other_player)
47
- end
48
-
49
- # Aliases for clarity
50
- alias elo_beat! beat!
51
- alias elo_lost_to! lost_to!
52
- alias elo_draw_with! draw_with!
53
-
54
- private
55
-
56
- def validate_opponent!(other_player)
57
- raise ArgumentError, 'Cannot play against nil' if other_player.nil?
58
- raise ArgumentError, 'Cannot play against yourself' if other_player == self
59
- raise ArgumentError, 'Opponent must respond to elo_ranking' unless other_player.respond_to?(:elo_ranking)
60
-
61
- # Check if the opponent is destroyed/deleted
62
- raise ArgumentError, 'Cannot play against a destroyed record' if other_player.respond_to?(:destroyed?) && other_player.destroyed?
63
-
64
- # Get the elo_ranking once and validate it
65
- opponent_ranking = other_player.elo_ranking
66
- raise ArgumentError, "Opponent's elo_ranking is not initialized" if opponent_ranking.nil?
67
- raise ArgumentError, "Opponent's elo_ranking is not saved" unless opponent_ranking.persisted?
68
- end
69
- end
70
- end
71
- end
1
+ # frozen_string_literal: true
2
+
3
+ module EloRankable
4
+ module HasEloRanking
5
+ def has_elo_ranking
6
+ # Set up the polymorphic association
7
+ has_one :elo_ranking, as: :rankable, class_name: 'EloRankable::EloRanking', dependent: :destroy
8
+
9
+ # Include instance methods
10
+ include InstanceMethods
11
+
12
+ # Add scopes for leaderboards
13
+ scope :by_elo_rating, -> { joins(:elo_ranking).order('elo_rankings.rating DESC') }
14
+ scope :top_rated, ->(limit = 10) { by_elo_rating.limit(limit) }
15
+ end
16
+
17
+ module InstanceMethods
18
+ def elo_ranking
19
+ super || create_elo_ranking!(
20
+ rating: EloRankable.config.base_rating,
21
+ games_played: 0
22
+ )
23
+ end
24
+
25
+ def elo_rating
26
+ elo_ranking.rating
27
+ end
28
+
29
+ def games_played
30
+ elo_ranking.games_played
31
+ end
32
+
33
+ # Domain-style DSL methods
34
+ def beat!(other_player)
35
+ validate_opponent!(other_player)
36
+ EloRankable::Calculator.update_ratings_for_win(self, other_player)
37
+ end
38
+
39
+ def lost_to!(other_player)
40
+ validate_opponent!(other_player)
41
+ EloRankable::Calculator.update_ratings_for_win(other_player, self)
42
+ end
43
+
44
+ def draw_with!(other_player)
45
+ validate_opponent!(other_player)
46
+ EloRankable::Calculator.update_ratings_for_draw(self, other_player)
47
+ end
48
+
49
+ # Aliases for clarity
50
+ alias elo_beat! beat!
51
+ alias elo_lost_to! lost_to!
52
+ alias elo_draw_with! draw_with!
53
+
54
+ private
55
+
56
+ def validate_opponent!(other_player)
57
+ raise ArgumentError, 'Cannot play against nil' if other_player.nil?
58
+ raise ArgumentError, 'Cannot play against yourself' if other_player == self
59
+ raise ArgumentError, 'Opponent must respond to elo_ranking' unless other_player.respond_to?(:elo_ranking)
60
+
61
+ # Check if the opponent is destroyed/deleted
62
+ raise ArgumentError, 'Cannot play against a destroyed record' if other_player.respond_to?(:destroyed?) && other_player.destroyed?
63
+
64
+ # Get the elo_ranking once and validate it
65
+ opponent_ranking = other_player.elo_ranking
66
+ raise ArgumentError, "Opponent's elo_ranking is not initialized" if opponent_ranking.nil?
67
+ raise ArgumentError, "Opponent's elo_ranking is not saved" unless opponent_ranking.persisted?
68
+ end
69
+ end
70
+ end
71
+ end
@@ -1,5 +1,5 @@
1
- # frozen_string_literal: true
2
-
3
- module EloRankable
4
- VERSION = '0.2.0'
5
- end
1
+ # frozen_string_literal: true
2
+
3
+ module EloRankable
4
+ VERSION = '0.2.1'
5
+ end