diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ed3e99cb61..29cbdd2d37 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,6 +15,7 @@ jobs:
- { prettyname: macOS, fullname: macos-latest }
- { prettyname: Linux, fullname: ubuntu-latest }
threadingMode: ['SingleThread', 'MultiThreaded']
+ timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v2
diff --git a/.github/workflows/report-nunit.yml b/.github/workflows/report-nunit.yml
index 381d2d49c5..e0ccd50989 100644
--- a/.github/workflows/report-nunit.yml
+++ b/.github/workflows/report-nunit.yml
@@ -21,6 +21,7 @@ jobs:
- { prettyname: macOS }
- { prettyname: Linux }
threadingMode: ['SingleThread', 'MultiThreaded']
+ timeout-minutes: 5
steps:
- name: Annotate CI run with test results
uses: dorny/test-reporter@v1.4.2
diff --git a/osu.Android.props b/osu.Android.props
index d0aff7b15e..c845d7f276 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -52,7 +52,7 @@
-
+
diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs
index 900691ecae..1ad45d2f13 100644
--- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs
+++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs
@@ -6,8 +6,8 @@ using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
-using osu.Game.Rulesets.Catch.UI;
using osu.Framework.Graphics;
+using osu.Game.Rulesets.Catch.UI;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Framework.Utils;
@@ -31,10 +31,23 @@ namespace osu.Game.Rulesets.Catch.Tests
[Resolved]
private OsuConfigManager config { get; set; }
- private Container droppedObjectContainer;
+ [Cached]
+ private readonly DroppedObjectContainer droppedObjectContainer;
+
+ private readonly Container trailContainer;
private TestCatcher catcher;
+ public TestSceneCatcher()
+ {
+ Add(trailContainer = new Container
+ {
+ Anchor = Anchor.Centre,
+ Depth = -1
+ });
+ Add(droppedObjectContainer = new DroppedObjectContainer());
+ }
+
[SetUp]
public void SetUp() => Schedule(() =>
{
@@ -43,20 +56,13 @@ namespace osu.Game.Rulesets.Catch.Tests
CircleSize = 0,
};
- var trailContainer = new Container();
- droppedObjectContainer = new Container();
- catcher = new TestCatcher(trailContainer, droppedObjectContainer, difficulty);
+ if (catcher != null)
+ Remove(catcher);
- Child = new Container
+ Add(catcher = new TestCatcher(trailContainer, difficulty)
{
- Anchor = Anchor.Centre,
- Children = new Drawable[]
- {
- trailContainer,
- droppedObjectContainer,
- catcher
- }
- };
+ Anchor = Anchor.Centre
+ });
});
[Test]
@@ -293,8 +299,8 @@ namespace osu.Game.Rulesets.Catch.Tests
{
public IEnumerable CaughtObjects => this.ChildrenOfType();
- public TestCatcher(Container trailsTarget, Container droppedObjectTarget, BeatmapDifficulty difficulty)
- : base(trailsTarget, droppedObjectTarget, difficulty)
+ public TestCatcher(Container trailsTarget, BeatmapDifficulty difficulty)
+ : base(trailsTarget, difficulty)
{
}
}
diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs
index 4af5098451..877e115e2f 100644
--- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs
+++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs
@@ -6,7 +6,6 @@ using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Framework.Threading;
using osu.Framework.Utils;
@@ -97,18 +96,12 @@ namespace osu.Game.Rulesets.Catch.Tests
SetContents(_ =>
{
- var droppedObjectContainer = new Container
- {
- RelativeSizeAxes = Axes.Both
- };
-
return new CatchInputManager(catchRuleset)
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
- droppedObjectContainer,
- new TestCatcherArea(droppedObjectContainer, beatmapDifficulty)
+ new TestCatcherArea(beatmapDifficulty)
{
Anchor = Anchor.Centre,
Origin = Anchor.TopCentre,
@@ -126,9 +119,13 @@ namespace osu.Game.Rulesets.Catch.Tests
private class TestCatcherArea : CatcherArea
{
- public TestCatcherArea(Container droppedObjectContainer, BeatmapDifficulty beatmapDifficulty)
- : base(droppedObjectContainer, beatmapDifficulty)
+ [Cached]
+ private readonly DroppedObjectContainer droppedObjectContainer;
+
+ public TestCatcherArea(BeatmapDifficulty beatmapDifficulty)
+ : base(beatmapDifficulty)
{
+ AddInternal(droppedObjectContainer = new DroppedObjectContainer());
}
public void ToggleHyperDash(bool status) => MovableCatcher.SetHyperDashState(status ? 2 : 1);
diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs
index 683a776dcc..7fa981d492 100644
--- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs
+++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs
@@ -118,11 +118,10 @@ namespace osu.Game.Rulesets.Catch.Tests
AddStep("create hyper-dashing catcher", () =>
{
- Child = setupSkinHierarchy(catcherArea = new CatcherArea(new Container())
+ Child = setupSkinHierarchy(catcherArea = new TestCatcherArea
{
Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Scale = new Vector2(4f),
+ Origin = Anchor.Centre
}, skin);
});
@@ -206,5 +205,18 @@ namespace osu.Game.Rulesets.Catch.Tests
{
}
}
+
+ private class TestCatcherArea : CatcherArea
+ {
+ [Cached]
+ private readonly DroppedObjectContainer droppedObjectContainer;
+
+ public TestCatcherArea()
+ {
+ Scale = new Vector2(4f);
+
+ AddInternal(droppedObjectContainer = new DroppedObjectContainer());
+ }
+ }
}
}
diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs
index 644facdabc..05cd29dff5 100644
--- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs
+++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs
@@ -3,7 +3,6 @@
using osu.Framework.Allocation;
using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Objects.Drawables;
@@ -27,6 +26,9 @@ namespace osu.Game.Rulesets.Catch.UI
///
public const float CENTER_X = WIDTH / 2;
+ [Cached]
+ private readonly DroppedObjectContainer droppedObjectContainer;
+
internal readonly CatcherArea CatcherArea;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) =>
@@ -35,12 +37,7 @@ namespace osu.Game.Rulesets.Catch.UI
public CatchPlayfield(BeatmapDifficulty difficulty)
{
- var droppedObjectContainer = new Container
- {
- RelativeSizeAxes = Axes.Both,
- };
-
- CatcherArea = new CatcherArea(droppedObjectContainer, difficulty)
+ CatcherArea = new CatcherArea(difficulty)
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.TopLeft,
@@ -48,7 +45,7 @@ namespace osu.Game.Rulesets.Catch.UI
InternalChildren = new[]
{
- droppedObjectContainer,
+ droppedObjectContainer = new DroppedObjectContainer(),
CatcherArea.MovableCatcher.CreateProxiedContent(),
HitObjectContainer.CreateProxy(),
// This ordering (`CatcherArea` before `HitObjectContainer`) is important to
diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs
index 1f01dbabb5..dcab9459ee 100644
--- a/osu.Game.Rulesets.Catch/UI/Catcher.cs
+++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs
@@ -79,7 +79,8 @@ namespace osu.Game.Rulesets.Catch.UI
///
/// Contains objects dropped from the plate.
///
- private readonly Container droppedObjectTarget;
+ [Resolved]
+ private DroppedObjectContainer droppedObjectTarget { get; set; }
public CatcherAnimationState CurrentState
{
@@ -134,10 +135,9 @@ namespace osu.Game.Rulesets.Catch.UI
private readonly DrawablePool caughtBananaPool;
private readonly DrawablePool caughtDropletPool;
- public Catcher([NotNull] Container trailsTarget, [NotNull] Container droppedObjectTarget, BeatmapDifficulty difficulty = null)
+ public Catcher([NotNull] Container trailsTarget, BeatmapDifficulty difficulty = null)
{
this.trailsTarget = trailsTarget;
- this.droppedObjectTarget = droppedObjectTarget;
Origin = Anchor.TopCentre;
diff --git a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs
index cdb15c2b4c..fea314df8d 100644
--- a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs
+++ b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs
@@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Catch.UI
///
private int currentDirection;
- public CatcherArea(Container droppedObjectContainer, BeatmapDifficulty difficulty = null)
+ public CatcherArea(BeatmapDifficulty difficulty = null)
{
Size = new Vector2(CatchPlayfield.WIDTH, CATCHER_SIZE);
Children = new Drawable[]
@@ -44,7 +44,7 @@ namespace osu.Game.Rulesets.Catch.UI
Margin = new MarginPadding { Bottom = 350f },
X = CatchPlayfield.CENTER_X
},
- MovableCatcher = new Catcher(this, droppedObjectContainer, difficulty) { X = CatchPlayfield.CENTER_X },
+ MovableCatcher = new Catcher(this, difficulty) { X = CatchPlayfield.CENTER_X },
};
}
diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs
index 80522ab36b..c961d98dc5 100644
--- a/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs
+++ b/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs
@@ -37,6 +37,7 @@ namespace osu.Game.Rulesets.Catch.UI
protected override void FreeAfterUse()
{
ClearTransforms();
+ Alpha = 1;
base.FreeAfterUse();
}
}
diff --git a/osu.Game.Rulesets.Catch/UI/DroppedObjectContainer.cs b/osu.Game.Rulesets.Catch/UI/DroppedObjectContainer.cs
new file mode 100644
index 0000000000..b44b0caae4
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/UI/DroppedObjectContainer.cs
@@ -0,0 +1,17 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Rulesets.Catch.Objects.Drawables;
+
+namespace osu.Game.Rulesets.Catch.UI
+{
+ public class DroppedObjectContainer : Container
+ {
+ public DroppedObjectContainer()
+ {
+ RelativeSizeAxes = Axes.Both;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs
index 1c89d9cd00..f89750a96e 100644
--- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs
+++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs
@@ -3,6 +3,7 @@
using osu.Framework.Allocation;
using osu.Framework.Graphics;
+using osu.Framework.Localisation;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Mania.Configuration;
@@ -47,7 +48,7 @@ namespace osu.Game.Rulesets.Mania
private class TimeSlider : OsuSliderBar
{
- public override string TooltipText => Current.Value.ToString("N0") + "ms";
+ public override LocalisableString TooltipText => Current.Value.ToString(@"N0") + "ms";
}
}
}
diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs
index 48e4db11ca..5b476526c9 100644
--- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs
+++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs
@@ -13,6 +13,7 @@ using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
+using osu.Framework.Localisation;
using osu.Framework.Utils;
using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
@@ -283,6 +284,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
}
}
- public string TooltipText => ControlPoint.Type.Value.ToString() ?? string.Empty;
+ public LocalisableString TooltipText => ControlPoint.Type.Value.ToString() ?? string.Empty;
}
}
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
index 97e3d82664..d1212096bf 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
@@ -12,6 +12,7 @@ using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI;
+using osu.Game.Rulesets.Osu.Utils;
using osuTK;
namespace osu.Game.Rulesets.Osu.Mods
@@ -23,15 +24,6 @@ namespace osu.Game.Rulesets.Osu.Mods
{
public override string Description => "It never gets boring!";
- // The relative distance to the edge of the playfield before objects' positions should start to "turn around" and curve towards the middle.
- // The closer the hit objects draw to the border, the sharper the turn
- private const float playfield_edge_ratio = 0.375f;
-
- private static readonly float border_distance_x = OsuPlayfield.BASE_SIZE.X * playfield_edge_ratio;
- private static readonly float border_distance_y = OsuPlayfield.BASE_SIZE.Y * playfield_edge_ratio;
-
- private static readonly Vector2 playfield_middle = OsuPlayfield.BASE_SIZE / 2;
-
private static readonly float playfield_diagonal = OsuPlayfield.BASE_SIZE.LengthFast;
private Random rng;
@@ -113,7 +105,7 @@ namespace osu.Game.Rulesets.Osu.Mods
distanceToPrev * (float)Math.Sin(current.AngleRad)
);
- posRelativeToPrev = getRotatedVector(previous.EndPositionRandomised, posRelativeToPrev);
+ posRelativeToPrev = OsuHitObjectGenerationUtils.RotateAwayFromEdge(previous.EndPositionRandomised, posRelativeToPrev);
current.AngleRad = (float)Math.Atan2(posRelativeToPrev.Y, posRelativeToPrev.X);
@@ -185,73 +177,6 @@ namespace osu.Game.Rulesets.Osu.Mods
}
}
- ///
- /// Determines the position of the current hit object relative to the previous one.
- ///
- /// The position of the current hit object relative to the previous one
- private Vector2 getRotatedVector(Vector2 prevPosChanged, Vector2 posRelativeToPrev)
- {
- var relativeRotationDistance = 0f;
-
- if (prevPosChanged.X < playfield_middle.X)
- {
- relativeRotationDistance = Math.Max(
- (border_distance_x - prevPosChanged.X) / border_distance_x,
- relativeRotationDistance
- );
- }
- else
- {
- relativeRotationDistance = Math.Max(
- (prevPosChanged.X - (OsuPlayfield.BASE_SIZE.X - border_distance_x)) / border_distance_x,
- relativeRotationDistance
- );
- }
-
- if (prevPosChanged.Y < playfield_middle.Y)
- {
- relativeRotationDistance = Math.Max(
- (border_distance_y - prevPosChanged.Y) / border_distance_y,
- relativeRotationDistance
- );
- }
- else
- {
- relativeRotationDistance = Math.Max(
- (prevPosChanged.Y - (OsuPlayfield.BASE_SIZE.Y - border_distance_y)) / border_distance_y,
- relativeRotationDistance
- );
- }
-
- return rotateVectorTowardsVector(posRelativeToPrev, playfield_middle - prevPosChanged, relativeRotationDistance / 2);
- }
-
- ///
- /// Rotates vector "initial" towards vector "destinantion"
- ///
- /// Vector to rotate to "destination"
- /// Vector "initial" should be rotated to
- /// The angle the vector should be rotated relative to the difference between the angles of the the two vectors.
- /// Resulting vector
- private Vector2 rotateVectorTowardsVector(Vector2 initial, Vector2 destination, float relativeDistance)
- {
- var initialAngleRad = Math.Atan2(initial.Y, initial.X);
- var destAngleRad = Math.Atan2(destination.Y, destination.X);
-
- var diff = destAngleRad - initialAngleRad;
-
- while (diff < -Math.PI) diff += 2 * Math.PI;
-
- while (diff > Math.PI) diff -= 2 * Math.PI;
-
- var finalAngleRad = initialAngleRad + relativeDistance * diff;
-
- return new Vector2(
- initial.Length * (float)Math.Cos(finalAngleRad),
- initial.Length * (float)Math.Sin(finalAngleRad)
- );
- }
-
private class RandomObjectInfo
{
public float AngleRad { get; set; }
diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs
new file mode 100644
index 0000000000..06b964a647
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs
@@ -0,0 +1,104 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using osu.Game.Rulesets.Osu.UI;
+using osuTK;
+
+namespace osu.Game.Rulesets.Osu.Utils
+{
+ public static class OsuHitObjectGenerationUtils
+ {
+ // The relative distance to the edge of the playfield before objects' positions should start to "turn around" and curve towards the middle.
+ // The closer the hit objects draw to the border, the sharper the turn
+ private const float playfield_edge_ratio = 0.375f;
+
+ private static readonly float border_distance_x = OsuPlayfield.BASE_SIZE.X * playfield_edge_ratio;
+ private static readonly float border_distance_y = OsuPlayfield.BASE_SIZE.Y * playfield_edge_ratio;
+
+ private static readonly Vector2 playfield_middle = OsuPlayfield.BASE_SIZE / 2;
+
+ ///
+ /// Rotate a hit object away from the playfield edge, while keeping a constant distance
+ /// from the previous object.
+ ///
+ ///
+ /// The extent of rotation depends on the position of the hit object. Hit objects
+ /// closer to the playfield edge will be rotated to a larger extent.
+ ///
+ /// Position of the previous hit object.
+ /// Position of the hit object to be rotated, relative to the previous hit object.
+ ///
+ /// The extent of rotation.
+ /// 0 means the hit object is never rotated.
+ /// 1 means the hit object will be fully rotated towards playfield center when it is originally at playfield edge.
+ ///
+ /// The new position of the hit object, relative to the previous one.
+ public static Vector2 RotateAwayFromEdge(Vector2 prevObjectPos, Vector2 posRelativeToPrev, float rotationRatio = 0.5f)
+ {
+ var relativeRotationDistance = 0f;
+
+ if (prevObjectPos.X < playfield_middle.X)
+ {
+ relativeRotationDistance = Math.Max(
+ (border_distance_x - prevObjectPos.X) / border_distance_x,
+ relativeRotationDistance
+ );
+ }
+ else
+ {
+ relativeRotationDistance = Math.Max(
+ (prevObjectPos.X - (OsuPlayfield.BASE_SIZE.X - border_distance_x)) / border_distance_x,
+ relativeRotationDistance
+ );
+ }
+
+ if (prevObjectPos.Y < playfield_middle.Y)
+ {
+ relativeRotationDistance = Math.Max(
+ (border_distance_y - prevObjectPos.Y) / border_distance_y,
+ relativeRotationDistance
+ );
+ }
+ else
+ {
+ relativeRotationDistance = Math.Max(
+ (prevObjectPos.Y - (OsuPlayfield.BASE_SIZE.Y - border_distance_y)) / border_distance_y,
+ relativeRotationDistance
+ );
+ }
+
+ return RotateVectorTowardsVector(
+ posRelativeToPrev,
+ playfield_middle - prevObjectPos,
+ Math.Min(1, relativeRotationDistance * rotationRatio)
+ );
+ }
+
+ ///
+ /// Rotates vector "initial" towards vector "destination".
+ ///
+ /// The vector to be rotated.
+ /// The vector that "initial" should be rotated towards.
+ /// How much "initial" should be rotated. 0 means no rotation. 1 means "initial" is fully rotated to equal "destination".
+ /// The rotated vector.
+ public static Vector2 RotateVectorTowardsVector(Vector2 initial, Vector2 destination, float rotationRatio)
+ {
+ var initialAngleRad = MathF.Atan2(initial.Y, initial.X);
+ var destAngleRad = MathF.Atan2(destination.Y, destination.X);
+
+ var diff = destAngleRad - initialAngleRad;
+
+ while (diff < -MathF.PI) diff += 2 * MathF.PI;
+
+ while (diff > MathF.PI) diff -= 2 * MathF.PI;
+
+ var finalAngleRad = initialAngleRad + rotationRatio * diff;
+
+ return new Vector2(
+ initial.Length * MathF.Cos(finalAngleRad),
+ initial.Length * MathF.Sin(finalAngleRad)
+ );
+ }
+ }
+}
diff --git a/osu.Game.Tests/Editing/Checks/CheckFewHitsoundsTest.cs b/osu.Game.Tests/Editing/Checks/CheckFewHitsoundsTest.cs
new file mode 100644
index 0000000000..cf5b3a42a4
--- /dev/null
+++ b/osu.Game.Tests/Editing/Checks/CheckFewHitsoundsTest.cs
@@ -0,0 +1,241 @@
+// 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 System.Linq;
+using NUnit.Framework;
+using osu.Game.Audio;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Edit.Checks;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Tests.Beatmaps;
+
+namespace osu.Game.Tests.Editing.Checks
+{
+ [TestFixture]
+ public class CheckFewHitsoundsTest
+ {
+ private CheckFewHitsounds check;
+
+ private List notHitsounded;
+ private List hitsounded;
+
+ [SetUp]
+ public void Setup()
+ {
+ check = new CheckFewHitsounds();
+ notHitsounded = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) };
+ hitsounded = new List
+ {
+ new HitSampleInfo(HitSampleInfo.HIT_NORMAL),
+ new HitSampleInfo(HitSampleInfo.HIT_FINISH)
+ };
+ }
+
+ [Test]
+ public void TestHitsounded()
+ {
+ var hitObjects = new List();
+
+ for (int i = 0; i < 16; ++i)
+ {
+ var samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) };
+
+ if ((i + 1) % 2 == 0)
+ samples.Add(new HitSampleInfo(HitSampleInfo.HIT_CLAP));
+ if ((i + 1) % 3 == 0)
+ samples.Add(new HitSampleInfo(HitSampleInfo.HIT_WHISTLE));
+ if ((i + 1) % 4 == 0)
+ samples.Add(new HitSampleInfo(HitSampleInfo.HIT_FINISH));
+
+ hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples });
+ }
+
+ assertOk(hitObjects);
+ }
+
+ [Test]
+ public void TestHitsoundedWithBreak()
+ {
+ var hitObjects = new List();
+
+ for (int i = 0; i < 32; ++i)
+ {
+ var samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) };
+
+ if ((i + 1) % 2 == 0)
+ samples.Add(new HitSampleInfo(HitSampleInfo.HIT_CLAP));
+ if ((i + 1) % 3 == 0)
+ samples.Add(new HitSampleInfo(HitSampleInfo.HIT_WHISTLE));
+ if ((i + 1) % 4 == 0)
+ samples.Add(new HitSampleInfo(HitSampleInfo.HIT_FINISH));
+ // Leaves a gap in which no hitsounds exist or can be added, and so shouldn't be an issue.
+ if (i > 8 && i < 24)
+ continue;
+
+ hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples });
+ }
+
+ assertOk(hitObjects);
+ }
+
+ [Test]
+ public void TestLightlyHitsounded()
+ {
+ var hitObjects = new List();
+
+ for (int i = 0; i < 30; ++i)
+ {
+ var samples = i % 8 == 0 ? hitsounded : notHitsounded;
+
+ hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples });
+ }
+
+ assertLongPeriodNegligible(hitObjects, count: 3);
+ }
+
+ [Test]
+ public void TestRarelyHitsounded()
+ {
+ var hitObjects = new List();
+
+ for (int i = 0; i < 30; ++i)
+ {
+ var samples = (i == 0 || i == 15) ? hitsounded : notHitsounded;
+
+ hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples });
+ }
+
+ // Should prompt one warning between 1st and 16th, and another between 16th and 31st.
+ assertLongPeriodWarning(hitObjects, count: 2);
+ }
+
+ [Test]
+ public void TestExtremelyRarelyHitsounded()
+ {
+ var hitObjects = new List();
+
+ for (int i = 0; i < 80; ++i)
+ {
+ var samples = i == 40 ? hitsounded : notHitsounded;
+
+ hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples });
+ }
+
+ // Should prompt one problem between 1st and 41st, and another between 41st and 81st.
+ assertLongPeriodProblem(hitObjects, count: 2);
+ }
+
+ [Test]
+ public void TestNotHitsounded()
+ {
+ var hitObjects = new List();
+
+ for (int i = 0; i < 20; ++i)
+ hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = notHitsounded });
+
+ assertNoHitsounds(hitObjects);
+ }
+
+ [Test]
+ public void TestNestedObjectsHitsounded()
+ {
+ var ticks = new List();
+ for (int i = 1; i < 16; ++i)
+ ticks.Add(new SliderTick { StartTime = 1000 * i, Samples = hitsounded });
+
+ var nested = new MockNestableHitObject(ticks.ToList(), 0, 16000)
+ {
+ Samples = hitsounded
+ };
+ nested.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
+
+ assertOk(new List { nested });
+ }
+
+ [Test]
+ public void TestNestedObjectsRarelyHitsounded()
+ {
+ var ticks = new List();
+ for (int i = 1; i < 16; ++i)
+ ticks.Add(new SliderTick { StartTime = 1000 * i, Samples = i == 0 ? hitsounded : notHitsounded });
+
+ var nested = new MockNestableHitObject(ticks.ToList(), 0, 16000)
+ {
+ Samples = hitsounded
+ };
+ nested.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
+
+ assertLongPeriodWarning(new List { nested });
+ }
+
+ [Test]
+ public void TestConcurrentObjects()
+ {
+ var hitObjects = new List();
+
+ var ticks = new List();
+ for (int i = 1; i < 10; ++i)
+ ticks.Add(new SliderTick { StartTime = 5000 * i, Samples = hitsounded });
+
+ var nested = new MockNestableHitObject(ticks.ToList(), 0, 50000)
+ {
+ Samples = notHitsounded
+ };
+ nested.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
+ hitObjects.Add(nested);
+
+ for (int i = 1; i <= 6; ++i)
+ hitObjects.Add(new HitCircle { StartTime = 10000 * i, Samples = notHitsounded });
+
+ assertOk(hitObjects);
+ }
+
+ private void assertOk(List hitObjects)
+ {
+ Assert.That(check.Run(getContext(hitObjects)), Is.Empty);
+ }
+
+ private void assertLongPeriodProblem(List hitObjects, int count = 1)
+ {
+ var issues = check.Run(getContext(hitObjects)).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(count));
+ Assert.That(issues.All(issue => issue.Template is CheckFewHitsounds.IssueTemplateLongPeriodProblem));
+ }
+
+ private void assertLongPeriodWarning(List hitObjects, int count = 1)
+ {
+ var issues = check.Run(getContext(hitObjects)).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(count));
+ Assert.That(issues.All(issue => issue.Template is CheckFewHitsounds.IssueTemplateLongPeriodWarning));
+ }
+
+ private void assertLongPeriodNegligible(List hitObjects, int count = 1)
+ {
+ var issues = check.Run(getContext(hitObjects)).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(count));
+ Assert.That(issues.All(issue => issue.Template is CheckFewHitsounds.IssueTemplateLongPeriodNegligible));
+ }
+
+ private void assertNoHitsounds(List hitObjects)
+ {
+ var issues = check.Run(getContext(hitObjects)).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.Any(issue => issue.Template is CheckFewHitsounds.IssueTemplateNoHitsounds));
+ }
+
+ private BeatmapVerifierContext getContext(List hitObjects)
+ {
+ var beatmap = new Beatmap { HitObjects = hitObjects };
+
+ return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
+ }
+ }
+}
diff --git a/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs b/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs
new file mode 100644
index 0000000000..41a8f72305
--- /dev/null
+++ b/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs
@@ -0,0 +1,289 @@
+// 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 System.Linq;
+using NUnit.Framework;
+using osu.Game.Audio;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Edit.Checks;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Tests.Beatmaps;
+
+namespace osu.Game.Tests.Editing.Checks
+{
+ [TestFixture]
+ public class CheckMutedObjectsTest
+ {
+ private CheckMutedObjects check;
+ private ControlPointInfo cpi;
+
+ private const int volume_regular = 50;
+ private const int volume_low = 15;
+ private const int volume_muted = 5;
+
+ [SetUp]
+ public void Setup()
+ {
+ check = new CheckMutedObjects();
+
+ cpi = new ControlPointInfo();
+ cpi.Add(0, new SampleControlPoint { SampleVolume = volume_regular });
+ cpi.Add(1000, new SampleControlPoint { SampleVolume = volume_low });
+ cpi.Add(2000, new SampleControlPoint { SampleVolume = volume_muted });
+ }
+
+ [Test]
+ public void TestNormalControlPointVolume()
+ {
+ var hitcircle = new HitCircle
+ {
+ StartTime = 0,
+ Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
+ };
+ hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ assertOk(new List { hitcircle });
+ }
+
+ [Test]
+ public void TestLowControlPointVolume()
+ {
+ var hitcircle = new HitCircle
+ {
+ StartTime = 1000,
+ Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
+ };
+ hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ assertLowVolume(new List { hitcircle });
+ }
+
+ [Test]
+ public void TestMutedControlPointVolume()
+ {
+ var hitcircle = new HitCircle
+ {
+ StartTime = 2000,
+ Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
+ };
+ hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ assertMuted(new List { hitcircle });
+ }
+
+ [Test]
+ public void TestNormalSampleVolume()
+ {
+ // The sample volume should take precedence over the control point volume.
+ var hitcircle = new HitCircle
+ {
+ StartTime = 2000,
+ Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_regular) }
+ };
+ hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ assertOk(new List { hitcircle });
+ }
+
+ [Test]
+ public void TestLowSampleVolume()
+ {
+ var hitcircle = new HitCircle
+ {
+ StartTime = 2000,
+ Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_low) }
+ };
+ hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ assertLowVolume(new List { hitcircle });
+ }
+
+ [Test]
+ public void TestMutedSampleVolume()
+ {
+ var hitcircle = new HitCircle
+ {
+ StartTime = 0,
+ Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_muted) }
+ };
+ hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ assertMuted(new List { hitcircle });
+ }
+
+ [Test]
+ public void TestNormalSampleVolumeSlider()
+ {
+ var sliderHead = new SliderHeadCircle
+ {
+ StartTime = 0,
+ Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
+ };
+ sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ var sliderTick = new SliderTick
+ {
+ StartTime = 250,
+ Samples = new List { new HitSampleInfo("slidertick", volume: volume_muted) } // Should be fine.
+ };
+ sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ var slider = new MockNestableHitObject(new List { sliderHead, sliderTick, }, startTime: 0, endTime: 500)
+ {
+ Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
+ };
+ slider.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ assertOk(new List { slider });
+ }
+
+ [Test]
+ public void TestMutedSampleVolumeSliderHead()
+ {
+ var sliderHead = new SliderHeadCircle
+ {
+ StartTime = 0,
+ Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_muted) }
+ };
+ sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ var sliderTick = new SliderTick
+ {
+ StartTime = 250,
+ Samples = new List { new HitSampleInfo("slidertick") }
+ };
+ sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ var slider = new MockNestableHitObject(new List { sliderHead, sliderTick, }, startTime: 0, endTime: 500)
+ {
+ Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } // Applies to the tail.
+ };
+ slider.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ assertMuted(new List { slider });
+ }
+
+ [Test]
+ public void TestMutedSampleVolumeSliderTail()
+ {
+ var sliderHead = new SliderHeadCircle
+ {
+ StartTime = 0,
+ Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
+ };
+ sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ var sliderTick = new SliderTick
+ {
+ StartTime = 250,
+ Samples = new List { new HitSampleInfo("slidertick") }
+ };
+ sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ var slider = new MockNestableHitObject(new List { sliderHead, sliderTick, }, startTime: 0, endTime: 2500)
+ {
+ Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_muted) } // Applies to the tail.
+ };
+ slider.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ assertMutedPassive(new List { slider });
+ }
+
+ [Test]
+ public void TestMutedControlPointVolumeSliderHead()
+ {
+ var sliderHead = new SliderHeadCircle
+ {
+ StartTime = 2000,
+ Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
+ };
+ sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ var sliderTick = new SliderTick
+ {
+ StartTime = 2250,
+ Samples = new List { new HitSampleInfo("slidertick") }
+ };
+ sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ var slider = new MockNestableHitObject(new List { sliderHead, sliderTick, }, startTime: 2000, endTime: 2500)
+ {
+ Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_regular) }
+ };
+ slider.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ assertMuted(new List { slider });
+ }
+
+ [Test]
+ public void TestMutedControlPointVolumeSliderTail()
+ {
+ var sliderHead = new SliderHeadCircle
+ {
+ StartTime = 0,
+ Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
+ };
+ sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ var sliderTick = new SliderTick
+ {
+ StartTime = 250,
+ Samples = new List { new HitSampleInfo("slidertick") }
+ };
+ sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ // Ends after the 5% control point.
+ var slider = new MockNestableHitObject(new List { sliderHead, sliderTick, }, startTime: 0, endTime: 2500)
+ {
+ Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
+ };
+ slider.ApplyDefaults(cpi, new BeatmapDifficulty());
+
+ assertMutedPassive(new List { slider });
+ }
+
+ private void assertOk(List hitObjects)
+ {
+ Assert.That(check.Run(getContext(hitObjects)), Is.Empty);
+ }
+
+ private void assertLowVolume(List hitObjects, int count = 1)
+ {
+ var issues = check.Run(getContext(hitObjects)).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(count));
+ Assert.That(issues.All(issue => issue.Template is CheckMutedObjects.IssueTemplateLowVolumeActive));
+ }
+
+ private void assertMuted(List hitObjects, int count = 1)
+ {
+ var issues = check.Run(getContext(hitObjects)).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(count));
+ Assert.That(issues.All(issue => issue.Template is CheckMutedObjects.IssueTemplateMutedActive));
+ }
+
+ private void assertMutedPassive(List hitObjects)
+ {
+ var issues = check.Run(getContext(hitObjects)).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.Any(issue => issue.Template is CheckMutedObjects.IssueTemplateMutedPassive));
+ }
+
+ private BeatmapVerifierContext getContext(List hitObjects)
+ {
+ var beatmap = new Beatmap
+ {
+ ControlPointInfo = cpi,
+ HitObjects = hitObjects
+ };
+
+ return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
+ }
+ }
+}
diff --git a/osu.Game.Tests/Editing/Checks/MockNestableHitObject.cs b/osu.Game.Tests/Editing/Checks/MockNestableHitObject.cs
new file mode 100644
index 0000000000..29938839d3
--- /dev/null
+++ b/osu.Game.Tests/Editing/Checks/MockNestableHitObject.cs
@@ -0,0 +1,36 @@
+// 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 System.Threading;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
+
+namespace osu.Game.Tests.Editing.Checks
+{
+ public sealed class MockNestableHitObject : HitObject, IHasDuration
+ {
+ private readonly IEnumerable toBeNested;
+
+ public MockNestableHitObject(IEnumerable toBeNested, double startTime, double endTime)
+ {
+ this.toBeNested = toBeNested;
+ StartTime = startTime;
+ EndTime = endTime;
+ }
+
+ protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
+ {
+ foreach (var hitObject in toBeNested)
+ AddNested(hitObject);
+ }
+
+ public double EndTime { get; }
+
+ public double Duration
+ {
+ get => EndTime - StartTime;
+ set => throw new System.NotImplementedException();
+ }
+ }
+}
diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs
index 9bd262a569..a55bdd2df8 100644
--- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs
+++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs
@@ -90,6 +90,20 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.Less(filterCriteria.DrainRate.Min, 6.1f);
}
+ [Test]
+ public void TestApplyOverallDifficultyQueries()
+ {
+ const string query = "od>4 easy od<8";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("easy", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(1, filterCriteria.SearchTerms.Length);
+ Assert.Greater(filterCriteria.OverallDifficulty.Min, 4.0);
+ Assert.Less(filterCriteria.OverallDifficulty.Min, 4.1);
+ Assert.Greater(filterCriteria.OverallDifficulty.Max, 7.9);
+ Assert.Less(filterCriteria.OverallDifficulty.Max, 8.0);
+ }
+
[Test]
public void TestApplyBPMQueries()
{
diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs
index 156d6b744e..5bfb676f81 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
@@ -14,6 +15,8 @@ using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.BeatmapListing;
using osu.Game.Rulesets;
+using osu.Game.Scoring;
+using osu.Game.Users;
namespace osu.Game.Tests.Visual.Online
{
@@ -23,6 +26,8 @@ namespace osu.Game.Tests.Visual.Online
private BeatmapListingOverlay overlay;
+ private BeatmapListingSearchControl searchControl => overlay.ChildrenOfType().Single();
+
[BackgroundDependencyLoader]
private void load()
{
@@ -39,6 +44,16 @@ namespace osu.Game.Tests.Visual.Online
return true;
};
+
+ AddStep("initialize dummy", () =>
+ {
+ // non-supporter user
+ ((DummyAPIAccess)API).LocalUser.Value = new User
+ {
+ Username = "TestBot",
+ Id = API.LocalUser.Value.Id + 1,
+ };
+ });
}
[Test]
@@ -58,13 +73,164 @@ namespace osu.Game.Tests.Visual.Online
AddUntilStep("placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true);
}
+ [Test]
+ public void TestUserWithoutSupporterUsesSupporterOnlyFiltersWithoutResults()
+ {
+ AddStep("fetch for 0 beatmaps", () => fetchFor());
+ AddStep("set dummy as non-supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = false);
+
+ // only Rank Achieved filter
+ setRankAchievedFilter(new[] { ScoreRank.XH });
+ supporterRequiredPlaceholderShown();
+
+ setRankAchievedFilter(Array.Empty());
+ notFoundPlaceholderShown();
+
+ // only Played filter
+ setPlayedFilter(SearchPlayed.Played);
+ supporterRequiredPlaceholderShown();
+
+ setPlayedFilter(SearchPlayed.Any);
+ notFoundPlaceholderShown();
+
+ // both RankAchieved and Played filters
+ setRankAchievedFilter(new[] { ScoreRank.XH });
+ setPlayedFilter(SearchPlayed.Played);
+ supporterRequiredPlaceholderShown();
+
+ setRankAchievedFilter(Array.Empty());
+ setPlayedFilter(SearchPlayed.Any);
+ notFoundPlaceholderShown();
+ }
+
+ [Test]
+ public void TestUserWithSupporterUsesSupporterOnlyFiltersWithoutResults()
+ {
+ AddStep("fetch for 0 beatmaps", () => fetchFor());
+ AddStep("set dummy as supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = true);
+
+ // only Rank Achieved filter
+ setRankAchievedFilter(new[] { ScoreRank.XH });
+ notFoundPlaceholderShown();
+
+ setRankAchievedFilter(Array.Empty());
+ notFoundPlaceholderShown();
+
+ // only Played filter
+ setPlayedFilter(SearchPlayed.Played);
+ notFoundPlaceholderShown();
+
+ setPlayedFilter(SearchPlayed.Any);
+ notFoundPlaceholderShown();
+
+ // both Rank Achieved and Played filters
+ setRankAchievedFilter(new[] { ScoreRank.XH });
+ setPlayedFilter(SearchPlayed.Played);
+ notFoundPlaceholderShown();
+
+ setRankAchievedFilter(Array.Empty());
+ setPlayedFilter(SearchPlayed.Any);
+ notFoundPlaceholderShown();
+ }
+
+ [Test]
+ public void TestUserWithoutSupporterUsesSupporterOnlyFiltersWithResults()
+ {
+ AddStep("fetch for 1 beatmap", () => fetchFor(CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet));
+ AddStep("set dummy as non-supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = false);
+
+ // only Rank Achieved filter
+ setRankAchievedFilter(new[] { ScoreRank.XH });
+ supporterRequiredPlaceholderShown();
+
+ setRankAchievedFilter(Array.Empty());
+ noPlaceholderShown();
+
+ // only Played filter
+ setPlayedFilter(SearchPlayed.Played);
+ supporterRequiredPlaceholderShown();
+
+ setPlayedFilter(SearchPlayed.Any);
+ noPlaceholderShown();
+
+ // both Rank Achieved and Played filters
+ setRankAchievedFilter(new[] { ScoreRank.XH });
+ setPlayedFilter(SearchPlayed.Played);
+ supporterRequiredPlaceholderShown();
+
+ setRankAchievedFilter(Array.Empty());
+ setPlayedFilter(SearchPlayed.Any);
+ noPlaceholderShown();
+ }
+
+ [Test]
+ public void TestUserWithSupporterUsesSupporterOnlyFiltersWithResults()
+ {
+ AddStep("fetch for 1 beatmap", () => fetchFor(CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet));
+ AddStep("set dummy as supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = true);
+
+ // only Rank Achieved filter
+ setRankAchievedFilter(new[] { ScoreRank.XH });
+ noPlaceholderShown();
+
+ setRankAchievedFilter(Array.Empty());
+ noPlaceholderShown();
+
+ // only Played filter
+ setPlayedFilter(SearchPlayed.Played);
+ noPlaceholderShown();
+
+ setPlayedFilter(SearchPlayed.Any);
+ noPlaceholderShown();
+
+ // both Rank Achieved and Played filters
+ setRankAchievedFilter(new[] { ScoreRank.XH });
+ setPlayedFilter(SearchPlayed.Played);
+ noPlaceholderShown();
+
+ setRankAchievedFilter(Array.Empty());
+ setPlayedFilter(SearchPlayed.Any);
+ noPlaceholderShown();
+ }
+
private void fetchFor(params BeatmapSetInfo[] beatmaps)
{
setsForResponse.Clear();
setsForResponse.AddRange(beatmaps.Select(b => new TestAPIBeatmapSet(b)));
// trigger arbitrary change for fetching.
- overlay.ChildrenOfType().Single().Query.TriggerChange();
+ searchControl.Query.TriggerChange();
+ }
+
+ private void setRankAchievedFilter(ScoreRank[] ranks)
+ {
+ AddStep($"set Rank Achieved filter to [{string.Join(',', ranks)}]", () =>
+ {
+ searchControl.Ranks.Clear();
+ searchControl.Ranks.AddRange(ranks);
+ });
+ }
+
+ private void setPlayedFilter(SearchPlayed played)
+ {
+ AddStep($"set Played filter to {played}", () => searchControl.Played.Value = played);
+ }
+
+ private void supporterRequiredPlaceholderShown()
+ {
+ AddUntilStep("\"supporter required\" placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true);
+ }
+
+ private void notFoundPlaceholderShown()
+ {
+ AddUntilStep("\"no maps found\" placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true);
+ }
+
+ private void noPlaceholderShown()
+ {
+ AddUntilStep("no placeholder shown", () =>
+ !overlay.ChildrenOfType().Any()
+ && !overlay.ChildrenOfType().Any());
}
private class TestAPIBeatmapSet : APIBeatmapSet
diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs
index 2885dbee00..df8ef92a05 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs
@@ -10,7 +10,6 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
-using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays.Mods;
using osu.Game.Rulesets;
@@ -107,7 +106,6 @@ namespace osu.Game.Tests.Visual.UserInterface
var conversionMods = osu.GetModsFor(ModType.Conversion);
var noFailMod = osu.GetModsFor(ModType.DifficultyReduction).FirstOrDefault(m => m is OsuModNoFail);
- var hiddenMod = harderMods.FirstOrDefault(m => m is OsuModHidden);
var doubleTimeMod = harderMods.OfType().FirstOrDefault(m => m.Mods.Any(a => a is OsuModDoubleTime));
@@ -120,8 +118,6 @@ namespace osu.Game.Tests.Visual.UserInterface
testMultiMod(doubleTimeMod);
testIncompatibleMods(easy, hardRock);
testDeselectAll(easierMods.Where(m => !(m is MultiMod)));
- testMultiplierTextColour(noFailMod, () => modSelect.LowMultiplierColour);
- testMultiplierTextColour(hiddenMod, () => modSelect.HighMultiplierColour);
testUnimplementedMod(targetMod);
}
@@ -149,7 +145,7 @@ namespace osu.Game.Tests.Visual.UserInterface
changeRuleset(0);
- AddAssert("ensure mods still selected", () => modDisplay.Current.Value.Single(m => m is OsuModNoFail) != null);
+ AddAssert("ensure mods still selected", () => modDisplay.Current.Value.SingleOrDefault(m => m is OsuModNoFail) != null);
changeRuleset(3);
@@ -316,17 +312,6 @@ namespace osu.Game.Tests.Visual.UserInterface
AddAssert("check for no selection", () => !modSelect.SelectedMods.Value.Any());
}
- private void testMultiplierTextColour(Mod mod, Func getCorrectColour)
- {
- checkLabelColor(() => Color4.White);
- selectNext(mod);
- AddWaitStep("wait for changing colour", 1);
- checkLabelColor(getCorrectColour);
- selectPrevious(mod);
- AddWaitStep("wait for changing colour", 1);
- checkLabelColor(() => Color4.White);
- }
-
private void testModsWithSameBaseType(Mod modA, Mod modB)
{
selectNext(modA);
@@ -348,7 +333,7 @@ namespace osu.Game.Tests.Visual.UserInterface
AddAssert($"check {mod.Name} is selected", () =>
{
var button = modSelect.GetModButton(mod);
- return modSelect.SelectedMods.Value.Single(m => m.Name == mod.Name) != null && button.SelectedMod.GetType() == mod.GetType() && button.Selected;
+ return modSelect.SelectedMods.Value.SingleOrDefault(m => m.Name == mod.Name) != null && button.SelectedMod.GetType() == mod.GetType() && button.Selected;
});
}
@@ -370,8 +355,6 @@ namespace osu.Game.Tests.Visual.UserInterface
});
}
- private void checkLabelColor(Func getColour) => AddAssert("check label has expected colour", () => modSelect.MultiplierLabel.Colour.AverageColour == getColour());
-
private void createDisplay(Func createOverlayFunc)
{
Children = new Drawable[]
@@ -408,7 +391,6 @@ namespace osu.Game.Tests.Visual.UserInterface
return section.ButtonsContainer.OfType().Single(b => b.Mods.Any(m => m.GetType() == mod.GetType()));
}
- public new OsuSpriteText MultiplierLabel => base.MultiplierLabel;
public new TriangleButton DeselectAllButton => base.DeselectAllButton;
public new Color4 LowMultiplierColour => base.LowMultiplierColour;
diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs
index c5374d50ab..096bccae9e 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs
@@ -10,6 +10,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
+using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osuTK;
@@ -59,7 +60,7 @@ namespace osu.Game.Tests.Visual.UserInterface
private class Icon : Container, IHasTooltip
{
- public string TooltipText { get; }
+ public LocalisableString TooltipText { get; }
public SpriteIcon SpriteIcon { get; }
diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs
index 00af06703d..86c8fb611f 100644
--- a/osu.Game/Beatmaps/BeatmapManager.cs
+++ b/osu.Game/Beatmaps/BeatmapManager.cs
@@ -191,8 +191,6 @@ namespace osu.Game.Beatmaps
{
var beatmapIds = beatmapSet.Beatmaps.Where(b => b.OnlineBeatmapID.HasValue).Select(b => b.OnlineBeatmapID).ToList();
- LogForModel(beatmapSet, $"Validating online IDs for {beatmapSet.Beatmaps.Count} beatmaps...");
-
// ensure all IDs are unique
if (beatmapIds.GroupBy(b => b).Any(g => g.Count() > 1))
{
diff --git a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs
index 5dff4fe282..7824205257 100644
--- a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs
+++ b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs
@@ -48,7 +48,6 @@ namespace osu.Game.Beatmaps
public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken)
{
- LogForModel(beatmapSet, "Performing online lookups...");
return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray());
}
diff --git a/osu.Game/Collections/CollectionManager.cs b/osu.Game/Collections/CollectionManager.cs
index b53cc659f7..fe04c70d62 100644
--- a/osu.Game/Collections/CollectionManager.cs
+++ b/osu.Game/Collections/CollectionManager.cs
@@ -35,6 +35,7 @@ namespace osu.Game.Collections
private const int database_version = 30000000;
private const string database_name = "collection.db";
+ private const string database_backup_name = "collection.db.bak";
public readonly BindableList Collections = new BindableList();
@@ -56,6 +57,17 @@ namespace osu.Game.Collections
{
Collections.CollectionChanged += collectionsChanged;
+ if (storage.Exists(database_backup_name))
+ {
+ // If a backup file exists, it means the previous write operation didn't run to completion.
+ // Always prefer the backup file in such a case as it's the most recent copy that is guaranteed to not be malformed.
+ //
+ // The database is saved 100ms after any change, and again when the game is closed, so there shouldn't be a large diff between the two files in the worst case.
+ if (storage.Exists(database_name))
+ storage.Delete(database_name);
+ File.Copy(storage.GetFullPath(database_backup_name), storage.GetFullPath(database_name));
+ }
+
if (storage.Exists(database_name))
{
List beatmapCollections;
@@ -68,7 +80,7 @@ namespace osu.Game.Collections
}
}
- private void collectionsChanged(object sender, NotifyCollectionChangedEventArgs e)
+ private void collectionsChanged(object sender, NotifyCollectionChangedEventArgs e) => Schedule(() =>
{
switch (e.Action)
{
@@ -92,7 +104,7 @@ namespace osu.Game.Collections
}
backgroundSave();
- }
+ });
///
/// Set an endpoint for notifications to be posted to.
@@ -257,27 +269,50 @@ namespace osu.Game.Collections
{
Interlocked.Increment(ref lastSave);
+ // This is NOT thread-safe!!
try
{
- // This is NOT thread-safe!!
+ var tempPath = Path.GetTempFileName();
- using (var sw = new SerializationWriter(storage.GetStream(database_name, FileAccess.Write)))
+ using (var ms = new MemoryStream())
{
- sw.Write(database_version);
-
- var collectionsCopy = Collections.ToArray();
- sw.Write(collectionsCopy.Length);
-
- foreach (var c in collectionsCopy)
+ using (var sw = new SerializationWriter(ms, true))
{
- sw.Write(c.Name.Value);
+ sw.Write(database_version);
- var beatmapsCopy = c.Beatmaps.ToArray();
- sw.Write(beatmapsCopy.Length);
+ var collectionsCopy = Collections.ToArray();
+ sw.Write(collectionsCopy.Length);
- foreach (var b in beatmapsCopy)
- sw.Write(b.MD5Hash);
+ foreach (var c in collectionsCopy)
+ {
+ sw.Write(c.Name.Value);
+
+ var beatmapsCopy = c.Beatmaps.ToArray();
+ sw.Write(beatmapsCopy.Length);
+
+ foreach (var b in beatmapsCopy)
+ sw.Write(b.MD5Hash);
+ }
}
+
+ using (var fs = File.OpenWrite(tempPath))
+ ms.WriteTo(fs);
+
+ var databasePath = storage.GetFullPath(database_name);
+ var databaseBackupPath = storage.GetFullPath(database_backup_name);
+
+ // Back up the existing database, clearing any existing backup.
+ if (File.Exists(databaseBackupPath))
+ File.Delete(databaseBackupPath);
+ if (File.Exists(databasePath))
+ File.Move(databasePath, databaseBackupPath);
+
+ // Move the new database in-place of the existing one.
+ File.Move(tempPath, databasePath);
+
+ // If everything succeeded up to this point, remove the backup file.
+ if (File.Exists(databaseBackupPath))
+ File.Delete(databaseBackupPath);
}
if (saveFailures < 10)
diff --git a/osu.Game/Configuration/SettingSourceAttribute.cs b/osu.Game/Configuration/SettingSourceAttribute.cs
index 3e50613093..f373e59417 100644
--- a/osu.Game/Configuration/SettingSourceAttribute.cs
+++ b/osu.Game/Configuration/SettingSourceAttribute.cs
@@ -12,6 +12,7 @@ using osu.Framework.Bindables;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Graphics;
using osu.Framework.Localisation;
+using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays.Settings;
namespace osu.Game.Configuration
@@ -30,7 +31,7 @@ namespace osu.Game.Configuration
{
public LocalisableString Label { get; }
- public string Description { get; }
+ public LocalisableString Description { get; }
public int? OrderPosition { get; }
@@ -149,7 +150,7 @@ namespace osu.Game.Configuration
break;
case IBindable bindable:
- var dropdownType = typeof(SettingsEnumDropdown<>).MakeGenericType(bindable.GetType().GetGenericArguments()[0]);
+ var dropdownType = typeof(ModSettingsEnumDropdown<>).MakeGenericType(bindable.GetType().GetGenericArguments()[0]);
var dropdown = (Drawable)Activator.CreateInstance(dropdownType);
dropdownType.GetProperty(nameof(SettingsDropdown
-
+
@@ -93,7 +93,7 @@
-
+