Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for tie handling in leaderboards. #46

Merged
merged 1 commit into from
Jul 28, 2014
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .rspec
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
--color
--format nested
--format documentation
--order random
4 changes: 1 addition & 3 deletions .ruby-version
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
ruby-1.8.7
ruby-1.9.3
ruby-2.0.0
ruby-2.1.2
3 changes: 1 addition & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
language: ruby
rvm:
- 2.0.0
- 2.1.2
- 1.9.3
- 1.8.7
services:
- redis-server
34 changes: 33 additions & 1 deletion README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ check out the [Redis documentation](http://redis.io/documentation).

## Compatibility

The gem has been built and tested under Ruby 1.8.7, Ruby 1.9.2 and Ruby 1.9.3.
The gem has been built and tested under Ruby 1.9.3 and Ruby 2.1.2.

## Usage

Expand Down Expand Up @@ -302,6 +302,38 @@ Use this method to do bulk insert of data, but be mindful of the amount of data
highscore_lb.rank_member_across(['highscores', 'more_highscores'], 'david', 50000, { :member_name => "david" })
```

### Alternate leaderboard types

The leaderboard library offers 3 styles of ranking. This is only an issue for members with the same score in a leaderboard.

Default: The `Leaderboard` class uses the default Redis sorted set ordering, whereby different members having the same score are ordered lexicographically. As per the Redis documentation on Redis sorted sets, "The lexicographic ordering used is binary, it compares strings as array of bytes."

Tie ranking: The `TieRankingLeaderboard` subclass of `Leaderboard` allows you to define a leaderboard where members with the same score are given the same rank. For example, members in a leaderboard with the associated scores would have the ranks of:

```
| member | score | rank |
-----------------------------
| member_1 | 50 | 1 |
| member_2 | 50 | 1 |
| member_3 | 30 | 2 |
| member_4 | 30 | 2 |
| member_5 | 10 | 3 |
```

The `TieRankingLeaderboard` accepts one additional option, `:ties_namespace` (default: ties), when initializing a new instance of this class. Please note that in its current implementation, the `TieRankingLeaderboard` class uses an additional sorted set to rank the scores, so please keep this in mind when you are doing any capacity planning for Redis with respect to memory usage.

Competition ranking: The `CompetitionRankingLeaderboard` subclass of `Leaderboard` allows you to define a leaderboard where members with the same score will have the same rank, and then a gap is left in the ranking numbers. For example, members in a leaderboard with the associated scores would have the ranks of:

```
| member | score | rank |
-----------------------------
| member_1 | 50 | 1 |
| member_2 | 50 | 1 |
| member_3 | 30 | 3 |
| member_4 | 30 | 3 |
| member_5 | 10 | 5 |
```

### Other useful methods

```
Expand Down
5 changes: 0 additions & 5 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,3 @@ RSpec::Core::RakeTask.new(:spec) do |spec|
end

task :default => :spec

desc "Run the specs against Ruby 1.8.7, 1.9.3, 2.0.0"
task :test_rubies do
system "rvm 1.8.7@leaderboard_gem,1.9.3@leaderboard_gem,2.0.0@leaderboard_gem do rake spec"
end
3 changes: 0 additions & 3 deletions leaderboard.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,5 @@ Gem::Specification.new do |s|

s.add_dependency('redis')
s.add_development_dependency('rake')
if '1.8.7'.eql?(RUBY_VERSION)
s.add_development_dependency('SystemTimer')
end
s.add_development_dependency('rspec')
end
98 changes: 98 additions & 0 deletions lib/competition_ranking_leaderboard.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
require 'leaderboard'

class CompetitionRankingLeaderboard < Leaderboard
# Retrieve the rank for a member in the named leaderboard.
#
# @param leaderboard_name [String] Name of the leaderboard.
# @param member [String] Member name.
#
# @return the rank for a member in the leaderboard.
def rank_for_in(leaderboard_name, member)
member_score = score_for_in(leaderboard_name, member)
if @reverse
return @redis_connection.zcount(leaderboard_name, '-inf', "(#{member_score}") + 1 rescue nil
else
return @redis_connection.zcount(leaderboard_name, "(#{member_score}", '+inf') + 1 rescue nil
end
end

# Retrieve the score and rank for a member in the named leaderboard.
#
# @param leaderboard_name [String]Name of the leaderboard.
# @param member [String] Member name.
#
# @return the score and rank for a member in the named leaderboard as a Hash.
def score_and_rank_for_in(leaderboard_name, member)
responses = @redis_connection.multi do |transaction|
transaction.zscore(leaderboard_name, member)
if @reverse
transaction.zrank(leaderboard_name, member)
else
transaction.zrevrank(leaderboard_name, member)
end
end

responses[0] = responses[0].to_f if responses[0]
responses[1] =
if @reverse
@redis_connection.zcount(leaderboard_name, '-inf', "(#{responses[0]}") + 1 rescue nil
else
@redis_connection.zcount(leaderboard_name, "(#{responses[0]}", '+inf') + 1 rescue nil
end

{@member_key => member, @score_key => responses[0], @rank_key => responses[1]}
end

# Retrieve a page of leaders from the named leaderboard for a given list of members.
#
# @param leaderboard_name [String] Name of the leaderboard.
# @param members [Array] Member names.
# @param options [Hash] Options to be used when retrieving the page from the named leaderboard.
#
# @return a page of leaders from the named leaderboard for a given list of members.
def ranked_in_list_in(leaderboard_name, members, options = {})
leaderboard_options = DEFAULT_LEADERBOARD_REQUEST_OPTIONS.dup
leaderboard_options.merge!(options)

ranks_for_members = []

responses = @redis_connection.multi do |transaction|
members.each do |member|
if @reverse
transaction.zrank(leaderboard_name, member)
else
transaction.zrevrank(leaderboard_name, member)
end
transaction.zscore(leaderboard_name, member)
end
end unless leaderboard_options[:members_only]

members.each_with_index do |member, index|
data = {}
data[@member_key] = member
unless leaderboard_options[:members_only]
data[@score_key] = responses[index * 2 + 1].to_f if responses[index * 2 + 1]
if @reverse
data[@rank_key] = @redis_connection.zcount(leaderboard_name, '-inf', "(#{data[@score_key]}") + 1 rescue nil
else
data[@rank_key] = @redis_connection.zcount(leaderboard_name, "(#{data[@score_key]}", '+inf') + 1 rescue nil
end
end

if leaderboard_options[:with_member_data]
data[@member_data_key] = member_data_for_in(leaderboard_name, member)
end

ranks_for_members << data
end

case leaderboard_options[:sort_by]
when :rank
ranks_for_members = ranks_for_members.sort_by { |member| member[@rank_key] }
when :score
ranks_for_members = ranks_for_members.sort_by { |member| member[@score_key] }
end

ranks_for_members
end
end
2 changes: 1 addition & 1 deletion lib/leaderboard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -966,7 +966,7 @@ def intersect_leaderboards(destination, keys, options = {:aggregate => :sum})
@redis_connection.zinterstore(destination, keys.insert(0, @leaderboard_name), options)
end

private
protected

# Key for retrieving optional member data.
#
Expand Down
Loading