diff --git a/app/v1/leaderboard.go b/app/v1/leaderboard.go index 664ce3a..8f7cee0 100644 --- a/app/v1/leaderboard.go +++ b/app/v1/leaderboard.go @@ -53,6 +53,25 @@ const lbUserQuery = ` FROM users INNER JOIN user_stats ON user_stats.user_id = users.id ` +const lbUserQueryWithAggregates = ` + SELECT + users.id, users.username, users.register_datetime, users.privileges, users.latest_activity, + users.username_aka, users.country, users.user_title, users.play_style, users.favourite_mode, + + user_stats.ranked_score, user_stats.total_score, user_stats.playcount, + user_stats.replays_watched, user_stats.total_hits, + user_stats.avg_accuracy, user_stats.pp, + + agg.pp_total_all_modes, agg.pp_stddev_all_modes, + agg.pp_total_classic, agg.pp_stddev_classic, + agg.pp_total_relax, agg.pp_stddev_relax, + agg.pp_total_std, agg.pp_total_taiko, agg.pp_total_catch, + agg.pp_stddev_std, agg.pp_stddev_taiko, agg.pp_stddev_catch, + agg.pp_std, agg.pp_std_rx, agg.pp_std_ap, agg.pp_taiko, agg.pp_taiko_rx, agg.pp_catch, agg.pp_catch_rx, agg.pp_mania + FROM users + INNER JOIN user_stats ON user_stats.user_id = users.id + LEFT JOIN player_pp_aggregates agg ON agg.player_id = users.id` + // previously done horrible hardcoding makes this the spaghetti it is func getLbUsersDb(p int, l int, rx int, modeInt int, sort string, md common.MethodData) []leaderboardUser { var query, order string @@ -122,6 +141,16 @@ func LeaderboardGET(md common.MethodData) common.CodeMessager { sort = "pp" } + // For standard sorts (pp/score) use old logic + if sort == "pp" || sort == "score" { + return getStandardLeaderboard(m, modeInt, p, l, rx, sort, md) + } + + // For new sorts (total/spp) use new logic with aggregates + return getAggregateLeaderboard(m, modeInt, p, l, rx, sort, md) +} + +func getStandardLeaderboard(m string, modeInt, p, l, rx int, sort string, md common.MethodData) common.CodeMessager { if sort != "pp" { resp := leaderboardResponse{Users: getLbUsersDb(p, l, rx, modeInt, sort, md)} resp.Code = 200 @@ -214,6 +243,227 @@ func LeaderboardGET(md common.MethodData) common.CodeMessager { return resp } +func getAggregateLeaderboard(m string, modeInt, p, l, rx int, sort string, md common.MethodData) common.CodeMessager { + var order string + + // Clean approach: simple sort=total and sort=spp + switch sort { + case "total": + order = getTotalOrder(modeInt, rx) + case "spp": + order = getStdDevOrder(modeInt, rx) + default: + // For backward compatibility can add old formats if needed + order = "ORDER BY user_stats.pp DESC, users.id ASC" + } + + query := fmt.Sprintf(lbUserQueryWithAggregates+` + WHERE (users.privileges & 3) >= 3 + AND user_stats.mode = ? + %s + LIMIT %d, %d`, order, p*l, l) + + rows, err := md.DB.Query(query, modeInt+(rx*4)) + if err != nil { + md.Err(err) + return Err500 + } + defer rows.Close() + + var resp leaderboardResponse + resp.Code = 200 + + // Scan rows with aggregates + for rows.Next() { + userDB := leaderboardUserDB{} + var chosenMode modeData + var ppTotalAll, ppStddevAll int + var ppTotalClassic, ppStddevClassic int + var ppTotalRelax, ppStddevRelax int + var ppTotalStd, ppTotalTaiko, ppTotalCatch int + var ppStddevStd, ppStddevTaiko, ppStddevCatch int + var ppStd, ppStdRx, ppStdAp, ppTaiko, ppTaikoRx, ppCatch, ppCatchRx, ppMania int + + err := rows.Scan( + &userDB.ID, &userDB.Username, &userDB.RegisteredOn, &userDB.Privileges, &userDB.LatestActivity, + + &userDB.UsernameAKA, &userDB.Country, &userDB.UserTitle, &userDB.PlayStyle, &userDB.FavouriteMode, + + &chosenMode.RankedScore, &chosenMode.TotalScore, &chosenMode.PlayCount, + &chosenMode.ReplaysWatched, &chosenMode.TotalHits, + &chosenMode.Accuracy, &chosenMode.PP, + + &ppTotalAll, &ppStddevAll, + &ppTotalClassic, &ppStddevClassic, + &ppTotalRelax, &ppStddevRelax, + &ppTotalStd, &ppTotalTaiko, &ppTotalCatch, + &ppStddevStd, &ppStddevTaiko, &ppStddevCatch, + &ppStd, &ppStdRx, &ppStdAp, &ppTaiko, &ppTaikoRx, &ppCatch, &ppCatchRx, &ppMania, + ) + if err != nil { + md.Err(err) + continue + } + + // Apply PPTotal based on mode/rx + applyPPTotal(&chosenMode, modeInt, rx, ppStd, ppStdRx, ppStdAp, ppTaiko, ppTaikoRx, ppCatch, ppCatchRx, ppMania) + + chosenMode.Level = ocl.GetLevelPrecise(int64(chosenMode.TotalScore)) + + var eligibleTitles []eligibleTitle + eligibleTitles, err = getEligibleTitles(md, userDB.ID, userDB.Privileges) + if err != nil { + md.Err(err) + return Err500 + } + + // Convert to API response format + u := userDB.toLeaderboardUser(eligibleTitles) + u.ChosenMode = chosenMode + + // For aggregate sorts use calculated positions + globalRank := p*l + 1 + u.ChosenMode.GlobalLeaderboardRank = &globalRank + // Country positions not supported for aggregate sorts + + resp.Users = append(resp.Users, u) + } + return resp +} + +func getTotalOrder(modeInt, rx int) string { + // Determine field for Total PP based on mode and rx + if modeInt >= 0 && rx >= 0 { + // Specific mode + specific rx + return fmt.Sprintf("ORDER BY %s DESC, users.id ASC", getIndividualPPField(modeInt, rx)) + } else if modeInt >= 0 { + // Specific mode, all rx - sum individual fields + switch modeInt { + case 0: // osu! all rx: std + std_rx + std_ap + return "ORDER BY (agg.pp_std + agg.pp_std_rx + agg.pp_std_ap) DESC, users.id ASC" + case 1: // taiko all rx: taiko + taiko_rx + return "ORDER BY (agg.pp_taiko + agg.pp_taiko_rx) DESC, users.id ASC" + case 2: // catch all rx: catch + catch_rx + return "ORDER BY (agg.pp_catch + agg.pp_catch_rx) DESC, users.id ASC" + case 3: // mania + return "ORDER BY agg.pp_mania DESC, users.id ASC" + default: + return "ORDER BY agg.pp_total_all_modes DESC, users.id ASC" + } + } else if rx >= 0 { + // All modes for specific rx + switch rx { + case 0: return "ORDER BY agg.pp_total_classic DESC, users.id ASC" // vanilla + case 1: return "ORDER BY agg.pp_total_relax DESC, users.id ASC" // relax + case 2: return "ORDER BY agg.pp_total_all_modes DESC, users.id ASC" // autopilot + default: return "ORDER BY agg.pp_total_all_modes DESC, users.id ASC" + } + } else { + // Total overall (no filters) + return "ORDER BY agg.pp_total_all_modes DESC, users.id ASC" + } +} + +func getStdDevOrder(modeInt, rx int) string { + // Determine field for SPP based on mode and rx + if modeInt >= 0 && rx >= 0 { + // Specific mode + specific rx + return fmt.Sprintf("ORDER BY %s DESC, users.id ASC", getIndividualStdDevField(modeInt, rx)) + } else if modeInt >= 0 { + // Specific mode, all rx + switch modeInt { + case 0: return "ORDER BY agg.pp_stddev_std DESC, users.id ASC" // osu! all rx + case 1: return "ORDER BY agg.pp_stddev_taiko DESC, users.id ASC" // taiko all rx + case 2: return "ORDER BY agg.pp_stddev_catch DESC, users.id ASC" // catch all rx + default: return "ORDER BY agg.pp_stddev_classic DESC, users.id ASC" // mania + } + } else if rx >= 0 { + // All modes for specific rx + switch rx { + case 0: return "ORDER BY agg.pp_stddev_classic DESC, users.id ASC" // vanilla + case 1: return "ORDER BY agg.pp_stddev_relax DESC, users.id ASC" // relax + case 2: return "ORDER BY agg.pp_stddev_all_modes DESC, users.id ASC" // autopilot + default: return "ORDER BY agg.pp_stddev_all_modes DESC, users.id ASC" + } + } else { + // Total overall (no filters) + return "ORDER BY agg.pp_stddev_all_modes DESC, users.id ASC" + } +} + +func getIndividualPPField(modeInt, rx int) string { + // Individual PP fields for specific mode and rx + switch modeInt { + case 0: // osu! + switch rx { + case 0: return "agg.pp_std" // vanilla + case 1: return "agg.pp_std_rx" // relax + case 2: return "agg.pp_std_ap" // autopilot + } + case 1: // taiko + switch rx { + case 0: return "agg.pp_taiko" // vanilla + case 1: return "agg.pp_taiko_rx" // relax + } + case 2: // catch + switch rx { + case 0: return "agg.pp_catch" // vanilla + case 1: return "agg.pp_catch_rx" // relax + } + case 3: // mania + return "agg.pp_mania" // only vanilla + } + return "user_stats.pp" // fallback +} + +func getIndividualStdDevField(modeInt, rx int) string { + // Individual StdDev fields for specific mode and rx + switch rx { + case 0: // vanilla + switch modeInt { + case 0: return "agg.pp_stddev_std" // osu! + case 1: return "agg.pp_stddev_taiko" // taiko + case 2: return "agg.pp_stddev_catch" // catch + default: return "agg.pp_stddev_classic" // mania and others + } + case 1: // relax + return "agg.pp_stddev_relax" + case 2: // autopilot + return "agg.pp_stddev_all_modes" // autopilot uses general aggregate + default: + return "agg.pp_stddev_all_modes" + } +} + +func applyPPTotal(chosenMode *modeData, modeInt, rx int, ppStd, ppStdRx, ppStdAp, ppTaiko, ppTaikoRx, ppCatch, ppCatchRx, ppMania int) { + // Apply chosen PP based on mode/rx + switch modeInt { + case 0: // osu! + switch rx { + case 1: + chosenMode.PPTotal = ppStdRx // relax osu! + case 2: + chosenMode.PPTotal = ppStdAp // autopilot osu! + default: + chosenMode.PPTotal = ppStd // vanilla osu! + } + case 1: // taiko + if rx == 1 { + chosenMode.PPTotal = ppTaikoRx // relax taiko + } else { + chosenMode.PPTotal = ppTaiko // vanilla taiko + } + case 2: // catch + if rx == 1 { + chosenMode.PPTotal = ppCatchRx // relax catch + } else { + chosenMode.PPTotal = ppCatch // vanilla catch + } + case 3: // mania + chosenMode.PPTotal = ppMania // mania + } +} + func leaderboardPosition(r *redis.Client, mode string, user int) *int { return _position(r, "ripple:leaderboard:"+mode, user) } diff --git a/app/v1/user.go b/app/v1/user.go index 28df9cb..8902da1 100644 --- a/app/v1/user.go +++ b/app/v1/user.go @@ -277,6 +277,8 @@ type userFullResponse struct { CMNotes *string `json:"cm_notes,omitempty"` BanDate *common.UnixTimestamp `json:"ban_date,omitempty"` Email string `json:"email,omitempty"` + PPTotal int `json:"pp_total"` + PPStdDev int `json:"pp_stddev"` } type silenceInfo struct { Reason string `json:"reason"` @@ -301,11 +303,13 @@ func UserFullGET(md common.MethodData) common.CodeMessager { // Scan user information into response err := md.DB.QueryRow(` SELECT - id, username, register_datetime, privileges, latest_activity, - username_aka, country, play_style, favourite_mode, custom_badge_icon, - custom_badge_name, can_custom_badge, show_custom_badge, silence_reason, - silence_end, notes, ban_datetime, email, clan_id, user_title + users.id, users.username, users.register_datetime, users.privileges, users.latest_activity, + users.username_aka, users.country, users.play_style, users.favourite_mode, users.custom_badge_icon, + users.custom_badge_name, users.can_custom_badge, users.show_custom_badge, users.silence_reason, + users.silence_end, users.notes, users.ban_datetime, users.email, users.clan_id, users.user_title, + COALESCE(agg.pp_total, 0), COALESCE(agg.pp_stddev, 0) FROM users + LEFT JOIN player_pp_aggregates agg ON agg.player_id = users.id WHERE `+whereClause+` AND `+md.User.OnlyUserPublic(true), userIdParam, ).Scan( @@ -313,6 +317,7 @@ func UserFullGET(md common.MethodData) common.CodeMessager { &userDB.UsernameAKA, &userDB.Country, &r.PlayStyle, &r.FavouriteMode, &b.Icon, &b.Name, &can, &show, &r.SilenceInfo.Reason, &r.SilenceInfo.End, &r.CMNotes, &r.BanDate, &r.Email, &r.Clan.ID, &userDB.UserTitle, + &r.PPTotal, &r.PPStdDev, ) switch { case err == sql.ErrNoRows: