From f8aa158a2e4274abf3af32ab6b998ef929593d0b Mon Sep 17 00:00:00 2001 From: Alexander Poon Date: Fri, 21 Aug 2026 17:11:00 -0400 Subject: [PATCH] Add support for User Band Name Fallback This adds support for User Band name fallback when a band name does not exist. For example, if a user steven3333 does not have a band name, in the game, we will likely see steven3333's band as the band name. However, in the Leaderboards, it just falls back to Unnamed Band. We shouldn't do that here. --- .gitignore | 2 + restapi/restapi.go | 38 +++++++---- tests/rest_test.go | 160 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 1a67f02..c5b4d86 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ binary_data/* .DS_Store + +.mongo-data diff --git a/restapi/restapi.go b/restapi/restapi.go index 378f164..069a2ae 100644 --- a/restapi/restapi.go +++ b/restapi/restapi.go @@ -155,6 +155,18 @@ func sendError(w http.ResponseWriter, statusCode int, message string) { sendJSON(w, statusCode, map[string]string{"error": message}) } +// resolveBandDisplayName mirrors in-game JSON leaderboard naming: +// band name → "'s Band" → "Unnamed Band". +func resolveBandDisplayName(bandName, username string) string { + if bandName != "" { + return bandName + } + if username != "" { + return username + "'s Band" + } + return "Unnamed Band" +} + // Handles the health check endpoint to verify if the database is reachable. // If somehow the DB has gone down, this will return a 503 Service Unavailable status so clients know that the service is not operational. func HealthHandler(w http.ResponseWriter, r *http.Request) { @@ -481,7 +493,7 @@ func LeaderboardHandler(w http.ResponseWriter, r *http.Request) { } } - // fetch all names at onnce in a single shot + // fetch all names at once in a single shot ctx := context.TODO() bandNameMap, err := database.GetBandNamesByOwnerPIDs(ctx, database.GocentralDatabase, bandPIDs) if err != nil { @@ -489,6 +501,12 @@ func LeaderboardHandler(w http.ResponseWriter, r *http.Request) { bandNameMap = make(map[int]string) } + bandOwnerUsernames, err := database.GetUsernamesByPIDs(ctx, database.GocentralDatabase, bandPIDs) + if err != nil { + log.Println("Error fetching band owner usernames:", err) + bandOwnerUsernames = make(map[int]string) + } + userNameMap, err := database.GetConsolePrefixedUsernamesByPIDs(ctx, database.GocentralDatabase, userPIDs) if err != nil { log.Println("Error fetching usernames:", err) @@ -505,11 +523,7 @@ func LeaderboardHandler(w http.ResponseWriter, r *http.Request) { var entryName string if isBandScore { - if name, ok := bandNameMap[score.OwnerPID]; ok { - entryName = name - } else { - entryName = "Unnamed Band" - } + entryName = resolveBandDisplayName(bandNameMap[score.OwnerPID], bandOwnerUsernames[score.OwnerPID]) } else { if name, ok := userNameMap[score.OwnerPID]; ok { entryName = name @@ -620,6 +634,12 @@ func BattleLeaderboardHandler(w http.ResponseWriter, r *http.Request) { bandNameMap = make(map[int]string) } + bandOwnerUsernames, err := database.GetUsernamesByPIDs(ctx, database.GocentralDatabase, bandPIDs) + if err != nil { + log.Println("Error fetching band owner usernames:", err) + bandOwnerUsernames = make(map[int]string) + } + userNameMap, err := database.GetConsolePrefixedUsernamesByPIDs(ctx, database.GocentralDatabase, userPIDs) if err != nil { log.Println("Error fetching usernames:", err) @@ -634,11 +654,7 @@ func BattleLeaderboardHandler(w http.ResponseWriter, r *http.Request) { var entryName string if isBandScore { - if name, ok := bandNameMap[score.OwnerPID]; ok { - entryName = name - } else { - entryName = "Unnamed Band" - } + entryName = resolveBandDisplayName(bandNameMap[score.OwnerPID], bandOwnerUsernames[score.OwnerPID]) } else { if name, ok := userNameMap[score.OwnerPID]; ok { entryName = name diff --git a/tests/rest_test.go b/tests/rest_test.go index 69722f7..b2aba4a 100644 --- a/tests/rest_test.go +++ b/tests/rest_test.go @@ -409,6 +409,166 @@ func TestBattleLeaderboardHandler_MissingBattleID(t *testing.T) { } } +// Tests band display-name fallbacks on the song leaderboard (role_id 10). +func TestLeaderboardHandler_BandNameFallback(t *testing.T) { + ctx := context.Background() + scoresCollection := database.GocentralDatabase.Collection("scores") + usersCollection := database.GocentralDatabase.Collection("users") + bandsCollection := database.GocentralDatabase.Collection("bands") + + testSongID := 777001 + namedOwnerPID := 777010 + noBandOwnerPID := 777011 + emptyBandOwnerPID := 777012 + orphanOwnerPID := 777013 + + usersCollection.InsertOne(ctx, map[string]interface{}{ + "pid": namedOwnerPID, "username": "named_owner", "console_type": 1, + }) + usersCollection.InsertOne(ctx, map[string]interface{}{ + "pid": noBandOwnerPID, "username": "noband_owner", "console_type": 1, + }) + usersCollection.InsertOne(ctx, map[string]interface{}{ + "pid": emptyBandOwnerPID, "username": "emptyband_owner", "console_type": 1, + }) + + bandsCollection.InsertOne(ctx, map[string]interface{}{ + "owner_pid": namedOwnerPID, "band_id": 777100, "name": "The Real Band", "console_type": 1, + }) + bandsCollection.InsertOne(ctx, map[string]interface{}{ + "owner_pid": emptyBandOwnerPID, "band_id": 777101, "name": "", "console_type": 1, + }) + + scores := []map[string]interface{}{ + {"pid": namedOwnerPID, "song_id": testSongID, "role_id": 10, "score": 4000, "stars": 5, "diff_id": 2, "notespct": 95, "instrument_mask": 0}, + {"pid": noBandOwnerPID, "song_id": testSongID, "role_id": 10, "score": 3000, "stars": 5, "diff_id": 2, "notespct": 95, "instrument_mask": 0}, + {"pid": emptyBandOwnerPID, "song_id": testSongID, "role_id": 10, "score": 2000, "stars": 5, "diff_id": 2, "notespct": 95, "instrument_mask": 0}, + {"pid": orphanOwnerPID, "song_id": testSongID, "role_id": 10, "score": 1000, "stars": 5, "diff_id": 2, "notespct": 95, "instrument_mask": 0}, + } + for _, score := range scores { + scoresCollection.InsertOne(ctx, score) + } + + defer func() { + scoresCollection.DeleteMany(ctx, bson.M{"song_id": testSongID}) + bandsCollection.DeleteMany(ctx, bson.M{"owner_pid": bson.M{"$in": []int{namedOwnerPID, emptyBandOwnerPID}}}) + usersCollection.DeleteMany(ctx, bson.M{"pid": bson.M{"$in": []int{namedOwnerPID, noBandOwnerPID, emptyBandOwnerPID}}}) + }() + + req := httptest.NewRequest("GET", "/leaderboard?song_id=777001&role_id=10", nil) + rr := httptest.NewRecorder() + restapi.LeaderboardHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("Expected status 200, got %d (body: %s)", rr.Code, rr.Body.String()) + } + + var response map[string][]restapi.LeaderboardEntry + decodeResponse(t, rr, &response) + leaderboard := response["leaderboard"] + if len(leaderboard) != 4 { + t.Fatalf("Expected 4 entries, got %d", len(leaderboard)) + } + + byPID := map[int]string{} + for _, entry := range leaderboard { + byPID[entry.PID] = entry.Name + } + + if byPID[namedOwnerPID] != "The Real Band" { + t.Errorf("named band: got %q, want %q", byPID[namedOwnerPID], "The Real Band") + } + if byPID[noBandOwnerPID] != "noband_owner's Band" { + t.Errorf("missing band: got %q, want %q", byPID[noBandOwnerPID], "noband_owner's Band") + } + if byPID[emptyBandOwnerPID] != "emptyband_owner's Band" { + t.Errorf("empty band name: got %q, want %q", byPID[emptyBandOwnerPID], "emptyband_owner's Band") + } + if byPID[orphanOwnerPID] != "Unnamed Band" { + t.Errorf("orphan pid: got %q, want %q", byPID[orphanOwnerPID], "Unnamed Band") + } +} + +// Tests band display-name fallbacks on the battle leaderboard (role_id 10). +func TestBattleLeaderboardHandler_BandNameFallback(t *testing.T) { + ctx := context.Background() + scoresCollection := database.GocentralDatabase.Collection("scores") + usersCollection := database.GocentralDatabase.Collection("users") + bandsCollection := database.GocentralDatabase.Collection("bands") + + testBattleID := 777002 + namedOwnerPID := 777020 + noBandOwnerPID := 777021 + emptyBandOwnerPID := 777022 + orphanOwnerPID := 777023 + + usersCollection.InsertOne(ctx, map[string]interface{}{ + "pid": namedOwnerPID, "username": "battle_named", "console_type": 1, + }) + usersCollection.InsertOne(ctx, map[string]interface{}{ + "pid": noBandOwnerPID, "username": "battle_noband", "console_type": 1, + }) + usersCollection.InsertOne(ctx, map[string]interface{}{ + "pid": emptyBandOwnerPID, "username": "battle_empty", "console_type": 1, + }) + + bandsCollection.InsertOne(ctx, map[string]interface{}{ + "owner_pid": namedOwnerPID, "band_id": 777200, "name": "Battle Band", "console_type": 1, + }) + bandsCollection.InsertOne(ctx, map[string]interface{}{ + "owner_pid": emptyBandOwnerPID, "band_id": 777201, "name": "", "console_type": 1, + }) + + scores := []map[string]interface{}{ + {"pid": namedOwnerPID, "battle_id": testBattleID, "song_id": 100, "role_id": 10, "score": 4000, "stars": 5, "diff_id": 2, "notespct": 95, "instrument_mask": 0}, + {"pid": noBandOwnerPID, "battle_id": testBattleID, "song_id": 100, "role_id": 10, "score": 3000, "stars": 5, "diff_id": 2, "notespct": 95, "instrument_mask": 0}, + {"pid": emptyBandOwnerPID, "battle_id": testBattleID, "song_id": 100, "role_id": 10, "score": 2000, "stars": 5, "diff_id": 2, "notespct": 95, "instrument_mask": 0}, + {"pid": orphanOwnerPID, "battle_id": testBattleID, "song_id": 100, "role_id": 10, "score": 1000, "stars": 5, "diff_id": 2, "notespct": 95, "instrument_mask": 0}, + } + for _, score := range scores { + scoresCollection.InsertOne(ctx, score) + } + + defer func() { + scoresCollection.DeleteMany(ctx, bson.M{"battle_id": testBattleID}) + bandsCollection.DeleteMany(ctx, bson.M{"owner_pid": bson.M{"$in": []int{namedOwnerPID, emptyBandOwnerPID}}}) + usersCollection.DeleteMany(ctx, bson.M{"pid": bson.M{"$in": []int{namedOwnerPID, noBandOwnerPID, emptyBandOwnerPID}}}) + }() + + req := httptest.NewRequest("GET", "/battle_leaderboard?battle_id=777002", nil) + rr := httptest.NewRecorder() + restapi.BattleLeaderboardHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("Expected status 200, got %d (body: %s)", rr.Code, rr.Body.String()) + } + + var response map[string][]restapi.BattleLeaderboardEntry + decodeResponse(t, rr, &response) + leaderboard := response["leaderboard"] + if len(leaderboard) != 4 { + t.Fatalf("Expected 4 entries, got %d", len(leaderboard)) + } + + byPID := map[int]string{} + for _, entry := range leaderboard { + byPID[entry.PID] = entry.Name + } + + if byPID[namedOwnerPID] != "Battle Band" { + t.Errorf("named band: got %q, want %q", byPID[namedOwnerPID], "Battle Band") + } + if byPID[noBandOwnerPID] != "battle_noband's Band" { + t.Errorf("missing band: got %q, want %q", byPID[noBandOwnerPID], "battle_noband's Band") + } + if byPID[emptyBandOwnerPID] != "battle_empty's Band" { + t.Errorf("empty band name: got %q, want %q", byPID[emptyBandOwnerPID], "battle_empty's Band") + } + if byPID[orphanOwnerPID] != "Unnamed Band" { + t.Errorf("orphan pid: got %q, want %q", byPID[orphanOwnerPID], "Unnamed Band") + } +} + // Tests the list banned players endpoint func TestListBannedPlayersHandler(t *testing.T) { rr := makeRequest(t, "GET", "/admin/bans", nil, restapi.ListBannedPlayersHandler)