redis = require './redis' async = require 'async' alts = require './alts' @algorithm = require('./elo') @DECAY_AMOUNT = 5 DEFAULT_PLAYER = @algorithm.createPlayer() USERS_RATED_KEY = "users:rated" USERS_ACTIVE_KEY = "users:active" RATINGS_KEY = "ratings" RATINGS_ATTRIBUTES = Object.keys(DEFAULT_PLAYER) RATINGS_SUBKEYS = {} for attribute in RATINGS_ATTRIBUTES RATINGS_SUBKEYS[attribute] = [RATINGS_KEY, attribute].join(':') RATINGS_MAXKEY = "ratings:max" RATINGS_PER_PAGE = 20 ALGORITHM_OPTIONS = systemConstant: 0.2 # Glicko2 tau @DEFAULT_RATING = DEFAULT_PLAYER['rating'] @results = WIN : 1 DRAW : 0.5 # In earlier generations, it's possible to draw. LOSE : 0 @setActive = (idArray, next) -> idArray = [idArray] if idArray not instanceof Array idArray = idArray.map((id) -> id.toLowerCase()) redis.sadd(USERS_ACTIVE_KEY, idArray, next) RATIOS_KEY = 'ratios' RATIOS_ATTRIBUTES = Object.keys(@results).map((key) -> key.toLowerCase()) RATIOS_SUBKEYS = {} for attribute in RATIOS_ATTRIBUTES RATIOS_SUBKEYS[attribute] = [RATIOS_KEY, attribute].join(':') RATIOS_STREAK_KEY = "#{RATIOS_KEY}:streak" RATIOS_MAXSTREAK_KEY = "#{RATIOS_KEY}:maxstreak" # Used internally by the ratings system to update # the max rating of user when a rating changes # Id can either be the actual id, or an alt id updateMaxRating = (id, next) => id = alts.getIdOwner(id).toLowerCase() alts.listUserAlts id, (err, altNames) => return next(err) if err # Retrieve a list of all rating Keys ids = (alts.uniqueId(id, name) for name in altNames) ids.push(id) @getRatings ids, (err, results) -> return next(err) if err redis.zadd(RATINGS_MAXKEY, Math.max(results...), id) next(null) # Update the max ratings for multiple players updateMaxRatings = (ids, next) -> ops = ids.map (id) -> (callback) -> updateMaxRating(id, callback) async.parallel ops, next updateMaxStreak = (id, next) => @getStreak id, (err, results) -> return next(err) if err if results.streak > results.maxStreak redis.hmset RATIOS_MAXSTREAK_KEY, id, results.streak, next else next(err) @getPlayer = (id, next) -> id = id.toLowerCase() multi = redis.multi() for attribute in RATINGS_ATTRIBUTES multi = multi.zscore(RATINGS_SUBKEYS[attribute], id) multi.exec (err, results) -> return next(err) if err object = {} for value, i in results attribute = RATINGS_ATTRIBUTES[i] value ||= DEFAULT_PLAYER[attribute] object[attribute] = Number(value) return next(null, object) @getRating = (id, next) -> id = id.toLowerCase() exports.getPlayer id, (err, player) -> return next(err) if err return next(null, Number(player.rating)) # Returns the maximum rating for a user among that user and his/her alts @getMaxRating = (id, next) -> id = id.toLowerCase() redis.zscore RATINGS_MAXKEY, id, (err, rating) -> return next(err) if err rating ||= 0 next(null, Number(rating)) @setRating = (id, newRating, next) => @setRatings([id], [newRating], next) @setRatings = (idArray, newRatingArray, next) -> idArray = idArray.map((id) -> id.toLowerCase()) multi = redis.multi() multi = multi.sadd(USERS_RATED_KEY, idArray) for id, i in idArray newRating = newRatingArray[i] multi = multi.zadd(RATINGS_SUBKEYS['rating'], newRating, id) multi.exec (err) -> return next(err) if err updateMaxRatings(idArray, next) @getPlayers = (idArray, next) -> idArray = idArray.map((id) -> id.toLowerCase()) callbacks = idArray.map (id) => (callback) => @getPlayer(id, callback) async.parallel(callbacks, next) @getRatings = (idArray, next) -> idArray = idArray.map((id) -> id.toLowerCase()) exports.getPlayers idArray, (err, players) -> return next(err) if err return next(null, players.map((p) -> Number(p.rating))) @getRank = (id, next) -> id = id.toLowerCase() redis.zrevrank RATINGS_SUBKEYS['rating'], id, (err, rank) -> return next(err) if err return next(null, null) if !rank? return next(null, rank + 1) # rank starts at 0 @getRanks = (idArray, next) -> idArray = idArray.map((id) -> id.toLowerCase()) multi = redis.multi() for id in idArray multi = multi.zrevrank(RATINGS_SUBKEYS['rating'], id) multi.exec (err, ranks) -> return next(err) if err ranks = ranks.map (rank) -> # Rank starts at 0, but it can also be null (doesn't exist). if rank? then rank + 1 else null next(null, ranks) @updatePlayer = (id, score, object, next) -> id = id.toLowerCase() multi = redis.multi() attribute = switch score when 1 then 'win' when 0 then 'lose' else 'draw' multi = multi.hincrby(RATIOS_SUBKEYS[attribute], id, 1) if attribute == "win" multi = multi.hincrby(RATIOS_STREAK_KEY, id, 1) else multi = multi.hset(RATIOS_STREAK_KEY, id, 0) multi = multi.sadd(USERS_RATED_KEY, id) for attribute in RATINGS_ATTRIBUTES value = object[attribute] multi = multi.zadd(RATINGS_SUBKEYS[attribute], value, id) multi.exec (err) -> return next(err) if err async.parallel([ updateMaxRating.bind(this, id) updateMaxStreak.bind(this, id) ], next) @updatePlayers = (id, opponentId, score, next) -> if score < 0 || score > 1 return next(new Error("Invalid match result: #{score}")) id = id.toLowerCase() opponentId = opponentId.toLowerCase() opponentScore = 1.0 - score exports.getPlayers [id, opponentId], (err, results) => return next(err) if err [player, opponent] = results winnerMatches = [{opponent, score}] loserMatches = [{opponent: player, score: opponentScore}] newWinner = exports.algorithm.calculate(player, winnerMatches, ALGORITHM_OPTIONS) newLoser = exports.algorithm.calculate(opponent, loserMatches, ALGORITHM_OPTIONS) async.parallel [ @updatePlayer.bind(this, id, score, newWinner) @updatePlayer.bind(this, opponentId, opponentScore, newLoser) ], (err, results) => return next(err) if err @getRatings([id, opponentId], next) @resetRating = (id, next) -> @resetRatings([id], next) @resetRatings = (idArray, next) -> idArray = idArray.map((id) -> id.toLowerCase()) multi = redis.multi() multi = multi.srem(USERS_RATED_KEY, idArray) for attribute, key of RATINGS_SUBKEYS multi = multi.zrem(key, idArray) multi.exec (err) -> return next(err) if err updateMaxRatings(idArray, next) @getRatio = (id, next) -> id = id.toLowerCase() multi = redis.multi() for attribute, key of RATIOS_SUBKEYS multi = multi.hget(key, id) multi.exec (err, results) -> return next(err) if err hash = {} for attribute, i in RATIOS_ATTRIBUTES hash[attribute] = Number(results[i]) || 0 return next(null, hash) @getRatios = (users, next) -> multi = redis.multi() for id in users for attribute, key of RATIOS_SUBKEYS multi = multi.hget(key, id) multi.exec (err, results) -> return next(err) if err hasharray = {} iter = 0 for id, i in users hash = {} for attribute, j in RATIOS_ATTRIBUTES hash[attribute] = Number(results[iter]) || 0 iter++ hasharray[id] = hash next(null, hasharray) @listRatings = (page = 1, perPage = RATINGS_PER_PAGE, next) -> if arguments.length == 2 && typeof perPage == 'function' [perPage, next] = [RATINGS_PER_PAGE, perPage] page -= 1 start = page * perPage end = start + (perPage - 1) redis.zrevrange RATINGS_MAXKEY, start, end, 'WITHSCORES', (err, r) -> return next(err) if err array = [] for i in [0...r.length] by 2 username = r[i] score = Number(r[i + 1]) # redis returns scores as strings array.push(username: username, score: score) next(null, array) @getStreak = (id, next) -> id = id.toLowerCase() multi = redis.multi() multi = multi.hget(RATIOS_STREAK_KEY, id) multi = multi.hget(RATIOS_MAXSTREAK_KEY, id) multi.exec (err, results) -> next(null, {streak: results[0], maxStreak: results[1]})