Browse Source

Relax leaderboards

pull/31/head
Giuseppe Guerra 3 months ago
parent
commit
d12713847e
8 changed files with 170 additions and 69 deletions
  1. +14
    -8
      handlers/getScoresHandler.pyx
  2. +26
    -23
      handlers/submitModularHandler.pyx
  3. +14
    -4
      helpers/leaderboardHelper.py
  4. +27
    -2
      objects/score.pyx
  5. +39
    -22
      objects/scoreboard.pyx
  6. +15
    -4
      personalBestCache.py
  7. +6
    -2
      pp/cicciobello.py
  8. +29
    -4
      pp/rippoppai.py

+ 14
- 8
handlers/getScoresHandler.pyx View File

@@ -73,6 +73,7 @@ class handler(requestsManager.asyncRequestHandler):
country = False
friends = False
modsFilter = -1
isRelax = userUtils.isRelaxLeaderboard(userID)
if scoreboardType == 4:
# Country leaderboard
country = True
@@ -97,7 +98,8 @@ class handler(requestsManager.asyncRequestHandler):

# Create leaderboard object, link it to bmap and get all scores
sboard = scoreboard.scoreboard(
username, gameMode, bmap, setScores=True, country=country, mods=modsFilter, friends=friends
username, gameMode, bmap, setScores=True, country=country,
mods=modsFilter, friends=friends, relax=isRelax
)

# Data to return
@@ -111,13 +113,17 @@ class handler(requestsManager.asyncRequestHandler):
knowsPPLeaderboard = glob.redis.get("lets:knows_pp_leaderboard:{}".format(userID)) is not None
if modsFilter & mods.AUTOPLAY > 0 and not knowsPPLeaderboard:
glob.redis.set("lets:knows_pp_leaderboard:{}".format(userID), "1", 1800)
glob.redis.publish("peppy:notification", json.dumps({
"userID": userID,
"message": "Hi there! Scores are now sorted by PP. You can change scores sort mode by "
"toggling the 'Auto' mod and filtering the leaderboard by Active mods. Note "
"that this option is available only for donors and we don't recommend saving "
"replays when the leaderboard is sorted by pp, due to some client limitations."
}))
glob.redis.publish(
"peppy:notification",
json.dumps({
"userID": userID,
"message":
"Hi there! Scores are now sorted by PP. You can change scores sort mode by "
"toggling the 'Auto' mod and filtering the leaderboard by Active mods. Note "
"that this option is available only for donors and we don't recommend saving "
"replays when the leaderboard is sorted by pp, due to some client limitations."
})
)

# Datadog stats
glob.dog.increment(glob.DATADOG_PREFIX+".served_leaderboards")


+ 26
- 23
handlers/submitModularHandler.pyx View File

@@ -88,8 +88,8 @@ class handler(requestsManager.asyncRequestHandler):
aeskey = "h89f2-890h2h89b34g-h80g134n90133"

# Get score data
log.debug("Decrypting score data...")
scoreData = aeshelper.decryptRinjdael(aeskey, iv, scoreDataEnc, True).split(":")
log.debug(scoreData)
if len(scoreData) < 16 or len(scoreData[0]) != 32:
return
username = scoreData[1].strip()
@@ -199,10 +199,10 @@ class handler(requestsManager.asyncRequestHandler):
# Right before submitting the score, get the personal best score object (we need it for charts)
if s.passed and s.oldPersonalBest > 0:
# We have an older personal best. Get its rank (try to get it from cache first)
oldPersonalBestRank = glob.personalBestCache.get(userID, s.fileMd5)
oldPersonalBestRank = glob.personalBestCache.get(userID, s.fileMd5, relax=s.isRelax)
if oldPersonalBestRank == 0:
# oldPersonalBestRank not found in cache, get it from db through a scoreboard object
oldScoreboard = scoreboard.scoreboard(username, s.gameMode, beatmapInfo, False)
oldScoreboard = scoreboard.scoreboard(username, s.gameMode, beatmapInfo, False, relax=s.isRelax)
oldScoreboard.setPersonalBestRank()
oldPersonalBestRank = max(oldScoreboard.personalBestRank, 0)
oldPersonalBest = score.score(s.oldPersonalBest, oldPersonalBestRank)
@@ -345,18 +345,17 @@ class handler(requestsManager.asyncRequestHandler):
).decode(),
})
), daemon=False).start()
else:
elif not restricted:
# Restrict if no replay was provided
if not restricted:
userUtils.restrict(userID)
userUtils.appendNotes(
userID,
"Restricted due to missing replay while submitting a score "
"(most likely they used a score submitter)"
)
log.cm("**{}** ({}) has been restricted due to replay not found on map {}".format(
username, userID, s.fileMd5
))
userUtils.restrict(userID)
userUtils.appendNotes(
userID,
"Restricted due to missing replay while submitting a score "
"(most likely they used a score submitter)"
)
log.cm("**{}** ({}) has been restricted due to replay not found on map {}".format(
username, userID, s.fileMd5
))

# Update beatmap playcount (and passcount)
beatmap.incrementPlaycount(s.fileMd5, s.passed)
@@ -375,12 +374,12 @@ class handler(requestsManager.asyncRequestHandler):
if s.passed:
# Get old stats and rank
oldUserStats = glob.userStatsCache.get(userID, s.gameMode)
oldRank = userUtils.getGameRank(userID, s.gameMode)
oldRank = userUtils.getGameRank(userID, s.gameMode, relax=s.isRelax)

# Always update users stats (total/ranked score, playcount, level, acc and pp)
# even if not passed
log.debug("Updating {}'s stats...".format(username))
userUtils.updateStats(userID, s)
userUtils.updateStats(userID, s, relax=s.isRelax)

# Update personal beatmaps playcount
userUtils.incrementUserBeatmapPlaycount(userID, s.gameMode, beatmapInfo.beatmapID)
@@ -390,16 +389,16 @@ class handler(requestsManager.asyncRequestHandler):
# (only if we passed that song)
if s.passed:
# Get new stats
newUserStats = userUtils.getUserStats(userID, s.gameMode)
newUserStats = userUtils.getUserStats(userID, s.gameMode, relax=s.isRelax)
glob.userStatsCache.update(userID, s.gameMode, newUserStats)

# Update leaderboard (global and country) if score/pp has changed
if s.completed == 3 and newUserStats["pp"] != oldUserStats["pp"]:
leaderboardHelper.update(userID, newUserStats["pp"], s.gameMode)
leaderboardHelper.updateCountry(userID, newUserStats["pp"], s.gameMode)
leaderboardHelper.update(userID, newUserStats["pp"], s.gameMode, relax=s.isRelax)
leaderboardHelper.updateCountry(userID, newUserStats["pp"], s.gameMode, relax=s.isRelax)

# Update total hits
userUtils.updateTotalHits(score=s)
userUtils.updateTotalHits(score=s, relax=s.isRelax)
# TODO: max combo

# Update latest activity
@@ -428,7 +427,7 @@ class handler(requestsManager.asyncRequestHandler):
glob.redis.publish("peppy:update_cached_stats", userID)

# Get personal best after submitting the score
newScoreboard = scoreboard.scoreboard(username, s.gameMode, beatmapInfo, False)
newScoreboard = scoreboard.scoreboard(username, s.gameMode, beatmapInfo, False, relax=s.isRelax)
newScoreboard.setPersonalBestRank()
personalBestID = newScoreboard.getPersonalBestID()
assert personalBestID is not None
@@ -497,12 +496,16 @@ class handler(requestsManager.asyncRequestHandler):

# send message to #announce if we're rank #1
if newScoreboard.personalBestRank == 1 and s.completed == 3 and not restricted:
annmsg = "[https://ripple.moe/?u={} {}] achieved rank #1 on [https://osu.ppy.sh/b/{} {}] ({})".format(
annmsg =\
"[https://ripple.moe/?u={} {}] " \
"achieved rank #1 on " \
"[https://osu.ppy.sh/b/{} {}] ({}, {})".format(
userID,
username.encode().decode("ASCII", "ignore"),
beatmapInfo.beatmapID,
beatmapInfo.songName.encode().decode("ASCII", "ignore"),
gameModes.getGamemodeFull(s.gameMode)
gameModes.getGamemodeFull(s.gameMode),
"relax" if s.isRelax else "classic"
)
requests.post(
"{}/api/v0/send_message".format(glob.conf["FOKABOT_API_BASE"].rstrip("/")),


+ 14
- 4
helpers/leaderboardHelper.py View File

@@ -32,7 +32,7 @@ def getRankInfo(userID, gameMode):
data["currentRank"] = position + 1
return data

def update(userID, newScore, gameMode):
def update(userID, newScore, gameMode, *, relax=False):
"""
Update gamemode's leaderboard.
Doesn't do anything if userID is banned/restricted.
@@ -40,14 +40,19 @@ def update(userID, newScore, gameMode):
:param userID: user
:param newScore: new score or pp
:param gameMode: gameMode number
:param relax: if True, update relax global leaderboard, otherwise update classic global leaderboard
"""
if userUtils.isAllowed(userID):
log.debug("Updating leaderboard...")
glob.redis.zadd("ripple:leaderboard:{}".format(scoreUtils.readableGameMode(gameMode)), str(userID), str(newScore))
glob.redis.zadd(
"ripple:leaderboard:{}{}".format(scoreUtils.readableGameMode(gameMode), ":relax" if relax else ""),
str(userID),
str(newScore)
)
else:
log.debug("Leaderboard update for user {} skipped (not allowed)".format(userID))

def updateCountry(userID, newScore, gameMode):
def updateCountry(userID, newScore, gameMode, *, relax=False):
"""
Update gamemode's country leaderboard.
Doesn't do anything if userID is banned/restricted.
@@ -55,13 +60,18 @@ def updateCountry(userID, newScore, gameMode):
:param userID: user, country is determined by the user
:param newScore: new score or pp
:param gameMode: gameMode number
:param relax: if True, update relax country leaderboard, otherwise update classic country leaderboard
:return:
"""
if userUtils.isAllowed(userID):
country = userUtils.getCountry(userID)
if country is not None and len(country) > 0 and country.lower() != "xx":
log.debug("Updating {} country leaderboard...".format(country))
k = "ripple:leaderboard:{}:{}".format(scoreUtils.readableGameMode(gameMode), country.lower())
k = "ripple:leaderboard:{}:{}{}".format(
scoreUtils.readableGameMode(gameMode),
country.lower(),
":relax" if relax else ""
)
glob.redis.zadd(k, str(userID), str(newScore))
else:
log.debug("Country leaderboard update for user {} skipped (not allowed)".format(userID))

+ 27
- 2
objects/score.pyx View File

@@ -67,6 +67,10 @@ class score:
return x // 0.75
return x

@property
def isRelax(self):
return self.mods & (mods.RELAX | mods.RELAX2) > 0

@property
def fullPlayTime(self):
return self._fullPlayTime
@@ -196,6 +200,7 @@ class score:
#self.rank = scoreData[12]
self.mods = int(scoreData[13])
self.passed = scoreData[14] == 'True'
log.debug("passed: {}".format(self.passed))
self.gameMode = int(scoreData[15])
#self.playDateTime = int(scoreData[16])
self.playDateTime = int(time.time())
@@ -235,22 +240,40 @@ class score:
try:
self.completed = 0
if not scoreUtils.isRankable(self.mods):
log.debug("Unrankable mods")
return
if self.passed:
log.debug("Passed")
# Get userID
userID = userUtils.getID(self.playerName)

# Make sure we don't have another score identical to this one
# TODO: time check
duplicate = glob.db.fetch("SELECT id FROM scores WHERE userid = %s AND beatmap_md5 = %s AND play_mode = %s AND score = %s LIMIT 1", [userID, self.fileMd5, self.gameMode, self.score])
duplicate = glob.db.fetch(
"SELECT id FROM scores "
"WHERE userid = %s AND beatmap_md5 = %s "
"AND is_relax = %s AND play_mode = %s "
"AND score = %s "
"LIMIT 1",
(userID, self.fileMd5, self.isRelax, self.gameMode, self.score)
)
if duplicate is not None:
# Found same score in db. Don't save this score.
log.debug("Score duplicate")
self.completed = -1
return

# No duplicates found.
# Get right "completed" value
personalBest = glob.db.fetch("SELECT id, score FROM scores WHERE userid = %s AND beatmap_md5 = %s AND play_mode = %s AND completed = 3 LIMIT 1", [userID, self.fileMd5, self.gameMode])
log.debug("No duplicated")
personalBest = glob.db.fetch(
"SELECT id, score FROM scores "
"WHERE userid = %s AND beatmap_md5 = %s "
"AND is_relax = %s AND play_mode = %s "
"AND completed = 3 "
"LIMIT 1",
(userID, self.fileMd5, self.isRelax, self.gameMode)
)
if personalBest is None:
# This is our first score on this map, so it's our best score
self.completed = 3
@@ -262,8 +285,10 @@ class score:
self.oldPersonalBest = personalBest["id"]
self.completed = 3 if self.score > personalBest["score"] else 2
elif self.quit:
log.debug("Quit")
self.completed = 0
elif self.failed:
log.debug("Failed")
self.completed = 1
finally:
log.debug("Completed status: {}".format(self.completed))


+ 39
- 22
objects/scoreboard.pyx View File

@@ -6,13 +6,16 @@ from objects import glob


class scoreboard:
def __init__(self, username, gameMode, beatmap, setScores = True, country = False, friends = False, mods = -1):
def __init__(
self, username, gameMode, beatmap, setScores = True,
country = False, friends = False, mods = -1, relax = False
):
"""
Initialize a leaderboard object

username -- username of who's requesting the scoreboard. None if not known
gameMode -- requested gameMode
beatmap -- beatmap objecy relative to this leaderboard
beatmap -- beatmap object relative to this leaderboard
setScores -- if True, will get personal/top 50 scores automatically. Optional. Default: True
"""
self.scores = [] # list containing all top 50 scores objects. First object is personal best
@@ -25,6 +28,7 @@ class scoreboard:
self.country = country
self.friends = friends
self.mods = mods
self.isRelax = relax
if setScores:
self.setScores()

@@ -47,6 +51,7 @@ class scoreboard:
select = "SELECT id FROM scores " \
"WHERE userid = %(userid)s " \
"AND beatmap_md5 = %(md5)s " \
"AND is_relax = %(isRelax)s " \
"AND play_mode = %(mode)s " \
"AND completed = 3"

@@ -68,8 +73,13 @@ class scoreboard:

# Build query, get params and run query
query = self.buildQuery(locals())
params = {"userid": self.userID, "md5": self.beatmap.fileMD5, "mode": self.gameMode, "mods": self.mods}
id_ = glob.db.fetch(query, params)
id_ = glob.db.fetch(query, {
"userid": self.userID,
"md5": self.beatmap.fileMD5,
"mode": self.gameMode,
"mods": self.mods,
"isRelax": self.isRelax
})
if id_ is None:
return None
return id_["id"]
@@ -113,6 +123,7 @@ class scoreboard:
"STRAIGHT_JOIN users_stats " \
"ON users.id = users_stats.id " \
"WHERE scores.beatmap_md5 = %(beatmap_md5)s " \
"AND scores.is_relax = %(isRelax)s " \
"AND scores.play_mode = %(play_mode)s " \
"AND scores.completed = 3 " \
"AND (users.is_public = 1 OR users.id = %(userid)s)"
@@ -154,29 +165,30 @@ class scoreboard:
"beatmap_md5": self.beatmap.fileMD5,
"play_mode": self.gameMode,
"userid": self.userID,
"mods": self.mods
"mods": self.mods,
"isRelax": int(self.isRelax)
}
topScores = glob.db.fetchAll(query, params)

# Set data for all scores
cdef int c = 1
cdef dict topScore
if topScores is not None:
for topScore in topScores:
# Create score object
s = score.score(topScore["id"], setData=False)
cdef int c = 1
# for c, topScore in enumerate(topScores):
for topScore in topScores:
# Create score object
s = score.score(topScore["id"], setData=False)

# Set data and rank from topScores's row
s.setDataFromDict(topScore)
s.rank = c
# Set data and rank from topScores's row
s.setDataFromDict(topScore)
s.rank = c

# Check if this top 50 score is our personal best
if s.playerName == self.username:
self.personalBestRank = c
# Check if this top 50 score is our personal best
if s.playerName == self.username:
self.personalBestRank = c

# Add this score to scores list and increment rank
self.scores.append(s)
c+=1
# Add this score to scores list and increment rank
self.scores.append(s)
c += 1

# If we have more than 50 scores, run query to get scores count
if c >= 50:
@@ -208,12 +220,13 @@ class scoreboard:
# Cache our personal best rank so we can eventually use it later as
# before personal best rank" in submit modular when building ranking panel
if self.personalBestRank >= 1:
glob.personalBestCache.set(self.userID, self.personalBestRank, self.beatmap.fileMD5)
glob.personalBestCache.set(self.userID, self.personalBestRank, self.beatmap.fileMD5, relax=self.isRelax)

def setPersonalBestRank(self):
# Before running the HUGE query, make sure we have a score on that map
cdef str query = "SELECT id FROM scores " \
"WHERE beatmap_md5 = %(md5)s " \
"AND is_relax = %(isRelax)s " \
"AND userid = %(userid)s " \
"AND play_mode = %(mode)s " \
"AND completed = 3"
@@ -235,7 +248,8 @@ class scoreboard:
"md5": self.beatmap.fileMD5,
"userid": self.userID,
"mode": self.gameMode,
"mods": self.mods
"mods": self.mods,
"isRelax": int(self.isRelax)
}
)
if hasScore is None:
@@ -249,12 +263,14 @@ class scoreboard:
WHERE scores.score >= (
SELECT score FROM scores
WHERE beatmap_md5 = %(md5)s
AND scores.is_relax = %(isRelax)s
AND play_mode = %(mode)s
AND completed = 3
AND userid = %(userid)s
LIMIT 1
)
AND scores.beatmap_md5 = %(md5)s
AND scores.is_relax = %(isRelax)s
AND scores.play_mode = %(mode)s
AND scores.completed = 3
AND (users.is_public = 1 OR users.id = %(userid)s)"""
@@ -279,7 +295,8 @@ class scoreboard:
"md5": self.beatmap.fileMD5,
"userid": self.userID,
"mode": self.gameMode,
"mods": self.mods
"mods": self.mods,
"isRelax": self.isRelax
}
)
if result is not None:


+ 15
- 4
personalBestCache.py View File

@@ -6,7 +6,7 @@ class cacheMiss(Exception):
pass

class personalBestCache:
def get(self, userID, fileMd5, country=False, friends=False, mods=-1):
def get(self, userID, fileMd5, country=False, friends=False, mods=-1, relax=False):
"""
Get cached personal best rank

@@ -14,6 +14,7 @@ class personalBestCache:
:param fileMd5: beatmap md5
:param country: True if country leaderboard, otherwise False
:param friends: True if friends leaderboard, otherwise False
:param relax: True if relax leaderboard, False if classic leaderboard
:param mods: leaderboard mods
:return: 0 if cache miss, otherwise rank number
"""
@@ -30,9 +31,14 @@ class personalBestCache:
cachedCountry = generalUtils.stringToBool(data[2])
cachedFriends = generalUtils.stringToBool(data[3])
cachedMods = int(data[4])
cachedRelax = generalUtils.stringToBool(data[5])

# Check if everything matches
if fileMd5 != cachedfileMd5 or country != cachedCountry or friends != cachedFriends or mods != cachedMods:
if fileMd5 != cachedfileMd5 \
or country != cachedCountry \
or friends != cachedFriends \
or mods != cachedMods \
or relax != cachedRelax:
raise cacheMiss()

# Cache hit
@@ -42,7 +48,7 @@ class personalBestCache:
log.debug("personalBestCache miss")
return 0

def set(self, userID, rank, fileMd5, country=False, friends=False, mods=-1):
def set(self, userID, rank, fileMd5, country=False, friends=False, mods=-1, relax=False):
"""
Set userID's redis personal best cache

@@ -52,7 +58,12 @@ class personalBestCache:
:param country: True if country leaderboard, otherwise False
:param friends: True if friends leaderboard, otherwise False
:param mods: leaderboard mods
:param relax: True if relax leaderboard, False if classic
:return:
"""
glob.redis.set("lets:personal_best_cache:{}".format(userID), "{}|{}|{}|{}|{}".format(rank, fileMd5, country, friends, mods), 1800)
glob.redis.set(
"lets:personal_best_cache:{}".format(userID),
"{}|{}|{}|{}|{}|{}".format(rank, fileMd5, country, friends, mods, relax),
1800
)
log.debug("personalBestCache set")

+ 6
- 2
pp/cicciobello.py View File

@@ -1,5 +1,5 @@
from common.log import logUtils as log
from common.constants import gameModes
from common.constants import gameModes, mods
from constants import exceptions
from helpers import mapsHelper

@@ -36,6 +36,10 @@ class Cicciobello:
self.pp = 0
self.calculate_pp()

@property
def unrelaxMods(self):
return self.mods & ~(mods.RELAX | mods.RELAX2)

def calculate_pp(self):
try:
# Cache beatmap
@@ -54,7 +58,7 @@ class Cicciobello:

# Calculate difficulty
calcBeatmap = CalcBeatmap(mapFile)
difficulty = Difficulty(beatmap=calcBeatmap, mods=self.mods)
difficulty = Difficulty(beatmap=calcBeatmap, mods=self.unrelaxMods)

# Calculate pp
if self.tillerino:


+ 29
- 4
pp/rippoppai.py View File

@@ -5,7 +5,7 @@ import json
import os
import subprocess

from common.constants import gameModes
from common.constants import gameModes, mods
from common.log import logUtils as log
from common.ripple import scoreUtils
from constants import exceptions
@@ -83,6 +83,24 @@ class oppai:
log.debug("oppai ~> Initialized oppai diffcalc")
self.calculatePP()

def fix_relax_pp(self, aim_pp: float, acc_pp: float, speed_pp: float) -> float:
final_multiplier = 1.12
if self.mods & mods.EASY:
final_multiplier *= 0.9
if self.mods & mods.SPUNOUT:
final_multiplier *= 0.95
aim = aim_pp ** 1.1
acc = acc_pp ** 1.1
speed = speed_pp ** 1.1
old_pp = ((aim + acc + speed) ** (1 / 1.1)) * final_multiplier
if self.mods & mods.RELAX:
speed = 0
if self.mods & mods.RELAX2:
aim = 0
pp = ((aim + acc + speed) ** (1 / 1.1)) * final_multiplier
log.debug(f"Fixed relax pp value: {pp}. Was {old_pp}")
return pp

@staticmethod
def _runOppaiProcess(command):
log.debug("oppai ~> running {}".format(command))
@@ -99,12 +117,17 @@ class oppai:
raise OppaiError("No pp/stars entry in oppai json output")
pp = output["pp"]
stars = output["stars"]
pp_parts = {
"aim": output.get("aim_pp", None),
"speed": output.get("speed_pp", None),
"acc": output.get("acc_pp", None),
}

log.debug("oppai ~> full output: {}".format(output))
log.debug("oppai ~> pp: {}, stars: {}".format(pp, stars))
except (json.JSONDecodeError, IndexError, OppaiError) as e:
raise OppaiError(e)
return pp, stars
return pp, stars, pp_parts

def calculatePP(self):
"""
@@ -146,19 +169,21 @@ class oppai:
# Calculate pp
if not self.tillerino:
# self.pp, self.stars = self._runOppaiProcess(command)
temp_pp, self.stars = self._runOppaiProcess(command)
temp_pp, self.stars, pp_parts = self._runOppaiProcess(command)
if (self.gameMode == gameModes.TAIKO and self.beatmap.starsStd > 0 and temp_pp > 800) or \
self.stars > 50:
# Invalidate pp for bugged taiko converteds and bugged inf pp std maps
self.pp = 0
else:
self.pp = temp_pp
if self.gameMode == gameModes.STD and self.score.isRelax:
self.pp = self.fix_relax_pp(pp_parts["aim"], pp_parts["acc"], pp_parts["speed"])
else:
pp_list = []
for acc in [100, 99, 98, 95]:
temp_command = command
temp_command += " {acc:.2f}%".format(acc=acc)
pp, self.stars = self._runOppaiProcess(temp_command)
pp, self.stars, _ = self._runOppaiProcess(temp_command)

# If this is a broken converted, set all pp to 0 and break the loop
if self.gameMode == gameModes.TAIKO and self.beatmap.starsStd > 0 and pp > 800:


Loading…
Cancel
Save