From b30faad7d9b5c659c14db2694ac7b5938606a3e1 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 11 Jun 2024 00:40:43 -0400
Subject: [PATCH] Save high scores and high ranks separately.

---
 source/funkin/play/PlayState.hx            |  29 ++--
 source/funkin/play/scoring/Scoring.hx      | 158 ++++++++++++---------
 source/funkin/save/Save.hx                 | 102 ++++++++++++-
 source/funkin/ui/freeplay/FreeplayState.hx |  14 +-
 source/funkin/ui/mainmenu/MainMenuState.hx |  27 ++++
 source/funkin/util/FileUtil.hx             |   1 +
 6 files changed, 251 insertions(+), 80 deletions(-)

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index b3d0a9f8a..ad7a398d4 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -2284,7 +2284,7 @@ class PlayState extends MusicBeatSubState
           health += Constants.HEALTH_HOLD_BONUS_PER_SECOND * elapsed;
           songScore += Std.int(Constants.SCORE_HOLD_BONUS_PER_SECOND * elapsed);
         }
-        
+
         // Make sure the player keeps singing while the note is held by the bot.
         if (isBotPlayMode && currentStage != null && currentStage.getBoyfriend() != null && currentStage.getBoyfriend().isSinging())
         {
@@ -2818,8 +2818,13 @@ class PlayState extends MusicBeatSubState
 
     deathCounter = 0;
 
+    // TODO: This line of code makes me sad, but you can't really fix it without a breaking migration.
+    // `easy`, `erect`, `normal-pico`, etc.
+    var suffixedDifficulty = (currentVariation != Constants.DEFAULT_VARIATION
+      && currentVariation != 'erect') ? '$currentDifficulty-${currentVariation}' : currentDifficulty;
+
     var isNewHighscore = false;
-    var prevScoreData:Null<SaveScoreData> = Save.instance.getSongScore(currentSong.id, currentDifficulty);
+    var prevScoreData:Null<SaveScoreData> = Save.instance.getSongScore(currentSong.id, suffixedDifficulty);
 
     if (currentSong != null && currentSong.validScore)
     {
@@ -2844,13 +2849,21 @@ class PlayState extends MusicBeatSubState
       // adds current song data into the tallies for the level (story levels)
       Highscore.talliesLevel = Highscore.combineTallies(Highscore.tallies, Highscore.talliesLevel);
 
-      if (!isPracticeMode && !isBotPlayMode && Save.instance.isSongHighScore(currentSong.id, currentDifficulty, data))
+      if (!isPracticeMode && !isBotPlayMode)
       {
-        Save.instance.setSongScore(currentSong.id, currentDifficulty, data);
-        #if newgrounds
-        NGio.postScore(score, currentSong.id);
-        #end
-        isNewHighscore = true;
+        isNewHighscore = Save.instance.isSongHighScore(currentSong.id, suffixedDifficulty, data);
+
+        // If no high score is present, save both score and rank.
+        // If score or rank are better, save the highest one.
+        // If neither are higher, nothing will change.
+        Save.instance.applySongRank(currentSong.id, suffixedDifficulty, data);
+
+        if (isNewHighscore)
+        {
+          #if newgrounds
+          NGio.postScore(score, currentSong.id);
+          #end
+        }
       }
     }
 
diff --git a/source/funkin/play/scoring/Scoring.hx b/source/funkin/play/scoring/Scoring.hx
index d6f71fc7e..dc2c40647 100644
--- a/source/funkin/play/scoring/Scoring.hx
+++ b/source/funkin/play/scoring/Scoring.hx
@@ -356,7 +356,10 @@ class Scoring
 
     // Perfect (Platinum) is a Sick Full Clear
     var isPerfectGold = scoreData.tallies.sick == scoreData.tallies.totalNotes;
-    if (isPerfectGold) return ScoringRank.PERFECT_GOLD;
+    if (isPerfectGold)
+    {
+      return ScoringRank.PERFECT_GOLD;
+    }
 
     // Else, use the standard grades
 
@@ -397,62 +400,79 @@ enum abstract ScoringRank(String)
   var GOOD;
   var SHIT;
 
-  @:op(A > B) static function compare(a:Null<ScoringRank>, b:Null<ScoringRank>):Bool
+  /**
+   * Converts ScoringRank to an integer value for comparison.
+   * Better ranks should be tied to a higher value.
+   */
+  static function getValue(rank:Null<ScoringRank>):Int
+  {
+    if (rank == null) return -1;
+    switch (rank)
+    {
+      case PERFECT_GOLD:
+        return 5;
+      case PERFECT:
+        return 4;
+      case EXCELLENT:
+        return 3;
+      case GREAT:
+        return 2;
+      case GOOD:
+        return 1;
+      case SHIT:
+        return 0;
+      default:
+        return -1;
+    }
+  }
+
+  // Yes, we really need a different function for each comparison operator.
+  @:op(A > B) static function compareGT(a:Null<ScoringRank>, b:Null<ScoringRank>):Bool
   {
     if (a != null && b == null) return true;
     if (a == null || b == null) return false;
 
-    var temp1:Int = 0;
-    var temp2:Int = 0;
+    var temp1:Int = getValue(a);
+    var temp2:Int = getValue(b);
 
-    // temp 1
-    switch (a)
-    {
-      case PERFECT_GOLD:
-        temp1 = 5;
-      case PERFECT:
-        temp1 = 4;
-      case EXCELLENT:
-        temp1 = 3;
-      case GREAT:
-        temp1 = 2;
-      case GOOD:
-        temp1 = 1;
-      case SHIT:
-        temp1 = 0;
-      default:
-        temp1 = -1;
-    }
-
-    // temp 2
-    switch (b)
-    {
-      case PERFECT_GOLD:
-        temp2 = 5;
-      case PERFECT:
-        temp2 = 4;
-      case EXCELLENT:
-        temp2 = 3;
-      case GREAT:
-        temp2 = 2;
-      case GOOD:
-        temp2 = 1;
-      case SHIT:
-        temp2 = 0;
-      default:
-        temp2 = -1;
-    }
-
-    if (temp1 > temp2)
-    {
-      return true;
-    }
-    else
-    {
-      return false;
-    }
+    return temp1 > temp2;
   }
 
+  @:op(A >= B) static function compareGTEQ(a:Null<ScoringRank>, b:Null<ScoringRank>):Bool
+  {
+    if (a != null && b == null) return true;
+    if (a == null || b == null) return false;
+
+    var temp1:Int = getValue(a);
+    var temp2:Int = getValue(b);
+
+    return temp1 >= temp2;
+  }
+
+  @:op(A < B) static function compareLT(a:Null<ScoringRank>, b:Null<ScoringRank>):Bool
+  {
+    if (a != null && b == null) return true;
+    if (a == null || b == null) return false;
+
+    var temp1:Int = getValue(a);
+    var temp2:Int = getValue(b);
+
+    return temp1 < temp2;
+  }
+
+  @:op(A <= B) static function compareLTEQ(a:Null<ScoringRank>, b:Null<ScoringRank>):Bool
+  {
+    if (a != null && b == null) return true;
+    if (a == null || b == null) return false;
+
+    var temp1:Int = getValue(a);
+    var temp2:Int = getValue(b);
+
+    return temp1 <= temp2;
+  }
+
+  // @:op(A == B) isn't necessary!
+
   /**
    * Delay in seconds
    */
@@ -462,15 +482,15 @@ enum abstract ScoringRank(String)
     {
       case PERFECT_GOLD | PERFECT:
         // return 2.5;
-        return 95/24;
+        return 95 / 24;
       case EXCELLENT:
         return 0;
       case GREAT:
-        return 5/24;
+        return 5 / 24;
       case GOOD:
-        return 3/24;
+        return 3 / 24;
       case SHIT:
-        return 2/24;
+        return 2 / 24;
       default:
         return 3.5;
     }
@@ -482,15 +502,15 @@ enum abstract ScoringRank(String)
     {
       case PERFECT_GOLD | PERFECT:
         // return 2.5;
-        return 95/24;
+        return 95 / 24;
       case EXCELLENT:
-        return 97/24;
+        return 97 / 24;
       case GREAT:
-        return 95/24;
+        return 95 / 24;
       case GOOD:
-        return 95/24;
+        return 95 / 24;
       case SHIT:
-        return 95/24;
+        return 95 / 24;
       default:
         return 3.5;
     }
@@ -502,15 +522,15 @@ enum abstract ScoringRank(String)
     {
       case PERFECT_GOLD | PERFECT:
         // return 2.5;
-        return 129/24;
+        return 129 / 24;
       case EXCELLENT:
-        return 122/24;
+        return 122 / 24;
       case GREAT:
-        return 109/24;
+        return 109 / 24;
       case GOOD:
-        return 107/24;
+        return 107 / 24;
       case SHIT:
-        return 186/24;
+        return 186 / 24;
       default:
         return 3.5;
     }
@@ -522,15 +542,15 @@ enum abstract ScoringRank(String)
     {
       case PERFECT_GOLD | PERFECT:
         // return 2.5;
-        return 140/24;
+        return 140 / 24;
       case EXCELLENT:
-        return 140/24;
+        return 140 / 24;
       case GREAT:
-        return 129/24;
+        return 129 / 24;
       case GOOD:
-        return 127/24;
+        return 127 / 24;
       case SHIT:
-        return 207/24;
+        return 207 / 24;
       default:
         return 3.5;
     }
diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx
index 2ff6b96cc..2900ce2be 100644
--- a/source/funkin/save/Save.hx
+++ b/source/funkin/save/Save.hx
@@ -1,6 +1,7 @@
 package funkin.save;
 
 import flixel.util.FlxSave;
+import funkin.util.FileUtil;
 import funkin.input.Controls.Device;
 import funkin.play.scoring.Scoring;
 import funkin.play.scoring.Scoring.ScoringRank;
@@ -58,7 +59,7 @@ class Save
       this.data = data;
 
     // Make sure the verison number is up to date before we flush.
-    this.data.version = Save.SAVE_DATA_VERSION;
+    updateVersionToLatest();
   }
 
   public static function getDefault():RawSaveData
@@ -503,7 +504,7 @@ class Save
   }
 
   /**
-   * Apply the score the user achieved for a given song on a given difficulty.
+   * Directly set the score the user achieved for a given song on a given difficulty.
    */
   public function setSongScore(songId:String, difficultyId:String, score:SaveScoreData):Void
   {
@@ -518,6 +519,44 @@ class Save
     flush();
   }
 
+  /**
+   * Only replace the ranking data for the song, because the old score is still better.
+   */
+  public function applySongRank(songId:String, difficultyId:String, newScoreData:SaveScoreData):Void
+  {
+    var newRank = Scoring.calculateRank(newScoreData);
+    if (newScoreData == null || newRank == null) return;
+
+    var song = data.scores.songs.get(songId);
+    if (song == null)
+    {
+      song = [];
+      data.scores.songs.set(songId, song);
+    }
+
+    var previousScoreData = song.get(difficultyId);
+
+    var previousRank = Scoring.calculateRank(previousScoreData);
+
+    if (previousScoreData == null || previousRank == null)
+    {
+      // Directly set the highscore.
+      setSongScore(songId, difficultyId, newScoreData);
+      return;
+    }
+
+    // Set the high score and the high rank separately.
+    var newScore:SaveScoreData =
+      {
+        score: (previousScoreData.score > newScoreData.score) ? previousScoreData.score : newScoreData.score,
+        tallies: (previousRank > newRank) ? previousScoreData.tallies : newScoreData.tallies
+      };
+
+    song.set(difficultyId, newScore);
+
+    flush();
+  }
+
   /**
    * Is the provided score data better than the current high score for the given song?
    * @param songId The song ID to check.
@@ -543,6 +582,39 @@ class Save
     return score.score > currentScore.score;
   }
 
+  /**
+   * Is the provided score data better than the current rank for the given song?
+   * @param songId The song ID to check.
+   * @param difficultyId The difficulty to check.
+   * @param score The score to check the rank for.
+   * @return Whether the score's rank is better than the current rank.
+   */
+  public function isSongHighRank(songId:String, difficultyId:String = 'normal', score:SaveScoreData):Bool
+  {
+    var newScoreRank = Scoring.calculateRank(score);
+    if (newScoreRank == null)
+    {
+      // The provided score is invalid.
+      return false;
+    }
+
+    var song = data.scores.songs.get(songId);
+    if (song == null)
+    {
+      song = [];
+      data.scores.songs.set(songId, song);
+    }
+    var currentScore = song.get(difficultyId);
+    var currentScoreRank = Scoring.calculateRank(currentScore);
+    if (currentScoreRank == null)
+    {
+      // There is no primary highscore for this song.
+      return true;
+    }
+
+    return newScoreRank > currentScoreRank;
+  }
+
   /**
    * Has the provided song been beaten on one of the listed difficulties?
    * @param songId The song ID to check.
@@ -832,6 +904,29 @@ class Save
       return cast legacySave.data;
     }
   }
+
+  /**
+   * Serialize this Save into a JSON string.
+   * @param pretty Whether the JSON should be big ol string (false),
+   * or formatted with tabs (true)
+   * @return The JSON string.
+   */
+  public function serialize(pretty:Bool = true):String
+  {
+    var ignoreNullOptionals = true;
+    var writer = new json2object.JsonWriter<RawSaveData>(ignoreNullOptionals);
+    return writer.write(data, pretty ? '  ' : null);
+  }
+
+  public function updateVersionToLatest():Void
+  {
+    this.data.version = Save.SAVE_DATA_VERSION;
+  }
+
+  public function debug_dumpSave():Void
+  {
+    FileUtil.saveFile(haxe.io.Bytes.ofString(this.serialize()), [FileUtil.FILE_FILTER_JSON], null, null, './save.json', 'Write save data as JSON...');
+  }
 }
 
 /**
@@ -904,6 +999,9 @@ typedef SaveHighScoresData =
 typedef SaveDataMods =
 {
   var enabledMods:Array<String>;
+
+  // TODO: Make this not trip up the serializer when debugging.
+  @:jignored
   var modOptions:Map<String, Dynamic>;
 }
 
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index c54091d26..f0ca9a942 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -1608,7 +1608,19 @@ class FreeplayState extends MusicBeatSubState
     var daSong:Null<FreeplaySongData> = grpCapsules.members[curSelected].songData;
     if (daSong != null)
     {
-      var songScore:SaveScoreData = Save.instance.getSongScore(grpCapsules.members[curSelected].songData.songId, currentDifficulty);
+      // TODO: Make this actually be the variation you're focused on. We don't need to fetch the song metadata just to calculate it.
+      var targetSong:Song = SongRegistry.instance.fetchEntry(grpCapsules.members[curSelected].songData.songId);
+      if (targetSong == null)
+      {
+        FlxG.log.warn('WARN: could not find song with id (${grpCapsules.members[curSelected].songData.songId})');
+        return;
+      }
+      var targetVariation:String = targetSong.getFirstValidVariation(currentDifficulty);
+
+      // TODO: This line of code makes me sad, but you can't really fix it without a breaking migration.
+      var suffixedDifficulty = (targetVariation != Constants.DEFAULT_VARIATION
+        && targetVariation != 'erect') ? '$currentDifficulty-${targetVariation}' : currentDifficulty;
+      var songScore:SaveScoreData = Save.instance.getSongScore(grpCapsules.members[curSelected].songData.songId, suffixedDifficulty);
       intendedScore = songScore?.score ?? 0;
       intendedCompletion = songScore == null ? 0.0 : ((songScore.tallies.sick + songScore.tallies.good) / songScore.tallies.totalNotes);
       rememberedDifficulty = currentDifficulty;
diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx
index b6ec25e61..d09536eea 100644
--- a/source/funkin/ui/mainmenu/MainMenuState.hx
+++ b/source/funkin/ui/mainmenu/MainMenuState.hx
@@ -371,6 +371,33 @@ class MainMenuState extends MusicBeatState
             }
         });
     }
+
+    if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.R)
+    {
+      // Give the user a hypothetical overridden score,
+      // and see if we can maintain that golden P rank.
+      funkin.save.Save.instance.setSongScore('tutorial', 'easy',
+        {
+          score: 1234567,
+          tallies:
+            {
+              sick: 0,
+              good: 0,
+              bad: 0,
+              shit: 1,
+              missed: 0,
+              combo: 0,
+              maxCombo: 0,
+              totalNotesHit: 1,
+              totalNotes: 10,
+            }
+        });
+    }
+
+    if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.E)
+    {
+      funkin.save.Save.instance.debug_dumpSave();
+    }
     #end
 
     if (FlxG.sound.music != null && FlxG.sound.music.volume < 0.8)
diff --git a/source/funkin/util/FileUtil.hx b/source/funkin/util/FileUtil.hx
index 7a7b1422c..00a0a14b7 100644
--- a/source/funkin/util/FileUtil.hx
+++ b/source/funkin/util/FileUtil.hx
@@ -19,6 +19,7 @@ import haxe.ui.containers.dialogs.Dialogs.FileDialogExtensionInfo;
 class FileUtil
 {
   public static final FILE_FILTER_FNFC:FileFilter = new FileFilter("Friday Night Funkin' Chart (.fnfc)", "*.fnfc");
+  public static final FILE_FILTER_JSON:FileFilter = new FileFilter("JSON Data File (.json)", "*.json");
   public static final FILE_FILTER_ZIP:FileFilter = new FileFilter("ZIP Archive (.zip)", "*.zip");
   public static final FILE_FILTER_PNG:FileFilter = new FileFilter("PNG Image (.png)", "*.png");