diff --git a/.github/workflows/diffcalc.yml b/.github/workflows/diffcalc.yml index d4150208d3..5f16e09040 100644 --- a/.github/workflows/diffcalc.yml +++ b/.github/workflows/diffcalc.yml @@ -189,8 +189,8 @@ jobs: COMMENT_BODY: ${{ github.event.comment.body }} run: | # Add comment environment - echo $COMMENT_BODY | sed -r 's/\r$//' | grep -E '^\w+=' | while read -r line; do - opt=$(echo ${line} | cut -d '=' -f1) + echo "$COMMENT_BODY" | sed -r 's/\r$//' | grep -E '^\w+=' | while read -r line; do + opt=$(echo "${line}" | cut -d '=' -f1) sed -i "s;^${opt}=.*$;${line};" "${{ needs.directory.outputs.GENERATOR_ENV }}" done diff --git a/osu.Android.props b/osu.Android.props index 3b90b1675c..6609db3027 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -10,7 +10,7 @@ true - + - \ No newline at end of file + + + diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs b/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs index 9323296b7f..66c76f9b17 100644 --- a/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs +++ b/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs @@ -4,11 +4,19 @@ using System; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; namespace osu.Game.Rulesets.Catch.Scoring { public partial class CatchScoreProcessor : ScoreProcessor { + private const double accuracy_cutoff_x = 1; + private const double accuracy_cutoff_s = 0.98; + private const double accuracy_cutoff_a = 0.94; + private const double accuracy_cutoff_b = 0.9; + private const double accuracy_cutoff_c = 0.85; + private const double accuracy_cutoff_d = 0; + private const int combo_cap = 200; private const double combo_base = 4; @@ -26,5 +34,50 @@ namespace osu.Game.Rulesets.Catch.Scoring protected override double GetComboScoreChange(JudgementResult result) => Judgement.ToNumericResult(result.Type) * Math.Min(Math.Max(0.5, Math.Log(result.ComboAfterJudgement, combo_base)), Math.Log(combo_cap, combo_base)); + + public override ScoreRank RankFromAccuracy(double accuracy) + { + if (accuracy == accuracy_cutoff_x) + return ScoreRank.X; + if (accuracy >= accuracy_cutoff_s) + return ScoreRank.S; + if (accuracy >= accuracy_cutoff_a) + return ScoreRank.A; + if (accuracy >= accuracy_cutoff_b) + return ScoreRank.B; + if (accuracy >= accuracy_cutoff_c) + return ScoreRank.C; + + return ScoreRank.D; + } + + public override double AccuracyCutoffFromRank(ScoreRank rank) + { + switch (rank) + { + case ScoreRank.X: + case ScoreRank.XH: + return accuracy_cutoff_x; + + case ScoreRank.S: + case ScoreRank.SH: + return accuracy_cutoff_s; + + case ScoreRank.A: + return accuracy_cutoff_a; + + case ScoreRank.B: + return accuracy_cutoff_b; + + case ScoreRank.C: + return accuracy_cutoff_c; + + case ScoreRank.D: + return accuracy_cutoff_d; + + default: + throw new ArgumentOutOfRangeException(nameof(rank), rank, null); + } + } } } diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaHealthProcessorTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaHealthProcessorTest.cs new file mode 100644 index 0000000000..315849f7de --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/ManiaHealthProcessorTest.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Scoring; + +namespace osu.Game.Rulesets.Mania.Tests +{ + [TestFixture] + public class ManiaHealthProcessorTest + { + [Test] + public void TestNoDrain() + { + var processor = new ManiaHealthProcessor(0); + processor.ApplyBeatmap(new ManiaBeatmap(new StageDefinition(4)) + { + HitObjects = + { + new Note { StartTime = 0 }, + new Note { StartTime = 1000 }, + } + }); + + // No matter what, mania doesn't have passive HP drain. + Assert.That(processor.DrainRate, Is.Zero); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs index aaef69f119..ccfe1501bd 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs @@ -174,9 +174,9 @@ namespace osu.Game.Rulesets.Mania.Beatmaps switch (original) { - case IHasDistance: + case IHasPath: { - var generator = new DistanceObjectPatternGenerator(Random, original, beatmap, lastPattern, originalBeatmap); + var generator = new PathObjectPatternGenerator(Random, original, beatmap, lastPattern, originalBeatmap); conversion = generator; var positionData = original as IHasPosition; diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PathObjectPatternGenerator.cs similarity index 96% rename from osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs rename to osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PathObjectPatternGenerator.cs index cce0944564..4922915c7d 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PathObjectPatternGenerator.cs @@ -22,13 +22,8 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// /// A pattern generator for IHasDistance hit objects. /// - internal class DistanceObjectPatternGenerator : PatternGenerator + internal class PathObjectPatternGenerator : PatternGenerator { - /// - /// Base osu! slider scoring distance. - /// - private const float osu_base_scoring_distance = 100; - public readonly int StartTime; public readonly int EndTime; public readonly int SegmentDuration; @@ -36,17 +31,17 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy private PatternType convertType; - public DistanceObjectPatternGenerator(LegacyRandom random, HitObject hitObject, ManiaBeatmap beatmap, Pattern previousPattern, IBeatmap originalBeatmap) + public PathObjectPatternGenerator(LegacyRandom random, HitObject hitObject, ManiaBeatmap beatmap, Pattern previousPattern, IBeatmap originalBeatmap) : base(random, hitObject, beatmap, previousPattern, originalBeatmap) { convertType = PatternType.None; if (!Beatmap.ControlPointInfo.EffectPointAt(hitObject.StartTime).KiaiMode) convertType = PatternType.LowProbability; - var distanceData = hitObject as IHasDistance; + var pathData = hitObject as IHasPath; var repeatsData = hitObject as IHasRepeats; - Debug.Assert(distanceData != null); + Debug.Assert(pathData != null); TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(hitObject.StartTime); @@ -60,8 +55,10 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy SpanCount = repeatsData?.SpanCount() ?? 1; StartTime = (int)Math.Round(hitObject.StartTime); + double distance = pathData.Path.ExpectedDistance.Value ?? 0; + // This matches stable's calculation. - EndTime = (int)Math.Floor(StartTime + distanceData.Distance * beatLength * SpanCount * 0.01 / beatmap.Difficulty.SliderMultiplier); + EndTime = (int)Math.Floor(StartTime + distance * beatLength * SpanCount * 0.01 / beatmap.Difficulty.SliderMultiplier); SegmentDuration = (EndTime - StartTime) / SpanCount; } diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaHealthProcessor.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaHealthProcessor.cs index 183550eb7b..a33eac83c2 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaHealthProcessor.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaHealthProcessor.cs @@ -15,6 +15,15 @@ namespace osu.Game.Rulesets.Mania.Scoring { } + protected override double ComputeDrainRate() + { + // Base call is run only to compute HP recovery (namely, `HpMultiplierNormal`). + // This closely mirrors (broken) behaviour of stable and as such is preserved unchanged. + base.ComputeDrainRate(); + + return 0; + } + protected override IEnumerable EnumerateTopLevelHitObjects() => Beatmap.HitObjects; protected override IEnumerable EnumerateNestedHitObjects(HitObject hitObject) => hitObject.NestedHitObjects; diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs index e46e2ec09c..2551321ff2 100644 --- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs +++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs @@ -109,9 +109,9 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps switch (obj) { - case IHasDistance distanceData: + case IHasPath pathData: { - if (shouldConvertSliderToHits(obj, beatmap, distanceData, out int taikoDuration, out double tickSpacing)) + if (shouldConvertSliderToHits(obj, beatmap, pathData, out int taikoDuration, out double tickSpacing)) { IList> allSamples = obj is IHasPathWithRepeats curveData ? curveData.NodeSamples : new List>(new[] { samples }); @@ -174,7 +174,7 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps } } - private bool shouldConvertSliderToHits(HitObject obj, IBeatmap beatmap, IHasDistance distanceData, out int taikoDuration, out double tickSpacing) + private bool shouldConvertSliderToHits(HitObject obj, IBeatmap beatmap, IHasPath pathData, out int taikoDuration, out double tickSpacing) { // DO NOT CHANGE OR REFACTOR ANYTHING IN HERE WITHOUT TESTING AGAINST _ALL_ BEATMAPS. // Some of these calculations look redundant, but they are not - extremely small floating point errors are introduced to maintain 1:1 compatibility with stable. @@ -182,7 +182,11 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps // The true distance, accounting for any repeats. This ends up being the drum roll distance later int spans = (obj as IHasRepeats)?.SpanCount() ?? 1; - double distance = distanceData.Distance * spans * LegacyBeatmapEncoder.LEGACY_TAIKO_VELOCITY_MULTIPLIER; + double distance = pathData.Path.ExpectedDistance.Value ?? 0; + + // Do not combine the following two lines! + distance *= LegacyBeatmapEncoder.LEGACY_TAIKO_VELOCITY_MULTIPLIER; + distance *= spans; TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(obj.StartTime); diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs index dcfe8ecb41..02432a1935 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs @@ -15,12 +15,14 @@ using osu.Game.IO; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Catch.Beatmaps; +using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Taiko; using osu.Game.Skinning; using osu.Game.Tests.Resources; using osuTK; @@ -1156,5 +1158,35 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(((IHasComboInformation)playable.HitObjects[17]).ComboIndexWithOffsets, Is.EqualTo(9)); } } + + [Test] + public void TestSliderConversionWithCustomDistance([Values("taiko", "mania")] string rulesetName) + { + using (var resStream = TestResources.OpenResource("custom-slider-length.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + Ruleset ruleset; + + switch (rulesetName) + { + case "taiko": + ruleset = new TaikoRuleset(); + break; + + case "mania": + ruleset = new ManiaRuleset(); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(rulesetName), rulesetName, null); + } + + var decoder = Decoder.GetDecoder(stream); + var working = new TestWorkingBeatmap(decoder.Decode(stream)); + IBeatmap beatmap = working.GetPlayableBeatmap(ruleset.RulesetInfo, Array.Empty()); + + Assert.That(beatmap.HitObjects[0].GetEndTime(), Is.EqualTo(3153)); + } + } } } diff --git a/osu.Game.Tests/Resources/custom-slider-length.osu b/osu.Game.Tests/Resources/custom-slider-length.osu new file mode 100644 index 0000000000..f7529918a9 --- /dev/null +++ b/osu.Game.Tests/Resources/custom-slider-length.osu @@ -0,0 +1,19 @@ +osu file format v14 + +[General] +Mode: 0 + +[Difficulty] +HPDrainRate:6 +CircleSize:7 +OverallDifficulty:7 +ApproachRate:10 +SliderMultiplier:1.7 +SliderTickRate:1 + +[TimingPoints] +29,333.333333333333,4,1,0,100,1,0 +29,-10000,4,1,0,100,0,0 + +[HitObjects] +256,192,29,6,0,P|384:192|384:192,1,159.375 \ No newline at end of file diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs index 6d309078e6..2d61c26a6b 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs @@ -378,6 +378,41 @@ namespace osu.Game.Tests.Visual.Multiplayer }, users); } + [Test] + public void TestAbortMatch() + { + AddStep("setup client", () => + { + multiplayerClient.Setup(m => m.StartMatch()) + .Callback(() => + { + multiplayerClient.Raise(m => m.LoadRequested -= null); + multiplayerClient.Object.Room!.State = MultiplayerRoomState.WaitingForLoad; + + // The local user state doesn't really matter, so let's do the same as the base implementation for these tests. + changeUserState(localUser.UserID, MultiplayerUserState.Idle); + }); + + multiplayerClient.Setup(m => m.AbortMatch()) + .Callback(() => + { + multiplayerClient.Object.Room!.State = MultiplayerRoomState.Open; + raiseRoomUpdated(); + }); + }); + + // Ready + ClickButtonWhenEnabled(); + + // Start match + ClickButtonWhenEnabled(); + AddUntilStep("countdown button disabled", () => !this.ChildrenOfType().Single().Enabled.Value); + + // Abort + ClickButtonWhenEnabled(); + AddStep("check abort request received", () => multiplayerClient.Verify(m => m.AbortMatch(), Times.Once)); + } + private void verifyGameplayStartFlow() { checkLocalUserState(MultiplayerUserState.Ready); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs index c17a9ddf5f..fa85c8c9f8 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs @@ -12,9 +12,12 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Framework.Threading; +using osu.Game.Online.API; +using osu.Game.Beatmaps; using osu.Game.Overlays.Settings; using osu.Game.Overlays.SkinEditor; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Play; @@ -38,6 +41,9 @@ namespace osu.Game.Tests.Visual.Navigation advanceToSongSelect(); openSkinEditor(); + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + switchToGameplayScene(); BarHitErrorMeter hitErrorMeter = null; @@ -98,6 +104,10 @@ namespace osu.Game.Tests.Visual.Navigation { advanceToSongSelect(); openSkinEditor(); + + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + switchToGameplayScene(); AddUntilStep("wait for components", () => skinEditor.ChildrenOfType().Any()); @@ -162,6 +172,9 @@ namespace osu.Game.Tests.Visual.Navigation openSkinEditor(); AddStep("select DT", () => Game.SelectedMods.Value = new Mod[] { new OsuModDoubleTime() }); + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + switchToGameplayScene(); AddAssert("DT still selected", () => ((Player)Game.ScreenStack.CurrentScreen).Mods.Value.Single() is OsuModDoubleTime); @@ -174,6 +187,9 @@ namespace osu.Game.Tests.Visual.Navigation openSkinEditor(); AddStep("select relax and spun out", () => Game.SelectedMods.Value = new Mod[] { new OsuModRelax(), new OsuModSpunOut() }); + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + switchToGameplayScene(); AddAssert("no mod selected", () => !((Player)Game.ScreenStack.CurrentScreen).Mods.Value.Any()); @@ -186,6 +202,9 @@ namespace osu.Game.Tests.Visual.Navigation openSkinEditor(); AddStep("select autoplay", () => Game.SelectedMods.Value = new Mod[] { new OsuModAutoplay() }); + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + switchToGameplayScene(); AddAssert("no mod selected", () => !((Player)Game.ScreenStack.CurrentScreen).Mods.Value.Any()); @@ -198,6 +217,9 @@ namespace osu.Game.Tests.Visual.Navigation openSkinEditor(); AddStep("select cinema", () => Game.SelectedMods.Value = new Mod[] { new OsuModCinema() }); + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + switchToGameplayScene(); AddAssert("no mod selected", () => !((Player)Game.ScreenStack.CurrentScreen).Mods.Value.Any()); @@ -240,6 +262,43 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("editor sidebars not empty", () => skinEditor.ChildrenOfType().SelectMany(sidebar => sidebar.Children).Count(), () => Is.GreaterThan(0)); } + [Test] + public void TestOpenSkinEditorGameplaySceneOnBeatmapWithNoObjects() + { + AddStep("set dummy beatmap", () => Game.Beatmap.SetDefault()); + advanceToSongSelect(); + + AddStep("create empty beatmap", () => Game.BeatmapManager.CreateNew(new OsuRuleset().RulesetInfo, new GuestUser())); + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + openSkinEditor(); + switchToGameplayScene(); + } + + [Test] + public void TestOpenSkinEditorGameplaySceneWhenDummyBeatmapActive() + { + AddStep("set dummy beatmap", () => Game.Beatmap.SetDefault()); + + openSkinEditor(); + } + + [Test] + public void TestOpenSkinEditorGameplaySceneWhenDifferentRulesetActive() + { + BeatmapSetInfo beatmapSet = null!; + + AddStep("import beatmap", () => beatmapSet = BeatmapImportHelper.LoadQuickOszIntoOsu(Game).GetResultSafely()); + AddStep("select mania difficulty", () => + { + var beatmap = beatmapSet.Beatmaps.First(b => b.Ruleset.OnlineID == 3); + Game.Beatmap.Value = Game.BeatmapManager.GetWorkingBeatmap(beatmap); + }); + + openSkinEditor(); + switchToGameplayScene(); + } + private void advanceToSongSelect() { PushAndConfirm(() => songSelect = new TestPlaySongSelect()); @@ -266,9 +325,6 @@ namespace osu.Game.Tests.Visual.Navigation private void switchToGameplayScene() { - AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); - AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); - AddStep("Click gameplay scene button", () => { InputManager.MoveMouseTo(skinEditor.ChildrenOfType().First(b => b.Text.ToString() == "Gameplay")); diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs index 7616b9b83c..c793535255 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Linq; using NUnit.Framework; @@ -56,92 +54,76 @@ namespace osu.Game.Tests.Visual.Online textContainer.Clear(); }); - [Test] - public void TestLinksGeneral() + [TestCase("test!")] + [TestCase("dev.ppy.sh!")] + [TestCase("https://dev.ppy.sh!", LinkAction.External)] + [TestCase("http://dev.ppy.sh!", LinkAction.External)] + [TestCase("forgothttps://dev.ppy.sh!", LinkAction.External)] + [TestCase("forgothttp://dev.ppy.sh!", LinkAction.External)] + [TestCase("00:12:345 - Test?", LinkAction.OpenEditorTimestamp)] + [TestCase("00:12:345 (1,2) - Test?", LinkAction.OpenEditorTimestamp)] + [TestCase($"{OsuGameBase.OSU_PROTOCOL}edit/00:12:345 - Test?", LinkAction.OpenEditorTimestamp)] + [TestCase($"{OsuGameBase.OSU_PROTOCOL}edit/00:12:345 (1,2) - Test?", LinkAction.OpenEditorTimestamp)] + [TestCase($"{OsuGameBase.OSU_PROTOCOL}00:12:345 - not an editor timestamp", LinkAction.External)] + [TestCase("Wiki link for tasty [[Performance Points]]", LinkAction.OpenWiki)] + [TestCase("(osu forums)[https://dev.ppy.sh/forum] (old link format)", LinkAction.External)] + [TestCase("[https://dev.ppy.sh/home New site] (new link format)", LinkAction.External)] + [TestCase("[osu forums](https://dev.ppy.sh/forum) (new link format 2)", LinkAction.External)] + [TestCase("[https://dev.ppy.sh/home This is only a link to the new osu webpage but this is supposed to test word wrap.]", LinkAction.External)] + [TestCase("Let's (try)[https://dev.ppy.sh/home] [https://dev.ppy.sh/b/252238 multiple links] https://dev.ppy.sh/home", LinkAction.External, LinkAction.OpenBeatmap, LinkAction.External)] + [TestCase("[https://dev.ppy.sh/home New link format with escaped [and \\[ paired] braces]", LinkAction.External)] + [TestCase("[Markdown link format with escaped [and \\[ paired] braces](https://dev.ppy.sh/home)", LinkAction.External)] + [TestCase("(Old link format with escaped (and \\( paired) parentheses)[https://dev.ppy.sh/home] and [[also a rogue wiki link]]", LinkAction.External, LinkAction.OpenWiki)] + [TestCase("#lobby or #osu would be blue (and work) in the ChatDisplay test (when a proper ChatOverlay is present).")] // note that there's 0 links here (they get removed if a channel is not found) + [TestCase("Join my multiplayer game osump://12346.", LinkAction.JoinMultiplayerMatch)] + [TestCase("Join my multiplayer gameosump://12346.", LinkAction.JoinMultiplayerMatch)] + [TestCase("Join my [multiplayer game](osump://12346).", LinkAction.JoinMultiplayerMatch)] + [TestCase($"Join my [#english]({OsuGameBase.OSU_PROTOCOL}chan/#english).", LinkAction.OpenChannel)] + [TestCase($"Join my {OsuGameBase.OSU_PROTOCOL}chan/#english.", LinkAction.OpenChannel)] + [TestCase($"Join my{OsuGameBase.OSU_PROTOCOL}chan/#english.", LinkAction.OpenChannel)] + [TestCase("Join my #english or #japanese channels.", LinkAction.OpenChannel, LinkAction.OpenChannel)] + [TestCase("Join my #english or #nonexistent #hashtag channels.", LinkAction.OpenChannel)] + [TestCase("Hello world\uD83D\uDE12(<--This is an emoji). There are more:\uD83D\uDE10\uD83D\uDE00,\uD83D\uDE20")] + public void TestLinksGeneral(string text, params LinkAction[] actions) { - int messageIndex = 0; + addMessageWithChecks(text, expectedActions: actions); + } - addMessageWithChecks("test!"); - addMessageWithChecks("dev.ppy.sh!"); - addMessageWithChecks("https://dev.ppy.sh!", 1, expectedActions: LinkAction.External); - addMessageWithChecks("http://dev.ppy.sh!", 1, expectedActions: LinkAction.External); - addMessageWithChecks("forgothttps://dev.ppy.sh!", 1, expectedActions: LinkAction.External); - addMessageWithChecks("forgothttp://dev.ppy.sh!", 1, expectedActions: LinkAction.External); - addMessageWithChecks("00:12:345 (1,2) - Test?", 1, expectedActions: LinkAction.OpenEditorTimestamp); - addMessageWithChecks("Wiki link for tasty [[Performance Points]]", 1, expectedActions: LinkAction.OpenWiki); - addMessageWithChecks("(osu forums)[https://dev.ppy.sh/forum] (old link format)", 1, expectedActions: LinkAction.External); - addMessageWithChecks("[https://dev.ppy.sh/home New site] (new link format)", 1, expectedActions: LinkAction.External); - addMessageWithChecks("[osu forums](https://dev.ppy.sh/forum) (new link format 2)", 1, expectedActions: LinkAction.External); - addMessageWithChecks("[https://dev.ppy.sh/home This is only a link to the new osu webpage but this is supposed to test word wrap.]", 1, expectedActions: LinkAction.External); - addMessageWithChecks("is now listening to [https://dev.ppy.sh/s/93523 IMAGE -MATERIAL- ]", 1, true, expectedActions: LinkAction.OpenBeatmapSet); - addMessageWithChecks("is now playing [https://dev.ppy.sh/b/252238 IMAGE -MATERIAL- ]", 1, true, expectedActions: LinkAction.OpenBeatmap); - addMessageWithChecks("Let's (try)[https://dev.ppy.sh/home] [https://dev.ppy.sh/b/252238 multiple links] https://dev.ppy.sh/home", 3, - expectedActions: new[] { LinkAction.External, LinkAction.OpenBeatmap, LinkAction.External }); - addMessageWithChecks("[https://dev.ppy.sh/home New link format with escaped [and \\[ paired] braces]", 1, expectedActions: LinkAction.External); - addMessageWithChecks("[Markdown link format with escaped [and \\[ paired] braces](https://dev.ppy.sh/home)", 1, expectedActions: LinkAction.External); - addMessageWithChecks("(Old link format with escaped (and \\( paired) parentheses)[https://dev.ppy.sh/home] and [[also a rogue wiki link]]", 2, - expectedActions: new[] { LinkAction.External, LinkAction.OpenWiki }); - // note that there's 0 links here (they get removed if a channel is not found) - addMessageWithChecks("#lobby or #osu would be blue (and work) in the ChatDisplay test (when a proper ChatOverlay is present)."); - addMessageWithChecks("I am important!", 0, false, true); - addMessageWithChecks("feels important", 0, true, true); - addMessageWithChecks("likes to post this [https://dev.ppy.sh/home link].", 1, true, true, expectedActions: LinkAction.External); - addMessageWithChecks("Join my multiplayer game osump://12346.", 1, expectedActions: LinkAction.JoinMultiplayerMatch); - addMessageWithChecks("Join my multiplayer gameosump://12346.", 1, expectedActions: LinkAction.JoinMultiplayerMatch); - addMessageWithChecks("Join my [multiplayer game](osump://12346).", 1, expectedActions: LinkAction.JoinMultiplayerMatch); - addMessageWithChecks($"Join my [#english]({OsuGameBase.OSU_PROTOCOL}chan/#english).", 1, expectedActions: LinkAction.OpenChannel); - addMessageWithChecks($"Join my {OsuGameBase.OSU_PROTOCOL}chan/#english.", 1, expectedActions: LinkAction.OpenChannel); - addMessageWithChecks($"Join my{OsuGameBase.OSU_PROTOCOL}chan/#english.", 1, expectedActions: LinkAction.OpenChannel); - addMessageWithChecks("Join my #english or #japanese channels.", 2, expectedActions: new[] { LinkAction.OpenChannel, LinkAction.OpenChannel }); - addMessageWithChecks("Join my #english or #nonexistent #hashtag channels.", 1, expectedActions: LinkAction.OpenChannel); - addMessageWithChecks("Hello world\uD83D\uDE12(<--This is an emoji). There are more:\uD83D\uDE10\uD83D\uDE00,\uD83D\uDE20"); + [TestCase("is now listening to [https://dev.ppy.sh/s/93523 IMAGE -MATERIAL- ]", true, false, LinkAction.OpenBeatmapSet)] + [TestCase("is now playing [https://dev.ppy.sh/b/252238 IMAGE -MATERIAL- ]", true, false, LinkAction.OpenBeatmap)] + [TestCase("I am important!", false, true)] + [TestCase("feels important", true, true)] + [TestCase("likes to post this [https://dev.ppy.sh/home link].", true, true, LinkAction.External)] + public void TestActionAndImportantLinks(string text, bool isAction, bool isImportant, params LinkAction[] expectedActions) + { + addMessageWithChecks(text, isAction, isImportant, expectedActions); + } - void addMessageWithChecks(string text, int linkAmount = 0, bool isAction = false, bool isImportant = false, params LinkAction[] expectedActions) + private void addMessageWithChecks(string text, bool isAction = false, bool isImportant = false, params LinkAction[] expectedActions) + { + ChatLine newLine = null!; + + AddStep("add message", () => { - ChatLine newLine = null; - int index = messageIndex++; + newLine = new ChatLine(new DummyMessage(text, isAction, isImportant)); + textContainer.Add(newLine); + }); - AddStep("add message", () => - { - newLine = new ChatLine(new DummyMessage(text, isAction, isImportant, index)); - textContainer.Add(newLine); - }); + AddAssert("msg has the right action", () => newLine.Message.Links.Select(l => l.Action), () => Is.EqualTo(expectedActions)); + AddAssert($"msg shows {expectedActions.Length} link(s)", isShowingLinks); - AddAssert($"msg #{index} has {linkAmount} link(s)", () => newLine.Message.Links.Count == linkAmount); - AddAssert($"msg #{index} has the right action", hasExpectedActions); - //AddAssert($"msg #{index} is " + (isAction ? "italic" : "not italic"), () => newLine.ContentFlow.Any() && isAction == isItalic()); - AddAssert($"msg #{index} shows {linkAmount} link(s)", isShowingLinks); + bool isShowingLinks() + { + bool hasBackground = !string.IsNullOrEmpty(newLine.Message.Sender.Colour); - bool hasExpectedActions() - { - var expectedActionsList = expectedActions.ToList(); + Color4 textColour = isAction && hasBackground ? Color4Extensions.FromHex(newLine.Message.Sender.Colour) : Color4.White; - if (expectedActionsList.Count != newLine.Message.Links.Count) - return false; + var linkCompilers = newLine.DrawableContentFlow.Where(d => d is DrawableLinkCompiler).ToList(); + var linkSprites = linkCompilers.SelectMany(comp => ((DrawableLinkCompiler)comp).Parts); - for (int i = 0; i < newLine.Message.Links.Count; i++) - { - var action = newLine.Message.Links[i].Action; - if (action != expectedActions[i]) return false; - } - - return true; - } - - //bool isItalic() => newLine.ContentFlow.Where(d => d is OsuSpriteText).Cast().All(sprite => sprite.Font.Italics); - - bool isShowingLinks() - { - bool hasBackground = !string.IsNullOrEmpty(newLine.Message.Sender.Colour); - - Color4 textColour = isAction && hasBackground ? Color4Extensions.FromHex(newLine.Message.Sender.Colour) : Color4.White; - - var linkCompilers = newLine.DrawableContentFlow.Where(d => d is DrawableLinkCompiler).ToList(); - var linkSprites = linkCompilers.SelectMany(comp => ((DrawableLinkCompiler)comp).Parts); - - return linkSprites.All(d => d.Colour == linkColour) - && newLine.DrawableContentFlow.Except(linkSprites.Concat(linkCompilers)).All(d => d.Colour == textColour); - } + return linkSprites.All(d => d.Colour == linkColour) + && newLine.DrawableContentFlow.Except(linkSprites.Concat(linkCompilers)).All(d => d.Colour == textColour) + && linkCompilers.Count == expectedActions.Length; } } @@ -155,7 +137,7 @@ namespace osu.Game.Tests.Visual.Online addEchoWithWait("[https://dev.ppy.sh/forum let's try multiple words too!]"); addEchoWithWait("(long loading times! clickable while loading?)[https://dev.ppy.sh/home]", null, 5000); - void addEchoWithWait(string text, string completeText = null, double delay = 250) + void addEchoWithWait(string text, string? completeText = null, double delay = 250) { int index = messageIndex++; @@ -184,21 +166,12 @@ namespace osu.Game.Tests.Visual.Online { private static long messageCounter; - internal static readonly APIUser TEST_SENDER_BACKGROUND = new APIUser - { - Username = @"i-am-important", - Id = 42, - Colour = "#250cc9", - }; - internal static readonly APIUser TEST_SENDER = new APIUser { Username = @"Somebody", Id = 1, }; - public new DateTimeOffset Timestamp = DateTimeOffset.Now; - public DummyMessage(string text, bool isAction = false, bool isImportant = false, int number = 0) : base(messageCounter++) { diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs b/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs index 03b168c72c..435dd77120 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs @@ -9,6 +9,8 @@ using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; @@ -22,31 +24,48 @@ namespace osu.Game.Tests.Visual.Ranking { public partial class TestSceneAccuracyCircle : OsuTestScene { - [TestCase(0)] - [TestCase(0.2)] - [TestCase(0.5)] - [TestCase(0.6999)] - [TestCase(0.7)] - [TestCase(0.75)] - [TestCase(0.7999)] - [TestCase(0.8)] - [TestCase(0.85)] - [TestCase(0.8999)] - [TestCase(0.9)] - [TestCase(0.925)] - [TestCase(0.9499)] - [TestCase(0.95)] - [TestCase(0.975)] - [TestCase(0.9999)] - [TestCase(1)] - public void TestRank(double accuracy) + [Test] + public void TestOsuRank() { - var score = createScore(accuracy, ScoreProcessor.RankFromAccuracy(accuracy)); - - addCircleStep(score); + addCircleStep(createScore(0, new OsuRuleset())); + addCircleStep(createScore(0.5, new OsuRuleset())); + addCircleStep(createScore(0.699, new OsuRuleset())); + addCircleStep(createScore(0.7, new OsuRuleset())); + addCircleStep(createScore(0.75, new OsuRuleset())); + addCircleStep(createScore(0.799, new OsuRuleset())); + addCircleStep(createScore(0.8, new OsuRuleset())); + addCircleStep(createScore(0.85, new OsuRuleset())); + addCircleStep(createScore(0.899, new OsuRuleset())); + addCircleStep(createScore(0.9, new OsuRuleset())); + addCircleStep(createScore(0.925, new OsuRuleset())); + addCircleStep(createScore(0.9499, new OsuRuleset())); + addCircleStep(createScore(0.95, new OsuRuleset())); + addCircleStep(createScore(0.975, new OsuRuleset())); + addCircleStep(createScore(0.99, new OsuRuleset())); + addCircleStep(createScore(1, new OsuRuleset())); } - private void addCircleStep(ScoreInfo score) => AddStep("add panel", () => + [Test] + public void TestCatchRank() + { + addCircleStep(createScore(0, new CatchRuleset())); + addCircleStep(createScore(0.5, new CatchRuleset())); + addCircleStep(createScore(0.8499, new CatchRuleset())); + addCircleStep(createScore(0.85, new CatchRuleset())); + addCircleStep(createScore(0.875, new CatchRuleset())); + addCircleStep(createScore(0.899, new CatchRuleset())); + addCircleStep(createScore(0.9, new CatchRuleset())); + addCircleStep(createScore(0.925, new CatchRuleset())); + addCircleStep(createScore(0.9399, new CatchRuleset())); + addCircleStep(createScore(0.94, new CatchRuleset())); + addCircleStep(createScore(0.9675, new CatchRuleset())); + addCircleStep(createScore(0.9799, new CatchRuleset())); + addCircleStep(createScore(0.98, new CatchRuleset())); + addCircleStep(createScore(0.99, new CatchRuleset())); + addCircleStep(createScore(1, new CatchRuleset())); + } + + private void addCircleStep(ScoreInfo score) => AddStep($"add panel ({score.DisplayAccuracy})", () => { Children = new Drawable[] { @@ -73,28 +92,33 @@ namespace osu.Game.Tests.Visual.Ranking }; }); - private ScoreInfo createScore(double accuracy, ScoreRank rank) => new ScoreInfo + private ScoreInfo createScore(double accuracy, Ruleset ruleset) { - User = new APIUser + var scoreProcessor = ruleset.CreateScoreProcessor(); + + return new ScoreInfo { - Id = 2, - Username = "peppy", - }, - BeatmapInfo = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo, - Ruleset = new OsuRuleset().RulesetInfo, - Mods = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() }, - TotalScore = 2845370, - Accuracy = accuracy, - MaxCombo = 999, - Rank = rank, - Date = DateTimeOffset.Now, - Statistics = - { - { HitResult.Miss, 1 }, - { HitResult.Meh, 50 }, - { HitResult.Good, 100 }, - { HitResult.Great, 300 }, - } - }; + User = new APIUser + { + Id = 2, + Username = "peppy", + }, + BeatmapInfo = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo, + Ruleset = ruleset.RulesetInfo, + Mods = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() }, + TotalScore = 2845370, + Accuracy = accuracy, + MaxCombo = 999, + Rank = scoreProcessor.RankFromAccuracy(accuracy), + Date = DateTimeOffset.Now, + Statistics = + { + { HitResult.Miss, 1 }, + { HitResult.Meh, 50 }, + { HitResult.Good, 100 }, + { HitResult.Great, 300 }, + } + }; + } } } diff --git a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs index 3aab9a24e1..b00d0ba316 100644 --- a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs +++ b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs @@ -1,8 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using osu.Framework.Localisation; +using osu.Game.Screens.Select; namespace osu.Game.Beatmaps { @@ -29,20 +29,22 @@ namespace osu.Game.Beatmaps return new RomanisableString($"{metadata.GetPreferred(true)}".Trim(), $"{metadata.GetPreferred(false)}".Trim()); } - public static List GetSearchableTerms(this IBeatmapInfo beatmapInfo) + public static bool Match(this IBeatmapInfo beatmapInfo, params FilterCriteria.OptionalTextFilter[] filters) { - var termsList = new List(BeatmapMetadataInfoExtensions.MAX_SEARCHABLE_TERM_COUNT + 1); - - addIfNotNull(beatmapInfo.DifficultyName); - - BeatmapMetadataInfoExtensions.CollectSearchableTerms(beatmapInfo.Metadata, termsList); - return termsList; - - void addIfNotNull(string? s) + foreach (var filter in filters) { - if (!string.IsNullOrEmpty(s)) - termsList.Add(s); + if (filter.Matches(beatmapInfo.DifficultyName)) + continue; + + if (BeatmapMetadataInfoExtensions.Match(beatmapInfo.Metadata, filter)) + continue; + + // failed to match a single filter at all - fail the whole match. + return false; } + + // got through all filters without failing any - pass the whole match. + return true; } private static string getVersionString(IBeatmapInfo beatmapInfo) => string.IsNullOrEmpty(beatmapInfo.DifficultyName) ? string.Empty : $"[{beatmapInfo.DifficultyName}]"; diff --git a/osu.Game/Beatmaps/BeatmapMetadataInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapMetadataInfoExtensions.cs index be96a66614..198469dba6 100644 --- a/osu.Game/Beatmaps/BeatmapMetadataInfoExtensions.cs +++ b/osu.Game/Beatmaps/BeatmapMetadataInfoExtensions.cs @@ -3,11 +3,14 @@ using System.Collections.Generic; using osu.Framework.Localisation; +using osu.Game.Screens.Select; namespace osu.Game.Beatmaps { public static class BeatmapMetadataInfoExtensions { + internal const int MAX_SEARCHABLE_TERM_COUNT = 7; + /// /// An array of all searchable terms provided in contained metadata. /// @@ -18,7 +21,18 @@ namespace osu.Game.Beatmaps return termsList.ToArray(); } - internal const int MAX_SEARCHABLE_TERM_COUNT = 7; + public static bool Match(IBeatmapMetadataInfo metadataInfo, FilterCriteria.OptionalTextFilter filter) + { + if (filter.Matches(metadataInfo.Author.Username)) return true; + if (filter.Matches(metadataInfo.Artist)) return true; + if (filter.Matches(metadataInfo.ArtistUnicode)) return true; + if (filter.Matches(metadataInfo.Title)) return true; + if (filter.Matches(metadataInfo.TitleUnicode)) return true; + if (filter.Matches(metadataInfo.Source)) return true; + if (filter.Matches(metadataInfo.Tags)) return true; + + return false; + } internal static void CollectSearchableTerms(IBeatmapMetadataInfo metadataInfo, IList termsList) { diff --git a/osu.Game/Graphics/Containers/LinkFlowContainer.cs b/osu.Game/Graphics/Containers/LinkFlowContainer.cs index 40e883f8ac..aa72996fff 100644 --- a/osu.Game/Graphics/Containers/LinkFlowContainer.cs +++ b/osu.Game/Graphics/Containers/LinkFlowContainer.cs @@ -12,6 +12,7 @@ using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; +using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Online; using osu.Game.Users; @@ -47,9 +48,16 @@ namespace osu.Game.Graphics.Containers foreach (var link in links) { + string displayText = text.Substring(link.Index, link.Length); + + if (previousLinkEnd > link.Index) + { + Logger.Log($@"Link ""{link.Url}"" with text ""{displayText}"" overlaps previous link, ignoring."); + continue; + } + AddText(text[previousLinkEnd..link.Index]); - string displayText = text.Substring(link.Index, link.Length); object linkArgument = link.Argument; string tooltip = displayText == link.Url ? null : link.Url; diff --git a/osu.Game/Localisation/EditorSetupStrings.cs b/osu.Game/Localisation/EditorSetupStrings.cs index 401411365b..eff6f9e6b8 100644 --- a/osu.Game/Localisation/EditorSetupStrings.cs +++ b/osu.Game/Localisation/EditorSetupStrings.cs @@ -179,21 +179,11 @@ namespace osu.Game.Localisation /// public static LocalisableString ClickToSelectTrack => new TranslatableString(getKey(@"click_to_select_track"), @"Click to select a track"); - /// - /// "Click to replace the track" - /// - public static LocalisableString ClickToReplaceTrack => new TranslatableString(getKey(@"click_to_replace_track"), @"Click to replace the track"); - /// /// "Click to select a background image" /// public static LocalisableString ClickToSelectBackground => new TranslatableString(getKey(@"click_to_select_background"), @"Click to select a background image"); - /// - /// "Click to replace the background image" - /// - public static LocalisableString ClickToReplaceBackground => new TranslatableString(getKey(@"click_to_replace_background"), @"Click to replace the background image"); - /// /// "Ruleset ({0})" /// diff --git a/osu.Game/Online/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs index 9a194dba47..f055633d64 100644 --- a/osu.Game/Online/Chat/MessageFormatter.cs +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -85,8 +85,8 @@ namespace osu.Game.Online.Chat if (escapeChars != null) displayText = escapeChars.Aggregate(displayText, (current, c) => current.Replace($"\\{c}", c.ToString())); - // Check for encapsulated links - if (result.Links.Find(l => (l.Index <= index && l.Index + l.Length >= index + m.Length) || (index <= l.Index && index + m.Length >= l.Index + l.Length)) == null) + // Check for overlapping links + if (!result.Links.Exists(l => l.Overlaps(index, m.Length))) { result.Text = result.Text.Remove(index, m.Length).Insert(index, displayText); @@ -364,7 +364,9 @@ namespace osu.Game.Online.Chat Argument = argument; } - public bool Overlaps(Link otherLink) => Index < otherLink.Index + otherLink.Length && otherLink.Index < Index + Length; + public bool Overlaps(Link otherLink) => Overlaps(otherLink.Index, otherLink.Length); + + public bool Overlaps(int otherIndex, int otherLength) => Index < otherIndex + otherLength && otherIndex < Index + Length; public int CompareTo(Link? otherLink) => Index > otherLink?.Index ? 1 : -1; } diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index 136c9cc8e7..a76f4ae955 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -421,7 +421,7 @@ namespace osu.Game.Online.Leaderboards { List items = new List(); - if (Score.Mods.Length > 0 && modsContainer.Any(s => s.IsHovered) && songSelect != null) + if (Score.Mods.Length > 0 && songSelect != null) items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = Score.Mods)); if (Score.Files.Count > 0) diff --git a/osu.Game/Online/Multiplayer/GameplayAbortReason.cs b/osu.Game/Online/Multiplayer/GameplayAbortReason.cs new file mode 100644 index 0000000000..15151ea68b --- /dev/null +++ b/osu.Game/Online/Multiplayer/GameplayAbortReason.cs @@ -0,0 +1,11 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Online.Multiplayer +{ + public enum GameplayAbortReason + { + LoadTookTooLong, + HostAbortedTheMatch + } +} diff --git a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs index a5fa49a94b..0452d8b79c 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs @@ -107,17 +107,18 @@ namespace osu.Game.Online.Multiplayer /// Task LoadRequested(); - /// - /// Signals that loading of gameplay is to be aborted. - /// - Task LoadAborted(); - /// /// Signals that gameplay has started. /// All users in the or states should begin gameplay as soon as possible. /// Task GameplayStarted(); + /// + /// Signals that gameplay has been aborted. + /// + /// The reason why gameplay was aborted. + Task GameplayAborted(GameplayAbortReason reason); + /// /// Signals that the match has ended, all players have finished and results are ready to be displayed. /// diff --git a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs index b7a608581c..55f00b447f 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs @@ -77,6 +77,11 @@ namespace osu.Game.Online.Multiplayer /// If an attempt to start the game occurs when the game's (or users') state disallows it. Task StartMatch(); + /// + /// As the host of a room, aborts an on-going match. + /// + Task AbortMatch(); + /// /// Aborts an ongoing gameplay load. /// diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 79f46c2095..bbf0e3697a 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -73,9 +73,9 @@ namespace osu.Game.Online.Multiplayer public virtual event Action? LoadRequested; /// - /// Invoked when the multiplayer server requests loading of play to be aborted. + /// Invoked when the multiplayer server requests gameplay to be aborted. /// - public event Action? LoadAborted; + public event Action? GameplayAborted; /// /// Invoked when the multiplayer server requests gameplay to be started. @@ -374,6 +374,8 @@ namespace osu.Game.Online.Multiplayer public abstract Task AbortGameplay(); + public abstract Task AbortMatch(); + public abstract Task AddPlaylistItem(MultiplayerPlaylistItem item); public abstract Task EditPlaylistItem(MultiplayerPlaylistItem item); @@ -682,14 +684,14 @@ namespace osu.Game.Online.Multiplayer return Task.CompletedTask; } - Task IMultiplayerClient.LoadAborted() + Task IMultiplayerClient.GameplayAborted(GameplayAbortReason reason) { Scheduler.Add(() => { if (Room == null) return; - LoadAborted?.Invoke(); + GameplayAborted?.Invoke(reason); }, false); return Task.CompletedTask; diff --git a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs index e400132693..40436d730e 100644 --- a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs @@ -58,7 +58,7 @@ namespace osu.Game.Online.Multiplayer connection.On(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged); connection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested); connection.On(nameof(IMultiplayerClient.GameplayStarted), ((IMultiplayerClient)this).GameplayStarted); - connection.On(nameof(IMultiplayerClient.LoadAborted), ((IMultiplayerClient)this).LoadAborted); + connection.On(nameof(IMultiplayerClient.GameplayAborted), ((IMultiplayerClient)this).GameplayAborted); connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady); connection.On>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged); connection.On(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged); @@ -226,6 +226,16 @@ namespace osu.Game.Online.Multiplayer return connection.InvokeAsync(nameof(IMultiplayerServer.AbortGameplay)); } + public override Task AbortMatch() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + + return connection.InvokeAsync(nameof(IMultiplayerServer.AbortMatch)); + } + public override Task AddPlaylistItem(MultiplayerPlaylistItem item) { if (!IsConnected.Value) diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs index f8784504b8..a645683c5f 100644 --- a/osu.Game/Overlays/BeatmapListingOverlay.cs +++ b/osu.Game/Overlays/BeatmapListingOverlay.cs @@ -183,9 +183,7 @@ namespace osu.Game.Overlays // new results may contain beatmaps from a previous page, // this is dodgy but matches web behaviour for now. // see: https://github.com/ppy/osu-web/issues/9270 - // todo: replace custom equality compraer with ExceptBy in net6.0 - // newCards = newCards.ExceptBy(foundContent.Select(c => c.BeatmapSet.OnlineID), c => c.BeatmapSet.OnlineID); - newCards = newCards.Except(foundContent, BeatmapCardEqualityComparer.Default); + newCards = newCards.ExceptBy(foundContent.Select(c => c.BeatmapSet.OnlineID), c => c.BeatmapSet.OnlineID); panelLoadTask = LoadComponentsAsync(newCards, loaded => { @@ -378,21 +376,5 @@ namespace osu.Game.Overlays if (shouldShowMore) filterControl.FetchNextPage(); } - - private class BeatmapCardEqualityComparer : IEqualityComparer - { - public static BeatmapCardEqualityComparer Default { get; } = new BeatmapCardEqualityComparer(); - - public bool Equals(BeatmapCard x, BeatmapCard y) - { - if (ReferenceEquals(x, y)) return true; - if (ReferenceEquals(x, null)) return false; - if (ReferenceEquals(y, null)) return false; - - return x.BeatmapSet.Equals(y.BeatmapSet); - } - - public int GetHashCode(BeatmapCard obj) => obj.BeatmapSet.GetHashCode(); - } } } diff --git a/osu.Game/Overlays/Chat/ChatOverlayTopBar.cs b/osu.Game/Overlays/Chat/ChatOverlayTopBar.cs index 84fd342493..4fc9fbb6d5 100644 --- a/osu.Game/Overlays/Chat/ChatOverlayTopBar.cs +++ b/osu.Game/Overlays/Chat/ChatOverlayTopBar.cs @@ -46,7 +46,7 @@ namespace osu.Game.Overlays.Chat { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Icon = HexaconsIcons.Social, + Icon = HexaconsIcons.Messaging, Size = new Vector2(24), }, // Placeholder text diff --git a/osu.Game/Overlays/Dialog/DangerousActionDialog.cs b/osu.Game/Overlays/Dialog/DangerousActionDialog.cs index c86570386f..42a3ff827c 100644 --- a/osu.Game/Overlays/Dialog/DangerousActionDialog.cs +++ b/osu.Game/Overlays/Dialog/DangerousActionDialog.cs @@ -23,6 +23,11 @@ namespace osu.Game.Overlays.Dialog /// protected Action? DangerousAction { get; set; } + /// + /// The action to perform if cancelled. + /// + protected Action? CancelAction { get; set; } + protected DangerousActionDialog() { HeaderText = DeleteConfirmationDialogStrings.HeaderText; @@ -38,7 +43,8 @@ namespace osu.Game.Overlays.Dialog }, new PopupDialogCancelButton { - Text = DeleteConfirmationDialogStrings.Cancel + Text = DeleteConfirmationDialogStrings.Cancel, + Action = () => CancelAction?.Invoke() } }; } diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs index 18d1c4c62b..be7ddd115b 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs @@ -17,7 +17,6 @@ using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; -using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens; @@ -26,6 +25,7 @@ using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Menu; using osu.Game.Screens.Play; using osu.Game.Screens.Select; +using osu.Game.Users; using osu.Game.Utils; using osuTK; @@ -55,9 +55,6 @@ namespace osu.Game.Overlays.SkinEditor [Resolved] private MusicController music { get; set; } = null!; - [Resolved] - private IBindable ruleset { get; set; } = null!; - [Resolved] private Bindable> mods { get; set; } = null!; @@ -139,6 +136,12 @@ namespace osu.Game.Overlays.SkinEditor { performer?.PerformFromScreen(screen => { + if (beatmap.Value is DummyWorkingBeatmap) + { + // presume we don't have anything good to play and just bail. + return; + } + // If we're playing the intro, switch away to another beatmap. if (beatmap.Value.BeatmapSetInfo.Protected) { @@ -150,7 +153,7 @@ namespace osu.Game.Overlays.SkinEditor if (screen is Player) return; - var replayGeneratingMod = ruleset.Value.CreateInstance().GetAutoplayMod(); + var replayGeneratingMod = beatmap.Value.BeatmapInfo.Ruleset.CreateInstance().GetAutoplayMod(); IReadOnlyList usableMods = mods.Value; @@ -285,6 +288,8 @@ namespace osu.Game.Overlays.SkinEditor private partial class EndlessPlayer : ReplayPlayer { + protected override UserActivity? InitialActivity => null; + public EndlessPlayer(Func, Score> createScore) : base(createScore, new PlayerConfiguration { @@ -294,10 +299,21 @@ namespace osu.Game.Overlays.SkinEditor { } + protected override void LoadComplete() + { + base.LoadComplete(); + + if (!LoadedBeatmapSuccessfully) + Scheduler.AddDelayed(this.Exit, 3000); + } + protected override void Update() { base.Update(); + if (!LoadedBeatmapSuccessfully) + return; + if (GameplayState.HasPassed) GameplayClockContainer.Seek(0); } diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 4e899479bd..92336b2c21 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -446,7 +446,7 @@ namespace osu.Game.Rulesets.Scoring /// /// Given an accuracy (0..1), return the correct . /// - public static ScoreRank RankFromAccuracy(double accuracy) + public virtual ScoreRank RankFromAccuracy(double accuracy) { if (accuracy == accuracy_cutoff_x) return ScoreRank.X; @@ -466,7 +466,7 @@ namespace osu.Game.Rulesets.Scoring /// Given a , return the cutoff accuracy (0..1). /// Accuracy must be greater than or equal to the cutoff to qualify for the provided rank. /// - public static double AccuracyCutoffFromRank(ScoreRank rank) + public virtual double AccuracyCutoffFromRank(ScoreRank rank) { switch (rank) { diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index d712702331..5545ba552e 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -342,23 +342,7 @@ namespace osu.Game.Scoring switch (r.result) { case HitResult.SmallTickHit: - { - int total = value + Statistics.GetValueOrDefault(HitResult.SmallTickMiss); - if (total > 0) - yield return new HitResultDisplayStatistic(r.result, value, total, r.displayName); - - break; - } - case HitResult.LargeTickHit: - { - int total = value + Statistics.GetValueOrDefault(HitResult.LargeTickMiss); - if (total > 0) - yield return new HitResultDisplayStatistic(r.result, value, total, r.displayName); - - break; - } - case HitResult.LargeBonus: case HitResult.SmallBonus: if (MaximumStatistics.TryGetValue(r.result, out int count) && count > 0) diff --git a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs index 8c84ad90ba..f6d20319cb 100644 --- a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs +++ b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs @@ -146,13 +146,8 @@ namespace osu.Game.Screens.Edit.Setup private void updatePlaceholderText() { - audioTrackChooser.Text = audioTrackChooser.Current.Value == null - ? EditorSetupStrings.ClickToSelectTrack - : EditorSetupStrings.ClickToReplaceTrack; - - backgroundChooser.Text = backgroundChooser.Current.Value == null - ? EditorSetupStrings.ClickToSelectBackground - : EditorSetupStrings.ClickToReplaceBackground; + audioTrackChooser.Text = audioTrackChooser.Current.Value?.Name ?? EditorSetupStrings.ClickToSelectTrack; + backgroundChooser.Text = backgroundChooser.Current.Value?.Name ?? EditorSetupStrings.ClickToSelectBackground; } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs index 44e18dd2bb..ba3508b24f 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs @@ -16,6 +16,8 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Threading; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.Countdown; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match @@ -28,6 +30,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match [CanBeNull] private IDisposable clickOperation; + [Resolved(canBeNull: true)] + private IDialogOverlay dialogOverlay { get; set; } + private Sample sampleReady; private Sample sampleReadyAll; private Sample sampleUnready; @@ -56,7 +61,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { RelativeSizeAxes = Axes.Both, Size = Vector2.One, - Action = onReadyClick, + Action = onReadyButtonClick, }, countdownButton = new MultiplayerCountdownButton { @@ -101,7 +106,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match endOperation(); } - private void onReadyClick() + private void onReadyButtonClick() { if (Room == null) return; @@ -109,9 +114,24 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Debug.Assert(clickOperation == null); clickOperation = ongoingOperationTracker.BeginOperation(); - if (isReady() && Client.IsHost && !Room.ActiveCountdowns.Any(c => c is MatchStartCountdown)) - startMatch(); - else + if (Client.IsHost) + { + if (Room.State == MultiplayerRoomState.Open) + { + if (isReady() && !Room.ActiveCountdowns.Any(c => c is MatchStartCountdown)) + startMatch(); + else + toggleReady(); + } + else + { + if (dialogOverlay == null) + abortMatch(); + else + dialogOverlay.Push(new ConfirmAbortDialog(abortMatch, endOperation)); + } + } + else if (Room.State != MultiplayerRoomState.Closed) toggleReady(); bool isReady() => Client.LocalUser?.State == MultiplayerUserState.Ready || Client.LocalUser?.State == MultiplayerUserState.Spectating; @@ -128,6 +148,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match // gameplay was not started due to an exception; unblock button. endOperation(); }); + + void abortMatch() => Client.AbortMatch().FireAndForget(endOperation, _ => endOperation()); } private void startCountdown(TimeSpan duration) @@ -189,7 +211,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match } readyButton.Enabled.Value = countdownButton.Enabled.Value = - Room.State == MultiplayerRoomState.Open + Room.State != MultiplayerRoomState.Closed && CurrentPlaylistItem.Value?.ID == Room.Settings.PlaylistItemId && !Room.Playlist.Single(i => i.ID == Room.Settings.PlaylistItemId).Expired && !operationInProgress.Value; @@ -198,6 +220,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match if (localUser?.State == MultiplayerUserState.Spectating) readyButton.Enabled.Value &= Client.IsHost && newCountReady > 0 && !Room.ActiveCountdowns.Any(c => c is MatchStartCountdown); + // When the local user is not the host, the button should only be enabled when no match is in progress. + if (!Client.IsHost) + readyButton.Enabled.Value &= Room.State == MultiplayerRoomState.Open; + + // At all times, the countdown button should only be enabled when no match is in progress. + countdownButton.Enabled.Value &= Room.State == MultiplayerRoomState.Open; + if (newCountReady == countReady) return; @@ -219,5 +248,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match countReady = newCountReady; }); } + + public partial class ConfirmAbortDialog : DangerousActionDialog + { + public ConfirmAbortDialog(Action abortMatch, Action cancel) + { + HeaderText = "Are you sure you want to abort the match?"; + + DangerousAction = abortMatch; + CancelAction = cancel; + } + } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs index 1be573bdb8..7ce3dde7c2 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs @@ -149,16 +149,19 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { switch (localUser?.State) { - default: - Text = "Ready"; - break; - case MultiplayerUserState.Spectating: case MultiplayerUserState.Ready: - Text = room.Host?.Equals(localUser) == true + Text = multiplayerClient.IsHost ? $"Start match {countText}" : $"Waiting for host... {countText}"; + break; + default: + // Show the abort button for the host as long as gameplay is in progress. + if (multiplayerClient.IsHost && room.State != MultiplayerRoomState.Open) + Text = "Abort the match"; + else + Text = "Ready"; break; } } @@ -193,12 +196,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match switch (localUser?.State) { default: - setGreen(); + // Show the abort button for the host as long as gameplay is in progress. + if (multiplayerClient.IsHost && room.State != MultiplayerRoomState.Open) + setRed(); + else + setGreen(); break; case MultiplayerUserState.Spectating: case MultiplayerUserState.Ready: - if (room?.Host?.Equals(localUser) == true && !room.ActiveCountdowns.Any(c => c is MatchStartCountdown)) + if (multiplayerClient.IsHost && !room.ActiveCountdowns.Any(c => c is MatchStartCountdown)) setGreen(); else setYellow(); @@ -206,15 +213,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match break; } - void setYellow() - { - BackgroundColour = colours.YellowDark; - } + void setYellow() => BackgroundColour = colours.YellowDark; - void setGreen() - { - BackgroundColour = colours.Green; - } + void setGreen() => BackgroundColour = colours.Green; + + void setRed() => BackgroundColour = colours.Red; } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs index edf5ce276a..7d27725775 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs @@ -23,7 +23,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer base.LoadComplete(); client.RoomUpdated += onRoomUpdated; - client.LoadAborted += onLoadAborted; + client.GameplayAborted += onGameplayAborted; onRoomUpdated(); } @@ -39,12 +39,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer transitionFromResults(); } - private void onLoadAborted() + private void onGameplayAborted(GameplayAbortReason reason) { // If the server aborts gameplay for this user (due to loading too slow), exit gameplay screens. if (!this.IsCurrentScreen()) { - Logger.Log("Gameplay aborted because loading the beatmap took too long.", LoggingTarget.Runtime, LogLevel.Important); + switch (reason) + { + case GameplayAbortReason.LoadTookTooLong: + Logger.Log("Gameplay aborted because loading the beatmap took too long.", LoggingTarget.Runtime, LogLevel.Important); + break; + + case GameplayAbortReason.HostAbortedTheMatch: + Logger.Log("The host aborted the match.", LoggingTarget.Runtime, LogLevel.Important); + break; + } + this.MakeCurrent(); } } diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs index 2ec4270c3c..8cbca74466 100644 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs @@ -29,13 +29,6 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy /// public partial class AccuracyCircle : CompositeDrawable { - private static readonly double accuracy_x = ScoreProcessor.AccuracyCutoffFromRank(ScoreRank.X); - private static readonly double accuracy_s = ScoreProcessor.AccuracyCutoffFromRank(ScoreRank.S); - private static readonly double accuracy_a = ScoreProcessor.AccuracyCutoffFromRank(ScoreRank.A); - private static readonly double accuracy_b = ScoreProcessor.AccuracyCutoffFromRank(ScoreRank.B); - private static readonly double accuracy_c = ScoreProcessor.AccuracyCutoffFromRank(ScoreRank.C); - private static readonly double accuracy_d = ScoreProcessor.AccuracyCutoffFromRank(ScoreRank.D); - /// /// Duration for the transforms causing this component to appear. /// @@ -110,12 +103,26 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy private double lastTickPlaybackTime; private bool isTicking; + private readonly double accuracyX; + private readonly double accuracyS; + private readonly double accuracyA; + private readonly double accuracyB; + private readonly double accuracyC; + private readonly double accuracyD; private readonly bool withFlair; public AccuracyCircle(ScoreInfo score, bool withFlair = false) { this.score = score; this.withFlair = withFlair; + + ScoreProcessor scoreProcessor = score.Ruleset.CreateInstance().CreateScoreProcessor(); + accuracyX = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.X); + accuracyS = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.S); + accuracyA = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.A); + accuracyB = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.B); + accuracyC = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.C); + accuracyD = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.D); } [BackgroundDependencyLoader] @@ -158,49 +165,49 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy RelativeSizeAxes = Axes.Both, Colour = OsuColour.ForRank(ScoreRank.X), InnerRadius = RANK_CIRCLE_RADIUS, - Current = { Value = accuracy_x } + Current = { Value = accuracyX } }, new CircularProgress { RelativeSizeAxes = Axes.Both, Colour = OsuColour.ForRank(ScoreRank.S), InnerRadius = RANK_CIRCLE_RADIUS, - Current = { Value = accuracy_x - virtual_ss_percentage } + Current = { Value = accuracyX - virtual_ss_percentage } }, new CircularProgress { RelativeSizeAxes = Axes.Both, Colour = OsuColour.ForRank(ScoreRank.A), InnerRadius = RANK_CIRCLE_RADIUS, - Current = { Value = accuracy_s } + Current = { Value = accuracyS } }, new CircularProgress { RelativeSizeAxes = Axes.Both, Colour = OsuColour.ForRank(ScoreRank.B), InnerRadius = RANK_CIRCLE_RADIUS, - Current = { Value = accuracy_a } + Current = { Value = accuracyA } }, new CircularProgress { RelativeSizeAxes = Axes.Both, Colour = OsuColour.ForRank(ScoreRank.C), InnerRadius = RANK_CIRCLE_RADIUS, - Current = { Value = accuracy_b } + Current = { Value = accuracyB } }, new CircularProgress { RelativeSizeAxes = Axes.Both, Colour = OsuColour.ForRank(ScoreRank.D), InnerRadius = RANK_CIRCLE_RADIUS, - Current = { Value = accuracy_c } + Current = { Value = accuracyC } }, - new RankNotch((float)accuracy_x), - new RankNotch((float)(accuracy_x - virtual_ss_percentage)), - new RankNotch((float)accuracy_s), - new RankNotch((float)accuracy_a), - new RankNotch((float)accuracy_b), - new RankNotch((float)accuracy_c), + new RankNotch((float)accuracyX), + new RankNotch((float)(accuracyX - virtual_ss_percentage)), + new RankNotch((float)accuracyS), + new RankNotch((float)accuracyA), + new RankNotch((float)accuracyB), + new RankNotch((float)accuracyC), new BufferedContainer { Name = "Graded circle mask", @@ -228,13 +235,13 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy Padding = new MarginPadding { Vertical = -15, Horizontal = -20 }, Children = new[] { + new RankBadge(accuracyD, Interpolation.Lerp(accuracyD, accuracyC, 0.5), getRank(ScoreRank.D)), + new RankBadge(accuracyC, Interpolation.Lerp(accuracyC, accuracyB, 0.5), getRank(ScoreRank.C)), + new RankBadge(accuracyB, Interpolation.Lerp(accuracyB, accuracyA, 0.5), getRank(ScoreRank.B)), // The S and A badges are moved down slightly to prevent collision with the SS badge. - new RankBadge(accuracy_x, accuracy_x, getRank(ScoreRank.X)), - new RankBadge(accuracy_s, Interpolation.Lerp(accuracy_s, (accuracy_x - virtual_ss_percentage), 0.25), getRank(ScoreRank.S)), - new RankBadge(accuracy_a, Interpolation.Lerp(accuracy_a, accuracy_s, 0.25), getRank(ScoreRank.A)), - new RankBadge(accuracy_b, Interpolation.Lerp(accuracy_b, accuracy_a, 0.5), getRank(ScoreRank.B)), - new RankBadge(accuracy_c, Interpolation.Lerp(accuracy_c, accuracy_b, 0.5), getRank(ScoreRank.C)), - new RankBadge(accuracy_d, Interpolation.Lerp(accuracy_d, accuracy_c, 0.5), getRank(ScoreRank.D)), + new RankBadge(accuracyA, Interpolation.Lerp(accuracyA, accuracyS, 0.25), getRank(ScoreRank.A)), + new RankBadge(accuracyS, Interpolation.Lerp(accuracyS, (accuracyX - virtual_ss_percentage), 0.25), getRank(ScoreRank.S)), + new RankBadge(accuracyX, accuracyX, getRank(ScoreRank.X)), } }, rankText = new RankText(score.Rank) @@ -280,10 +287,10 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy double targetAccuracy = score.Accuracy; double[] notchPercentages = { - accuracy_s, - accuracy_a, - accuracy_b, - accuracy_c, + accuracyS, + accuracyA, + accuracyB, + accuracyC, }; // Ensure the gauge overshoots or undershoots a bit so it doesn't land in the gaps of the inner graded circle (caused by `RankNotch`es), @@ -302,7 +309,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy if (score.Rank == ScoreRank.X || score.Rank == ScoreRank.XH) targetAccuracy = 1; else - targetAccuracy = Math.Min(accuracy_x - virtual_ss_percentage - NOTCH_WIDTH_PERCENTAGE / 2, targetAccuracy); + targetAccuracy = Math.Min(accuracyX - virtual_ss_percentage - NOTCH_WIDTH_PERCENTAGE / 2, targetAccuracy); // The accuracy circle gauge visually fills up a bit too much. // This wouldn't normally matter but we want it to align properly with the inner graded circle in the above cases. @@ -339,7 +346,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy if (badge.Accuracy > score.Accuracy) continue; - using (BeginDelayedSequence(inverseEasing(ACCURACY_TRANSFORM_EASING, Math.Min(accuracy_x - virtual_ss_percentage, badge.Accuracy) / targetAccuracy) * ACCURACY_TRANSFORM_DURATION)) + using (BeginDelayedSequence(inverseEasing(ACCURACY_TRANSFORM_EASING, Math.Min(accuracyX - virtual_ss_percentage, badge.Accuracy) / targetAccuracy) * ACCURACY_TRANSFORM_DURATION)) { badge.Appear(); diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index 1d40862df7..45594bd0e8 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -41,6 +41,21 @@ namespace osu.Game.Screens.Select.Carousel return match; } + if (criteria.SearchTerms.Length > 0) + { + match = BeatmapInfo.Match(criteria.SearchTerms); + + // if a match wasn't found via text matching of terms, do a second catch-all check matching against online IDs. + // this should be done after text matching so we can prioritise matching numbers in metadata. + if (!match && criteria.SearchNumber.HasValue) + { + match = (BeatmapInfo.OnlineID == criteria.SearchNumber.Value) || + (BeatmapInfo.BeatmapSet?.OnlineID == criteria.SearchNumber.Value); + } + } + + if (!match) return false; + match &= !criteria.StarDifficulty.HasFilter || criteria.StarDifficulty.IsInRange(BeatmapInfo.StarRating); match &= !criteria.ApproachRate.HasFilter || criteria.ApproachRate.IsInRange(BeatmapInfo.Difficulty.ApproachRate); match &= !criteria.DrainRate.HasFilter || criteria.DrainRate.IsInRange(BeatmapInfo.Difficulty.DrainRate); @@ -64,40 +79,6 @@ namespace osu.Game.Screens.Select.Carousel if (!match) return false; - if (criteria.SearchTerms.Length > 0) - { - var searchableTerms = BeatmapInfo.GetSearchableTerms(); - - foreach (FilterCriteria.OptionalTextFilter criteriaTerm in criteria.SearchTerms) - { - bool any = false; - - // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator - foreach (string searchTerm in searchableTerms) - { - if (!criteriaTerm.Matches(searchTerm)) continue; - - any = true; - break; - } - - if (any) continue; - - match = false; - break; - } - - // if a match wasn't found via text matching of terms, do a second catch-all check matching against online IDs. - // this should be done after text matching so we can prioritise matching numbers in metadata. - if (!match && criteria.SearchNumber.HasValue) - { - match = (BeatmapInfo.OnlineID == criteria.SearchNumber.Value) || - (BeatmapInfo.BeatmapSet?.OnlineID == criteria.SearchNumber.Value); - } - } - - if (!match) return false; - match &= criteria.CollectionBeatmapMD5Hashes?.Contains(BeatmapInfo.MD5Hash) ?? true; if (match && criteria.RulesetCriteria != null) match &= criteria.RulesetCriteria.Matches(BeatmapInfo); diff --git a/osu.Game/Screens/Select/Carousel/CarouselGroup.cs b/osu.Game/Screens/Select/Carousel/CarouselGroup.cs index 9302578038..c353ee98ae 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselGroup.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselGroup.cs @@ -86,16 +86,20 @@ namespace osu.Game.Screens.Select.Carousel items.ForEach(c => c.Filter(criteria)); - criteriaComparer = Comparer.Create((x, y) => + // Sorting is expensive, so only perform if it's actually changed. + if (lastCriteria?.Sort != criteria.Sort) { - int comparison = x.CompareTo(criteria, y); - if (comparison != 0) - return comparison; + criteriaComparer = Comparer.Create((x, y) => + { + int comparison = x.CompareTo(criteria, y); + if (comparison != 0) + return comparison; - return x.ItemID.CompareTo(y.ItemID); - }); + return x.ItemID.CompareTo(y.ItemID); + }); - items.Sort(criteriaComparer); + items.Sort(criteriaComparer); + } lastCriteria = criteria; } diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 812a16c484..811f623ee5 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -176,13 +176,15 @@ namespace osu.Game.Screens.Select { default: case MatchMode.Substring: - return value.Contains(SearchTerm, StringComparison.InvariantCultureIgnoreCase); + // Note that we are using ordinal here to avoid performance issues caused by globalisation concerns. + // See https://github.com/ppy/osu/issues/11571 / https://github.com/dotnet/docs/issues/18423. + return value.Contains(SearchTerm, StringComparison.OrdinalIgnoreCase); case MatchMode.IsolatedPhrase: return Regex.IsMatch(value, $@"(^|\s){Regex.Escape(searchTerm)}($|\s)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); case MatchMode.FullPhrase: - return CultureInfo.InvariantCulture.CompareInfo.Compare(value, searchTerm, CompareOptions.IgnoreCase) == 0; + return CultureInfo.InvariantCulture.CompareInfo.Compare(value, searchTerm, CompareOptions.OrdinalIgnoreCase) == 0; } } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 577104db45..4c3deac1d7 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -396,6 +396,12 @@ namespace osu.Game.Tests.Visual.Multiplayer return Task.CompletedTask; } + public override async Task AbortMatch() + { + ChangeUserState(api.LocalUser.Value.Id, MultiplayerUserState.Idle); + await ((IMultiplayerClient)this).GameplayAborted(GameplayAbortReason.HostAbortedTheMatch).ConfigureAwait(false); + } + public async Task AddUserPlaylistItem(int userId, MultiplayerPlaylistItem item) { Debug.Assert(ServerRoom != null); diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 4d1fdaa89a..cc100605b4 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,7 +36,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/osu.iOS.props b/osu.iOS.props index 7e5c5be4ea..a6b3527466 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -23,6 +23,6 @@ iossimulator-x64 - +