1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-11 10:18:22 +08:00

Merge pull request #26630 from peppy/s-rank-change

Change S rank to require no miss
This commit is contained in:
Dean Herbert 2024-01-24 13:27:40 +09:00 committed by GitHub
commit b272d34960
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 342 additions and 272 deletions

View File

@ -89,7 +89,7 @@ namespace osu.Game.Rulesets.Catch.Scoring
return baseIncrease * Math.Min(Math.Max(0.5, Math.Log(result.ComboAfterJudgement, combo_base)), Math.Log(combo_cap, combo_base)); return baseIncrease * Math.Min(Math.Max(0.5, Math.Log(result.ComboAfterJudgement, combo_base)), Math.Log(combo_cap, combo_base));
} }
public override ScoreRank RankFromAccuracy(double accuracy) public override ScoreRank RankFromScore(double accuracy, IReadOnlyDictionary<HitResult, int> results)
{ {
if (accuracy == accuracy_cutoff_x) if (accuracy == accuracy_cutoff_x)
return ScoreRank.X; return ScoreRank.X;

View File

@ -1,9 +1,11 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Osu.Judgements;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
namespace osu.Game.Rulesets.Osu.Scoring namespace osu.Game.Rulesets.Osu.Scoring
{ {
@ -14,6 +16,22 @@ namespace osu.Game.Rulesets.Osu.Scoring
{ {
} }
public override ScoreRank RankFromScore(double accuracy, IReadOnlyDictionary<HitResult, int> results)
{
ScoreRank rank = base.RankFromScore(accuracy, results);
switch (rank)
{
case ScoreRank.S:
case ScoreRank.X:
if (results.GetValueOrDefault(HitResult.Miss) > 0)
rank = ScoreRank.A;
break;
}
return rank;
}
protected override HitEvent CreateHitEvent(JudgementResult result) protected override HitEvent CreateHitEvent(JudgementResult result)
=> base.CreateHitEvent(result).With((result as OsuHitCircleJudgementResult)?.CursorPositionAtHit); => base.CreateHitEvent(result).With((result as OsuHitCircleJudgementResult)?.CursorPositionAtHit);
} }

View File

@ -2,9 +2,11 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Generic;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Scoring;
namespace osu.Game.Rulesets.Taiko.Scoring namespace osu.Game.Rulesets.Taiko.Scoring
{ {
@ -33,6 +35,22 @@ namespace osu.Game.Rulesets.Taiko.Scoring
* strongScaleValue(result); * strongScaleValue(result);
} }
public override ScoreRank RankFromScore(double accuracy, IReadOnlyDictionary<HitResult, int> results)
{
ScoreRank rank = base.RankFromScore(accuracy, results);
switch (rank)
{
case ScoreRank.S:
case ScoreRank.X:
if (results.GetValueOrDefault(HitResult.Miss) > 0)
rank = ScoreRank.A;
break;
}
return rank;
}
public override int GetBaseScoreForResult(HitResult result) public override int GetBaseScoreForResult(HitResult result)
{ {
switch (result) switch (result)

View File

@ -10,7 +10,6 @@ using System.IO;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Extensions; using osu.Framework.Extensions;
using osu.Framework.Utils;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats; using osu.Game.Beatmaps.Formats;
using osu.Game.Beatmaps.Legacy; using osu.Game.Beatmaps.Legacy;
@ -23,6 +22,7 @@ using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays;
@ -59,14 +59,14 @@ namespace osu.Game.Tests.Beatmaps.Formats
Assert.AreEqual(2, score.ScoreInfo.Statistics[HitResult.Great]); Assert.AreEqual(2, score.ScoreInfo.Statistics[HitResult.Great]);
Assert.AreEqual(1, score.ScoreInfo.Statistics[HitResult.Good]); Assert.AreEqual(1, score.ScoreInfo.Statistics[HitResult.Good]);
Assert.AreEqual(829_931, score.ScoreInfo.TotalScore); Assert.AreEqual(829_931, score.ScoreInfo.LegacyTotalScore);
Assert.AreEqual(3, score.ScoreInfo.MaxCombo); Assert.AreEqual(3, score.ScoreInfo.MaxCombo);
Assert.IsTrue(score.ScoreInfo.Mods.Any(m => m is ManiaModClassic)); Assert.IsTrue(score.ScoreInfo.Mods.Any(m => m is ManiaModClassic));
Assert.IsTrue(score.ScoreInfo.APIMods.Any(m => m.Acronym == "CL")); Assert.IsTrue(score.ScoreInfo.APIMods.Any(m => m.Acronym == "CL"));
Assert.IsTrue(score.ScoreInfo.ModsJson.Contains("CL")); Assert.IsTrue(score.ScoreInfo.ModsJson.Contains("CL"));
Assert.IsTrue(Precision.AlmostEquals(0.8889, score.ScoreInfo.Accuracy, 0.0001)); Assert.That((2 * 300d + 1 * 200) / (3 * 305d), Is.EqualTo(score.ScoreInfo.Accuracy).Within(0.0001));
Assert.AreEqual(ScoreRank.B, score.ScoreInfo.Rank); Assert.AreEqual(ScoreRank.B, score.ScoreInfo.Rank);
Assert.That(score.Replay.Frames, Is.Not.Empty); Assert.That(score.Replay.Frames, Is.Not.Empty);
@ -252,7 +252,7 @@ namespace osu.Game.Tests.Beatmaps.Formats
} }
[Test] [Test]
public void AccuracyAndRankOfStableScorePreserved() public void AccuracyOfStableScoreRecomputed()
{ {
var memoryStream = new MemoryStream(); var memoryStream = new MemoryStream();
@ -261,15 +261,16 @@ namespace osu.Game.Tests.Beatmaps.Formats
// and we want to emulate a stable score here // and we want to emulate a stable score here
using (var sw = new SerializationWriter(memoryStream, true)) using (var sw = new SerializationWriter(memoryStream, true))
{ {
sw.Write((byte)0); // ruleset id (osu!) sw.Write((byte)3); // ruleset id (mania).
// mania is used intentionally as it is the only ruleset wherein default accuracy calculation is changed in lazer
sw.Write(20240116); // version (anything below `LegacyScoreEncoder.FIRST_LAZER_VERSION` is stable) sw.Write(20240116); // version (anything below `LegacyScoreEncoder.FIRST_LAZER_VERSION` is stable)
sw.Write(string.Empty.ComputeMD5Hash()); // beatmap hash, irrelevant to this test sw.Write(string.Empty.ComputeMD5Hash()); // beatmap hash, irrelevant to this test
sw.Write("username"); // irrelevant to this test sw.Write("username"); // irrelevant to this test
sw.Write(string.Empty.ComputeMD5Hash()); // score hash, irrelevant to this test sw.Write(string.Empty.ComputeMD5Hash()); // score hash, irrelevant to this test
sw.Write((ushort)198); // count300 sw.Write((ushort)1); // count300
sw.Write((ushort)1); // count100 sw.Write((ushort)0); // count100
sw.Write((ushort)0); // count50 sw.Write((ushort)0); // count50
sw.Write((ushort)0); // countGeki sw.Write((ushort)198); // countGeki (perfects / "rainbow 300s" in mania)
sw.Write((ushort)0); // countKatu sw.Write((ushort)0); // countKatu
sw.Write((ushort)1); // countMiss sw.Write((ushort)1); // countMiss
sw.Write(12345678); // total score, irrelevant to this test sw.Write(12345678); // total score, irrelevant to this test
@ -287,13 +288,54 @@ namespace osu.Game.Tests.Beatmaps.Formats
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(decoded.ScoreInfo.Accuracy, Is.EqualTo((double)(198 * 300 + 100) / (200 * 300))); Assert.That(decoded.ScoreInfo.Accuracy, Is.EqualTo((double)(198 * 305 + 300) / (200 * 305)));
Assert.That(decoded.ScoreInfo.Rank, Is.EqualTo(ScoreRank.A)); Assert.That(decoded.ScoreInfo.Rank, Is.EqualTo(ScoreRank.SH));
}); });
} }
[Test] [Test]
public void AccuracyAndRankOfLazerScorePreserved() public void RankOfStableScoreUsesLazerDefinitions()
{
var memoryStream = new MemoryStream();
// local partial implementation of legacy score encoder
// this is done half for readability, half because `LegacyScoreEncoder` forces `LATEST_VERSION`
// and we want to emulate a stable score here
using (var sw = new SerializationWriter(memoryStream, true))
{
sw.Write((byte)0); // ruleset id (osu!)
sw.Write(20240116); // version (anything below `LegacyScoreEncoder.FIRST_LAZER_VERSION` is stable)
sw.Write(string.Empty.ComputeMD5Hash()); // beatmap hash, irrelevant to this test
sw.Write("username"); // irrelevant to this test
sw.Write(string.Empty.ComputeMD5Hash()); // score hash, irrelevant to this test
sw.Write((ushort)195); // count300
sw.Write((ushort)1); // count100
sw.Write((ushort)4); // count50
sw.Write((ushort)0); // countGeki
sw.Write((ushort)0); // countKatu
sw.Write((ushort)0); // countMiss
sw.Write(12345678); // total score, irrelevant to this test
sw.Write((ushort)1000); // max combo, irrelevant to this test
sw.Write(false); // full combo, irrelevant to this test
sw.Write((int)LegacyMods.Hidden); // mods
sw.Write(string.Empty); // hp graph, irrelevant
sw.Write(DateTime.Now); // date, irrelevant
sw.Write(Array.Empty<byte>()); // replay data, irrelevant
sw.Write((long)1234); // legacy online ID, irrelevant
}
memoryStream.Seek(0, SeekOrigin.Begin);
var decoded = new TestLegacyScoreDecoder().Parse(memoryStream);
Assert.Multiple(() =>
{
// In stable this would be an A because there are over 1% 50s. But that's not a thing in lazer.
Assert.That(decoded.ScoreInfo.Rank, Is.EqualTo(ScoreRank.SH));
});
}
[Test]
public void AccuracyRankAndTotalScoreOfLazerScorePreserved()
{ {
var ruleset = new OsuRuleset().RulesetInfo; var ruleset = new OsuRuleset().RulesetInfo;
@ -321,8 +363,10 @@ namespace osu.Game.Tests.Beatmaps.Formats
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(decodedAfterEncode.ScoreInfo.TotalScore, Is.EqualTo(284_537));
Assert.That(decodedAfterEncode.ScoreInfo.LegacyTotalScore, Is.Null);
Assert.That(decodedAfterEncode.ScoreInfo.Accuracy, Is.EqualTo((double)(199 * 300 + 30) / (200 * 300 + 30))); Assert.That(decodedAfterEncode.ScoreInfo.Accuracy, Is.EqualTo((double)(199 * 300 + 30) / (200 * 300 + 30)));
Assert.That(decodedAfterEncode.ScoreInfo.Rank, Is.EqualTo(ScoreRank.SH)); Assert.That(decodedAfterEncode.ScoreInfo.Rank, Is.EqualTo(ScoreRank.A));
}); });
} }
@ -415,6 +459,12 @@ namespace osu.Game.Tests.Beatmaps.Formats
Ruleset = new OsuRuleset().RulesetInfo, Ruleset = new OsuRuleset().RulesetInfo,
Difficulty = new BeatmapDifficulty(), Difficulty = new BeatmapDifficulty(),
BeatmapVersion = beatmapVersion, BeatmapVersion = beatmapVersion,
},
// needs to have at least one objects so that `StandardisedScoreMigrationTools` doesn't die
// when trying to recompute total score.
HitObjects =
{
new HitCircle()
} }
}); });
} }

View File

@ -9,6 +9,7 @@ using osu.Framework.Bindables;
using osu.Framework.Extensions; using osu.Framework.Extensions;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Scoring.Legacy; using osu.Game.Scoring.Legacy;
@ -210,31 +211,6 @@ namespace osu.Game.Tests.Database
AddAssert("Score version not upgraded", () => Realm.Run(r => r.Find<ScoreInfo>(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(30000001)); AddAssert("Score version not upgraded", () => Realm.Run(r => r.Find<ScoreInfo>(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(30000001));
} }
[Test]
public void TestNonLegacyScoreNotSubjectToUpgrades()
{
ScoreInfo scoreInfo = null!;
TestBackgroundDataStoreProcessor processor = null!;
AddStep("Add score which requires upgrade (and has beatmap)", () =>
{
Realm.Write(r =>
{
r.Add(scoreInfo = new ScoreInfo(ruleset: r.All<RulesetInfo>().First(), beatmap: r.All<BeatmapInfo>().First())
{
TotalScoreVersion = 30000005,
LegacyTotalScore = 123456,
});
});
});
AddStep("Run background processor", () => Add(processor = new TestBackgroundDataStoreProcessor()));
AddUntilStep("Wait for completion", () => processor.Completed);
AddAssert("Score not marked as failed", () => Realm.Run(r => r.Find<ScoreInfo>(scoreInfo.ID)!.BackgroundReprocessingFailed), () => Is.False);
AddAssert("Score version not upgraded", () => Realm.Run(r => r.Find<ScoreInfo>(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(30000005));
}
public partial class TestBackgroundDataStoreProcessor : BackgroundDataStoreProcessor public partial class TestBackgroundDataStoreProcessor : BackgroundDataStoreProcessor
{ {
protected override int TimeToSleepDuringGameplay => 10; protected override int TimeToSleepDuringGameplay => 10;

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Generic;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -96,6 +97,14 @@ namespace osu.Game.Tests.Visual.Ranking
{ {
var scoreProcessor = ruleset.CreateScoreProcessor(); var scoreProcessor = ruleset.CreateScoreProcessor();
var statistics = new Dictionary<HitResult, int>
{
{ HitResult.Miss, 1 },
{ HitResult.Meh, 50 },
{ HitResult.Good, 100 },
{ HitResult.Great, 300 },
};
return new ScoreInfo return new ScoreInfo
{ {
User = new APIUser User = new APIUser
@ -109,15 +118,9 @@ namespace osu.Game.Tests.Visual.Ranking
TotalScore = 2845370, TotalScore = 2845370,
Accuracy = accuracy, Accuracy = accuracy,
MaxCombo = 999, MaxCombo = 999,
Rank = scoreProcessor.RankFromAccuracy(accuracy), Rank = scoreProcessor.RankFromScore(accuracy, statistics),
Date = DateTimeOffset.Now, Date = DateTimeOffset.Now,
Statistics = Statistics = statistics,
{
{ HitResult.Miss, 1 },
{ HitResult.Meh, 50 },
{ HitResult.Good, 100 },
{ HitResult.Great, 300 },
}
}; };
} }
} }

View File

@ -21,6 +21,7 @@ using osu.Game.Online.API;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Screens; using osu.Game.Screens;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
@ -71,15 +72,16 @@ namespace osu.Game.Tests.Visual.Ranking
private int onlineScoreID = 1; private int onlineScoreID = 1;
[TestCase(1, ScoreRank.X)] [TestCase(1, ScoreRank.X, 0)]
[TestCase(0.9999, ScoreRank.S)] [TestCase(0.9999, ScoreRank.S, 0)]
[TestCase(0.975, ScoreRank.S)] [TestCase(0.975, ScoreRank.S, 0)]
[TestCase(0.925, ScoreRank.A)] [TestCase(0.975, ScoreRank.A, 1)]
[TestCase(0.85, ScoreRank.B)] [TestCase(0.925, ScoreRank.A, 5)]
[TestCase(0.75, ScoreRank.C)] [TestCase(0.85, ScoreRank.B, 9)]
[TestCase(0.5, ScoreRank.D)] [TestCase(0.75, ScoreRank.C, 11)]
[TestCase(0.2, ScoreRank.D)] [TestCase(0.5, ScoreRank.D, 21)]
public void TestResultsWithPlayer(double accuracy, ScoreRank rank) [TestCase(0.2, ScoreRank.D, 51)]
public void TestResultsWithPlayer(double accuracy, ScoreRank rank, int missCount)
{ {
TestResultsScreen screen = null; TestResultsScreen screen = null;
@ -91,6 +93,7 @@ namespace osu.Game.Tests.Visual.Ranking
score.HitEvents = TestSceneStatisticsPanel.CreatePositionDistributedHitEvents(); score.HitEvents = TestSceneStatisticsPanel.CreatePositionDistributedHitEvents();
score.Accuracy = accuracy; score.Accuracy = accuracy;
score.Rank = rank; score.Rank = rank;
score.Statistics[HitResult.Miss] = missCount;
return screen = createResultsScreen(score); return screen = createResultsScreen(score);
}); });

View File

@ -12,7 +12,6 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Logging; using osu.Framework.Logging;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Extensions; using osu.Game.Extensions;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Overlays; using osu.Game.Overlays;
@ -22,7 +21,7 @@ using osu.Game.Scoring;
using osu.Game.Scoring.Legacy; using osu.Game.Scoring.Legacy;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
namespace osu.Game namespace osu.Game.Database
{ {
/// <summary> /// <summary>
/// Performs background updating of data stores at startup. /// Performs background updating of data stores at startup.
@ -74,6 +73,7 @@ namespace osu.Game
processBeatmapsWithMissingObjectCounts(); processBeatmapsWithMissingObjectCounts();
processScoresWithMissingStatistics(); processScoresWithMissingStatistics();
convertLegacyTotalScoreToStandardised(); convertLegacyTotalScoreToStandardised();
upgradeScoreRanks();
}, TaskCreationOptions.LongRunning).ContinueWith(t => }, TaskCreationOptions.LongRunning).ContinueWith(t =>
{ {
if (t.Exception?.InnerException is ObjectDisposedException) if (t.Exception?.InnerException is ObjectDisposedException)
@ -355,7 +355,7 @@ namespace osu.Game
realmAccess.Write(r => realmAccess.Write(r =>
{ {
ScoreInfo s = r.Find<ScoreInfo>(id)!; ScoreInfo s = r.Find<ScoreInfo>(id)!;
StandardisedScoreMigrationTools.UpdateFromLegacy(s, beatmapManager); StandardisedScoreMigrationTools.UpdateFromLegacy(s, beatmapManager.GetWorkingBeatmap(s.BeatmapInfo));
s.TotalScoreVersion = LegacyScoreEncoder.LATEST_VERSION; s.TotalScoreVersion = LegacyScoreEncoder.LATEST_VERSION;
}); });
@ -376,6 +376,66 @@ namespace osu.Game
completeNotification(notification, processedCount, scoreIds.Count, failedCount); completeNotification(notification, processedCount, scoreIds.Count, failedCount);
} }
private void upgradeScoreRanks()
{
Logger.Log("Querying for scores that need rank upgrades...");
HashSet<Guid> scoreIds = realmAccess.Run(r => new HashSet<Guid>(
r.All<ScoreInfo>()
.Where(s => s.TotalScoreVersion < LegacyScoreEncoder.LATEST_VERSION)
.AsEnumerable()
// must be done after materialisation, as realm doesn't support
// filtering on nested property predicates or projection via `.Select()`
.Where(s => s.Ruleset.IsLegacyRuleset())
.Select(s => s.ID)));
Logger.Log($"Found {scoreIds.Count} scores which require rank upgrades.");
if (scoreIds.Count == 0)
return;
var notification = showProgressNotification(scoreIds.Count, "Adjusting ranks of scores", "scores now have more correct ranks");
int processedCount = 0;
int failedCount = 0;
foreach (var id in scoreIds)
{
if (notification?.State == ProgressNotificationState.Cancelled)
break;
updateNotificationProgress(notification, processedCount, scoreIds.Count);
sleepIfRequired();
try
{
// Can't use async overload because we're not on the update thread.
// ReSharper disable once MethodHasAsyncOverload
realmAccess.Write(r =>
{
ScoreInfo s = r.Find<ScoreInfo>(id)!;
s.Rank = StandardisedScoreMigrationTools.ComputeRank(s);
s.TotalScoreVersion = LegacyScoreEncoder.LATEST_VERSION;
});
++processedCount;
}
catch (ObjectDisposedException)
{
throw;
}
catch (Exception e)
{
Logger.Log($"Failed to update rank score {id}: {e}");
realmAccess.Write(r => r.Find<ScoreInfo>(id)!.BackgroundReprocessingFailed = true);
++failedCount;
}
}
completeNotification(notification, processedCount, scoreIds.Count, failedCount);
}
private void updateNotificationProgress(ProgressNotification? notification, int processedCount, int totalCount) private void updateNotificationProgress(ProgressNotification? notification, int processedCount, int totalCount)
{ {
if (notification == null) if (notification == null)

View File

@ -232,42 +232,60 @@ namespace osu.Game.Database
} }
/// <summary> /// <summary>
/// Updates a legacy <see cref="ScoreInfo"/> to standardised scoring. /// Updates a <see cref="ScoreInfo"/> to standardised scoring.
/// This will recompite the score's <see cref="ScoreInfo.Accuracy"/> (always), <see cref="ScoreInfo.Rank"/> (always),
/// and <see cref="ScoreInfo.TotalScore"/> (if the score comes from stable).
/// The total score from stable - if any applicable - will be stored to <see cref="ScoreInfo.LegacyTotalScore"/>.
/// </summary> /// </summary>
/// <param name="score">The score to update.</param> /// <param name="score">The score to update.</param>
/// <param name="beatmaps">A <see cref="BeatmapManager"/> used for <see cref="WorkingBeatmap"/> lookups.</param> /// <param name="beatmap">The <see cref="WorkingBeatmap"/> applicable for this score.</param>
public static void UpdateFromLegacy(ScoreInfo score, BeatmapManager beatmaps) public static void UpdateFromLegacy(ScoreInfo score, WorkingBeatmap beatmap)
{ {
score.TotalScore = convertFromLegacyTotalScore(score, beatmaps); var ruleset = score.Ruleset.CreateInstance();
score.Accuracy = ComputeAccuracy(score); var scoreProcessor = ruleset.CreateScoreProcessor();
// warning: ordering is important here - both total score and ranks are dependent on accuracy!
score.Accuracy = computeAccuracy(score, scoreProcessor);
score.Rank = computeRank(score, scoreProcessor);
score.TotalScore = convertFromLegacyTotalScore(score, ruleset, beatmap);
} }
/// <summary> /// <summary>
/// Updates a legacy <see cref="ScoreInfo"/> to standardised scoring. /// Updates a <see cref="ScoreInfo"/> to standardised scoring.
/// This will recompute the score's <see cref="ScoreInfo.Accuracy"/> (always), <see cref="ScoreInfo.Rank"/> (always),
/// and <see cref="ScoreInfo.TotalScore"/> (if the score comes from stable).
/// The total score from stable - if any applicable - will be stored to <see cref="ScoreInfo.LegacyTotalScore"/>.
/// </summary> /// </summary>
/// <remarks>
/// This overload is intended for server-side flows.
/// See: https://github.com/ppy/osu-queue-score-statistics/blob/3681e92ac91c6c61922094bdbc7e92e6217dd0fc/osu.Server.Queues.ScoreStatisticsProcessor/Commands/Queue/BatchInserter.cs
/// </remarks>
/// <param name="score">The score to update.</param> /// <param name="score">The score to update.</param>
/// <param name="ruleset">The <see cref="Ruleset"/> in which the score was set.</param>
/// <param name="difficulty">The beatmap difficulty.</param> /// <param name="difficulty">The beatmap difficulty.</param>
/// <param name="attributes">The legacy scoring attributes for the beatmap which the score was set on.</param> /// <param name="attributes">The legacy scoring attributes for the beatmap which the score was set on.</param>
public static void UpdateFromLegacy(ScoreInfo score, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes) public static void UpdateFromLegacy(ScoreInfo score, Ruleset ruleset, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes)
{ {
score.TotalScore = convertFromLegacyTotalScore(score, difficulty, attributes); var scoreProcessor = ruleset.CreateScoreProcessor();
score.Accuracy = ComputeAccuracy(score);
// warning: ordering is important here - both total score and ranks are dependent on accuracy!
score.Accuracy = computeAccuracy(score, scoreProcessor);
score.Rank = computeRank(score, scoreProcessor);
score.TotalScore = convertFromLegacyTotalScore(score, ruleset, difficulty, attributes);
} }
/// <summary> /// <summary>
/// Converts from <see cref="ScoreInfo.LegacyTotalScore"/> to the new standardised scoring of <see cref="ScoreProcessor"/>. /// Converts from <see cref="ScoreInfo.LegacyTotalScore"/> to the new standardised scoring of <see cref="ScoreProcessor"/>.
/// </summary> /// </summary>
/// <param name="score">The score to convert the total score of.</param> /// <param name="score">The score to convert the total score of.</param>
/// <param name="beatmaps">A <see cref="BeatmapManager"/> used for <see cref="WorkingBeatmap"/> lookups.</param> /// <param name="ruleset">The <see cref="Ruleset"/> in which the score was set.</param>
/// <param name="beatmap">The <see cref="WorkingBeatmap"/> applicable for this score.</param>
/// <returns>The standardised total score.</returns> /// <returns>The standardised total score.</returns>
private static long convertFromLegacyTotalScore(ScoreInfo score, BeatmapManager beatmaps) private static long convertFromLegacyTotalScore(ScoreInfo score, Ruleset ruleset, WorkingBeatmap beatmap)
{ {
if (!score.IsLegacyScore) if (!score.IsLegacyScore)
return score.TotalScore; return score.TotalScore;
WorkingBeatmap beatmap = beatmaps.GetWorkingBeatmap(score.BeatmapInfo);
Ruleset ruleset = score.Ruleset.CreateInstance();
if (ruleset is not ILegacyRuleset legacyRuleset) if (ruleset is not ILegacyRuleset legacyRuleset)
return score.TotalScore; return score.TotalScore;
@ -283,24 +301,24 @@ namespace osu.Game.Database
ILegacyScoreSimulator sv1Simulator = legacyRuleset.CreateLegacyScoreSimulator(); ILegacyScoreSimulator sv1Simulator = legacyRuleset.CreateLegacyScoreSimulator();
LegacyScoreAttributes attributes = sv1Simulator.Simulate(beatmap, playableBeatmap); LegacyScoreAttributes attributes = sv1Simulator.Simulate(beatmap, playableBeatmap);
return convertFromLegacyTotalScore(score, LegacyBeatmapConversionDifficultyInfo.FromBeatmap(beatmap.Beatmap), attributes); return convertFromLegacyTotalScore(score, ruleset, LegacyBeatmapConversionDifficultyInfo.FromBeatmap(beatmap.Beatmap), attributes);
} }
/// <summary> /// <summary>
/// Converts from <see cref="ScoreInfo.LegacyTotalScore"/> to the new standardised scoring of <see cref="ScoreProcessor"/>. /// Converts from <see cref="ScoreInfo.LegacyTotalScore"/> to the new standardised scoring of <see cref="ScoreProcessor"/>.
/// </summary> /// </summary>
/// <param name="score">The score to convert the total score of.</param> /// <param name="score">The score to convert the total score of.</param>
/// <param name="ruleset">The <see cref="Ruleset"/> in which the score was set.</param>
/// <param name="difficulty">The beatmap difficulty.</param> /// <param name="difficulty">The beatmap difficulty.</param>
/// <param name="attributes">The legacy scoring attributes for the beatmap which the score was set on.</param> /// <param name="attributes">The legacy scoring attributes for the beatmap which the score was set on.</param>
/// <returns>The standardised total score.</returns> /// <returns>The standardised total score.</returns>
private static long convertFromLegacyTotalScore(ScoreInfo score, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes) private static long convertFromLegacyTotalScore(ScoreInfo score, Ruleset ruleset, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes)
{ {
if (!score.IsLegacyScore) if (!score.IsLegacyScore)
return score.TotalScore; return score.TotalScore;
Debug.Assert(score.LegacyTotalScore != null); Debug.Assert(score.LegacyTotalScore != null);
Ruleset ruleset = score.Ruleset.CreateInstance();
if (ruleset is not ILegacyRuleset legacyRuleset) if (ruleset is not ILegacyRuleset legacyRuleset)
return score.TotalScore; return score.TotalScore;
@ -474,14 +492,9 @@ namespace osu.Game.Database
break; break;
case 3: case 3:
// in the mania case accuracy actually changes between score V1 and score V2 / standardised
// (PERFECT weighting changes from 300 to 305),
// so for better accuracy recompute accuracy locally based on hit statistics and use that instead,
double scoreV2Accuracy = ComputeAccuracy(score);
convertedTotalScore = (long)Math.Round(( convertedTotalScore = (long)Math.Round((
850000 * comboProportion 850000 * comboProportion
+ 150000 * Math.Pow(scoreV2Accuracy, 2 + 2 * scoreV2Accuracy) + 150000 * Math.Pow(score.Accuracy, 2 + 2 * score.Accuracy)
+ bonusProportion) * modMultiplier); + bonusProportion) * modMultiplier);
break; break;
@ -584,11 +597,8 @@ namespace osu.Game.Database
} }
} }
public static double ComputeAccuracy(ScoreInfo scoreInfo) private static double computeAccuracy(ScoreInfo scoreInfo, ScoreProcessor scoreProcessor)
{ {
Ruleset ruleset = scoreInfo.Ruleset.CreateInstance();
ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor();
int baseScore = scoreInfo.Statistics.Where(kvp => kvp.Key.AffectsAccuracy()) int baseScore = scoreInfo.Statistics.Where(kvp => kvp.Key.AffectsAccuracy())
.Sum(kvp => kvp.Value * scoreProcessor.GetBaseScoreForResult(kvp.Key)); .Sum(kvp => kvp.Value * scoreProcessor.GetBaseScoreForResult(kvp.Key));
int maxBaseScore = scoreInfo.MaximumStatistics.Where(kvp => kvp.Key.AffectsAccuracy()) int maxBaseScore = scoreInfo.MaximumStatistics.Where(kvp => kvp.Key.AffectsAccuracy())
@ -597,6 +607,18 @@ namespace osu.Game.Database
return maxBaseScore == 0 ? 1 : baseScore / (double)maxBaseScore; return maxBaseScore == 0 ? 1 : baseScore / (double)maxBaseScore;
} }
public static ScoreRank ComputeRank(ScoreInfo scoreInfo) => computeRank(scoreInfo, scoreInfo.Ruleset.CreateInstance().CreateScoreProcessor());
private static ScoreRank computeRank(ScoreInfo scoreInfo, ScoreProcessor scoreProcessor)
{
var rank = scoreProcessor.RankFromScore(scoreInfo.Accuracy, scoreInfo.Statistics);
foreach (var mod in scoreInfo.Mods.OfType<IApplicableToScoreProcessor>())
rank = mod.AdjustRank(rank, scoreInfo.Accuracy);
return rank;
}
/// <summary> /// <summary>
/// Used to populate the <paramref name="score"/> model using data parsed from its corresponding replay file. /// Used to populate the <paramref name="score"/> model using data parsed from its corresponding replay file.
/// </summary> /// </summary>

View File

@ -370,7 +370,7 @@ namespace osu.Game.Rulesets.Scoring
if (rank.Value == ScoreRank.F) if (rank.Value == ScoreRank.F)
return; return;
rank.Value = RankFromAccuracy(Accuracy.Value); rank.Value = RankFromScore(Accuracy.Value, ScoreResultCounts);
foreach (var mod in Mods.Value.OfType<IApplicableToScoreProcessor>()) foreach (var mod in Mods.Value.OfType<IApplicableToScoreProcessor>())
rank.Value = mod.AdjustRank(Rank.Value, Accuracy.Value); rank.Value = mod.AdjustRank(Rank.Value, Accuracy.Value);
} }
@ -505,7 +505,7 @@ namespace osu.Game.Rulesets.Scoring
/// <summary> /// <summary>
/// Given an accuracy (0..1), return the correct <see cref="ScoreRank"/>. /// Given an accuracy (0..1), return the correct <see cref="ScoreRank"/>.
/// </summary> /// </summary>
public virtual ScoreRank RankFromAccuracy(double accuracy) public virtual ScoreRank RankFromScore(double accuracy, IReadOnlyDictionary<HitResult, int> results)
{ {
if (accuracy == accuracy_cutoff_x) if (accuracy == accuracy_cutoff_x)
return ScoreRank.X; return ScoreRank.X;

View File

@ -4,6 +4,7 @@
#nullable disable #nullable disable
using System; using System;
using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
@ -19,6 +20,7 @@ using osu.Game.Replays.Legacy;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring;
using SharpCompress.Compressors.LZMA; using SharpCompress.Compressors.LZMA;
namespace osu.Game.Scoring.Legacy namespace osu.Game.Scoring.Legacy
@ -38,7 +40,6 @@ namespace osu.Game.Scoring.Legacy
}; };
WorkingBeatmap workingBeatmap; WorkingBeatmap workingBeatmap;
byte[] compressedScoreInfo = null;
using (SerializationReader sr = new SerializationReader(stream)) using (SerializationReader sr = new SerializationReader(stream))
{ {
@ -107,6 +108,8 @@ namespace osu.Game.Scoring.Legacy
else if (version >= 20121008) else if (version >= 20121008)
scoreInfo.LegacyOnlineID = sr.ReadInt32(); scoreInfo.LegacyOnlineID = sr.ReadInt32();
byte[] compressedScoreInfo = null;
if (version >= 30000001) if (version >= 30000001)
compressedScoreInfo = sr.ReadByteArray(); compressedScoreInfo = sr.ReadByteArray();
@ -130,10 +133,12 @@ namespace osu.Game.Scoring.Legacy
} }
} }
if (score.ScoreInfo.IsLegacyScore || compressedScoreInfo == null) PopulateMaximumStatistics(score.ScoreInfo, workingBeatmap);
PopulateLegacyAccuracyAndRank(score.ScoreInfo);
else if (score.ScoreInfo.IsLegacyScore)
populateLazerAccuracyAndRank(score.ScoreInfo); score.ScoreInfo.LegacyTotalScore = score.ScoreInfo.TotalScore;
StandardisedScoreMigrationTools.UpdateFromLegacy(score.ScoreInfo, workingBeatmap);
// before returning for database import, we must restore the database-sourced BeatmapInfo. // before returning for database import, we must restore the database-sourced BeatmapInfo.
// if not, the clone operation in GetPlayableBeatmap will cause a dereference and subsequent database exception. // if not, the clone operation in GetPlayableBeatmap will cause a dereference and subsequent database exception.
@ -171,121 +176,65 @@ namespace osu.Game.Scoring.Legacy
} }
/// <summary> /// <summary>
/// Populates the accuracy of a given <see cref="ScoreInfo"/> from its contained statistics. /// Populates the <see cref="ScoreInfo.MaximumStatistics"/> for a given <see cref="ScoreInfo"/>.
/// </summary> /// </summary>
/// <remarks> /// <param name="score">The score to populate the statistics of.</param>
/// Legacy use only. /// <param name="workingBeatmap">The corresponding <see cref="WorkingBeatmap"/>.</param>
/// </remarks> internal static void PopulateMaximumStatistics(ScoreInfo score, WorkingBeatmap workingBeatmap)
/// <param name="score">The <see cref="ScoreInfo"/> to populate.</param>
public static void PopulateLegacyAccuracyAndRank(ScoreInfo score)
{ {
int countMiss = score.GetCountMiss() ?? 0; Debug.Assert(score.BeatmapInfo != null);
int count50 = score.GetCount50() ?? 0;
int count100 = score.GetCount100() ?? 0;
int count300 = score.GetCount300() ?? 0;
int countGeki = score.GetCountGeki() ?? 0;
int countKatu = score.GetCountKatu() ?? 0;
switch (score.Ruleset.OnlineID) if (score.MaximumStatistics.Select(kvp => kvp.Value).Sum() > 0)
return;
var ruleset = score.Ruleset.Detach();
var rulesetInstance = ruleset.CreateInstance();
var scoreProcessor = rulesetInstance.CreateScoreProcessor();
// Populate the maximum statistics.
HitResult maxBasicResult = rulesetInstance.GetHitResults()
.Select(h => h.result)
.Where(h => h.IsBasic()).MaxBy(scoreProcessor.GetBaseScoreForResult);
foreach ((HitResult result, int count) in score.Statistics)
{ {
case 0: switch (result)
{ {
int totalHits = count50 + count100 + count300 + countMiss; case HitResult.LargeTickHit:
score.Accuracy = totalHits > 0 ? (double)(count50 * 50 + count100 * 100 + count300 * 300) / (totalHits * 300) : 1; case HitResult.LargeTickMiss:
score.MaximumStatistics[HitResult.LargeTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.LargeTickHit) + count;
float ratio300 = (float)count300 / totalHits;
float ratio50 = (float)count50 / totalHits;
if (ratio300 == 1)
score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.XH : ScoreRank.X;
else if (ratio300 > 0.9 && ratio50 <= 0.01 && countMiss == 0)
score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.SH : ScoreRank.S;
else if ((ratio300 > 0.8 && countMiss == 0) || ratio300 > 0.9)
score.Rank = ScoreRank.A;
else if ((ratio300 > 0.7 && countMiss == 0) || ratio300 > 0.8)
score.Rank = ScoreRank.B;
else if (ratio300 > 0.6)
score.Rank = ScoreRank.C;
else
score.Rank = ScoreRank.D;
break; break;
}
case 1: case HitResult.SmallTickHit:
{ case HitResult.SmallTickMiss:
int totalHits = count50 + count100 + count300 + countMiss; score.MaximumStatistics[HitResult.SmallTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) + count;
score.Accuracy = totalHits > 0 ? (double)(count100 * 150 + count300 * 300) / (totalHits * 300) : 1;
float ratio300 = (float)count300 / totalHits;
float ratio50 = (float)count50 / totalHits;
if (ratio300 == 1)
score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.XH : ScoreRank.X;
else if (ratio300 > 0.9 && ratio50 <= 0.01 && countMiss == 0)
score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.SH : ScoreRank.S;
else if ((ratio300 > 0.8 && countMiss == 0) || ratio300 > 0.9)
score.Rank = ScoreRank.A;
else if ((ratio300 > 0.7 && countMiss == 0) || ratio300 > 0.8)
score.Rank = ScoreRank.B;
else if (ratio300 > 0.6)
score.Rank = ScoreRank.C;
else
score.Rank = ScoreRank.D;
break; break;
}
case 2: case HitResult.IgnoreHit:
{ case HitResult.IgnoreMiss:
int totalHits = count50 + count100 + count300 + countMiss + countKatu; case HitResult.SmallBonus:
score.Accuracy = totalHits > 0 ? (double)(count50 + count100 + count300) / totalHits : 1; case HitResult.LargeBonus:
if (score.Accuracy == 1)
score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.XH : ScoreRank.X;
else if (score.Accuracy > 0.98)
score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.SH : ScoreRank.S;
else if (score.Accuracy > 0.94)
score.Rank = ScoreRank.A;
else if (score.Accuracy > 0.9)
score.Rank = ScoreRank.B;
else if (score.Accuracy > 0.85)
score.Rank = ScoreRank.C;
else
score.Rank = ScoreRank.D;
break; break;
}
case 3: default:
{ score.MaximumStatistics[maxBasicResult] = score.MaximumStatistics.GetValueOrDefault(maxBasicResult) + count;
int totalHits = count50 + count100 + count300 + countMiss + countGeki + countKatu;
score.Accuracy = totalHits > 0 ? (double)(count50 * 50 + count100 * 100 + countKatu * 200 + (count300 + countGeki) * 300) / (totalHits * 300) : 1;
if (score.Accuracy == 1)
score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.XH : ScoreRank.X;
else if (score.Accuracy > 0.95)
score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.SH : ScoreRank.S;
else if (score.Accuracy > 0.9)
score.Rank = ScoreRank.A;
else if (score.Accuracy > 0.8)
score.Rank = ScoreRank.B;
else if (score.Accuracy > 0.7)
score.Rank = ScoreRank.C;
else
score.Rank = ScoreRank.D;
break; break;
} }
} }
}
private void populateLazerAccuracyAndRank(ScoreInfo scoreInfo) if (!score.IsLegacyScore)
{ return;
scoreInfo.Accuracy = StandardisedScoreMigrationTools.ComputeAccuracy(scoreInfo);
var rank = currentRuleset.CreateScoreProcessor().RankFromAccuracy(scoreInfo.Accuracy); #pragma warning disable CS0618
// In osu! and osu!mania, some judgements affect combo but aren't stored to scores.
// A special hit result is used to pad out the combo value to match, based on the max combo from the difficulty attributes.
var calculator = rulesetInstance.CreateDifficultyCalculator(workingBeatmap);
var attributes = calculator.Calculate(score.Mods);
foreach (var mod in scoreInfo.Mods.OfType<IApplicableToScoreProcessor>()) int maxComboFromStatistics = score.MaximumStatistics.Where(kvp => kvp.Key.AffectsCombo()).Select(kvp => kvp.Value).DefaultIfEmpty(0).Sum();
rank = mod.AdjustRank(rank, scoreInfo.Accuracy); if (attributes.MaxCombo > maxComboFromStatistics)
score.MaximumStatistics[HitResult.LegacyComboIncrease] = attributes.MaxCombo - maxComboFromStatistics;
scoreInfo.Rank = rank; #pragma warning restore CS0618
} }
private void readLegacyReplay(Replay replay, StreamReader reader) private void readLegacyReplay(Replay replay, StreamReader reader)

View File

@ -43,9 +43,10 @@ namespace osu.Game.Scoring.Legacy
/// 30000012: Fix incorrect total score conversion on selected beatmaps after implementing the more correct /// 30000012: Fix incorrect total score conversion on selected beatmaps after implementing the more correct
/// <see cref="LegacyRulesetExtensions.CalculateDifficultyPeppyStars"/> method. Reconvert all scores. /// <see cref="LegacyRulesetExtensions.CalculateDifficultyPeppyStars"/> method. Reconvert all scores.
/// </description></item> /// </description></item>
/// <item><description>30000013: All local scores will use lazer definitions of ranks for consistency. Recalculates the rank of all scores.</description></item>
/// </list> /// </list>
/// </remarks> /// </remarks>
public const int LATEST_VERSION = 30000012; public const int LATEST_VERSION = 30000013;
/// <summary> /// <summary>
/// The first stable-compatible YYYYMMDD format version given to lazer usage of replays. /// The first stable-compatible YYYYMMDD format version given to lazer usage of replays.

View File

@ -17,7 +17,6 @@ using osu.Game.Scoring.Legacy;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Rulesets.Scoring;
using Realms; using Realms;
namespace osu.Game.Scoring namespace osu.Game.Scoring
@ -91,8 +90,6 @@ namespace osu.Game.Scoring
ArgumentNullException.ThrowIfNull(model.BeatmapInfo); ArgumentNullException.ThrowIfNull(model.BeatmapInfo);
ArgumentNullException.ThrowIfNull(model.Ruleset); ArgumentNullException.ThrowIfNull(model.Ruleset);
PopulateMaximumStatistics(model);
if (string.IsNullOrEmpty(model.StatisticsJson)) if (string.IsNullOrEmpty(model.StatisticsJson))
model.StatisticsJson = JsonConvert.SerializeObject(model.Statistics); model.StatisticsJson = JsonConvert.SerializeObject(model.Statistics);
@ -103,75 +100,6 @@ namespace osu.Game.Scoring
// this requires: max combo, statistics, max statistics (where available), and mods to already be populated on the score. // this requires: max combo, statistics, max statistics (where available), and mods to already be populated on the score.
if (StandardisedScoreMigrationTools.ShouldMigrateToNewStandardised(model)) if (StandardisedScoreMigrationTools.ShouldMigrateToNewStandardised(model))
model.TotalScore = StandardisedScoreMigrationTools.GetNewStandardised(model); model.TotalScore = StandardisedScoreMigrationTools.GetNewStandardised(model);
else if (model.IsLegacyScore)
{
model.LegacyTotalScore = model.TotalScore;
StandardisedScoreMigrationTools.UpdateFromLegacy(model, beatmaps());
}
}
/// <summary>
/// Populates the <see cref="ScoreInfo.MaximumStatistics"/> for a given <see cref="ScoreInfo"/>.
/// </summary>
/// <param name="score">The score to populate the statistics of.</param>
public void PopulateMaximumStatistics(ScoreInfo score)
{
Debug.Assert(score.BeatmapInfo != null);
if (score.MaximumStatistics.Select(kvp => kvp.Value).Sum() > 0)
return;
var beatmap = score.BeatmapInfo!.Detach();
var ruleset = score.Ruleset.Detach();
var rulesetInstance = ruleset.CreateInstance();
var scoreProcessor = rulesetInstance.CreateScoreProcessor();
Debug.Assert(rulesetInstance != null);
// Populate the maximum statistics.
HitResult maxBasicResult = rulesetInstance.GetHitResults()
.Select(h => h.result)
.Where(h => h.IsBasic()).MaxBy(scoreProcessor.GetBaseScoreForResult);
foreach ((HitResult result, int count) in score.Statistics)
{
switch (result)
{
case HitResult.LargeTickHit:
case HitResult.LargeTickMiss:
score.MaximumStatistics[HitResult.LargeTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.LargeTickHit) + count;
break;
case HitResult.SmallTickHit:
case HitResult.SmallTickMiss:
score.MaximumStatistics[HitResult.SmallTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) + count;
break;
case HitResult.IgnoreHit:
case HitResult.IgnoreMiss:
case HitResult.SmallBonus:
case HitResult.LargeBonus:
break;
default:
score.MaximumStatistics[maxBasicResult] = score.MaximumStatistics.GetValueOrDefault(maxBasicResult) + count;
break;
}
}
if (!score.IsLegacyScore)
return;
#pragma warning disable CS0618
// In osu! and osu!mania, some judgements affect combo but aren't stored to scores.
// A special hit result is used to pad out the combo value to match, based on the max combo from the difficulty attributes.
var calculator = rulesetInstance.CreateDifficultyCalculator(beatmaps().GetWorkingBeatmap(beatmap));
var attributes = calculator.Calculate(score.Mods);
int maxComboFromStatistics = score.MaximumStatistics.Where(kvp => kvp.Key.AffectsCombo()).Select(kvp => kvp.Value).DefaultIfEmpty(0).Sum();
if (attributes.MaxCombo > maxComboFromStatistics)
score.MaximumStatistics[HitResult.LegacyComboIncrease] = attributes.MaxCombo - maxComboFromStatistics;
#pragma warning restore CS0618
} }
// Very naive local caching to improve performance of large score imports (where the username is usually the same for most or all scores). // Very naive local caching to improve performance of large score imports (where the username is usually the same for most or all scores).

View File

@ -5,6 +5,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Threading; using System.Threading;
@ -26,6 +27,7 @@ namespace osu.Game.Scoring
{ {
public class ScoreManager : ModelManager<ScoreInfo>, IModelImporter<ScoreInfo> public class ScoreManager : ModelManager<ScoreInfo>, IModelImporter<ScoreInfo>
{ {
private readonly Func<BeatmapManager> beatmaps;
private readonly OsuConfigManager configManager; private readonly OsuConfigManager configManager;
private readonly ScoreImporter scoreImporter; private readonly ScoreImporter scoreImporter;
private readonly LegacyScoreExporter scoreExporter; private readonly LegacyScoreExporter scoreExporter;
@ -44,6 +46,7 @@ namespace osu.Game.Scoring
OsuConfigManager configManager = null) OsuConfigManager configManager = null)
: base(storage, realm) : base(storage, realm)
{ {
this.beatmaps = beatmaps;
this.configManager = configManager; this.configManager = configManager;
scoreImporter = new ScoreImporter(rulesets, beatmaps, storage, realm, api) scoreImporter = new ScoreImporter(rulesets, beatmaps, storage, realm, api)
@ -171,7 +174,11 @@ namespace osu.Game.Scoring
/// Populates the <see cref="ScoreInfo.MaximumStatistics"/> for a given <see cref="ScoreInfo"/>. /// Populates the <see cref="ScoreInfo.MaximumStatistics"/> for a given <see cref="ScoreInfo"/>.
/// </summary> /// </summary>
/// <param name="score">The score to populate the statistics of.</param> /// <param name="score">The score to populate the statistics of.</param>
public void PopulateMaximumStatistics(ScoreInfo score) => scoreImporter.PopulateMaximumStatistics(score); public void PopulateMaximumStatistics(ScoreInfo score)
{
Debug.Assert(score.BeatmapInfo != null);
LegacyScoreDecoder.PopulateMaximumStatistics(score, beatmaps().GetWorkingBeatmap(score.BeatmapInfo.Detach()));
}
#region Implementation of IPresentImports<ScoreInfo> #region Implementation of IPresentImports<ScoreInfo>

View File

@ -21,6 +21,7 @@ using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK; using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.Ranking.Expanded.Accuracy namespace osu.Game.Screens.Ranking.Expanded.Accuracy
{ {
@ -111,6 +112,9 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy
private readonly double accuracyD; private readonly double accuracyD;
private readonly bool withFlair; private readonly bool withFlair;
private readonly bool isFailedSDueToMisses;
private RankText failedSRankText;
public AccuracyCircle(ScoreInfo score, bool withFlair = false) public AccuracyCircle(ScoreInfo score, bool withFlair = false)
{ {
this.score = score; this.score = score;
@ -119,10 +123,13 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy
ScoreProcessor scoreProcessor = score.Ruleset.CreateInstance().CreateScoreProcessor(); ScoreProcessor scoreProcessor = score.Ruleset.CreateInstance().CreateScoreProcessor();
accuracyX = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.X); accuracyX = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.X);
accuracyS = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.S); accuracyS = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.S);
accuracyA = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.A); accuracyA = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.A);
accuracyB = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.B); accuracyB = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.B);
accuracyC = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.C); accuracyC = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.C);
accuracyD = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.D); accuracyD = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.D);
isFailedSDueToMisses = score.Accuracy >= accuracyS && score.Rank == ScoreRank.A;
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
@ -249,6 +256,9 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy
if (withFlair) if (withFlair)
{ {
if (isFailedSDueToMisses)
AddInternal(failedSRankText = new RankText(ScoreRank.S));
AddRangeInternal(new Drawable[] AddRangeInternal(new Drawable[]
{ {
rankImpactSound = new PoolableSkinnableSample(new SampleInfo(impactSampleName)), rankImpactSound = new PoolableSkinnableSample(new SampleInfo(impactSampleName)),
@ -387,6 +397,31 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy
}); });
} }
} }
if (isFailedSDueToMisses)
{
const double adjust_duration = 200;
using (BeginDelayedSequence(TEXT_APPEAR_DELAY - adjust_duration))
{
failedSRankText.FadeIn(adjust_duration);
using (BeginDelayedSequence(adjust_duration))
{
failedSRankText
.FadeColour(Color4.Red, 800, Easing.Out)
.RotateTo(10, 1000, Easing.Out)
.MoveToY(100, 1000, Easing.In)
.FadeOut(800, Easing.Out);
accuracyCircle
.FillTo(accuracyS - NOTCH_WIDTH_PERCENTAGE / 2 - visual_alignment_offset, 70, Easing.OutQuint);
badges.Single(b => b.Rank == ScoreRank.S)
.FadeOut(70, Easing.OutQuint);
}
}
}
} }
} }

View File

@ -32,7 +32,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy
/// </summary> /// </summary>
private readonly double displayPosition; private readonly double displayPosition;
private readonly ScoreRank rank; public readonly ScoreRank Rank;
private Drawable rankContainer; private Drawable rankContainer;
private Drawable overlay; private Drawable overlay;
@ -47,7 +47,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy
{ {
Accuracy = accuracy; Accuracy = accuracy;
displayPosition = position; displayPosition = position;
this.rank = rank; Rank = rank;
RelativeSizeAxes = Axes.Both; RelativeSizeAxes = Axes.Both;
Alpha = 0; Alpha = 0;
@ -62,7 +62,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy
Size = new Vector2(28, 14), Size = new Vector2(28, 14),
Children = new[] Children = new[]
{ {
new DrawableRank(rank), new DrawableRank(Rank),
overlay = new CircularContainer overlay = new CircularContainer
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
@ -71,7 +71,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy
EdgeEffect = new EdgeEffectParameters EdgeEffect = new EdgeEffectParameters
{ {
Type = EdgeEffectType.Glow, Type = EdgeEffectType.Glow,
Colour = OsuColour.ForRank(rank).Opacity(0.2f), Colour = OsuColour.ForRank(Rank).Opacity(0.2f),
Radius = 10, Radius = 10,
}, },
Child = new Box Child = new Box