diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj
index 538aaf2d7a..2461351110 100644
--- a/osu.Desktop/osu.Desktop.csproj
+++ b/osu.Desktop/osu.Desktop.csproj
@@ -23,10 +23,10 @@
-
+
-
+
diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs
new file mode 100644
index 0000000000..30686cb947
--- /dev/null
+++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs
@@ -0,0 +1,201 @@
+// 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.Beatmaps;
+using osu.Game.Rulesets;
+using osu.Game.Screens.Select;
+using osu.Game.Screens.Select.Carousel;
+
+namespace osu.Game.Tests.NonVisual.Filtering
+{
+ [TestFixture]
+ public class FilterMatchingTest
+ {
+ private BeatmapInfo getExampleBeatmap() => new BeatmapInfo
+ {
+ Ruleset = new RulesetInfo { ID = 5 },
+ StarDifficulty = 4.0d,
+ BaseDifficulty = new BeatmapDifficulty
+ {
+ ApproachRate = 5.0f,
+ DrainRate = 3.0f,
+ CircleSize = 2.0f,
+ },
+ Metadata = new BeatmapMetadata
+ {
+ Artist = "The Artist",
+ ArtistUnicode = "check unicode too",
+ Title = "Title goes here",
+ TitleUnicode = "Title goes here",
+ AuthorString = "The Author",
+ Source = "unit tests",
+ Tags = "look for tags too",
+ },
+ Version = "version as well",
+ Length = 2500,
+ BPM = 160,
+ BeatDivisor = 12,
+ Status = BeatmapSetOnlineStatus.Loved
+ };
+
+ [Test]
+ public void TestCriteriaMatchingNoRuleset()
+ {
+ var exampleBeatmapInfo = getExampleBeatmap();
+ var criteria = new FilterCriteria();
+ var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+ carouselItem.Filter(criteria);
+ Assert.IsFalse(carouselItem.Filtered.Value);
+ }
+
+ [Test]
+ public void TestCriteriaMatchingSpecificRuleset()
+ {
+ var exampleBeatmapInfo = getExampleBeatmap();
+ var criteria = new FilterCriteria
+ {
+ Ruleset = new RulesetInfo { ID = 6 }
+ };
+ var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+ carouselItem.Filter(criteria);
+ Assert.IsTrue(carouselItem.Filtered.Value);
+ }
+
+ [Test]
+ public void TestCriteriaMatchingConvertedBeatmaps()
+ {
+ var exampleBeatmapInfo = getExampleBeatmap();
+ var criteria = new FilterCriteria
+ {
+ Ruleset = new RulesetInfo { ID = 6 },
+ AllowConvertedBeatmaps = true
+ };
+ var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+ carouselItem.Filter(criteria);
+ Assert.IsFalse(carouselItem.Filtered.Value);
+ }
+
+ [Test]
+ [TestCase(true)]
+ [TestCase(false)]
+ public void TestCriteriaMatchingRangeMin(bool inclusive)
+ {
+ var exampleBeatmapInfo = getExampleBeatmap();
+ var criteria = new FilterCriteria
+ {
+ Ruleset = new RulesetInfo { ID = 6 },
+ AllowConvertedBeatmaps = true,
+ ApproachRate = new FilterCriteria.OptionalRange
+ {
+ IsLowerInclusive = inclusive,
+ Min = 5.0f
+ }
+ };
+ var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+ carouselItem.Filter(criteria);
+ Assert.AreEqual(!inclusive, carouselItem.Filtered.Value);
+ }
+
+ [Test]
+ [TestCase(true)]
+ [TestCase(false)]
+ public void TestCriteriaMatchingRangeMax(bool inclusive)
+ {
+ var exampleBeatmapInfo = getExampleBeatmap();
+ var criteria = new FilterCriteria
+ {
+ Ruleset = new RulesetInfo { ID = 6 },
+ AllowConvertedBeatmaps = true,
+ BPM = new FilterCriteria.OptionalRange
+ {
+ IsUpperInclusive = inclusive,
+ Max = 160d
+ }
+ };
+ var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+ carouselItem.Filter(criteria);
+ Assert.AreEqual(!inclusive, carouselItem.Filtered.Value);
+ }
+
+ [Test]
+ [TestCase("artist", false)]
+ [TestCase("artist title author", false)]
+ [TestCase("an artist", true)]
+ [TestCase("tags too", false)]
+ [TestCase("version", false)]
+ [TestCase("an auteur", true)]
+ public void TestCriteriaMatchingTerms(string terms, bool filtered)
+ {
+ var exampleBeatmapInfo = getExampleBeatmap();
+ var criteria = new FilterCriteria
+ {
+ Ruleset = new RulesetInfo { ID = 6 },
+ AllowConvertedBeatmaps = true,
+ SearchText = terms
+ };
+ var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+ carouselItem.Filter(criteria);
+ Assert.AreEqual(filtered, carouselItem.Filtered.Value);
+ }
+
+ [Test]
+ [TestCase("", false)]
+ [TestCase("The", false)]
+ [TestCase("THE", false)]
+ [TestCase("author", false)]
+ [TestCase("the author", false)]
+ [TestCase("the author AND then something else", true)]
+ [TestCase("unknown", true)]
+ public void TestCriteriaMatchingCreator(string creatorName, bool filtered)
+ {
+ var exampleBeatmapInfo = getExampleBeatmap();
+ var criteria = new FilterCriteria
+ {
+ Creator = new FilterCriteria.OptionalTextFilter { SearchTerm = creatorName }
+ };
+ var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+ carouselItem.Filter(criteria);
+ Assert.AreEqual(filtered, carouselItem.Filtered.Value);
+ }
+
+ [Test]
+ [TestCase("", false)]
+ [TestCase("The", false)]
+ [TestCase("THE", false)]
+ [TestCase("artist", false)]
+ [TestCase("the artist", false)]
+ [TestCase("the artist AND then something else", true)]
+ [TestCase("unicode too", false)]
+ [TestCase("unknown", true)]
+ public void TestCriteriaMatchingArtist(string artistName, bool filtered)
+ {
+ var exampleBeatmapInfo = getExampleBeatmap();
+ var criteria = new FilterCriteria
+ {
+ Artist = new FilterCriteria.OptionalTextFilter { SearchTerm = artistName }
+ };
+ var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+ carouselItem.Filter(criteria);
+ Assert.AreEqual(filtered, carouselItem.Filtered.Value);
+ }
+
+ [Test]
+ [TestCase("", false)]
+ [TestCase("artist", false)]
+ [TestCase("unknown", true)]
+ public void TestCriteriaMatchingArtistWithNullUnicodeName(string artistName, bool filtered)
+ {
+ var exampleBeatmapInfo = getExampleBeatmap();
+ exampleBeatmapInfo.Metadata.ArtistUnicode = null;
+
+ var criteria = new FilterCriteria
+ {
+ Artist = new FilterCriteria.OptionalTextFilter { SearchTerm = artistName }
+ };
+ var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+ carouselItem.Filter(criteria);
+ Assert.AreEqual(filtered, carouselItem.Filtered.Value);
+ }
+ }
+}
diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs
new file mode 100644
index 0000000000..9869ddde41
--- /dev/null
+++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs
@@ -0,0 +1,184 @@
+// 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 NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Screens.Select;
+
+namespace osu.Game.Tests.NonVisual.Filtering
+{
+ [TestFixture]
+ public class FilterQueryParserTest
+ {
+ [Test]
+ public void TestApplyQueriesBareWords()
+ {
+ const string query = "looking for a beatmap";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("looking for a beatmap", filterCriteria.SearchText);
+ Assert.AreEqual(4, filterCriteria.SearchTerms.Length);
+ }
+
+ /*
+ * The following tests have been written a bit strangely (they don't check exact
+ * bound equality with what the filter says).
+ * This is to account for floating-point arithmetic issues.
+ * For example, specifying a bpm<140 filter would previously match beatmaps with BPM
+ * of 139.99999, which would be displayed in the UI as 140.
+ * Due to this the tests check the last tick inside the range and the first tick
+ * outside of the range.
+ */
+
+ [Test]
+ public void TestApplyStarQueries()
+ {
+ const string query = "stars<4 easy";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("easy", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(1, filterCriteria.SearchTerms.Length);
+ Assert.IsNotNull(filterCriteria.StarDifficulty.Max);
+ Assert.Greater(filterCriteria.StarDifficulty.Max, 3.99d);
+ Assert.Less(filterCriteria.StarDifficulty.Max, 4.00d);
+ Assert.IsNull(filterCriteria.StarDifficulty.Min);
+ }
+
+ [Test]
+ public void TestApplyApproachRateQueries()
+ {
+ const string query = "ar>=9 difficult";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("difficult", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(1, filterCriteria.SearchTerms.Length);
+ Assert.IsNotNull(filterCriteria.ApproachRate.Min);
+ Assert.Greater(filterCriteria.ApproachRate.Min, 8.9f);
+ Assert.Less(filterCriteria.ApproachRate.Min, 9.0f);
+ Assert.IsNull(filterCriteria.ApproachRate.Max);
+ }
+
+ [Test]
+ public void TestApplyDrainRateQueries()
+ {
+ const string query = "dr>2 quite specific dr<:6";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("quite specific", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(2, filterCriteria.SearchTerms.Length);
+ Assert.Greater(filterCriteria.DrainRate.Min, 2.0f);
+ Assert.Less(filterCriteria.DrainRate.Min, 2.1f);
+ Assert.Greater(filterCriteria.DrainRate.Max, 6.0f);
+ Assert.Less(filterCriteria.DrainRate.Min, 6.1f);
+ }
+
+ [Test]
+ public void TestApplyBPMQueries()
+ {
+ const string query = "bpm>:200 gotta go fast";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("gotta go fast", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(3, filterCriteria.SearchTerms.Length);
+ Assert.IsNotNull(filterCriteria.BPM.Min);
+ Assert.Greater(filterCriteria.BPM.Min, 199.99d);
+ Assert.Less(filterCriteria.BPM.Min, 200.00d);
+ Assert.IsNull(filterCriteria.BPM.Max);
+ }
+
+ private static object[] lengthQueryExamples =
+ {
+ new object[] { "6ms", TimeSpan.FromMilliseconds(6), TimeSpan.FromMilliseconds(1) },
+ new object[] { "23s", TimeSpan.FromSeconds(23), TimeSpan.FromSeconds(1) },
+ new object[] { "9m", TimeSpan.FromMinutes(9), TimeSpan.FromMinutes(1) },
+ new object[] { "0.25h", TimeSpan.FromHours(0.25), TimeSpan.FromHours(1) },
+ new object[] { "70", TimeSpan.FromSeconds(70), TimeSpan.FromSeconds(1) },
+ };
+
+ [Test]
+ [TestCaseSource(nameof(lengthQueryExamples))]
+ public void TestApplyLengthQueries(string lengthQuery, TimeSpan expectedLength, TimeSpan scale)
+ {
+ string query = $"length={lengthQuery} time";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("time", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(1, filterCriteria.SearchTerms.Length);
+ Assert.AreEqual(expectedLength.TotalMilliseconds - scale.TotalMilliseconds / 2.0, filterCriteria.Length.Min);
+ Assert.AreEqual(expectedLength.TotalMilliseconds + scale.TotalMilliseconds / 2.0, filterCriteria.Length.Max);
+ }
+
+ [Test]
+ public void TestApplyDivisorQueries()
+ {
+ const string query = "that's a time signature alright! divisor:12";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("that's a time signature alright!", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(5, filterCriteria.SearchTerms.Length);
+ Assert.AreEqual(12, filterCriteria.BeatDivisor.Min);
+ Assert.IsTrue(filterCriteria.BeatDivisor.IsLowerInclusive);
+ Assert.AreEqual(12, filterCriteria.BeatDivisor.Max);
+ Assert.IsTrue(filterCriteria.BeatDivisor.IsUpperInclusive);
+ }
+
+ [Test]
+ public void TestApplyStatusQueries()
+ {
+ const string query = "I want the pp status=ranked";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("I want the pp", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(4, filterCriteria.SearchTerms.Length);
+ Assert.AreEqual(BeatmapSetOnlineStatus.Ranked, filterCriteria.OnlineStatus.Min);
+ Assert.IsTrue(filterCriteria.OnlineStatus.IsLowerInclusive);
+ Assert.AreEqual(BeatmapSetOnlineStatus.Ranked, filterCriteria.OnlineStatus.Max);
+ Assert.IsTrue(filterCriteria.OnlineStatus.IsUpperInclusive);
+ }
+
+ [Test]
+ public void TestApplyCreatorQueries()
+ {
+ const string query = "beatmap specifically by creator=my_fav";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("beatmap specifically by", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(3, filterCriteria.SearchTerms.Length);
+ Assert.AreEqual("my_fav", filterCriteria.Creator.SearchTerm);
+ }
+
+ [Test]
+ public void TestApplyArtistQueries()
+ {
+ const string query = "find me songs by artist=singer please";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("find me songs by please", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(5, filterCriteria.SearchTerms.Length);
+ Assert.AreEqual("singer", filterCriteria.Artist.SearchTerm);
+ }
+
+ [Test]
+ public void TestApplyArtistQueriesWithSpaces()
+ {
+ const string query = "really like artist=\"name with space\" yes";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("really like yes", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(3, filterCriteria.SearchTerms.Length);
+ Assert.AreEqual("name with space", filterCriteria.Artist.SearchTerm);
+ }
+
+ [Test]
+ public void TestApplyArtistQueriesOneDoubleQuote()
+ {
+ const string query = "weird artist=double\"quote";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("weird", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(1, filterCriteria.SearchTerms.Length);
+ Assert.AreEqual("double\"quote", filterCriteria.Artist.SearchTerm);
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs
index 6669ec7da3..71399106f4 100644
--- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs
+++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs
@@ -242,6 +242,21 @@ namespace osu.Game.Tests.Visual.SongSelect
AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false));
AddAssert("Selection is non-null", () => currentSelection != null);
+
+ setSelected(1, 3);
+ AddStep("Apply a range filter", () => carousel.Filter(new FilterCriteria
+ {
+ SearchText = "#3",
+ StarDifficulty = new FilterCriteria.OptionalRange
+ {
+ Min = 2,
+ Max = 5.5,
+ IsLowerInclusive = true
+ }
+ }, false));
+ checkSelected(3, 2);
+
+ AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false));
}
///
diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledComponent.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledComponent.cs
index 73e0191adb..a762d561c2 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledComponent.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledComponent.cs
@@ -25,7 +25,7 @@ namespace osu.Game.Tests.Visual.UserInterface
{
AddStep("create component", () =>
{
- LabelledComponent component;
+ LabelledComponent component;
Child = new Container
{
@@ -33,7 +33,7 @@ namespace osu.Game.Tests.Visual.UserInterface
Origin = Anchor.Centre,
Width = 500,
AutoSizeAxes = Axes.Y,
- Child = component = padded ? (LabelledComponent)new PaddedLabelledComponent() : new NonPaddedLabelledComponent(),
+ Child = component = padded ? (LabelledComponent)new PaddedLabelledComponent() : new NonPaddedLabelledComponent(),
};
component.Label = "a sample component";
@@ -41,7 +41,7 @@ namespace osu.Game.Tests.Visual.UserInterface
});
}
- private class PaddedLabelledComponent : LabelledComponent
+ private class PaddedLabelledComponent : LabelledComponent
{
public PaddedLabelledComponent()
: base(true)
@@ -57,7 +57,7 @@ namespace osu.Game.Tests.Visual.UserInterface
};
}
- private class NonPaddedLabelledComponent : LabelledComponent
+ private class NonPaddedLabelledComponent : LabelledComponent
{
public NonPaddedLabelledComponent()
: base(false)
diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneSetupScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneSetupScreen.cs
new file mode 100644
index 0000000000..650b4c5412
--- /dev/null
+++ b/osu.Game.Tournament.Tests/Screens/TestSceneSetupScreen.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.Allocation;
+using osu.Game.Tournament.Screens;
+
+namespace osu.Game.Tournament.Tests.Screens
+{
+ public class TestSceneSetupScreen : TournamentTestScene
+ {
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ Add(new SetupScreen());
+ }
+ }
+}
diff --git a/osu.Game.Tournament/IPC/FileBasedIPC.cs b/osu.Game.Tournament/IPC/FileBasedIPC.cs
index 4fd858bd12..e05d96e098 100644
--- a/osu.Game.Tournament/IPC/FileBasedIPC.cs
+++ b/osu.Game.Tournament/IPC/FileBasedIPC.cs
@@ -9,6 +9,7 @@ using osu.Framework.Allocation;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Platform.Windows;
+using osu.Framework.Threading;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Online.API;
@@ -26,103 +27,120 @@ namespace osu.Game.Tournament.IPC
[Resolved]
protected RulesetStore Rulesets { get; private set; }
+ [Resolved]
+ private GameHost host { get; set; }
+
+ [Resolved]
+ private LadderInfo ladder { get; set; }
+
private int lastBeatmapId;
+ private ScheduledDelegate scheduled;
+
+ public Storage Storage { get; private set; }
[BackgroundDependencyLoader]
- private void load(LadderInfo ladder, GameHost host)
+ private void load()
{
- StableStorage stable;
+ LocateStableStorage();
+ }
+
+ public Storage LocateStableStorage()
+ {
+ scheduled?.Cancel();
+
+ Storage = null;
try
{
- stable = new StableStorage(host as DesktopGameHost);
+ Storage = new StableStorage(host as DesktopGameHost);
+
+ const string file_ipc_filename = "ipc.txt";
+ const string file_ipc_state_filename = "ipc-state.txt";
+ const string file_ipc_scores_filename = "ipc-scores.txt";
+ const string file_ipc_channel_filename = "ipc-channel.txt";
+
+ if (Storage.Exists(file_ipc_filename))
+ scheduled = Scheduler.AddDelayed(delegate
+ {
+ try
+ {
+ using (var stream = Storage.GetStream(file_ipc_filename))
+ using (var sr = new StreamReader(stream))
+ {
+ var beatmapId = int.Parse(sr.ReadLine());
+ var mods = int.Parse(sr.ReadLine());
+
+ if (lastBeatmapId != beatmapId)
+ {
+ lastBeatmapId = beatmapId;
+
+ var existing = ladder.CurrentMatch.Value?.Round.Value?.Beatmaps.FirstOrDefault(b => b.ID == beatmapId && b.BeatmapInfo != null);
+
+ if (existing != null)
+ Beatmap.Value = existing.BeatmapInfo;
+ else
+ {
+ var req = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = beatmapId });
+ req.Success += b => Beatmap.Value = b.ToBeatmap(Rulesets);
+ API.Queue(req);
+ }
+ }
+
+ Mods.Value = (LegacyMods)mods;
+ }
+ }
+ catch
+ {
+ // file might be in use.
+ }
+
+ try
+ {
+ using (var stream = Storage.GetStream(file_ipc_channel_filename))
+ using (var sr = new StreamReader(stream))
+ {
+ ChatChannel.Value = sr.ReadLine();
+ }
+ }
+ catch (Exception)
+ {
+ // file might be in use.
+ }
+
+ try
+ {
+ using (var stream = Storage.GetStream(file_ipc_state_filename))
+ using (var sr = new StreamReader(stream))
+ {
+ State.Value = (TourneyState)Enum.Parse(typeof(TourneyState), sr.ReadLine());
+ }
+ }
+ catch (Exception)
+ {
+ // file might be in use.
+ }
+
+ try
+ {
+ using (var stream = Storage.GetStream(file_ipc_scores_filename))
+ using (var sr = new StreamReader(stream))
+ {
+ Score1.Value = int.Parse(sr.ReadLine());
+ Score2.Value = int.Parse(sr.ReadLine());
+ }
+ }
+ catch (Exception)
+ {
+ // file might be in use.
+ }
+ }, 250, true);
}
catch (Exception e)
{
Logger.Error(e, "Stable installation could not be found; disabling file based IPC");
- return;
}
- const string file_ipc_filename = "ipc.txt";
- const string file_ipc_state_filename = "ipc-state.txt";
- const string file_ipc_scores_filename = "ipc-scores.txt";
- const string file_ipc_channel_filename = "ipc-channel.txt";
-
- if (stable.Exists(file_ipc_filename))
- Scheduler.AddDelayed(delegate
- {
- try
- {
- using (var stream = stable.GetStream(file_ipc_filename))
- using (var sr = new StreamReader(stream))
- {
- var beatmapId = int.Parse(sr.ReadLine());
- var mods = int.Parse(sr.ReadLine());
-
- if (lastBeatmapId != beatmapId)
- {
- lastBeatmapId = beatmapId;
-
- var existing = ladder.CurrentMatch.Value?.Round.Value?.Beatmaps.FirstOrDefault(b => b.ID == beatmapId && b.BeatmapInfo != null);
-
- if (existing != null)
- Beatmap.Value = existing.BeatmapInfo;
- else
- {
- var req = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = beatmapId });
- req.Success += b => Beatmap.Value = b.ToBeatmap(Rulesets);
- API.Queue(req);
- }
- }
-
- Mods.Value = (LegacyMods)mods;
- }
- }
- catch
- {
- // file might be in use.
- }
-
- try
- {
- using (var stream = stable.GetStream(file_ipc_channel_filename))
- using (var sr = new StreamReader(stream))
- {
- ChatChannel.Value = sr.ReadLine();
- }
- }
- catch (Exception)
- {
- // file might be in use.
- }
-
- try
- {
- using (var stream = stable.GetStream(file_ipc_state_filename))
- using (var sr = new StreamReader(stream))
- {
- State.Value = (TourneyState)Enum.Parse(typeof(TourneyState), sr.ReadLine());
- }
- }
- catch (Exception)
- {
- // file might be in use.
- }
-
- try
- {
- using (var stream = stable.GetStream(file_ipc_scores_filename))
- using (var sr = new StreamReader(stream))
- {
- Score1.Value = int.Parse(sr.ReadLine());
- Score2.Value = int.Parse(sr.ReadLine());
- }
- }
- catch (Exception)
- {
- // file might be in use.
- }
- }, 250, true);
+ return Storage;
}
///
diff --git a/osu.Game.Tournament/Screens/SetupScreen.cs b/osu.Game.Tournament/Screens/SetupScreen.cs
new file mode 100644
index 0000000000..7a44e7a0e1
--- /dev/null
+++ b/osu.Game.Tournament/Screens/SetupScreen.cs
@@ -0,0 +1,142 @@
+// 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.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Online.API;
+using osu.Game.Overlays;
+using osu.Game.Screens.Edit.Setup.Components.LabelledComponents;
+using osu.Game.Tournament.IPC;
+using osuTK;
+using osuTK.Graphics;
+
+namespace osu.Game.Tournament.Screens
+{
+ public class SetupScreen : TournamentScreen, IProvideVideo
+ {
+ private FillFlowContainer fillFlow;
+
+ private LoginOverlay loginOverlay;
+
+ [Resolved]
+ private MatchIPCInfo ipc { get; set; }
+
+ [Resolved]
+ private IAPIProvider api { get; set; }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ InternalChild = fillFlow = new FillFlowContainer
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Direction = FillDirection.Vertical,
+ Padding = new MarginPadding(10),
+ Spacing = new Vector2(10),
+ };
+
+ api.LocalUser.BindValueChanged(_ => Schedule(reload));
+ reload();
+ }
+
+ private void reload()
+ {
+ var fileBasedIpc = ipc as FileBasedIPC;
+
+ fillFlow.Children = new Drawable[]
+ {
+ new ActionableInfo
+ {
+ Label = "Current IPC source",
+ ButtonText = "Refresh",
+ Action = () =>
+ {
+ fileBasedIpc?.LocateStableStorage();
+ reload();
+ },
+ Value = fileBasedIpc?.Storage?.GetFullPath(string.Empty) ?? "Not found",
+ Failing = fileBasedIpc?.Storage == null,
+ Description = "The osu!stable installation which is currently being used as a data source. If a source is not found, make sure you have created an empty ipc.txt in your stable cutting-edge installation, and that it is registered as the default osu! install."
+ },
+ new ActionableInfo
+ {
+ Label = "Current User",
+ ButtonText = "Change Login",
+ Action = () =>
+ {
+ api.Logout();
+
+ if (loginOverlay == null)
+ {
+ AddInternal(loginOverlay = new LoginOverlay
+ {
+ Anchor = Anchor.TopRight,
+ Origin = Anchor.TopRight,
+ });
+ }
+
+ loginOverlay.State.Value = Visibility.Visible;
+ },
+ Value = api?.LocalUser.Value.Username,
+ Failing = api?.IsLoggedIn != true,
+ Description = "In order to access the API and display metadata, a login is required."
+ }
+ };
+ }
+
+ private class ActionableInfo : LabelledComponent
+ {
+ private OsuButton button;
+
+ public ActionableInfo()
+ : base(true)
+ {
+ }
+
+ public string ButtonText
+ {
+ set => button.Text = value;
+ }
+
+ public string Value
+ {
+ set => valueText.Text = value;
+ }
+
+ public bool Failing
+ {
+ set => valueText.Colour = value ? Color4.Red : Color4.White;
+ }
+
+ public Action Action;
+
+ private OsuSpriteText valueText;
+
+ protected override Drawable CreateComponent() => new Container
+ {
+ AutoSizeAxes = Axes.Y,
+ RelativeSizeAxes = Axes.X,
+ Children = new Drawable[]
+ {
+ valueText = new OsuSpriteText
+ {
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
+ },
+ button = new TriangleButton
+ {
+ Anchor = Anchor.CentreRight,
+ Origin = Anchor.CentreRight,
+ Size = new Vector2(100, 30),
+ Action = () => Action?.Invoke()
+ },
+ }
+ };
+ }
+ }
+}
diff --git a/osu.Game.Tournament/TournamentSceneManager.cs b/osu.Game.Tournament/TournamentSceneManager.cs
index 4c255be463..02ee1c8603 100644
--- a/osu.Game.Tournament/TournamentSceneManager.cs
+++ b/osu.Game.Tournament/TournamentSceneManager.cs
@@ -69,6 +69,7 @@ namespace osu.Game.Tournament
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
+ new SetupScreen(),
new ScheduleScreen(),
new LadderScreen(),
new LadderEditorScreen(),
@@ -106,6 +107,8 @@ namespace osu.Game.Tournament
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
+ new OsuButton { RelativeSizeAxes = Axes.X, Text = "Setup", Action = () => SetScreen(typeof(SetupScreen)) },
+ new Container { RelativeSizeAxes = Axes.X, Height = 50 },
new OsuButton { RelativeSizeAxes = Axes.X, Text = "Team Editor", Action = () => SetScreen(typeof(TeamEditorScreen)) },
new OsuButton { RelativeSizeAxes = Axes.X, Text = "Rounds Editor", Action = () => SetScreen(typeof(RoundEditorScreen)) },
new OsuButton { RelativeSizeAxes = Axes.X, Text = "Bracket Editor", Action = () => SetScreen(typeof(LadderEditorScreen)) },
@@ -127,7 +130,7 @@ namespace osu.Game.Tournament
},
};
- SetScreen(typeof(ScheduleScreen));
+ SetScreen(typeof(SetupScreen));
}
public void SetScreen(Type screenType)
diff --git a/osu.Game.Tournament/osu.Game.Tournament.csproj b/osu.Game.Tournament/osu.Game.Tournament.csproj
index 4790fcbcde..bddaff0a80 100644
--- a/osu.Game.Tournament/osu.Game.Tournament.csproj
+++ b/osu.Game.Tournament/osu.Game.Tournament.csproj
@@ -11,6 +11,6 @@
-
+
\ No newline at end of file
diff --git a/osu.Game/Overlays/Changelog/ChangelogBuild.cs b/osu.Game/Overlays/Changelog/ChangelogBuild.cs
index 11dc2049fd..bce1be5941 100644
--- a/osu.Game/Overlays/Changelog/ChangelogBuild.cs
+++ b/osu.Game/Overlays/Changelog/ChangelogBuild.cs
@@ -15,6 +15,7 @@ using osu.Game.Users;
using osuTK.Graphics;
using osu.Framework.Allocation;
using System.Net;
+using osuTK;
namespace osu.Game.Overlays.Changelog
{
@@ -67,22 +68,34 @@ namespace osu.Game.Overlays.Changelog
foreach (APIChangelogEntry entry in categoryEntries)
{
- LinkFlowContainer title = new LinkFlowContainer
- {
- Direction = FillDirection.Full,
- RelativeSizeAxes = Axes.X,
- AutoSizeAxes = Axes.Y,
- Margin = new MarginPadding { Vertical = 5 },
- };
-
var entryColour = entry.Major ? colours.YellowLight : Color4.White;
- title.AddIcon(entry.Type == ChangelogEntryType.Fix ? FontAwesome.Solid.Check : FontAwesome.Solid.Plus, t =>
+ LinkFlowContainer title;
+
+ Container titleContainer = new Container
{
- t.Font = fontSmall;
- t.Colour = entryColour;
- t.Padding = new MarginPadding { Left = -17, Right = 5 };
- });
+ AutoSizeAxes = Axes.Y,
+ RelativeSizeAxes = Axes.X,
+ Margin = new MarginPadding { Vertical = 5 },
+ Children = new Drawable[]
+ {
+ new SpriteIcon
+ {
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreRight,
+ Size = new Vector2(fontSmall.Size),
+ Icon = entry.Type == ChangelogEntryType.Fix ? FontAwesome.Solid.Check : FontAwesome.Solid.Plus,
+ Colour = entryColour,
+ Margin = new MarginPadding { Right = 5 },
+ },
+ title = new LinkFlowContainer
+ {
+ Direction = FillDirection.Full,
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ }
+ }
+ };
title.AddText(entry.Title, t =>
{
@@ -139,7 +152,7 @@ namespace osu.Game.Overlays.Changelog
t.Colour = entryColour;
});
- ChangelogEntries.Add(title);
+ ChangelogEntries.Add(titleContainer);
if (!string.IsNullOrEmpty(entry.MessageHtml))
{
diff --git a/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs b/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs
index 66fec1ecf9..b02b1a5489 100644
--- a/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs
+++ b/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs
@@ -67,7 +67,7 @@ namespace osu.Game.Overlays.Settings.Sections.General
api?.Register(this);
}
- public void APIStateChanged(IAPIProvider api, APIState state)
+ public void APIStateChanged(IAPIProvider api, APIState state) => Schedule(() =>
{
form = null;
@@ -184,7 +184,7 @@ namespace osu.Game.Overlays.Settings.Sections.General
}
if (form != null) GetContainingInputManager()?.ChangeFocus(form);
- }
+ });
public override bool AcceptsFocus => true;
diff --git a/osu.Game/Screens/Edit/Setup/Components/LabelledComponents/LabelledComponent.cs b/osu.Game/Screens/Edit/Setup/Components/LabelledComponents/LabelledComponent.cs
index 19e9c329d6..770065cb0e 100644
--- a/osu.Game/Screens/Edit/Setup/Components/LabelledComponents/LabelledComponent.cs
+++ b/osu.Game/Screens/Edit/Setup/Components/LabelledComponents/LabelledComponent.cs
@@ -11,7 +11,8 @@ using osuTK;
namespace osu.Game.Screens.Edit.Setup.Components.LabelledComponents
{
- public abstract class LabelledComponent : CompositeDrawable
+ public abstract class LabelledComponent : CompositeDrawable
+ where T : Drawable
{
protected const float CONTENT_PADDING_VERTICAL = 10;
protected const float CONTENT_PADDING_HORIZONTAL = 15;
@@ -20,15 +21,15 @@ namespace osu.Game.Screens.Edit.Setup.Components.LabelledComponents
///
/// The component that is being displayed.
///
- protected readonly Drawable Component;
+ protected readonly T Component;
private readonly OsuTextFlowContainer labelText;
private readonly OsuTextFlowContainer descriptionText;
///
- /// Creates a new .
+ /// Creates a new .
///
- /// Whether the component should be padded or should be expanded to the bounds of this .
+ /// Whether the component should be padded or should be expanded to the bounds of this .
protected LabelledComponent(bool padded)
{
RelativeSizeAxes = Axes.X;
@@ -127,6 +128,6 @@ namespace osu.Game.Screens.Edit.Setup.Components.LabelledComponents
/// Creates the component that should be displayed.
///
/// The component.
- protected abstract Drawable CreateComponent();
+ protected abstract T CreateComponent();
}
}
diff --git a/osu.Game/Screens/Menu/Button.cs b/osu.Game/Screens/Menu/Button.cs
index 1bf25a2504..ffeadb96c7 100644
--- a/osu.Game/Screens/Menu/Button.cs
+++ b/osu.Game/Screens/Menu/Button.cs
@@ -31,6 +31,8 @@ namespace osu.Game.Screens.Menu
{
public event Action StateChanged;
+ public readonly Key TriggerKey;
+
private readonly Container iconText;
private readonly Container box;
private readonly Box boxHoverLayer;
@@ -43,7 +45,6 @@ namespace osu.Game.Screens.Menu
public ButtonSystemState VisibleState = ButtonSystemState.TopLevel;
private readonly Action clickAction;
- private readonly Key triggerKey;
private SampleChannel sampleClick;
private SampleChannel sampleHover;
@@ -53,7 +54,7 @@ namespace osu.Game.Screens.Menu
{
this.sampleName = sampleName;
this.clickAction = clickAction;
- this.triggerKey = triggerKey;
+ TriggerKey = triggerKey;
AutoSizeAxes = Axes.Both;
Alpha = 0;
@@ -210,7 +211,7 @@ namespace osu.Game.Screens.Menu
if (e.Repeat || e.ControlPressed || e.ShiftPressed || e.AltPressed)
return false;
- if (triggerKey == e.Key && triggerKey != Key.Unknown)
+ if (TriggerKey == e.Key && TriggerKey != Key.Unknown)
{
trigger();
return true;
diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs
index 1a3e1213b4..ed8e4c70f9 100644
--- a/osu.Game/Screens/Menu/ButtonSystem.cs
+++ b/osu.Game/Screens/Menu/ButtonSystem.cs
@@ -14,6 +14,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Bindings;
+using osu.Framework.Input.Events;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Threading;
@@ -180,6 +181,20 @@ namespace osu.Game.Screens.Menu
State = ButtonSystemState.Initial;
}
+ protected override bool OnKeyDown(KeyDownEvent e)
+ {
+ if (State == ButtonSystemState.Initial)
+ {
+ if (buttonsTopLevel.Any(b => e.Key == b.TriggerKey))
+ {
+ logo?.Click();
+ return true;
+ }
+ }
+
+ return base.OnKeyDown(e);
+ }
+
public bool OnPressed(GlobalAction action)
{
switch (action)
diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs
index 9cc84c8bdd..6c3c9d20f3 100644
--- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs
+++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs
@@ -39,6 +39,10 @@ namespace osu.Game.Screens.Select.Carousel
match &= criteria.BeatDivisor.IsInRange(Beatmap.BeatDivisor);
match &= criteria.OnlineStatus.IsInRange(Beatmap.Status);
+ match &= criteria.Creator.Matches(Beatmap.Metadata.AuthorString);
+ match &= criteria.Artist.Matches(Beatmap.Metadata.Artist) ||
+ criteria.Artist.Matches(Beatmap.Metadata.ArtistUnicode);
+
if (match)
foreach (var criteriaTerm in criteria.SearchTerms)
match &=
diff --git a/osu.Game/Screens/Select/FilterControl.cs b/osu.Game/Screens/Select/FilterControl.cs
index e3c23f7e22..91f1ca0307 100644
--- a/osu.Game/Screens/Select/FilterControl.cs
+++ b/osu.Game/Screens/Select/FilterControl.cs
@@ -16,8 +16,6 @@ using Container = osu.Framework.Graphics.Containers.Container;
using osu.Framework.Graphics.Shapes;
using osu.Game.Configuration;
using osu.Game.Rulesets;
-using System.Text.RegularExpressions;
-using osu.Game.Beatmaps;
namespace osu.Game.Screens.Select
{
@@ -47,10 +45,7 @@ namespace osu.Game.Screens.Select
Ruleset = ruleset.Value
};
- applyQueries(criteria, ref query);
-
- criteria.SearchText = query;
-
+ FilterQueryParser.ApplyQueries(criteria, query);
return criteria;
}
@@ -181,129 +176,5 @@ namespace osu.Game.Screens.Select
}
private void updateCriteria() => FilterChanged?.Invoke(CreateCriteria());
-
- private static readonly Regex query_syntax_regex = new Regex(
- @"\b(?stars|ar|dr|cs|divisor|length|objects|bpm|status)(?[=:><]+)(?\S*)",
- RegexOptions.Compiled | RegexOptions.IgnoreCase);
-
- private void applyQueries(FilterCriteria criteria, ref string query)
- {
- foreach (Match match in query_syntax_regex.Matches(query))
- {
- var key = match.Groups["key"].Value.ToLower();
- var op = match.Groups["op"].Value;
- var value = match.Groups["value"].Value;
-
- switch (key)
- {
- case "stars" when float.TryParse(value, out var stars):
- updateCriteriaRange(ref criteria.StarDifficulty, op, stars);
- break;
-
- case "ar" when float.TryParse(value, out var ar):
- updateCriteriaRange(ref criteria.ApproachRate, op, ar);
- break;
-
- case "dr" when float.TryParse(value, out var dr):
- updateCriteriaRange(ref criteria.DrainRate, op, dr);
- break;
-
- case "cs" when float.TryParse(value, out var cs):
- updateCriteriaRange(ref criteria.CircleSize, op, cs);
- break;
-
- case "bpm" when double.TryParse(value, out var bpm):
- updateCriteriaRange(ref criteria.BPM, op, bpm);
- break;
-
- case "length" when double.TryParse(value.TrimEnd('m', 's', 'h'), out var length):
- var scale =
- value.EndsWith("ms") ? 1 :
- value.EndsWith("s") ? 1000 :
- value.EndsWith("m") ? 60000 :
- value.EndsWith("h") ? 3600000 : 1000;
-
- updateCriteriaRange(ref criteria.Length, op, length * scale, scale / 2.0);
- break;
-
- case "divisor" when int.TryParse(value, out var divisor):
- updateCriteriaRange(ref criteria.BeatDivisor, op, divisor);
- break;
-
- case "status" when Enum.TryParse(value, true, out var statusValue):
- updateCriteriaRange(ref criteria.OnlineStatus, op, statusValue);
- break;
- }
-
- query = query.Replace(match.ToString(), "");
- }
- }
-
- private void updateCriteriaRange(ref FilterCriteria.OptionalRange range, string op, float value, float tolerance = 0.05f)
- {
- updateCriteriaRange(ref range, op, value);
-
- switch (op)
- {
- case "=":
- case ":":
- range.Min = value - tolerance;
- range.Max = value + tolerance;
- break;
- }
- }
-
- private void updateCriteriaRange(ref FilterCriteria.OptionalRange range, string op, double value, double tolerance = 0.05)
- {
- updateCriteriaRange(ref range, op, value);
-
- switch (op)
- {
- case "=":
- case ":":
- range.Min = value - tolerance;
- range.Max = value + tolerance;
- break;
- }
- }
-
- private void updateCriteriaRange(ref FilterCriteria.OptionalRange range, string op, T value)
- where T : struct, IComparable
- {
- switch (op)
- {
- default:
- return;
-
- case "=":
- case ":":
- range.IsInclusive = true;
- range.Min = value;
- range.Max = value;
- break;
-
- case ">":
- range.IsInclusive = false;
- range.Min = value;
- break;
-
- case ">=":
- case ">:":
- range.IsInclusive = true;
- range.Min = value;
- break;
-
- case "<":
- range.IsInclusive = false;
- range.Max = value;
- break;
-
- case "<=":
- case "<:":
- range.IsInclusive = true;
- range.Max = value;
- break;
- }
- }
}
}
diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs
index a3fa1b10ca..c2cbac905e 100644
--- a/osu.Game/Screens/Select/FilterCriteria.cs
+++ b/osu.Game/Screens/Select/FilterCriteria.cs
@@ -23,6 +23,8 @@ namespace osu.Game.Screens.Select
public OptionalRange BPM;
public OptionalRange BeatDivisor;
public OptionalRange OnlineStatus;
+ public OptionalTextFilter Creator;
+ public OptionalTextFilter Artist;
public string[] SearchTerms = Array.Empty();
@@ -53,7 +55,7 @@ namespace osu.Game.Screens.Select
if (comparison < 0)
return false;
- if (comparison == 0 && !IsInclusive)
+ if (comparison == 0 && !IsLowerInclusive)
return false;
}
@@ -64,7 +66,7 @@ namespace osu.Game.Screens.Select
if (comparison > 0)
return false;
- if (comparison == 0 && !IsInclusive)
+ if (comparison == 0 && !IsUpperInclusive)
return false;
}
@@ -73,12 +75,33 @@ namespace osu.Game.Screens.Select
public T? Min;
public T? Max;
- public bool IsInclusive;
+ public bool IsLowerInclusive;
+ public bool IsUpperInclusive;
public bool Equals(OptionalRange other)
=> Min.Equals(other.Min)
&& Max.Equals(other.Max)
- && IsInclusive.Equals(other.IsInclusive);
+ && IsLowerInclusive.Equals(other.IsLowerInclusive)
+ && IsUpperInclusive.Equals(other.IsUpperInclusive);
+ }
+
+ public struct OptionalTextFilter : IEquatable
+ {
+ public bool Matches(string value)
+ {
+ if (string.IsNullOrEmpty(SearchTerm))
+ return true;
+
+ // search term is guaranteed to be non-empty, so if the string we're comparing is empty, it's not matching
+ if (string.IsNullOrEmpty(value))
+ return false;
+
+ return value.IndexOf(SearchTerm, StringComparison.InvariantCultureIgnoreCase) >= 0;
+ }
+
+ public string SearchTerm;
+
+ public bool Equals(OptionalTextFilter other) => SearchTerm?.Equals(other.SearchTerm) ?? true;
}
}
}
diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs
new file mode 100644
index 0000000000..ffe1258168
--- /dev/null
+++ b/osu.Game/Screens/Select/FilterQueryParser.cs
@@ -0,0 +1,211 @@
+// 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.Globalization;
+using System.Text.RegularExpressions;
+using osu.Game.Beatmaps;
+
+namespace osu.Game.Screens.Select
+{
+ internal static class FilterQueryParser
+ {
+ private static readonly Regex query_syntax_regex = new Regex(
+ @"\b(?stars|ar|dr|cs|divisor|length|objects|bpm|status|creator|artist)(?[=:><]+)(?("".*"")|(\S*))",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ internal static void ApplyQueries(FilterCriteria criteria, string query)
+ {
+ foreach (Match match in query_syntax_regex.Matches(query))
+ {
+ var key = match.Groups["key"].Value.ToLower();
+ var op = match.Groups["op"].Value;
+ var value = match.Groups["value"].Value;
+
+ parseKeywordCriteria(criteria, key, value, op);
+
+ query = query.Replace(match.ToString(), "");
+ }
+
+ criteria.SearchText = query;
+ }
+
+ private static void parseKeywordCriteria(FilterCriteria criteria, string key, string value, string op)
+ {
+ switch (key)
+ {
+ case "stars" when parseFloatWithPoint(value, out var stars):
+ updateCriteriaRange(ref criteria.StarDifficulty, op, stars, 0.01f / 2);
+ break;
+
+ case "ar" when parseFloatWithPoint(value, out var ar):
+ updateCriteriaRange(ref criteria.ApproachRate, op, ar, 0.1f / 2);
+ break;
+
+ case "dr" when parseFloatWithPoint(value, out var dr):
+ updateCriteriaRange(ref criteria.DrainRate, op, dr, 0.1f / 2);
+ break;
+
+ case "cs" when parseFloatWithPoint(value, out var cs):
+ updateCriteriaRange(ref criteria.CircleSize, op, cs, 0.1f / 2);
+ break;
+
+ case "bpm" when parseDoubleWithPoint(value, out var bpm):
+ updateCriteriaRange(ref criteria.BPM, op, bpm, 0.01d / 2);
+ break;
+
+ case "length" when parseDoubleWithPoint(value.TrimEnd('m', 's', 'h'), out var length):
+ var scale = getLengthScale(value);
+ updateCriteriaRange(ref criteria.Length, op, length * scale, scale / 2.0);
+ break;
+
+ case "divisor" when parseInt(value, out var divisor):
+ updateCriteriaRange(ref criteria.BeatDivisor, op, divisor);
+ break;
+
+ case "status" when Enum.TryParse(value, true, out var statusValue):
+ updateCriteriaRange(ref criteria.OnlineStatus, op, statusValue);
+ break;
+
+ case "creator":
+ updateCriteriaText(ref criteria.Creator, op, value);
+ break;
+
+ case "artist":
+ updateCriteriaText(ref criteria.Artist, op, value);
+ break;
+ }
+ }
+
+ private static int getLengthScale(string value) =>
+ value.EndsWith("ms") ? 1 :
+ value.EndsWith("s") ? 1000 :
+ value.EndsWith("m") ? 60000 :
+ value.EndsWith("h") ? 3600000 : 1000;
+
+ private static bool parseFloatWithPoint(string value, out float result) =>
+ float.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out result);
+
+ private static bool parseDoubleWithPoint(string value, out double result) =>
+ double.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out result);
+
+ private static bool parseInt(string value, out int result) =>
+ int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out result);
+
+ private static void updateCriteriaText(ref FilterCriteria.OptionalTextFilter textFilter, string op, string value)
+ {
+ switch (op)
+ {
+ case "=":
+ case ":":
+ textFilter.SearchTerm = value.Trim('"');
+ break;
+ }
+ }
+
+ private static void updateCriteriaRange(ref FilterCriteria.OptionalRange range, string op, float value, float tolerance = 0.05f)
+ {
+ switch (op)
+ {
+ default:
+ return;
+
+ case "=":
+ case ":":
+ range.Min = value - tolerance;
+ range.Max = value + tolerance;
+ break;
+
+ case ">":
+ range.Min = value + tolerance;
+ break;
+
+ case ">=":
+ case ">:":
+ range.Min = value - tolerance;
+ break;
+
+ case "<":
+ range.Max = value - tolerance;
+ break;
+
+ case "<=":
+ case "<:":
+ range.Max = value + tolerance;
+ break;
+ }
+ }
+
+ private static void updateCriteriaRange(ref FilterCriteria.OptionalRange range, string op, double value, double tolerance = 0.05)
+ {
+ switch (op)
+ {
+ default:
+ return;
+
+ case "=":
+ case ":":
+ range.Min = value - tolerance;
+ range.Max = value + tolerance;
+ break;
+
+ case ">":
+ range.Min = value + tolerance;
+ break;
+
+ case ">=":
+ case ">:":
+ range.Min = value - tolerance;
+ break;
+
+ case "<":
+ range.Max = value - tolerance;
+ break;
+
+ case "<=":
+ case "<:":
+ range.Max = value + tolerance;
+ break;
+ }
+ }
+
+ private static void updateCriteriaRange(ref FilterCriteria.OptionalRange range, string op, T value)
+ where T : struct, IComparable
+ {
+ switch (op)
+ {
+ default:
+ return;
+
+ case "=":
+ case ":":
+ range.IsLowerInclusive = range.IsUpperInclusive = true;
+ range.Min = value;
+ range.Max = value;
+ break;
+
+ case ">":
+ range.IsLowerInclusive = false;
+ range.Min = value;
+ break;
+
+ case ">=":
+ case ">:":
+ range.IsLowerInclusive = true;
+ range.Min = value;
+ break;
+
+ case "<":
+ range.IsUpperInclusive = false;
+ range.Max = value;
+ break;
+
+ case "<=":
+ case "<:":
+ range.IsUpperInclusive = true;
+ range.Max = value;
+ break;
+ }
+ }
+ }
+}
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index a27a94b8f9..a699217503 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -30,6 +30,6 @@
-
+
diff --git a/osu.iOS.props b/osu.iOS.props
index a6516e6d1b..7803ea1e49 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -123,7 +123,7 @@
-
+