diff --git a/cmd/console/cmd.go b/cmd/console/cmd.go index 3876fa0..4f4ec51 100644 --- a/cmd/console/cmd.go +++ b/cmd/console/cmd.go @@ -51,6 +51,7 @@ func init() { RootCmd.AddCommand(commands.ClanRankMapCmd) RootCmd.AddCommand(commands.DenyOnHoldCmd) RootCmd.AddCommand(commands.DatabaseScoresBatchDeleteFailed) + RootCmd.AddCommand(commands.DatabaseScoresModsBackfill) RootCmd.AddCommand(commands.DeleteScoreCmd) RootCmd.AddCommand(commands.FixStatsCmd) RootCmd.AddCommand(commands.UpdateStripePriceId) diff --git a/cmd/console/commands/database_scores_mods_backfill.go b/cmd/console/commands/database_scores_mods_backfill.go new file mode 100644 index 0000000..0cca281 --- /dev/null +++ b/cmd/console/commands/database_scores_mods_backfill.go @@ -0,0 +1,110 @@ +package commands + +import ( + "fmt" + "time" + + "github.com/Quaver/api2/db" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "gorm.io/gorm" +) + +var scoreModsBackfillBatchSize int +var scoreModsBackfillSleepMs int +var scoreModsBackfillMaxBatches int64 + +var DatabaseScoresModsBackfill = &cobra.Command{ + Use: "database:scores:mods:backfill", + Short: "Backfills score modifier lookup columns in batches", + RunE: func(cmd *cobra.Command, args []string) error { + if scoreModsBackfillBatchSize <= 0 { + return fmt.Errorf("batch-size must be greater than 0") + } + + if scoreModsBackfillSleepMs < 0 { + return fmt.Errorf("sleep-ms cannot be negative") + } + + if scoreModsBackfillMaxBatches < 0 { + return fmt.Errorf("max-batches cannot be negative") + } + + var completedBatches int64 + var totalUpdated int64 + lastScoreID := 0 + + logrus.Infof( + "Backfilling score modifier columns for all maps with batches of %d", + scoreModsBackfillBatchSize, + ) + + for { + if scoreModsBackfillMaxBatches > 0 && completedBatches >= scoreModsBackfillMaxBatches { + logrus.Infof( + "Reached max-batches=%d after updating %d rows. Run the command again to resume.", + scoreModsBackfillMaxBatches, + totalUpdated, + ) + return nil + } + + var scoreIDs []int + + result := db.SQL.WithContext(cmd.Context()). + Model(&db.Score{}). + Where("mods_migrated = 0 AND id > ?", lastScoreID). + Order("id ASC"). + Limit(scoreModsBackfillBatchSize). + Pluck("id", &scoreIDs) + + if result.Error != nil { + return fmt.Errorf("retrieve unmigrated scores after score id %d: %w", lastScoreID, result.Error) + } + + if len(scoreIDs) == 0 { + break + } + + result = db.SQL.WithContext(cmd.Context()). + Model(&db.Score{}). + Where("id IN ? AND mods_migrated = 0", scoreIDs). + UpdateColumn("mods", gorm.Expr("mods")) + + if result.Error != nil { + return fmt.Errorf("backfill score modifier columns through score id %d: %w", scoreIDs[len(scoreIDs)-1], result.Error) + } + + lastScoreID = scoreIDs[len(scoreIDs)-1] + completedBatches++ + totalUpdated += result.RowsAffected + + logrus.Infof( + "Backfilled score modifier columns through score id %d (%d rows, %d total, %d batches complete)", + lastScoreID, + result.RowsAffected, + totalUpdated, + completedBatches, + ) + + if scoreModsBackfillSleepMs > 0 { + time.Sleep(time.Duration(scoreModsBackfillSleepMs) * time.Millisecond) + } + } + + logrus.Infof( + "Score modifier column backfill complete. Processed %d batches and updated %d rows.", + completedBatches, + totalUpdated, + ) + logrus.Info("Set score_mod_columns_ready=true and restart all API instances to enable indexed score modifier lookups.") + + return nil + }, +} + +func init() { + DatabaseScoresModsBackfill.Flags().IntVar(&scoreModsBackfillBatchSize, "batch-size", 5000, "Maximum number of scores to backfill per batch") + DatabaseScoresModsBackfill.Flags().IntVar(&scoreModsBackfillSleepMs, "sleep-ms", 0, "Milliseconds to sleep between batches") + DatabaseScoresModsBackfill.Flags().Int64Var(&scoreModsBackfillMaxBatches, "max-batches", 0, "Maximum number of batches to process before exiting") +} diff --git a/cmd/console/commands/database_scores_mods_backfill_test.go b/cmd/console/commands/database_scores_mods_backfill_test.go new file mode 100644 index 0000000..4e974d1 --- /dev/null +++ b/cmd/console/commands/database_scores_mods_backfill_test.go @@ -0,0 +1,54 @@ +package commands + +import "testing" + +func TestDatabaseScoresModsBackfillValidation(t *testing.T) { + originalBatchSize := scoreModsBackfillBatchSize + originalSleepMs := scoreModsBackfillSleepMs + originalMaxBatches := scoreModsBackfillMaxBatches + + t.Cleanup(func() { + scoreModsBackfillBatchSize = originalBatchSize + scoreModsBackfillSleepMs = originalSleepMs + scoreModsBackfillMaxBatches = originalMaxBatches + }) + + tests := []struct { + name string + batchSize int + sleepMs int + maxBatches int64 + wantError string + }{ + { + name: "zero batch size", + batchSize: 0, + wantError: "batch-size must be greater than 0", + }, + { + name: "negative sleep", + batchSize: 1, + sleepMs: -1, + wantError: "sleep-ms cannot be negative", + }, + { + name: "negative max batches", + batchSize: 1, + maxBatches: -1, + wantError: "max-batches cannot be negative", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scoreModsBackfillBatchSize = tt.batchSize + scoreModsBackfillSleepMs = tt.sleepMs + scoreModsBackfillMaxBatches = tt.maxBatches + err := DatabaseScoresModsBackfill.RunE(DatabaseScoresModsBackfill, nil) + + if err == nil || err.Error() != tt.wantError { + t.Fatalf("RunE() error = %v, want %q", err, tt.wantError) + } + }) + } +} diff --git a/cmd/database/migrations/29_score_mod_columns.down.sql b/cmd/database/migrations/29_score_mod_columns.down.sql new file mode 100644 index 0000000..3284c4b --- /dev/null +++ b/cmd/database/migrations/29_score_mod_columns.down.sql @@ -0,0 +1,12 @@ +DROP TRIGGER IF EXISTS scores_mod_columns_bu; +DROP TRIGGER IF EXISTS scores_mod_columns_bi; + +ALTER TABLE scores + DROP COLUMN mods_migrated, + DROP COLUMN no_miss, + DROP COLUMN full_ln, + DROP COLUMN inverse, + DROP COLUMN no_long_notes, + DROP COLUMN no_slider_velocities, + DROP COLUMN mirror, + DROP COLUMN speed_rate; diff --git a/cmd/database/migrations/29_score_mod_columns.up.sql b/cmd/database/migrations/29_score_mod_columns.up.sql new file mode 100644 index 0000000..206c035 --- /dev/null +++ b/cmd/database/migrations/29_score_mod_columns.up.sql @@ -0,0 +1,97 @@ +ALTER TABLE scores + ADD COLUMN speed_rate SMALLINT UNSIGNED NULL AFTER mods, + ADD COLUMN mirror TINYINT(1) NULL AFTER speed_rate, + ADD COLUMN no_slider_velocities TINYINT(1) NULL AFTER mirror, + ADD COLUMN no_long_notes TINYINT(1) NULL AFTER no_slider_velocities, + ADD COLUMN inverse TINYINT(1) NULL AFTER no_long_notes, + ADD COLUMN full_ln TINYINT(1) NULL AFTER inverse, + ADD COLUMN no_miss TINYINT(1) NULL AFTER full_ln, + ADD COLUMN mods_migrated TINYINT(1) NOT NULL DEFAULT 0 AFTER no_miss; + +CREATE TRIGGER scores_mod_columns_bi + BEFORE INSERT ON scores + FOR EACH ROW + SET NEW.speed_rate = CASE + WHEN (NEW.mods & 2) != 0 THEN 50 + WHEN (NEW.mods & 16777216) != 0 THEN 55 + WHEN (NEW.mods & 4) != 0 THEN 60 + WHEN (NEW.mods & 33554432) != 0 THEN 65 + WHEN (NEW.mods & 8) != 0 THEN 70 + WHEN (NEW.mods & 67108864) != 0 THEN 75 + WHEN (NEW.mods & 16) != 0 THEN 80 + WHEN (NEW.mods & 134217728) != 0 THEN 85 + WHEN (NEW.mods & 32) != 0 THEN 90 + WHEN (NEW.mods & 268435456) != 0 THEN 95 + WHEN (NEW.mods & 8589934592) != 0 THEN 105 + WHEN (NEW.mods & 64) != 0 THEN 110 + WHEN (NEW.mods & 17179869184) != 0 THEN 115 + WHEN (NEW.mods & 128) != 0 THEN 120 + WHEN (NEW.mods & 34359738368) != 0 THEN 125 + WHEN (NEW.mods & 256) != 0 THEN 130 + WHEN (NEW.mods & 68719476736) != 0 THEN 135 + WHEN (NEW.mods & 512) != 0 THEN 140 + WHEN (NEW.mods & 137438953472) != 0 THEN 145 + WHEN (NEW.mods & 1024) != 0 THEN 150 + WHEN (NEW.mods & 274877906944) != 0 THEN 155 + WHEN (NEW.mods & 2048) != 0 THEN 160 + WHEN (NEW.mods & 549755813888) != 0 THEN 165 + WHEN (NEW.mods & 4096) != 0 THEN 170 + WHEN (NEW.mods & 1099511627776) != 0 THEN 175 + WHEN (NEW.mods & 8192) != 0 THEN 180 + WHEN (NEW.mods & 2199023255552) != 0 THEN 185 + WHEN (NEW.mods & 16384) != 0 THEN 190 + WHEN (NEW.mods & 4398046511104) != 0 THEN 195 + WHEN (NEW.mods & 32768) != 0 THEN 200 + ELSE 100 + END, + NEW.mirror = IF((NEW.mods & 2147483648) != 0, 1, 0), + NEW.no_slider_velocities = IF((NEW.mods & 1) != 0, 1, 0), + NEW.no_long_notes = IF((NEW.mods & 4194304) != 0, 1, 0), + NEW.inverse = IF((NEW.mods & 536870912) != 0, 1, 0), + NEW.full_ln = IF((NEW.mods & 1073741824) != 0, 1, 0), + NEW.no_miss = IF((NEW.mods & 17592186044416) != 0, 1, 0), + NEW.mods_migrated = 1; + +CREATE TRIGGER scores_mod_columns_bu + BEFORE UPDATE ON scores + FOR EACH ROW + SET NEW.speed_rate = CASE + WHEN (NEW.mods & 2) != 0 THEN 50 + WHEN (NEW.mods & 16777216) != 0 THEN 55 + WHEN (NEW.mods & 4) != 0 THEN 60 + WHEN (NEW.mods & 33554432) != 0 THEN 65 + WHEN (NEW.mods & 8) != 0 THEN 70 + WHEN (NEW.mods & 67108864) != 0 THEN 75 + WHEN (NEW.mods & 16) != 0 THEN 80 + WHEN (NEW.mods & 134217728) != 0 THEN 85 + WHEN (NEW.mods & 32) != 0 THEN 90 + WHEN (NEW.mods & 268435456) != 0 THEN 95 + WHEN (NEW.mods & 8589934592) != 0 THEN 105 + WHEN (NEW.mods & 64) != 0 THEN 110 + WHEN (NEW.mods & 17179869184) != 0 THEN 115 + WHEN (NEW.mods & 128) != 0 THEN 120 + WHEN (NEW.mods & 34359738368) != 0 THEN 125 + WHEN (NEW.mods & 256) != 0 THEN 130 + WHEN (NEW.mods & 68719476736) != 0 THEN 135 + WHEN (NEW.mods & 512) != 0 THEN 140 + WHEN (NEW.mods & 137438953472) != 0 THEN 145 + WHEN (NEW.mods & 1024) != 0 THEN 150 + WHEN (NEW.mods & 274877906944) != 0 THEN 155 + WHEN (NEW.mods & 2048) != 0 THEN 160 + WHEN (NEW.mods & 549755813888) != 0 THEN 165 + WHEN (NEW.mods & 4096) != 0 THEN 170 + WHEN (NEW.mods & 1099511627776) != 0 THEN 175 + WHEN (NEW.mods & 8192) != 0 THEN 180 + WHEN (NEW.mods & 2199023255552) != 0 THEN 185 + WHEN (NEW.mods & 16384) != 0 THEN 190 + WHEN (NEW.mods & 4398046511104) != 0 THEN 195 + WHEN (NEW.mods & 32768) != 0 THEN 200 + ELSE 100 + END, + NEW.mirror = IF((NEW.mods & 2147483648) != 0, 1, 0), + NEW.no_slider_velocities = IF((NEW.mods & 1) != 0, 1, 0), + NEW.no_long_notes = IF((NEW.mods & 4194304) != 0, 1, 0), + NEW.inverse = IF((NEW.mods & 536870912) != 0, 1, 0), + NEW.full_ln = IF((NEW.mods & 1073741824) != 0, 1, 0), + NEW.no_miss = IF((NEW.mods & 17592186044416) != 0, 1, 0), + NEW.mods_migrated = 1; diff --git a/cmd/database/migrations/30_score_mod_column_indexes.down.sql b/cmd/database/migrations/30_score_mod_column_indexes.down.sql new file mode 100644 index 0000000..cf97a25 --- /dev/null +++ b/cmd/database/migrations/30_score_mod_column_indexes.down.sql @@ -0,0 +1,3 @@ +DROP INDEX scores_mod_no_miss_idx ON scores; +DROP INDEX scores_mod_mirror_idx ON scores; +DROP INDEX scores_mod_speed_rate_idx ON scores; diff --git a/cmd/database/migrations/30_score_mod_column_indexes.up.sql b/cmd/database/migrations/30_score_mod_column_indexes.up.sql new file mode 100644 index 0000000..e76f6a5 --- /dev/null +++ b/cmd/database/migrations/30_score_mod_column_indexes.up.sql @@ -0,0 +1,8 @@ +CREATE INDEX scores_mod_speed_rate_idx + ON scores (map_md5, failed, speed_rate, user_id, performance_rating DESC, timestamp DESC); + +CREATE INDEX scores_mod_mirror_idx + ON scores (map_md5, failed, mirror, user_id, performance_rating DESC, timestamp DESC); + +CREATE INDEX scores_mod_no_miss_idx + ON scores (map_md5, failed, no_miss, user_id, performance_rating DESC, timestamp DESC); diff --git a/cmd/database/migrations/32_scores_global_scoreboard_index.down.sql b/cmd/database/migrations/32_scores_global_scoreboard_index.down.sql new file mode 100644 index 0000000..efec603 --- /dev/null +++ b/cmd/database/migrations/32_scores_global_scoreboard_index.down.sql @@ -0,0 +1 @@ +DROP INDEX scores_global_scoreboard_idx ON scores; diff --git a/cmd/database/migrations/32_scores_global_scoreboard_index.up.sql b/cmd/database/migrations/32_scores_global_scoreboard_index.up.sql new file mode 100644 index 0000000..a3ac5e6 --- /dev/null +++ b/cmd/database/migrations/32_scores_global_scoreboard_index.up.sql @@ -0,0 +1,2 @@ +CREATE INDEX scores_global_scoreboard_idx + ON scores (map_md5, personal_best, performance_rating DESC, user_id); diff --git a/cmd/database/migrations/33_scores_index_cleanup.down.sql b/cmd/database/migrations/33_scores_index_cleanup.down.sql new file mode 100644 index 0000000..7c46de4 --- /dev/null +++ b/cmd/database/migrations/33_scores_index_cleanup.down.sql @@ -0,0 +1,8 @@ +CREATE UNIQUE INDEX `UNIQUE` + ON scores (id); + +CREATE INDEX personal_best + ON scores (personal_best); + +CREATE INDEX mods + ON scores (mods); diff --git a/cmd/database/migrations/33_scores_index_cleanup.up.sql b/cmd/database/migrations/33_scores_index_cleanup.up.sql new file mode 100644 index 0000000..ac94634 --- /dev/null +++ b/cmd/database/migrations/33_scores_index_cleanup.up.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS `UNIQUE` ON scores; +DROP INDEX IF EXISTS personal_best ON scores; +DROP INDEX IF EXISTS mods ON scores; +DROP INDEX IF EXISTS scores_mod_nsv_idx ON scores; +DROP INDEX IF EXISTS scores_mod_nln_idx ON scores; +DROP INDEX IF EXISTS scores_mod_inverse_idx ON scores; +DROP INDEX IF EXISTS scores_mod_full_ln_idx ON scores; diff --git a/config.example.json b/config.example.json index a167a0c..cf8dbdc 100644 --- a/config.example.json +++ b/config.example.json @@ -1,5 +1,6 @@ { "is_production": false, + "score_mod_columns_ready": false, "api_url": "https://api.quavergame.com", "website_url": "http://localhost:8081", "jwt_secret": "", @@ -140,4 +141,4 @@ "schedule": "0 * * * *" } } -} \ No newline at end of file +} diff --git a/config/config.go b/config/config.go index 16645c6..6e569a8 100644 --- a/config/config.go +++ b/config/config.go @@ -9,7 +9,8 @@ import ( ) type Config struct { - IsProduction bool `json:"is_production"` + IsProduction bool `json:"is_production"` + ScoreModColumnsReady bool `json:"score_mod_columns_ready"` APIUrl string `json:"api_url"` diff --git a/db/scores.go b/db/scores.go index 5e6ee9e..3ed2fb1 100644 --- a/db/scores.go +++ b/db/scores.go @@ -2,10 +2,12 @@ package db import ( "encoding/json" + "errors" "fmt" "math" "time" + "github.com/Quaver/api2/config" "github.com/Quaver/api2/enums" "github.com/redis/go-redis/v9" "gorm.io/gorm" @@ -22,6 +24,14 @@ type Score struct { IsPersonalBest bool `gorm:"column:personal_best" json:"is_personal_best"` PerformanceRating float64 `gorm:"column:performance_rating" json:"performance_rating"` Modifiers int64 `gorm:"column:mods" json:"modifiers"` + SpeedRate int `gorm:"column:speed_rate;->" json:"speed_rate"` + Mirror bool `gorm:"column:mirror;->" json:"mirror"` + NoSliderVelocities bool `gorm:"column:no_slider_velocities;->" json:"no_slider_velocities"` + NoLongNotes bool `gorm:"column:no_long_notes;->" json:"no_long_notes"` + Inverse bool `gorm:"column:inverse;->" json:"inverse"` + FullLN bool `gorm:"column:full_ln;->" json:"full_ln"` + NoMiss bool `gorm:"column:no_miss;->" json:"no_miss"` + ModsMigrated bool `gorm:"column:mods_migrated;->" json:"-"` Failed bool `gorm:"column:failed" json:"failed"` TotalScore int `gorm:"column:total_score" json:"total_score"` Accuracy float64 `gorm:"column:accuracy" json:"accuracy"` @@ -63,6 +73,18 @@ func (s *Score) BeforeCreate(*gorm.DB) (err error) { func (s *Score) AfterFind(*gorm.DB) (err error) { s.TimestampJSON = time.UnixMilli(s.Timestamp) + + if !s.ModsMigrated { + mods := enums.Mods(s.Modifiers) + s.SpeedRate = enums.GetSpeedRate(mods) + s.Mirror = enums.IsModActivated(mods, enums.ModMirror) + s.NoSliderVelocities = enums.IsModActivated(mods, enums.ModNoSliderVelocities) + s.NoLongNotes = enums.IsModActivated(mods, enums.ModNoLongNotes) + s.Inverse = enums.IsModActivated(mods, enums.ModInverse) + s.FullLN = enums.IsModActivated(mods, enums.ModFullLN) + s.NoMiss = enums.IsModActivated(mods, enums.ModNoMiss) + } + return nil } @@ -209,25 +231,38 @@ func GetGlobalScoresForMap(md5 string, useCache bool) ([]*Score, error) { var scores = make([]*Score, 0) - result := SQL. - Joins("User"). - Where("scores.map_md5 = ? "+ - "AND scores.personal_best = 1 "+ - "AND User.allowed = 1", md5). - Order("scores.performance_rating DESC"). - Limit(100). - Find(&scores) + result := SQL.Raw(fmt.Sprintf(` + WITH TopScores AS ( + SELECT scores.id + FROM scores + JOIN users filter_user ON scores.user_id = filter_user.id + WHERE scores.map_md5 = ? + AND scores.personal_best = 1 + AND filter_user.allowed = 1 + ORDER BY scores.performance_rating DESC + LIMIT 100 + ) + SELECT %v + FROM TopScores + JOIN scores s ON s.id = TopScores.id + JOIN users u ON s.user_id = u.id + ORDER BY s.performance_rating DESC`, scoreboardScoreAndUserColumns), md5). + Scan(&scores) if result.Error != nil { return nil, result.Error } for _, score := range scores { - if err := score.User.AfterFind(SQL); err != nil { + if err := score.AfterFind(SQL); err != nil { return nil, err } } + if err := hydrateScoreboardUsers(scores); err != nil { + return nil, err + } + if useCache { if err := cacheScoreboard(scoreboardGlobal, md5, scores, 0); err != nil { return nil, err @@ -265,10 +300,8 @@ func GetCountryScoresForMap(md5 string, country string) ([]*Score, error) { return nil, result.Error } - for _, score := range scores { - if err := score.User.AfterFind(SQL); err != nil { - return nil, err - } + if err := hydrateScoreboardUsers(scores); err != nil { + return nil, err } if err := cacheScoreboard(scoreboardCountry, md5, scores, 0); err != nil { @@ -291,6 +324,13 @@ func GetModifierScoresForMap(md5 string, mods int64) ([]*Score, error) { } var scores = make([]*Score, 0) + modsQuery, modsArgs, err := getModifierScoreFilter("s", mods) + + if err != nil { + return nil, err + } + + args := append([]any{md5}, modsArgs...) result := SQL.Raw(fmt.Sprintf(` WITH RankedScores AS ( @@ -301,10 +341,10 @@ func GetModifierScoresForMap(md5 string, mods int64) ([]*Score, error) { FROM scores s WHERE s.map_md5 = ? - AND (mods & ?) != 0 + %v AND s.failed = 0 ) - %v`, getSelectUserScoreboardQuery(100)), md5, mods). + %v`, modsQuery, getSelectUserScoreboardQuery(100)), args...). Scan(&scores) if result.Error != nil { @@ -315,10 +355,10 @@ func GetModifierScoresForMap(md5 string, mods int64) ([]*Score, error) { if err := score.AfterFind(SQL); err != nil { return nil, err } + } - if err := score.User.AfterFind(SQL); err != nil { - return nil, err - } + if err := hydrateScoreboardUsers(scores); err != nil { + return nil, err } if err := cacheScoreboard(scoreboardMods, md5, scores, mods); err != nil { @@ -341,16 +381,14 @@ func GetRateScoresForMap(md5 string, mods int64) ([]*Score, error) { } var scores = make([]*Score, 0) + rateQuery, rateArgs, err := getRateScoreFilter("s", mods) - modsQuery := "" - - if mods == 0 { - modsQuery = "AND (s.mods = 0 OR s.mods = ?) " - mods = 2147483648 // TODO: USE ENUM - } else { - modsQuery = "AND (s.mods & ?) != 0 " + if err != nil { + return nil, err } + args := append([]any{md5}, rateArgs...) + result := SQL.Raw(fmt.Sprintf(` WITH RankedScores AS ( SELECT @@ -363,7 +401,7 @@ func GetRateScoresForMap(md5 string, mods int64) ([]*Score, error) { AND s.failed = 0 %v ) - %v`, modsQuery, getSelectUserScoreboardQuery(100)), md5, mods). + %v`, rateQuery, getSelectUserScoreboardQuery(100)), args...). Scan(&scores) if result.Error != nil { @@ -374,10 +412,10 @@ func GetRateScoresForMap(md5 string, mods int64) ([]*Score, error) { if err := score.AfterFind(SQL); err != nil { return nil, err } + } - if err := score.User.AfterFind(SQL); err != nil { - return nil, err - } + if err := hydrateScoreboardUsers(scores); err != nil { + return nil, err } if err := cacheScoreboard(scoreboardRate, md5, scores, mods); err != nil { @@ -423,10 +461,10 @@ func GetAllScoresForMap(md5 string) ([]*Score, error) { if err := score.AfterFind(SQL); err != nil { return nil, err } + } - if err := score.User.AfterFind(SQL); err != nil { - return nil, err - } + if err := hydrateScoreboardUsers(scores); err != nil { + return nil, err } if err := cacheScoreboard(scoreboardAll, md5, scores, 0); err != nil { @@ -516,22 +554,22 @@ func GetUserPersonalBestScoreAll(userId int, md5 string) (*Score, error) { // GetUserPersonalBestScoreMods Retrieves a user's personal best modifier score on a given map func GetUserPersonalBestScoreMods(userId int, md5 string, mods int64) (*Score, error) { var score *Score + modsQuery, modsArgs, err := getModifierScoreFilter("scores", mods) - modsQueryStr := "" - - if mods == 0 { - modsQueryStr = "AND scores.mods = ? " - } else { - modsQueryStr = "AND (scores.mods & ?) != 0 " + if err != nil { + return nil, err } + args := append([]any{md5}, modsArgs...) + args = append(args, userId) + result := SQL. Joins("User"). Where("scores.map_md5 = ? "+ "AND scores.failed = 0 "+ - modsQueryStr+ + modsQuery+ "AND User.id = ? "+ - "AND User.allowed = 1", md5, mods, userId). + "AND User.allowed = 1", args...). Order("scores.performance_rating DESC"). First(&score) @@ -549,23 +587,22 @@ func GetUserPersonalBestScoreMods(userId int, md5 string, mods int64) (*Score, e // GetUserPersonalBestScoreRate Retrieves a user's personal best rate score on a given map func GetUserPersonalBestScoreRate(userId int, md5 string, mods int64) (*Score, error) { var score *Score + rateQuery, rateArgs, err := getRateScoreFilter("scores", mods) - modsQuery := "" - - if mods == 0 { - modsQuery = "AND (scores.mods = 0 OR scores.mods = ?) " - mods = 2147483648 // TODO: USE ENUM - } else { - modsQuery = "AND (scores.mods & ?) != 0 " + if err != nil { + return nil, err } + args := append([]any{md5}, rateArgs...) + args = append(args, userId) + result := SQL. Joins("User"). Where("scores.map_md5 = ? "+ "AND scores.failed = 0 "+ - modsQuery+ + rateQuery+ "AND User.id = ? "+ - "AND User.allowed = 1", md5, mods, userId). + "AND User.allowed = 1", args...). Order("scores.performance_rating DESC"). First(&score) @@ -667,6 +704,8 @@ func CalculateOverallAccuracy(scores []*Score) float64 { type scoreboardType string const ( + scoreboardCacheVersion = "v2" + scoreboardGlobal scoreboardType = "global" scoreboardCountry scoreboardType = "country" scoreboardFriends scoreboardType = "friends" @@ -679,9 +718,22 @@ const ( func scoreboardRedisKey(md5 string, scoreboard scoreboardType, mods int64) string { switch scoreboard { case scoreboardMods, scoreboardRate: - return fmt.Sprintf("quaver:scoreboard:%v:%v:%v", md5, scoreboard, mods) + filterMode := "legacy" + + if scoreModColumnsReady() { + filterMode = "columns" + } + + return fmt.Sprintf( + "quaver:scoreboard:%v:%v:%v:%v:%v", + scoreboardCacheVersion, + md5, + scoreboard, + mods, + filterMode, + ) default: - return fmt.Sprintf("quaver:scoreboard:%v:%v", md5, scoreboard) + return fmt.Sprintf("quaver:scoreboard:%v:%v:%v", scoreboardCacheVersion, md5, scoreboard) } } @@ -753,10 +805,7 @@ func getCachedScoreboard(scoreboard scoreboardType, md5 string, mods int64) ([]* return scores, nil } -// Returns a query to select user scores from non personal best scoreboards. -func getSelectUserScoreboardQuery(limit int, donatorOnly ...bool) string { - query := ` - SELECT s.user_id, +const scoreboardScoreAndUserColumns = `s.user_id, s.*, u.id AS User__id, u.steam_id AS User__steam_id, @@ -777,13 +826,20 @@ func getSelectUserScoreboardQuery(limit int, donatorOnly ...bool) string { u.discord_id AS User__discord_id, u.information AS User__information, u.clan_id AS User__clan_id, - u.clan_leave_time AS User__clan_leave_time + u.clan_leave_time AS User__clan_leave_time, + u.accent_color_customizable AS User__accent_color_customizable, + u.accent_color AS User__accent_color` + +// Returns a query to select user scores from non personal best scoreboards. +func getSelectUserScoreboardQuery(limit int, donatorOnly ...bool) string { + query := fmt.Sprintf(` + SELECT %v FROM RankedScores rs JOIN scores s ON s.id = rs.score_id JOIN users u ON s.user_id = u.id JOIN maps m ON s.map_md5 = m.md5 WHERE rs.rnk = 1 AND u.allowed = 1 - ` + `, scoreboardScoreAndUserColumns) if len(donatorOnly) > 0 && donatorOnly[0] == true { query += " AND u.donator_end_time > 0" } @@ -807,3 +863,178 @@ func comparePointers[T comparable](a, b *T) bool { return *a == *b } + +func hydrateScoreboardUsers(scores []*Score) error { + usersByID := make(map[int][]*User, len(scores)) + usersByClanID := make(map[int][]*User) + + for _, score := range scores { + if score.User == nil { + continue + } + + user := score.User + + if err := user.populateAfterFindFields(); err != nil { + continue + } + + if user.StatsKeys4 != nil { + if ranks, err := GetUserRanksForMode(user, enums.GameModeKeys4); err == nil { + user.StatsKeys4.Ranks = ranks + } + } + + if user.StatsKeys7 != nil { + if ranks, err := GetUserRanksForMode(user, enums.GameModeKeys7); err == nil { + user.StatsKeys7.Ranks = ranks + } + } + + usersByID[user.Id] = append(usersByID[user.Id], user) + + if user.ClanId != nil { + usersByClanID[*user.ClanId] = append(usersByClanID[*user.ClanId], user) + } + } + + if len(usersByID) == 0 { + return nil + } + + userIDs := make([]int, 0, len(usersByID)) + + for userID := range usersByID { + userIDs = append(userIDs, userID) + } + + if statuses, err := getUserClientStatuses(userIDs); err == nil { + for userID, status := range statuses { + for _, user := range usersByID[userID] { + user.ClientStatus = status + } + } + } + + if len(usersByClanID) == 0 { + return nil + } + + clanIDs := make([]int, 0, len(usersByClanID)) + + for clanID := range usersByClanID { + clanIDs = append(clanIDs, clanID) + } + + var clans []Clan + result := SQL. + Select("id", "tag", "accent_color"). + Where("id IN ?", clanIDs). + Find(&clans) + + if result.Error != nil { + return result.Error + } + + clansByID := make(map[int]Clan, len(clans)) + + for _, clan := range clans { + clansByID[clan.Id] = clan + } + + for clanID, users := range usersByClanID { + clan := clansByID[clanID] + + for _, user := range users { + tag := clan.Tag + user.ClanTag = &tag + user.ClanAccentColor = clan.AccentColor + } + } + + return nil +} + +var scoreModifierColumns = map[enums.Mods]string{ + enums.ModNoSliderVelocities: "no_slider_velocities", + enums.ModNoLongNotes: "no_long_notes", + enums.ModInverse: "inverse", + enums.ModFullLN: "full_ln", + enums.ModMirror: "mirror", + enums.ModNoMiss: "no_miss", +} + +var ErrUnsupportedScoreModifierFilter = errors.New("modifier filter is not supported by score lookup columns") + +func scoreModColumnsReady() bool { + return config.Instance != nil && config.Instance.ScoreModColumnsReady +} + +func getModifierScoreFilter(tableAlias string, mods int64) (string, []any, error) { + if mods == 0 { + return fmt.Sprintf("AND %v.mods = ? ", tableAlias), []any{int64(0)}, nil + } + + if !scoreModColumnsReady() { + return fmt.Sprintf("AND (%v.mods & ?) != 0 ", tableAlias), []any{mods}, nil + } + + columns := make([]string, 0) + + for i := 0; (1 << i) < enums.ModEnumMaxValue-1; i++ { + mod := enums.Mods(1 << i) + + if !enums.IsModActivated(enums.Mods(mods), mod) { + continue + } + + column, ok := scoreModifierColumns[mod] + + if !ok { + return "", nil, ErrUnsupportedScoreModifierFilter + } + + columns = append(columns, fmt.Sprintf("AND %v.%v = 1 ", tableAlias, column)) + } + + if len(columns) == 0 { + return "", nil, ErrUnsupportedScoreModifierFilter + } + + query := "" + + for _, column := range columns { + query += column + } + + return query, []any{}, nil +} + +func getRateScoreFilter(tableAlias string, mods int64) (string, []any, error) { + if !scoreModColumnsReady() { + if mods == 0 { + return fmt.Sprintf( + "AND (%v.mods = 0 OR %v.mods = ?) ", + tableAlias, + tableAlias, + ), []any{int64(enums.ModMirror)}, nil + } + + return fmt.Sprintf("AND (%v.mods & ?) != 0 ", tableAlias), []any{mods}, nil + } + + modCombo := enums.Mods(mods) + speedRate := enums.GetSpeedRate(modCombo) + + if mods == 0 { + return fmt.Sprintf("AND %v.speed_rate = ? ", tableAlias), []any{speedRate}, nil + } + + _, ok := enums.GetSpeedMod(modCombo) + + if !ok { + return "", nil, ErrUnsupportedScoreModifierFilter + } + + return fmt.Sprintf("AND %v.speed_rate = ? ", tableAlias), []any{speedRate}, nil +} diff --git a/db/scores_test.go b/db/scores_test.go new file mode 100644 index 0000000..3db50c7 --- /dev/null +++ b/db/scores_test.go @@ -0,0 +1,243 @@ +package db + +import ( + "errors" + "reflect" + "testing" + "time" + + "github.com/Quaver/api2/config" + "github.com/Quaver/api2/enums" +) + +func setScoreModColumnsReadyForTest(t *testing.T, ready bool) { + t.Helper() + + original := config.Instance + configured := &config.Config{ScoreModColumnsReady: ready} + config.Instance = configured + + t.Cleanup(func() { + config.Instance = original + }) +} + +func TestGetModifierScoreFilter(t *testing.T) { + tests := []struct { + name string + alias string + mods int64 + ready bool + wantQuery string + wantArgs []any + wantErr error + }{ + { + name: "none", + alias: "scores", + mods: 0, + wantQuery: "AND scores.mods = ? ", + wantArgs: []any{int64(0)}, + }, + { + name: "legacy supported modifier", + alias: "s", + mods: int64(enums.ModMirror), + wantQuery: "AND (s.mods & ?) != 0 ", + wantArgs: []any{int64(enums.ModMirror)}, + }, + { + name: "legacy unsupported modifier remains available", + alias: "s", + mods: int64(enums.ModNoFail), + wantQuery: "AND (s.mods & ?) != 0 ", + wantArgs: []any{int64(enums.ModNoFail)}, + }, + { + name: "lookup single supported modifier", + alias: "s", + mods: int64(enums.ModMirror), + ready: true, + wantQuery: "AND s.mirror = 1 ", + wantArgs: []any{}, + }, + { + name: "lookup multiple supported modifiers", + alias: "scores", + mods: int64(enums.ModMirror | enums.ModNoMiss), + ready: true, + wantQuery: "AND scores.mirror = 1 AND scores.no_miss = 1 ", + wantArgs: []any{}, + }, + { + name: "lookup unsupported modifier errors", + alias: "s", + mods: int64(enums.ModNoFail), + ready: true, + wantErr: ErrUnsupportedScoreModifierFilter, + }, + { + name: "lookup mixed supported and unsupported errors", + alias: "s", + mods: int64(enums.ModMirror | enums.ModNoFail), + ready: true, + wantErr: ErrUnsupportedScoreModifierFilter, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setScoreModColumnsReadyForTest(t, tt.ready) + gotQuery, gotArgs, err := getModifierScoreFilter(tt.alias, tt.mods) + + if !errors.Is(err, tt.wantErr) { + t.Fatalf("err = %v, want %v", err, tt.wantErr) + } + + if tt.wantErr != nil { + return + } + + if gotQuery != tt.wantQuery { + t.Fatalf("query = %q, want %q", gotQuery, tt.wantQuery) + } + + if !reflect.DeepEqual(gotArgs, tt.wantArgs) { + t.Fatalf("args = %#v, want %#v", gotArgs, tt.wantArgs) + } + }) + } +} + +func TestGetRateScoreFilter(t *testing.T) { + tests := []struct { + name string + alias string + mods int64 + ready bool + wantQuery string + wantArgs []any + wantErr error + }{ + { + name: "legacy none", + alias: "s", + mods: 0, + wantQuery: "AND (s.mods = 0 OR s.mods = ?) ", + wantArgs: []any{int64(enums.ModMirror)}, + }, + { + name: "legacy speed modifier", + alias: "scores", + mods: int64(enums.ModSpeed15X), + wantQuery: "AND (scores.mods & ?) != 0 ", + wantArgs: []any{int64(enums.ModSpeed15X)}, + }, + { + name: "legacy non-speed modifier remains available", + alias: "s", + mods: int64(enums.ModMirror), + wantQuery: "AND (s.mods & ?) != 0 ", + wantArgs: []any{int64(enums.ModMirror)}, + }, + { + name: "lookup none", + alias: "s", + mods: 0, + ready: true, + wantQuery: "AND s.speed_rate = ? ", + wantArgs: []any{100}, + }, + { + name: "lookup speed modifier", + alias: "scores", + mods: int64(enums.ModSpeed15X), + ready: true, + wantQuery: "AND scores.speed_rate = ? ", + wantArgs: []any{150}, + }, + { + name: "lookup non-speed errors", + alias: "s", + mods: int64(enums.ModMirror), + ready: true, + wantErr: ErrUnsupportedScoreModifierFilter, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setScoreModColumnsReadyForTest(t, tt.ready) + gotQuery, gotArgs, err := getRateScoreFilter(tt.alias, tt.mods) + + if !errors.Is(err, tt.wantErr) { + t.Fatalf("err = %v, want %v", err, tt.wantErr) + } + + if tt.wantErr != nil { + return + } + + if gotQuery != tt.wantQuery { + t.Fatalf("query = %q, want %q", gotQuery, tt.wantQuery) + } + + if !reflect.DeepEqual(gotArgs, tt.wantArgs) { + t.Fatalf("args = %#v, want %#v", gotArgs, tt.wantArgs) + } + }) + } +} + +func TestScoreboardRedisKeySeparatesModifierFilterModes(t *testing.T) { + setScoreModColumnsReadyForTest(t, false) + legacyKey := scoreboardRedisKey("map", scoreboardMods, int64(enums.ModMirror)) + + config.Instance.ScoreModColumnsReady = true + columnsKey := scoreboardRedisKey("map", scoreboardMods, int64(enums.ModMirror)) + + if legacyKey == columnsKey { + t.Fatalf("modifier scoreboard cache key did not change with filter mode: %q", legacyKey) + } +} + +func TestScoreAfterFindPopulatesLegacyModifierFields(t *testing.T) { + timestamp := int64(1_721_234_567_890) + score := &Score{ + Timestamp: timestamp, + Modifiers: int64(enums.ModSpeed15X | enums.ModMirror | enums.ModNoMiss), + } + + if err := score.AfterFind(nil); err != nil { + t.Fatalf("AfterFind() error = %v", err) + } + + if want := time.UnixMilli(timestamp); !score.TimestampJSON.Equal(want) { + t.Fatalf("TimestampJSON = %v, want %v", score.TimestampJSON, want) + } + + if score.SpeedRate != 150 { + t.Fatalf("SpeedRate = %d, want 150", score.SpeedRate) + } + + if !score.Mirror || !score.NoMiss { + t.Fatalf("legacy modifier fields were not populated: mirror=%v no_miss=%v", score.Mirror, score.NoMiss) + } +} + +func TestScoreAfterFindPreservesMigratedModifierFields(t *testing.T) { + score := &Score{ + Modifiers: int64(enums.ModSpeed15X | enums.ModMirror), + SpeedRate: 125, + Mirror: false, + ModsMigrated: true, + } + + if err := score.AfterFind(nil); err != nil { + t.Fatalf("AfterFind() error = %v", err) + } + + if score.SpeedRate != 125 || score.Mirror { + t.Fatalf("migrated modifier fields changed: speed_rate=%d mirror=%v", score.SpeedRate, score.Mirror) + } +} diff --git a/db/users.go b/db/users.go index e345f5d..c9375f8 100644 --- a/db/users.go +++ b/db/users.go @@ -81,35 +81,47 @@ func (u *User) BeforeCreate(*gorm.DB) (err error) { } func (u *User) AfterFind(*gorm.DB) (err error) { - u.TimeRegisteredJSON = time.UnixMilli(u.TimeRegistered) - u.MuteEndTimeJSON = time.UnixMilli(u.MuteEndTime) - u.LatestActivityJSON = time.UnixMilli(u.LatestActivity) - u.DonatorEndTimeJSON = time.UnixMilli(u.DonatorEndTime) - u.ClanLeaveTimeJSON = time.UnixMilli(u.ClanLeaveTime) + if err := u.populateAfterFindFields(); err != nil { + return nil + } if status, err := GetUserClientStatus(u.Id); err == nil { u.ClientStatus = status } - if keys4Ranks, err := GetUserRanksForMode(u, enums.GameModeKeys4); err == nil && u.StatsKeys4 != nil { - u.StatsKeys4.Ranks = keys4Ranks + if u.StatsKeys4 != nil { + if keys4Ranks, err := GetUserRanksForMode(u, enums.GameModeKeys4); err == nil { + u.StatsKeys4.Ranks = keys4Ranks + } + } + + if u.StatsKeys7 != nil { + if keys7Ranks, err := GetUserRanksForMode(u, enums.GameModeKeys7); err == nil { + u.StatsKeys7.Ranks = keys7Ranks + } } - if keys7Ranks, err := GetUserRanksForMode(u, enums.GameModeKeys7); err == nil && u.StatsKeys7 != nil { - u.StatsKeys7.Ranks = keys7Ranks + if err := u.SetClanTagAndColor(); err != nil { + return err } + return nil +} + +func (u *User) populateAfterFindFields() error { + u.TimeRegisteredJSON = time.UnixMilli(u.TimeRegistered) + u.MuteEndTimeJSON = time.UnixMilli(u.MuteEndTime) + u.LatestActivityJSON = time.UnixMilli(u.LatestActivity) + u.DonatorEndTimeJSON = time.UnixMilli(u.DonatorEndTime) + u.ClanLeaveTimeJSON = time.UnixMilli(u.ClanLeaveTime) + if u.Information != nil { if err := json.Unmarshal([]byte(*u.Information), &u.MiscInformation); err != nil { logrus.Errorf("Error unmarshalling misc user info for user: %v", u.Id) - return nil + return err } } - if err := u.SetClanTagAndColor(); err != nil { - return err - } - return nil } @@ -441,15 +453,46 @@ func GetUserClientStatus(id int) (*UserClientStatus, error) { return nil, err } - if len(result) == 0 { - return nil, nil + return userClientStatusFromValues(result), nil +} + +// getUserClientStatuses retrieves multiple client statuses in one Redis round trip. +func getUserClientStatuses(ids []int) (map[int]*UserClientStatus, error) { + statuses := make(map[int]*UserClientStatus, len(ids)) + commands := make(map[int]*redis.MapStringStringCmd, len(ids)) + + _, err := Redis.Pipelined(RedisCtx, func(pipe redis.Pipeliner) error { + for _, id := range ids { + commands[id] = pipe.HGetAll(RedisCtx, fmt.Sprintf("quaver:server:user_status:%v", id)) + } + + return nil + }) + + if err != nil && err != redis.Nil { + logrus.Errorf("Error getting user statuses from redis: %v", err) + return nil, err + } + + for id, command := range commands { + if status := userClientStatusFromValues(command.Val()); status != nil { + statuses[id] = status + } + } + + return statuses, nil +} + +func userClientStatusFromValues(values map[string]string) *UserClientStatus { + if len(values) == 0 { + return nil } return &UserClientStatus{ - Status: parseRedisIntWithDefault(result["s"], 0), - Mode: parseRedisIntWithDefault(result["m"], 1), - Content: result["c"], - }, nil + Status: parseRedisIntWithDefault(values["s"], 0), + Mode: parseRedisIntWithDefault(values["m"], 1), + Content: values["c"], + } } // GetUserRanksForMode Retrieves a user's global and country ranks for a game mode diff --git a/db/users_hydration_test.go b/db/users_hydration_test.go new file mode 100644 index 0000000..d5e3e9b --- /dev/null +++ b/db/users_hydration_test.go @@ -0,0 +1,48 @@ +package db + +import "testing" + +func TestUserClientStatusFromValues(t *testing.T) { + tests := []struct { + name string + values map[string]string + want *UserClientStatus + }{ + { + name: "empty status", + values: map[string]string{}, + }, + { + name: "populated status", + values: map[string]string{ + "s": "2", + "m": "7", + "c": "Playing", + }, + want: &UserClientStatus{Status: 2, Mode: 7, Content: "Playing"}, + }, + { + name: "invalid values use defaults", + values: map[string]string{"s": "invalid", "m": "invalid"}, + want: &UserClientStatus{Status: 0, Mode: 1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := userClientStatusFromValues(tt.values) + + if tt.want == nil { + if got != nil { + t.Fatalf("userClientStatusFromValues() = %#v, want nil", got) + } + + return + } + + if got == nil || *got != *tt.want { + t.Fatalf("userClientStatusFromValues() = %#v, want %#v", got, tt.want) + } + }) + } +} diff --git a/enums/mods.go b/enums/mods.go index f5fd565..808ad6f 100644 --- a/enums/mods.go +++ b/enums/mods.go @@ -91,6 +91,44 @@ var RankedMods = []Mods{ ModNoMiss, } +type SpeedModRate struct { + Mod Mods + Rate int +} + +var SpeedModRates = []SpeedModRate{ + {Mod: ModSpeed05X, Rate: 50}, + {Mod: ModSpeed055X, Rate: 55}, + {Mod: ModSpeed06X, Rate: 60}, + {Mod: ModSpeed065X, Rate: 65}, + {Mod: ModSpeed07X, Rate: 70}, + {Mod: ModSpeed075X, Rate: 75}, + {Mod: ModSpeed08X, Rate: 80}, + {Mod: ModSpeed085X, Rate: 85}, + {Mod: ModSpeed09X, Rate: 90}, + {Mod: ModSpeed095X, Rate: 95}, + {Mod: ModSpeed105X, Rate: 105}, + {Mod: ModSpeed11X, Rate: 110}, + {Mod: ModSpeed115X, Rate: 115}, + {Mod: ModSpeed12X, Rate: 120}, + {Mod: ModSpeed125X, Rate: 125}, + {Mod: ModSpeed13X, Rate: 130}, + {Mod: ModSpeed135X, Rate: 135}, + {Mod: ModSpeed14X, Rate: 140}, + {Mod: ModSpeed145X, Rate: 145}, + {Mod: ModSpeed15X, Rate: 150}, + {Mod: ModSpeed155X, Rate: 155}, + {Mod: ModSpeed16X, Rate: 160}, + {Mod: ModSpeed165X, Rate: 165}, + {Mod: ModSpeed17X, Rate: 170}, + {Mod: ModSpeed175X, Rate: 175}, + {Mod: ModSpeed18X, Rate: 180}, + {Mod: ModSpeed185X, Rate: 185}, + {Mod: ModSpeed19X, Rate: 190}, + {Mod: ModSpeed195X, Rate: 195}, + {Mod: ModSpeed20X, Rate: 200}, +} + var ModStrings = map[Mods]string{ ModNoSliderVelocities: "NSV", ModSpeed05X: "0.5x", @@ -141,6 +179,45 @@ var ModStrings = map[Mods]string{ ModEnumMaxValue: "INVALID!", } +// GetSpeedRate returns the speed rate in integer hundredths. +func GetSpeedRate(modCombo Mods) int { + speedMod, ok := GetSpeedMod(modCombo) + + if !ok { + return 100 + } + + for _, speedModRate := range SpeedModRates { + if speedModRate.Mod == speedMod { + return speedModRate.Rate + } + } + + return 100 +} + +// GetSpeedMod returns the first speed modifier in a modifier combination. +func GetSpeedMod(modCombo Mods) (Mods, bool) { + for _, speedMod := range SpeedModRates { + if IsModActivated(modCombo, speedMod.Mod) { + return speedMod.Mod, true + } + } + + return 0, false +} + +// SpeedModMask returns a mask containing every speed modifier. +func SpeedModMask() Mods { + var mask Mods + + for _, speedMod := range SpeedModRates { + mask |= speedMod.Mod + } + + return mask +} + // IsModActivated Returns if a given mod is activated in a mod combo func IsModActivated(modCombo Mods, mod Mods) bool { return modCombo&mod != 0 diff --git a/enums/mods_test.go b/enums/mods_test.go new file mode 100644 index 0000000..cc77104 --- /dev/null +++ b/enums/mods_test.go @@ -0,0 +1,52 @@ +package enums + +import "testing" + +func TestGetSpeedRate(t *testing.T) { + tests := []struct { + name string + mods Mods + want int + }{ + {name: "none", mods: 0, want: 100}, + {name: "0.5x", mods: ModSpeed05X, want: 50}, + {name: "0.55x", mods: ModSpeed055X, want: 55}, + {name: "0.6x", mods: ModSpeed06X, want: 60}, + {name: "0.65x", mods: ModSpeed065X, want: 65}, + {name: "0.7x", mods: ModSpeed07X, want: 70}, + {name: "0.75x", mods: ModSpeed075X, want: 75}, + {name: "0.8x", mods: ModSpeed08X, want: 80}, + {name: "0.85x", mods: ModSpeed085X, want: 85}, + {name: "0.9x", mods: ModSpeed09X, want: 90}, + {name: "0.95x", mods: ModSpeed095X, want: 95}, + {name: "1.05x", mods: ModSpeed105X, want: 105}, + {name: "1.1x", mods: ModSpeed11X, want: 110}, + {name: "1.15x", mods: ModSpeed115X, want: 115}, + {name: "1.2x", mods: ModSpeed12X, want: 120}, + {name: "1.25x", mods: ModSpeed125X, want: 125}, + {name: "1.3x", mods: ModSpeed13X, want: 130}, + {name: "1.35x", mods: ModSpeed135X, want: 135}, + {name: "1.4x", mods: ModSpeed14X, want: 140}, + {name: "1.45x", mods: ModSpeed145X, want: 145}, + {name: "1.5x", mods: ModSpeed15X, want: 150}, + {name: "1.55x", mods: ModSpeed155X, want: 155}, + {name: "1.6x", mods: ModSpeed16X, want: 160}, + {name: "1.65x", mods: ModSpeed165X, want: 165}, + {name: "1.7x", mods: ModSpeed17X, want: 170}, + {name: "1.75x", mods: ModSpeed175X, want: 175}, + {name: "1.8x", mods: ModSpeed18X, want: 180}, + {name: "1.85x", mods: ModSpeed185X, want: 185}, + {name: "1.9x", mods: ModSpeed19X, want: 190}, + {name: "1.95x", mods: ModSpeed195X, want: 195}, + {name: "2.0x", mods: ModSpeed20X, want: 200}, + {name: "non-speed", mods: ModMirror, want: 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := GetSpeedRate(tt.mods); got != tt.want { + t.Fatalf("GetSpeedRate(%v) = %v, want %v", tt.mods, got, tt.want) + } + }) + } +} diff --git a/handlers/scoreboard.go b/handlers/scoreboard.go index 119559d..b8fb88f 100644 --- a/handlers/scoreboard.go +++ b/handlers/scoreboard.go @@ -1,12 +1,14 @@ package handlers import ( + "errors" + "net/http" + "strconv" + "github.com/Quaver/api2/db" "github.com/Quaver/api2/enums" "github.com/gin-gonic/gin" "gorm.io/gorm" - "net/http" - "strconv" ) const ( @@ -124,6 +126,10 @@ func GetModifierScoresForMap(c *gin.Context) *APIError { scores, err := db.GetModifierScoresForMap(dbMap.MD5, mods) + if errors.Is(err, db.ErrUnsupportedScoreModifierFilter) { + return APIErrorBadRequest("This modifier is not supported by score lookup columns.") + } + if err != nil { return APIErrorServerError("Error retrieving modifier scoreboard", err) } @@ -159,6 +165,10 @@ func GetRateScoresForMap(c *gin.Context) *APIError { scores, err := db.GetRateScoresForMap(dbMap.MD5, mods) + if errors.Is(err, db.ErrUnsupportedScoreModifierFilter) { + return APIErrorBadRequest("You must provide a valid speed modifier value.") + } + if err != nil { return APIErrorServerError("Error retrieving rate scoreboard", err) } @@ -338,6 +348,10 @@ func GetUserPersonalBestScoreMods(c *gin.Context) *APIError { score, err := db.GetUserPersonalBestScoreMods(userId, dbMap.MD5, mods) + if errors.Is(err, db.ErrUnsupportedScoreModifierFilter) { + return APIErrorBadRequest("This modifier is not supported by score lookup columns.") + } + if err != nil && err != gorm.ErrRecordNotFound { return APIErrorServerError("Error retrieving personal best mods score from database", err) } @@ -377,6 +391,10 @@ func GetUserPersonalBestScoreRate(c *gin.Context) *APIError { score, err := db.GetUserPersonalBestScoreRate(userId, dbMap.MD5, mods) + if errors.Is(err, db.ErrUnsupportedScoreModifierFilter) { + return APIErrorBadRequest("You must provide a valid speed modifier value.") + } + if err != nil && err != gorm.ErrRecordNotFound { return APIErrorServerError("Error retrieving personal best rate score from database", err) }