1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-15 11:23:04 +08:00

Compare commits

...

79 Commits

205 changed files with 4176 additions and 951 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.416.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.513.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.
+18 -7
View File
@@ -20,13 +20,24 @@ using Uri = Android.Net.Uri;
namespace osu.Android
{
[Activity(ConfigurationChanges = DEFAULT_CONFIG_CHANGES, Exported = true, LaunchMode = DEFAULT_LAUNCH_MODE, MainLauncher = true)]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osz", DataHost = "*", DataMimeType = "*/*")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osk", DataHost = "*", DataMimeType = "*/*")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osr", DataHost = "*", DataMimeType = "*/*")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-beatmap-archive")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-skin-archive")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-replay")]
[IntentFilter(new[] { Intent.ActionSend, Intent.ActionSendMultiple }, Categories = new[] { Intent.CategoryDefault }, DataMimeTypes = new[]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, Label = "Import beatmap", DataScheme = "content", DataPathPattern = ".*\\\\.osz", DataHost = "*",
DataMimeType = "*/*")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, Label = "Import skin", DataScheme = "content", DataPathPattern = ".*\\\\.osk", DataHost = "*",
DataMimeType = "*/*")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, Label = "Import replay", DataScheme = "content", DataPathPattern = ".*\\\\.osr", DataHost = "*",
DataMimeType = "*/*")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, Label = "Import beatmap", DataScheme = "content", DataMimeType = "application/x-osu-beatmap-archive")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, Label = "Import skin", DataScheme = "content", DataMimeType = "application/x-osu-skin-archive")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, Label = "Import replay", DataScheme = "content", DataMimeType = "application/x-osu-replay")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, Label = "Import file", DataScheme = "content", DataMimeTypes = new[]
{
"application/zip",
"application/octet-stream",
"application/download",
"application/x-zip",
"application/x-zip-compressed",
})]
[IntentFilter(new[] { Intent.ActionSend, Intent.ActionSendMultiple }, Categories = new[] { Intent.CategoryDefault }, Label = "Import", DataMimeTypes = new[]
{
"application/zip",
"application/octet-stream",
+1 -1
View File
@@ -190,7 +190,7 @@ namespace osu.Desktop
}
// user party
if (!hideIdentifiableInformation && multiplayerClient.Room != null && multiplayerClient.Room.Settings.MatchType != MatchType.Matchmaking)
if (!hideIdentifiableInformation && multiplayerClient.Room != null && !multiplayerClient.Room.Settings.MatchType.IsMatchmakingType())
{
MultiplayerRoom room = multiplayerClient.Room;
@@ -0,0 +1,13 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using Newtonsoft.Json;
namespace osu.Desktop.IPC.Messages
{
public class HitCountMessage : OsuWebSocketMessage
{
[JsonProperty("new_hits")]
public long NewHits { get; init; }
}
}
@@ -0,0 +1,19 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using Newtonsoft.Json;
using osu.Framework.Extensions.TypeExtensions;
namespace osu.Desktop.IPC.Messages
{
public abstract class OsuWebSocketMessage
{
[JsonProperty("type")]
public string Type { get; }
protected OsuWebSocketMessage()
{
Type = GetType().ReadableName();
}
}
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using System.Threading;
using osu.Desktop.IPC.Messages;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Game.Configuration;
using osu.Game.IPC;
using osu.Game.Online.Multiplayer;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using JsonConvert = Newtonsoft.Json.JsonConvert;
namespace osu.Desktop.IPC
{
public partial class OsuWebSocketProvider : Component
{
private WebSocketServer? server;
private readonly Bindable<ScoreInfo> lastLocalScore = new Bindable<ScoreInfo>();
[BackgroundDependencyLoader]
private void load(SessionStatics sessionStatics)
{
server = new WebSocketServer(49727);
server.StartAsync().FireAndForget(onError: ex => Logger.Error(ex, "Failed to start websocket"));
sessionStatics.BindWith(Static.LastLocalUserScore, lastLocalScore);
}
protected override void LoadComplete()
{
base.LoadComplete();
lastLocalScore.BindValueChanged(val =>
{
if (val.NewValue == null)
return;
if (server?.IsRunning != true)
return;
var msg = new HitCountMessage { NewHits = val.NewValue.Statistics.Where(kv => kv.Key.IsBasic() && kv.Key.IsHit()).Sum(kv => kv.Value) };
broadcast(msg);
});
}
private void broadcast(OsuWebSocketMessage message)
{
if (server?.IsRunning != true)
return;
string messageString = JsonConvert.SerializeObject(message);
server.BroadcastAsync(messageString).FireAndForget();
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (server?.IsRunning == true)
{
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(10));
server.StopAsync(cts.Token).WaitSafely();
server = null;
}
}
}
}
+6
View File
@@ -6,6 +6,7 @@ using System.IO;
using System.Reflection;
using System.Runtime.Versioning;
using Microsoft.Win32;
using osu.Desktop.IPC;
using osu.Desktop.Performance;
using osu.Desktop.Security;
using osu.Framework.Platform;
@@ -35,6 +36,8 @@ namespace osu.Desktop
public bool IsFirstRun { get; init; }
public bool EnableWebSocketServer { get; init; }
public OsuGameDesktop(string[]? args = null)
: base(args)
{
@@ -148,6 +151,9 @@ namespace osu.Desktop
osuSchemeLinkIPCChannel = new OsuSchemeLinkIPCChannel(Host, this);
archiveImportIPCChannel = new ArchiveImportIPCChannel(Host, this);
if (EnableWebSocketServer)
Add(new OsuWebSocketProvider());
}
public override void SetHost(GameHost host)
+2 -1
View File
@@ -140,7 +140,8 @@ namespace osu.Desktop
{
host.Run(new OsuGameDesktop(args)
{
IsFirstRun = isFirstRun
IsFirstRun = isFirstRun,
EnableWebSocketServer = Environment.GetEnvironmentVariable("OSU_WEBSOCKET_SERVER") == "1",
});
}
}
+2 -1
View File
@@ -25,7 +25,8 @@
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="System.IO.Packaging" Version="10.0.5" />
<PackageReference Include="DiscordRichPresence" Version="1.6.1.70" />
<!-- Held back due to invite bug in newer versions. See https://github.com/Lachee/discord-rpc-csharp/issues/286-->
<PackageReference Include="DiscordRichPresence" Version="1.5.0.51" />
<PackageReference Include="Velopack" Version="0.0.1298" />
</ItemGroup>
<ItemGroup Label="Resources">
@@ -14,6 +14,9 @@ namespace osu.Game.Rulesets.Catch.Edit
{
private readonly List<ICheck> checks = new List<ICheck>
{
// Audio
new CheckCatchFewHitsounds(),
// Compose
new CheckBananaShowerGap(),
new CheckConcurrentObjects(),
@@ -0,0 +1,14 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Catch.Objects;
namespace osu.Game.Rulesets.Catch.Edit.Checks
{
public class CheckCatchFewHitsounds : CheckFewHitsounds
{
protected override bool IsExcludedFromHitsounding(HitObject hitObject) => hitObject is BananaShower;
}
}
@@ -117,6 +117,15 @@ namespace osu.Game.Rulesets.Catch.Edit.Setup
Beatmap.Difficulty.CircleSize = circleSizeSlider.Current.Value;
Beatmap.Difficulty.DrainRate = healthDrainSlider.Current.Value;
Beatmap.Difficulty.ApproachRate = approachRateSlider.Current.Value;
// in lazer catch, Overall Difficulty does *nothing* - as it should be in a sane world.
// in stable, it does *one extremely specific thing* which is influence the infamous `difficultyPeppyStars`
// which in turn affects score V1 (see `LegacyRulesetExtensions.CalculateDifficultyPeppyStars()`).
// there is a Ranking Criteria rule saying that Overall Difficulty and Approach Rate should match:
// https://osu.ppy.sh/wiki/en/Ranking_criteria/osu!catch
// the one case wherein that breaks stable is on some marathon maps;
// on those setting Overall Difficulty too high can lead to score V1 exceeding 32 bits ("score overflow").
// that case can be manually handled by mappers.
Beatmap.Difficulty.OverallDifficulty = approachRateSlider.Current.Value;
Beatmap.Difficulty.SliderMultiplier = baseVelocitySlider.Current.Value;
Beatmap.Difficulty.SliderTickRate = tickRateSlider.Current.Value;
@@ -20,10 +20,10 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
[Test]
public void TestKeyCountChange()
{
FormSliderBar<float> keyCount = null!;
FormSliderBar<int> keyCount = null!;
AddStep("go to setup screen", () => InputManager.Key(Key.F4));
AddUntilStep("retrieve key count slider", () => keyCount = Editor.ChildrenOfType<SetupScreen>().Single().ChildrenOfType<FormSliderBar<float>>().First(), () => Is.Not.Null);
AddUntilStep("retrieve key count slider", () => keyCount = Editor.ChildrenOfType<SetupScreen>().Single().ChildrenOfType<FormSliderBar<int>>().First(), () => Is.Not.Null);
AddAssert("key count is 5", () => keyCount.Current.Value, () => Is.EqualTo(5));
AddStep("change key count to 8", () =>
{
@@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
});
AddUntilStep("dialog visible", () => Game.ChildrenOfType<IDialogOverlay>().SingleOrDefault()?.CurrentDialog, Is.InstanceOf<SaveAndReloadEditorDialog>);
AddStep("refuse", () => InputManager.Key(Key.Number2));
AddAssert("key count is 5", () => keyCount.Current.Value, () => Is.EqualTo(5));
AddUntilStep("key count is 5", () => keyCount.Current.Value, () => Is.EqualTo(5));
AddStep("change key count to 8 again", () =>
{
@@ -41,5 +41,32 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
AddStep("acquiesce", () => InputManager.Key(Key.Number1));
AddUntilStep("beatmap became 8K", () => Game.Beatmap.Value.BeatmapInfo.Difficulty.CircleSize, () => Is.EqualTo(8));
}
[Test]
public void TestDualStagesChange()
{
FormCheckBox dualStages = null!;
FormSliderBar<int> keyCount = null!;
AddStep("go to setup screen", () => InputManager.Key(Key.F4));
AddUntilStep("retrieve dual stages checkbox", () => dualStages = Editor.ChildrenOfType<SetupScreen>().Single().ChildrenOfType<FormCheckBox>().First(), () => Is.Not.Null);
AddUntilStep("retrieve key count slider", () => keyCount = Editor.ChildrenOfType<SetupScreen>().Single().ChildrenOfType<FormSliderBar<int>>().First(), () => Is.Not.Null);
AddAssert("key count is 5", () => keyCount.Current.Value, () => Is.EqualTo(5));
AddStep("set dual stages", () =>
{
dualStages.Current.Value = true;
});
AddUntilStep("dialog visible", () => Game.ChildrenOfType<IDialogOverlay>().SingleOrDefault()?.CurrentDialog, Is.InstanceOf<SaveAndReloadEditorDialog>);
AddStep("refuse", () => InputManager.Key(Key.Number2));
AddUntilStep("key count is 5", () => keyCount.Current.Value, () => Is.EqualTo(5));
AddStep("set dual stages again", () =>
{
dualStages.Current.Value = true;
});
AddUntilStep("dialog visible", () => Game.ChildrenOfType<IDialogOverlay>().Single().CurrentDialog, Is.InstanceOf<SaveAndReloadEditorDialog>);
AddStep("acquiesce", () => InputManager.Key(Key.Number1));
AddUntilStep("beatmap became 12K", () => Game.Beatmap.Value.BeatmapInfo.Difficulty.CircleSize, () => Is.EqualTo(12));
}
}
}
@@ -57,6 +57,28 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
AddAssert("time is unchanged", () => EditorClock.CurrentTime, () => Is.EqualTo(initialTime));
}
[Test]
public void TestNoTwoObjectsAtSameTimeAndColumn()
{
AddStep("change seek setting to false", () => config.SetValue(OsuSetting.EditorAutoSeekOnPlacement, false));
AddStep("clear beatmap", () => EditorBeatmap.Clear());
AddStep("select note placement tool", () => InputManager.Key(Key.Number2));
AddStep("move mouse to centre of last column", () => InputManager.MoveMouseTo(this.ChildrenOfType<Column>().Last().ScreenSpaceDrawQuad.Centre));
AddStep("place note", () => InputManager.Click(MouseButton.Left));
AddAssert("beatmap has 1 object", () => EditorBeatmap.HitObjects, () => Has.Count.EqualTo(1));
AddStep("select note placement tool", () => InputManager.Key(Key.Number2));
AddStep("move mouse to centre of first column", () => InputManager.MoveMouseTo(this.ChildrenOfType<Column>().First().ScreenSpaceDrawQuad.Centre));
AddStep("place note", () => InputManager.Click(MouseButton.Left));
AddAssert("beatmap has 2 objects", () => EditorBeatmap.HitObjects, () => Has.Count.EqualTo(2));
AddStep("select note placement tool", () => InputManager.Key(Key.Number2));
AddStep("move mouse to centre of last column", () => InputManager.MoveMouseTo(this.ChildrenOfType<Column>().Last().ScreenSpaceDrawQuad.Centre));
AddStep("place note", () => InputManager.Click(MouseButton.Left));
AddAssert("beatmap has 2 objects", () => EditorBeatmap.HitObjects, () => Has.Count.EqualTo(2));
}
private void placeObject()
{
AddStep("select note placement tool", () => InputManager.Key(Key.Number2));
@@ -7,6 +7,7 @@ using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Localisation;
using osu.Game.Resources.Localisation.Web;
@@ -19,13 +20,22 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
{
public override LocalisableString Title => EditorSetupStrings.DifficultyHeader;
private FormSliderBar<float> keyCountSlider { get; set; } = null!;
private FormSliderBar<int> keyCountSlider { get; set; } = null!;
private FormCheckBox dualStages { get; set; } = null!;
private FormCheckBox specialStyle { get; set; } = null!;
private FormSliderBar<float> healthDrainSlider { get; set; } = null!;
private FormSliderBar<float> overallDifficultySlider { get; set; } = null!;
private FormSliderBar<double> baseVelocitySlider { get; set; } = null!;
private FormSliderBar<double> tickRateSlider { get; set; } = null!;
private readonly BindableInt singleStageKeyCount = new BindableInt
{
Default = (int)BeatmapDifficulty.DEFAULT_DIFFICULTY,
Precision = 1,
};
private readonly BindableInt actualKeyCount = new BindableInt();
[Resolved]
private Editor? editor { get; set; }
@@ -37,20 +47,19 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
{
Children = new Drawable[]
{
keyCountSlider = new FormSliderBar<float>
keyCountSlider = new FormSliderBar<int>
{
Caption = BeatmapsetsStrings.ShowStatsCsMania,
HintText = "The number of columns in the beatmap",
Current = new BindableFloat(Beatmap.Difficulty.CircleSize)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 1,
},
Current = singleStageKeyCount,
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
dualStages = new FormCheckBox
{
Caption = "Dual stages",
HintText = "Doubles the number of keys by adding a second stage."
},
specialStyle = new FormCheckBox
{
Caption = "Use special (N+1) style",
@@ -117,16 +126,54 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
},
};
keyCountSlider.Current.BindValueChanged(updateKeyCount);
setStateFromActualKeyCount((int)Beatmap.Difficulty.CircleSize);
keyCountSlider.Current.BindValueChanged(_ => calculateActualKeyCount());
dualStages.Current.BindValueChanged(_ =>
{
updateSingleStageKeyCountBounds();
calculateActualKeyCount();
});
actualKeyCount.BindValueChanged(updateKeyCount);
healthDrainSlider.Current.BindValueChanged(_ => updateValues());
overallDifficultySlider.Current.BindValueChanged(_ => updateValues());
baseVelocitySlider.Current.BindValueChanged(_ => updateValues());
tickRateSlider.Current.BindValueChanged(_ => updateValues());
}
private void updateSingleStageKeyCountBounds()
{
singleStageKeyCount.MinValue = dualStages.Current.Value ? ManiaRuleset.MAX_STAGE_KEYS / 2 + 1 : 1;
singleStageKeyCount.MaxValue = dualStages.Current.Value ? LegacyBeatmapDecoder.MAX_MANIA_KEY_COUNT / 2 : ManiaRuleset.MAX_STAGE_KEYS;
}
private void setStateFromActualKeyCount(int keyCount)
{
actualKeyCount.Value = keyCount;
if (keyCount > 10)
{
dualStages.Current.Value = true;
singleStageKeyCount.Value = keyCount / 2;
}
else
{
dualStages.Current.Value = false;
singleStageKeyCount.Value = keyCount;
}
updateSingleStageKeyCountBounds();
}
private void calculateActualKeyCount()
{
actualKeyCount.Value = keyCountSlider.Current.Value * (dualStages.Current.Value ? 2 : 1);
}
private bool updatingKeyCount;
private void updateKeyCount(ValueChangedEvent<float> keyCount)
private void updateKeyCount(ValueChangedEvent<int> keyCount)
{
if (updatingKeyCount) return;
@@ -143,7 +190,8 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
Schedule(() =>
{
changeHandler!.RestoreState(-1);
Beatmap.Difficulty.CircleSize = keyCountSlider.Current.Value = keyCount.OldValue;
Beatmap.Difficulty.CircleSize = keyCount.OldValue;
setStateFromActualKeyCount(keyCount.OldValue);
updatingKeyCount = false;
});
}
@@ -158,7 +206,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
{
// for now, update these on commit rather than making BeatmapMetadata bindables.
// after switching database engines we can reconsider if switching to bindables is a good direction.
Beatmap.Difficulty.CircleSize = keyCountSlider.Current.Value;
Beatmap.Difficulty.CircleSize = actualKeyCount.Value;
Beatmap.SpecialStyle = specialStyle.Current.Value;
Beatmap.Difficulty.DrainRate = healthDrainSlider.Current.Value;
Beatmap.Difficulty.OverallDifficulty = overallDifficultySlider.Current.Value;
+16
View File
@@ -485,6 +485,22 @@ namespace osu.Game.Rulesets.Mania
};
}
public override IEnumerable<RulesetBeatmapAttribute> GetBeatmapAttributesForRankedPlayCard(IBeatmapInfo beatmapInfo, IReadOnlyCollection<Mod> mods)
{
var attributes = GetBeatmapAttributesForDisplay(beatmapInfo, mods).ToList();
// Key count attribute isn't relevant to ranked play (it's decided by the pool).
attributes.RemoveAll(a => a.Acronym == "KC");
float holdNoteRatio = beatmapInfo.TotalObjectCount == 0 ? 0 : (float)beatmapInfo.EndTimeObjectCount / beatmapInfo.TotalObjectCount;
attributes.Insert(0, new RulesetBeatmapAttribute("Hold notes", @"HN", holdNoteRatio, holdNoteRatio, 1)
{
ValueFormat = "P0"
});
return attributes;
}
public override IRulesetFilterCriteria CreateRulesetFilterCriteria()
{
return new ManiaFilterCriteria();
@@ -73,6 +73,12 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddStep("end slider placement", () => InputManager.Click(MouseButton.Right));
AddStep("seek to slider end", () =>
{
var slider = (Slider)EditorBeatmap.HitObjects.Single();
EditorClock.Seek(slider.EndTime);
});
AddStep("enter circle placement mode", () => InputManager.Key(Key.Number2));
AddStep("move mouse slightly", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.205f, 0)));
@@ -0,0 +1,14 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Edit.Checks
{
public class CheckOsuFewHitsounds : CheckFewHitsounds
{
protected override bool IsExcludedFromHitsounding(HitObject hitObject) => hitObject is Spinner;
}
}
@@ -14,6 +14,9 @@ namespace osu.Game.Rulesets.Osu.Edit
{
private readonly List<ICheck> checks = new List<ICheck>
{
// Audio
new CheckOsuFewHitsounds(),
// Compose
new CheckOffscreenObjects(),
new CheckTooShortSpinners(),
@@ -125,13 +125,23 @@ namespace osu.Game.Rulesets.Osu.Edit
{
startPositionXSlider = new ExpandableSlider<float>
{
Current = StartPositionX,
Current = new BindableFloat
{
MinValue = -OsuPlayfield.BASE_SIZE.X / 2,
MaxValue = OsuPlayfield.BASE_SIZE.X / 2,
Precision = 0.1f,
},
KeyboardStep = 1,
ExpandedLabelText = "X offset",
},
startPositionYSlider = new ExpandableSlider<float>
{
Current = StartPositionY,
Current = new BindableFloat
{
MinValue = -OsuPlayfield.BASE_SIZE.Y / 2,
MaxValue = OsuPlayfield.BASE_SIZE.Y / 2,
Precision = 0.1f,
},
KeyboardStep = 1,
ExpandedLabelText = "Y offset",
},
@@ -186,15 +196,27 @@ namespace osu.Game.Rulesets.Osu.Edit
StartPositionX.BindValueChanged(x =>
{
startPositionXSlider.ContractedLabelText = $"X: {x.NewValue:#,0.##}";
startPositionXSlider.Current.Value = x.NewValue - OsuPlayfield.BASE_SIZE.X / 2;
StartPosition.Value = new Vector2(x.NewValue, StartPosition.Value.Y);
}, true);
StartPositionY.BindValueChanged(y =>
{
startPositionYSlider.ContractedLabelText = $"Y: {y.NewValue:#,0.##}";
startPositionYSlider.Current.Value = y.NewValue - OsuPlayfield.BASE_SIZE.Y / 2;
StartPosition.Value = new Vector2(StartPosition.Value.X, y.NewValue);
}, true);
startPositionXSlider.Current.BindValueChanged(x =>
{
StartPositionX.Value = x.NewValue + OsuPlayfield.BASE_SIZE.X / 2;
});
startPositionYSlider.Current.BindValueChanged(y =>
{
StartPositionY.Value = y.NewValue + OsuPlayfield.BASE_SIZE.Y / 2;
});
StartPosition.BindValueChanged(pos =>
{
StartPositionX.Value = pos.NewValue.X;
+1 -1
View File
@@ -21,7 +21,7 @@ using osuTK.Graphics;
namespace osu.Game.Rulesets.Osu.Mods
{
public partial class OsuModBlinds : Mod, IApplicableToDrawableRuleset<OsuHitObject>, IApplicableToHealthProcessor
public partial class OsuModBlinds : ModBlinds, IApplicableToDrawableRuleset<OsuHitObject>, IApplicableToHealthProcessor
{
public override string Name => "Blinds";
public override LocalisableString Description => "Play with blinds on your screen.";
@@ -16,7 +16,7 @@ using osu.Game.Rulesets.Osu.Skinning.Default;
namespace osu.Game.Rulesets.Osu.Mods
{
public class OsuModTraceable : ModWithVisibilityAdjustment, IRequiresApproachCircles
public class OsuModTraceable : ModTraceable, IRequiresApproachCircles
{
public override string Name => "Traceable";
public override string Acronym => "TC";
@@ -18,11 +18,13 @@ namespace osu.Game.Rulesets.Taiko.Configuration
base.InitialiseDefaults();
SetDefault(TaikoRulesetSetting.TouchControlScheme, TaikoTouchControlScheme.KDDK);
SetDefault(TaikoRulesetSetting.RateAdjustedHitAnimation, true);
}
}
public enum TaikoRulesetSetting
{
TouchControlScheme
TouchControlScheme,
RateAdjustedHitAnimation,
}
}
@@ -7,12 +7,15 @@ using System;
using System.Diagnostics;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Configuration;
using osu.Game.Rulesets.Taiko.Skinning.Default;
using osu.Game.Screens.Play;
using osu.Game.Skinning;
using osuTK;
@@ -34,12 +37,15 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
private set;
}
private bool validActionPressed;
private double? lastPressHandleTime;
[Resolved(CanBeNull = true)]
private TaikoRulesetConfigManager taikoConfig { get; set; }
private readonly Bindable<bool> rateAdjustedHitAnimations = new Bindable<bool>(true);
private readonly Bindable<HitType> type = new Bindable<HitType>();
private bool validActionPressed;
private double? lastPressHandleTime;
public DrawableHit()
: this(null)
{
@@ -51,6 +57,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
FillMode = FillMode.Fit;
}
[BackgroundDependencyLoader]
private void load()
{
taikoConfig?.BindWith(TaikoRulesetSetting.RateAdjustedHitAnimation, rateAdjustedHitAnimations);
}
protected override void OnApply()
{
type.BindTo(HitObject.TypeBindable);
@@ -168,11 +180,15 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
if (SnapJudgementLocation)
MainPiece.MoveToX(-X);
this.ScaleTo(0.8f, gravity_time * 2, Easing.OutQuad);
// Rate independent to match stable.
double rate = Math.Abs((Clock as IGameplayClock)?.GetTrueGameplayRate() ?? Clock.Rate);
double length = gravity_time * (rateAdjustedHitAnimations.Value ? 1 : rate);
this.MoveToY(-gravity_travel_height, gravity_time, Easing.Out)
this.ScaleTo(0.8f, length * 2, Easing.OutQuad);
this.MoveToY(-gravity_travel_height, length, Easing.Out)
.Then()
.MoveToY(gravity_travel_height * 2, gravity_time * 2, Easing.In);
.MoveToY(gravity_travel_height * 2, length * 2, Easing.In);
this.FadeOut(800);
break;
@@ -3,6 +3,7 @@
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Localisation;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Localisation;
@@ -31,7 +32,16 @@ namespace osu.Game.Rulesets.Taiko
{
Caption = RulesetSettingsStrings.TouchControlScheme,
Current = config.GetBindable<TaikoTouchControlScheme>(TaikoRulesetSetting.TouchControlScheme)
}),
new SettingsItemV2(new FormCheckBox
{
Caption = RulesetSettingsStrings.RateAdjustedHitAnimation,
HintText = RulesetSettingsStrings.RateAdjustedHitAnimationTooltip,
Current = config.GetBindable<bool>(TaikoRulesetSetting.RateAdjustedHitAnimation)
})
{
ApplyClassicDefault = c => ((IHasCurrentValue<bool>)c).Current.Value = false,
}
};
}
}
@@ -7,9 +7,11 @@ using NUnit.Framework;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Timing;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Edit.Checks;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Tests.Beatmaps;
@@ -18,7 +20,7 @@ namespace osu.Game.Tests.Editing.Checks
[TestFixture]
public class CheckFewHitsoundsTest
{
private CheckFewHitsounds check = null!;
private CheckOsuFewHitsounds check = null!;
private List<HitSampleInfo> notHitsounded = null!;
private List<HitSampleInfo> hitsounded = null!;
@@ -26,7 +28,7 @@ namespace osu.Game.Tests.Editing.Checks
[SetUp]
public void Setup()
{
check = new CheckFewHitsounds();
check = new CheckOsuFewHitsounds();
notHitsounded = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) };
hitsounded = new List<HitSampleInfo>
{
@@ -82,6 +84,43 @@ namespace osu.Game.Tests.Editing.Checks
assertOk(hitObjects);
}
[Test]
public void TestRarelyHitsoundedLongWallTimeMostlyBreak()
{
var hitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Samples = hitsounded },
new HitCircle { StartTime = 1000, Samples = notHitsounded },
new HitCircle { StartTime = 2000, Samples = notHitsounded },
new HitCircle { StartTime = 3000, Samples = notHitsounded },
new HitCircle { StartTime = 4000, Samples = notHitsounded },
new HitCircle { StartTime = 5000, Samples = notHitsounded },
new HitCircle { StartTime = 10000, Samples = hitsounded },
};
// 10s since last hitsound, but 6s overlap a break → 4s without hitsounds (below warning threshold).
assertOk(hitObjects, new BreakPeriod(4000, 10000));
}
[Test]
public void TestRarelyHitsoundedLongWallTimeMostlySpinner()
{
var hitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Samples = hitsounded },
new HitCircle { StartTime = 200, Samples = notHitsounded },
new HitCircle { StartTime = 400, Samples = notHitsounded },
new HitCircle { StartTime = 600, Samples = notHitsounded },
new HitCircle { StartTime = 800, Samples = notHitsounded },
new HitCircle { StartTime = 1000, Samples = notHitsounded },
new Spinner { StartTime = 1200, EndTime = 21200, Samples = notHitsounded },
new HitCircle { StartTime = 21400, Samples = hitsounded },
};
// 21.4s since last hitsound, but 20s overlap a spinner → 1.4s without hitsounds.
assertOk(hitObjects);
}
[Test]
public void TestLightlyHitsounded()
{
@@ -194,9 +233,9 @@ namespace osu.Game.Tests.Editing.Checks
assertOk(hitObjects);
}
private void assertOk(List<HitObject> hitObjects)
private void assertOk(List<HitObject> hitObjects, params BreakPeriod[] breaks)
{
Assert.That(check.Run(getContext(hitObjects)), Is.Empty);
Assert.That(check.Run(getContext(hitObjects, breaks)), Is.Empty);
}
private void assertLongPeriodProblem(List<HitObject> hitObjects, int count = 1)
@@ -231,10 +270,13 @@ namespace osu.Game.Tests.Editing.Checks
Assert.That(issues.Any(issue => issue.Template is CheckFewHitsounds.IssueTemplateNoHitsounds));
}
private BeatmapVerifierContext getContext(List<HitObject> hitObjects)
private BeatmapVerifierContext getContext(List<HitObject> hitObjects, params BreakPeriod[] breaks)
{
var beatmap = new Beatmap<HitObject> { HitObjects = hitObjects };
foreach (var b in breaks)
beatmap.Breaks.Add(b);
return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
}
}
@@ -51,7 +51,15 @@ namespace osu.Game.Tests.Extensions
[TestCase(0.4, true, 2, ExpectedResult = "40%")]
[TestCase(1e-6, false, 6, ExpectedResult = "0,000001")]
[TestCase(0.48333, true, 4, ExpectedResult = "48,33%")]
public string TestCultureSensitivity(double input, bool percent, int decimalDigits)
public string TestCultureSensitivityDecimalPoint(double input, bool percent, int decimalDigits)
{
return input.ToStandardFormattedString(decimalDigits, percent);
}
[Test]
[SetCulture("sv-SE")]
[TestCase(-1e-6, false, 6, ExpectedResult = "0,000001")]
public string TestCultureSensitivityNegativeSign(double input, bool percent, int decimalDigits)
{
return input.ToStandardFormattedString(decimalDigits, percent);
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using osu.Game.IPC;
namespace osu.Game.Tests.IPC
{
public sealed class WebSocketClient : IDisposable
{
public event Action<string>? MessageReceived;
public event Action? Closed;
private readonly int port;
private WebSocketChannel? channel;
public WebSocketClient(int port)
{
this.port = port;
}
public async Task Start(CancellationToken cancellationToken = default)
{
var webSocket = new ClientWebSocket();
await webSocket.ConnectAsync(new Uri($@"ws://localhost:{port}/"), cancellationToken);
channel = new WebSocketChannel(webSocket);
channel.MessageReceived += msg => MessageReceived?.Invoke(msg);
channel.ClosedPrematurely += () => Closed?.Invoke();
channel.Start(cancellationToken);
}
public async Task SendAsync(string message)
{
if (channel == null)
throw new InvalidOperationException($@"Must {nameof(Start)} first.");
await channel.SendAsync(message);
}
public async Task StopAsync(CancellationToken stoppingToken = default)
{
try
{
if (channel != null)
await channel.StopAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// has to be caught manually because outer task isn't accepting `stoppingToken`.
}
}
public void Dispose()
{
channel?.Dispose();
}
}
}
+274
View File
@@ -0,0 +1,274 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Game.IPC;
using osu.Game.Online.Multiplayer;
namespace osu.Game.Tests.IPC
{
[TestFixture]
public class WebSocketTest
{
[Test]
public async Task TestClientInitiatedDuplexCommunication()
{
const int port = 54321;
var server = new WebSocketServer(port);
var client = new WebSocketClient(port);
var duplexComplete = new ManualResetEventSlim(false);
server.MessageReceived += (clientId, msg) =>
{
if (msg != "PING")
return;
// ReSharper disable once AccessToDisposedClosure
server.SendAsync(clientId, "PONG").FireAndForget();
};
client.MessageReceived += msg =>
{
if (msg != "PONG")
return;
duplexComplete.Set();
};
await server.StartAsync();
await client.Start();
await client.SendAsync("PING");
Assert.That(duplexComplete.Wait(10_000));
await client.StopAsync();
await server.StopAsync();
client.Dispose();
server.Dispose();
}
[Test]
public async Task TestServerInitiatedDuplexCommunication()
{
const int port = 54321;
var server = new WebSocketServer(port);
var client = new WebSocketClient(port);
var clientConnected = new ManualResetEventSlim();
var duplexComplete = new ManualResetEventSlim();
client.MessageReceived += msg =>
{
if (msg != "PING")
return;
// ReSharper disable once AccessToDisposedClosure
client.SendAsync("PONG").FireAndForget();
};
server.ClientConnected += _ => clientConnected.Set();
server.MessageReceived += (_, msg) =>
{
if (msg != "PONG")
return;
duplexComplete.Set();
};
await server.StartAsync();
await client.Start();
Assert.That(clientConnected.Wait(10_000));
await server.SendAsync(1, "PING");
Assert.That(duplexComplete.Wait(10_000));
await client.StopAsync();
await server.StopAsync();
client.Dispose();
server.Dispose();
}
[Test]
public async Task TestServerBroadcast()
{
const int port = 54321;
const int client_count = 5;
var server = new WebSocketServer(port);
var clients = new List<WebSocketClient>(client_count);
var connectionCountdown = new CountdownEvent(client_count);
var receiptCountdown = new CountdownEvent(client_count);
for (int i = 0; i < client_count; ++i)
{
var client = new WebSocketClient(port);
client.MessageReceived += msg =>
{
if (msg != "HI ALL")
return;
receiptCountdown.Signal();
};
clients.Add(client);
}
server.ClientConnected += _ => connectionCountdown.Signal();
await server.StartAsync();
foreach (var client in clients)
await client.Start();
Assert.That(connectionCountdown.Wait(10_000));
await server.BroadcastAsync("HI ALL");
Assert.That(receiptCountdown.Wait(10_000));
foreach (var client in clients)
{
await client.StopAsync();
client.Dispose();
}
await server.StopAsync();
server.Dispose();
}
[Test]
public async Task TestClientSoftAborts()
{
const int port = 54321;
var server = new WebSocketServer(port);
var client = new WebSocketClient(port);
await server.StartAsync();
await client.Start();
await client.StopAsync();
client.Dispose();
await server.StopAsync();
server.Dispose();
}
[Test]
public async Task TestClientHardAborts()
{
const int port = 54321;
var server = new WebSocketServer(port);
var client = new WebSocketClient(port);
await server.StartAsync();
await client.Start();
await client.StopAsync(new CancellationToken(true));
client.Dispose();
await server.StopAsync();
server.Dispose();
}
[Test]
public async Task TestServerSoftAborts()
{
const int port = 54321;
var server = new WebSocketServer(port);
var client = new WebSocketClient(port);
await server.StartAsync();
await client.Start();
await server.StopAsync();
server.Dispose();
await client.StopAsync();
client.Dispose();
}
[Test]
public async Task TestServerHardAborts()
{
const int port = 54321;
var server = new WebSocketServer(port);
var client = new WebSocketClient(port);
await server.StartAsync();
await client.Start();
await server.StopAsync(new CancellationToken(true));
server.Dispose();
await client.StopAsync();
client.Dispose();
}
[Test]
public async Task TestClientMessageTooLong()
{
const int port = 54321;
var server = new WebSocketServer(port);
var client = new WebSocketClient(port);
var clientClosed = new ManualResetEventSlim();
client.Closed += clientClosed.Set;
await server.StartAsync();
await client.Start();
await client.SendAsync(new string('0', 9999));
Assert.That(clientClosed.Wait(10_000));
await client.StopAsync();
client.Dispose();
var client2 = new WebSocketClient(port);
var duplexComplete = new ManualResetEventSlim();
server.MessageReceived += (clientId, msg) =>
{
if (msg != "PING")
return;
// ReSharper disable once AccessToDisposedClosure
server.SendAsync(clientId, "PONG").FireAndForget();
};
client2.MessageReceived += msg =>
{
if (msg != "PONG")
return;
duplexComplete.Set();
};
await client2.Start();
await client2.SendAsync("PING");
Assert.That(duplexComplete.Wait(10000));
await client2.StopAsync();
await server.StopAsync();
client2.Dispose();
server.Dispose();
}
[Test]
public async Task TestStartStopServerWithoutReceivingClients()
{
const int port = 54321;
var server = new WebSocketServer(port);
await server.StartAsync();
await server.StopAsync();
server.Dispose();
}
}
}
+19
View File
@@ -7,8 +7,10 @@ using System.Linq;
using Moq;
using NUnit.Framework;
using NUnit.Framework.Legacy;
using osu.Framework.Bindables;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Localisation;
using osu.Game.Configuration;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Catch;
@@ -33,6 +35,17 @@ namespace osu.Game.Tests.Mods
Assert.That(invalid, Is.EquivalentTo(new[] { mod.Object }));
}
[Test]
public void TestModIsNotCompatibleWithItselfEvenIfSettingsDiffer()
{
var mod1 = new Mock<CustomMod3>();
var mod2 = new Mock<CustomMod3>();
mod2.Setup(m => m.Setting).Returns(new BindableBool(true));
Assert.That(ModUtils.CheckCompatibleSet(new[] { mod1.Object, mod2.Object }, out var invalid), Is.False);
Assert.That(invalid, Is.EquivalentTo(new[] { mod2.Object }));
}
[Test]
public void TestModIsCompatibleByItself()
{
@@ -397,6 +410,12 @@ namespace osu.Game.Tests.Mods
{
}
public abstract class CustomMod3 : Mod, IModCompatibilitySpecification
{
[SettingSource("Setting")]
public virtual BindableBool Setting { get; } = new BindableBool();
}
private class InvalidMultiplayerMod : Mod
{
public override string Name => string.Empty;
@@ -0,0 +1,8 @@
[
{"beatmapset_id":2529695,"difficulty_rating":7.61701,"id":5590715,"mode":"mania","status":"ranked","total_length":396,"user_id":13371424,"version":"[4K] Memoriae Effervescentes","accuracy":7.2,"ar":5,"bpm":220,"convert":false,"count_circles":2097,"count_sliders":3556,"count_spinners":0,"cs":4,"deleted_at":null,"drain":8.2,"hit_length":395,"is_scoreable":true,"last_updated":"2026-04-21T08:35:33Z","mode_int":3,"passcount":1485,"playcount":4006,"ranked":1,"url":"https:\/\/osu.ppy.sh\/beatmaps\/5590715","checksum":"5b43b30845408f6bf0cc02d19fa475a4","beatmapset":{"anime_cover":false,"artist":"Laur","artist_unicode":"Laur","covers":{"cover":"https:\/\/assets.ppy.sh\/beatmaps\/2529695\/covers\/cover.jpg?1776760548","cover@2x":"https:\/\/assets.ppy.sh\/beatmaps\/2529695\/covers\/cover@2x.jpg?1776760548","card":"https:\/\/assets.ppy.sh\/beatmaps\/2529695\/covers\/card.jpg?1776760548","card@2x":"https:\/\/assets.ppy.sh\/beatmaps\/2529695\/covers\/card@2x.jpg?1776760548","list":"https:\/\/assets.ppy.sh\/beatmaps\/2529695\/covers\/list.jpg?1776760548","list@2x":"https:\/\/assets.ppy.sh\/beatmaps\/2529695\/covers\/list@2x.jpg?1776760548","slimcover":"https:\/\/assets.ppy.sh\/beatmaps\/2529695\/covers\/slimcover.jpg?1776760548","slimcover@2x":"https:\/\/assets.ppy.sh\/beatmaps\/2529695\/covers\/slimcover@2x.jpg?1776760548"},"creator":"Ainer","favourite_count":55,"genre_id":10,"hype":null,"id":2529695,"language_id":5,"nsfw":false,"offset":0,"play_count":4102,"preview_url":"https:\/\/b.ppy.sh\/preview\/2529695.mp3","source":"osu!mania 7K World Cup 2026","spotlight":false,"status":"ranked","title":"SEV-26","title_unicode":"SEV-26","track_id":11695,"user_id":13371424,"video":false,"bpm":180,"can_be_hyped":false,"deleted_at":null,"discussion_enabled":true,"discussion_locked":false,"is_scoreable":true,"last_updated":"2026-04-21T08:35:32Z","legacy_thread_url":"https:\/\/osu.ppy.sh\/community\/forums\/topics\/2191856","nominations_summary":{"current":2,"eligible_main_rulesets":["mania"],"required_meta":{"main_ruleset":2,"non_main_ruleset":1}},"ranked":1,"ranked_date":"2026-04-28T09:22:15Z","rating":8.96296,"storyboard":false,"submitted_date":"2026-03-28T00:58:23Z","tags":"featured artist fa mappers' guild mg mpg osu! original electronic instrumental neurofunk hardcore psytrance speedcore tearout dubstep orchestral artcore sev26 sylvatic encephalitis virus grand finals grandfinals gf mwc 2026 world cup mwc2026 mwc7k2026 tb tiebreaker alicia antipole symmatrix- sardines bruh_shen tehfire polytetral hourius naraicat alexdunk lowgraphics sakura006","availability":{"download_disabled":false,"more_information":null},"ratings":[0,3,0,0,0,0,0,0,0,1,23]},"current_user_playcount":0,"failtimes":{"fail":[0,0,0,0,0,0,0,0,9,0,9,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,18,18,0,0,0,0,0,0,0,0,9,9,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,18,0,0,18,9,9,18,0,18,9,0,0,0,0,0],"exit":[0,36,36,45,63,9,9,54,18,63,72,18,9,0,9,9,0,18,27,18,18,45,0,9,9,9,9,27,9,9,9,9,9,0,0,9,0,9,0,0,0,0,0,0,18,0,0,0,0,9,0,9,0,0,0,0,18,18,9,9,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,18,0,0,9,9,0,0,9,18,0,9,0,0,0,0,0,0,0]},"max_combo":9163,"owners":[{"id":13371424,"username":"Ainer"},{"id":17258072,"username":"Alicia"}]},
{"beatmapset_id":2518293,"difficulty_rating":3.61767,"id":5602450,"mode":"mania","status":"ranked","total_length":282,"user_id":8425052,"version":"[4K] Innocence","accuracy":8,"ar":5,"bpm":140,"convert":false,"count_circles":3618,"count_sliders":0,"count_spinners":0,"cs":4,"deleted_at":null,"drain":8,"hit_length":281,"is_scoreable":true,"last_updated":"2026-04-13T00:00:32Z","mode_int":3,"passcount":369,"playcount":1963,"ranked":1,"url":"https:\/\/osu.ppy.sh\/beatmaps\/5602450","checksum":"51319d68b9373eb98807ea5a2187d803","beatmapset":{"anime_cover":false,"artist":"rejection","artist_unicode":"rejection","covers":{"cover":"https:\/\/assets.ppy.sh\/beatmaps\/2518293\/covers\/cover.jpg?1776038446","cover@2x":"https:\/\/assets.ppy.sh\/beatmaps\/2518293\/covers\/cover@2x.jpg?1776038446","card":"https:\/\/assets.ppy.sh\/beatmaps\/2518293\/covers\/card.jpg?1776038446","card@2x":"https:\/\/assets.ppy.sh\/beatmaps\/2518293\/covers\/card@2x.jpg?1776038446","list":"https:\/\/assets.ppy.sh\/beatmaps\/2518293\/covers\/list.jpg?1776038446","list@2x":"https:\/\/assets.ppy.sh\/beatmaps\/2518293\/covers\/list@2x.jpg?1776038446","slimcover":"https:\/\/assets.ppy.sh\/beatmaps\/2518293\/covers\/slimcover.jpg?1776038446","slimcover@2x":"https:\/\/assets.ppy.sh\/beatmaps\/2518293\/covers\/slimcover@2x.jpg?1776038446"},"creator":"Stella-","favourite_count":48,"genre_id":10,"hype":null,"id":2518293,"language_id":3,"nsfw":false,"offset":0,"play_count":2031,"preview_url":"https:\/\/b.ppy.sh\/preview\/2518293.mp3","source":"","spotlight":false,"status":"ranked","title":"White Canvas (feat. Aitsuki Nakuru)","title_unicode":"White Canvas (feat. \u85cd\u6708\u306a\u304f\u308b)","track_id":5057,"user_id":8425052,"video":false,"bpm":140,"can_be_hyped":false,"deleted_at":null,"discussion_enabled":true,"discussion_locked":false,"is_scoreable":true,"last_updated":"2026-04-13T00:00:32Z","legacy_thread_url":"https:\/\/osu.ppy.sh\/community\/forums\/topics\/2185354","nominations_summary":{"current":2,"eligible_main_rulesets":["mania"],"required_meta":{"main_ruleset":2,"non_main_ruleset":1}},"ranked":1,"ranked_date":"2026-04-21T23:42:39Z","rating":8.66667,"storyboard":false,"submitted_date":"2026-03-06T10:45:47Z","tags":"fa featured artist japanese pop jpop j-pop electronic encore emotional -emotional vocal pop 02- 2 megarex mrx-074 mrx074 female vocals vocalist m3-2020\u79cb fall m3-46 muse dash the future","availability":{"download_disabled":false,"more_information":null},"ratings":[0,0,0,0,0,1,0,1,0,0,4]},"current_user_playcount":0,"failtimes":{"fail":[0,0,0,0,9,0,9,0,0,9,0,0,0,0,0,0,0,0,0,0,0,9,0,9,0,0,0,9,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"exit":[0,0,18,18,9,63,9,27,18,9,18,9,18,9,27,9,36,0,0,9,9,0,9,0,18,27,0,0,0,9,9,0,0,0,0,9,0,9,0,9,9,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,9,0,0,0,9,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},"max_combo":3618,"owners":[{"id":8425052,"username":"Stella-"}]},
{"beatmapset_id":1665948,"difficulty_rating":4.87529,"id":3871759,"mode":"mania","status":"ranked","total_length":379,"user_id":17272017,"version":"[4K] Time Freeze Illusion","accuracy":8.5,"ar":5,"bpm":200,"convert":false,"count_circles":5264,"count_sliders":629,"count_spinners":0,"cs":4,"deleted_at":null,"drain":8,"hit_length":377,"is_scoreable":true,"last_updated":"2026-04-13T09:52:53Z","mode_int":3,"passcount":308,"playcount":1738,"ranked":1,"url":"https:\/\/osu.ppy.sh\/beatmaps\/3871759","checksum":"34b98351e32ca3db24ea9fc0055a923b","beatmapset":{"anime_cover":true,"artist":"Release Hallucination","artist_unicode":"Release Hallucination","covers":{"cover":"https:\/\/assets.ppy.sh\/beatmaps\/1665948\/covers\/cover.jpg?1776073987","cover@2x":"https:\/\/assets.ppy.sh\/beatmaps\/1665948\/covers\/cover@2x.jpg?1776073987","card":"https:\/\/assets.ppy.sh\/beatmaps\/1665948\/covers\/card.jpg?1776073987","card@2x":"https:\/\/assets.ppy.sh\/beatmaps\/1665948\/covers\/card@2x.jpg?1776073987","list":"https:\/\/assets.ppy.sh\/beatmaps\/1665948\/covers\/list.jpg?1776073987","list@2x":"https:\/\/assets.ppy.sh\/beatmaps\/1665948\/covers\/list@2x.jpg?1776073987","slimcover":"https:\/\/assets.ppy.sh\/beatmaps\/1665948\/covers\/slimcover.jpg?1776073987","slimcover@2x":"https:\/\/assets.ppy.sh\/beatmaps\/1665948\/covers\/slimcover@2x.jpg?1776073987"},"creator":"Lazurent","favourite_count":51,"genre_id":11,"hype":null,"id":1665948,"language_id":3,"nsfw":false,"offset":0,"play_count":4811,"preview_url":"https:\/\/b.ppy.sh\/preview\/1665948.mp3","source":"","spotlight":false,"status":"ranked","title":"Chronostasis","title_unicode":"Chronostasis","track_id":4954,"user_id":17272017,"video":false,"bpm":200,"can_be_hyped":false,"deleted_at":null,"discussion_enabled":true,"discussion_locked":false,"is_scoreable":true,"last_updated":"2026-04-13T09:52:53Z","legacy_thread_url":"https:\/\/osu.ppy.sh\/community\/forums\/topics\/1494910","nominations_summary":{"current":2,"eligible_main_rulesets":["mania"],"required_meta":{"main_ruleset":2,"non_main_ruleset":1}},"ranked":1,"ranked_date":"2026-04-20T11:25:45Z","rating":8.33333,"storyboard":false,"submitted_date":"2022-01-03T14:00:15Z","tags":"m3-38 symphonic progressive metal marathon emi gothic kaorin kaoru hirato japanese \u30af\u30ed\u30ce\u30b9\u30bf\u30b7\u30b9 fa featured artist mg mpg mappers' guild l43yrnt","availability":{"download_disabled":false,"more_information":null},"ratings":[0,1,0,0,0,0,0,0,0,1,4]},"current_user_playcount":0,"failtimes":{"fail":[0,0,0,0,9,9,63,18,45,18,45,27,0,0,0,0,0,9,0,0,0,0,9,18,0,0,0,9,9,0,0,0,0,0,18,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,18,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0],"exit":[0,0,18,63,63,135,261,216,126,81,81,36,18,27,45,27,27,36,9,9,0,18,18,81,27,18,9,18,18,9,0,0,0,36,45,36,0,0,0,0,0,0,9,9,0,18,0,0,9,0,9,0,9,0,0,9,0,9,9,0,0,9,0,0,0,9,0,0,0,0,0,0,0,0,0,9,9,0,0,0,0,0,9,0,0,0,0,0,0,0,0,9,0,0,9,0,0,0,0,9]},"max_combo":6974,"owners":[{"id":17272017,"username":"Lazurent"}]},
{"beatmapset_id":2535368,"difficulty_rating":5.82018,"id":5606802,"mode":"mania","status":"ranked","total_length":296,"user_id":10085090,"version":"[4K] Desperate Soul","accuracy":8.5,"ar":5,"bpm":240,"convert":false,"count_circles":6288,"count_sliders":0,"count_spinners":0,"cs":4,"deleted_at":null,"drain":8,"hit_length":290,"is_scoreable":true,"last_updated":"2026-04-16T18:03:43Z","mode_int":3,"passcount":208,"playcount":1738,"ranked":1,"url":"https:\/\/osu.ppy.sh\/beatmaps\/5606802","checksum":"55451c2f43654e4ec7b9bb156b93148e","beatmapset":{"anime_cover":false,"artist":"Imperial Circus Dead Decadence","artist_unicode":"Imperial Circus Dead Decadence","covers":{"cover":"https:\/\/assets.ppy.sh\/beatmaps\/2535368\/covers\/cover.jpg?1776362637","cover@2x":"https:\/\/assets.ppy.sh\/beatmaps\/2535368\/covers\/cover@2x.jpg?1776362637","card":"https:\/\/assets.ppy.sh\/beatmaps\/2535368\/covers\/card.jpg?1776362637","card@2x":"https:\/\/assets.ppy.sh\/beatmaps\/2535368\/covers\/card@2x.jpg?1776362637","list":"https:\/\/assets.ppy.sh\/beatmaps\/2535368\/covers\/list.jpg?1776362637","list@2x":"https:\/\/assets.ppy.sh\/beatmaps\/2535368\/covers\/list@2x.jpg?1776362637","slimcover":"https:\/\/assets.ppy.sh\/beatmaps\/2535368\/covers\/slimcover.jpg?1776362637","slimcover@2x":"https:\/\/assets.ppy.sh\/beatmaps\/2535368\/covers\/slimcover@2x.jpg?1776362637"},"creator":"Carpihat","favourite_count":48,"genre_id":11,"hype":null,"id":2535368,"language_id":3,"nsfw":false,"offset":0,"play_count":1961,"preview_url":"https:\/\/b.ppy.sh\/preview\/2535368.mp3","source":"","spotlight":false,"status":"ranked","title":"Jashin no Konrei, Gi wa Ai to Shiru.","title_unicode":"\u90aa\u795e\u306e\u5a5a\u793c\u3001\u5100\u306f\u611b\u3068\u77e5\u308b\u3002","track_id":862,"user_id":10085090,"video":false,"bpm":240,"can_be_hyped":false,"deleted_at":null,"discussion_enabled":true,"discussion_locked":false,"is_scoreable":true,"last_updated":"2026-04-16T18:03:42Z","legacy_thread_url":"https:\/\/osu.ppy.sh\/community\/forums\/topics\/2195210","nominations_summary":{"current":2,"eligible_main_rulesets":["mania"],"required_meta":{"main_ruleset":2,"non_main_ruleset":1}},"ranked":1,"ranked_date":"2026-04-18T18:05:56Z","rating":10,"storyboard":false,"submitted_date":"2026-04-07T09:36:37Z","tags":"cthulhu wedding blackened melodic symphonic death black metal melodeath icdd \u72c2\u304a\u3057\u304f\u54b2\u3044\u305f\u51c4\u60e8\u306a\u9ab8\u306f\u594f\u3067\u3001\u611b\u304a\u3057\u304f\u88c2\u3044\u305f\u5c11\u5973\u306f\u8056\u9910\u306e\u8a5e\u3092\u8b33\u3046\u3002 kurooshiku saita seisan na mukuro wa kanaderu itooshiku shoujo seisen no kotoba wo utau fa featured artist japanese mpg mg mappers' guild","availability":{"download_disabled":false,"more_information":null},"ratings":[0,0,0,0,0,0,0,0,0,0,1]},"current_user_playcount":0,"failtimes":{"fail":[0,0,0,9,27,27,27,9,9,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"exit":[0,0,0,27,63,117,108,36,63,36,9,45,9,27,9,0,9,9,0,36,9,36,0,9,0,9,9,0,0,0,27,9,9,0,0,0,0,0,9,0,9,27,9,0,9,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},"max_combo":6288,"owners":[{"id":10085090,"username":"Carpihat"}]},
{"beatmapset_id":2348607,"difficulty_rating":5.57212,"id":5140134,"mode":"mania","status":"ranked","total_length":263,"user_id":23384715,"version":"[4K] Enraged Apparition","accuracy":8,"ar":10,"bpm":250,"convert":false,"count_circles":4682,"count_sliders":120,"count_spinners":0,"cs":4,"deleted_at":null,"drain":8,"hit_length":251,"is_scoreable":true,"last_updated":"2026-03-26T20:54:51Z","mode_int":3,"passcount":404,"playcount":2674,"ranked":1,"url":"https:\/\/osu.ppy.sh\/beatmaps\/5140134","checksum":"a2dd0c1b8aa0d3396b47621b758c6adf","beatmapset":{"anime_cover":true,"artist":"Imperial Circus Dead Decadence","artist_unicode":"Imperial Circus Dead Decadence","covers":{"cover":"https:\/\/assets.ppy.sh\/beatmaps\/2348607\/covers\/cover.jpg?1774558514","cover@2x":"https:\/\/assets.ppy.sh\/beatmaps\/2348607\/covers\/cover@2x.jpg?1774558514","card":"https:\/\/assets.ppy.sh\/beatmaps\/2348607\/covers\/card.jpg?1774558514","card@2x":"https:\/\/assets.ppy.sh\/beatmaps\/2348607\/covers\/card@2x.jpg?1774558514","list":"https:\/\/assets.ppy.sh\/beatmaps\/2348607\/covers\/list.jpg?1774558514","list@2x":"https:\/\/assets.ppy.sh\/beatmaps\/2348607\/covers\/list@2x.jpg?1774558514","slimcover":"https:\/\/assets.ppy.sh\/beatmaps\/2348607\/covers\/slimcover.jpg?1774558514","slimcover@2x":"https:\/\/assets.ppy.sh\/beatmaps\/2348607\/covers\/slimcover@2x.jpg?1774558514"},"creator":"Sayuka","favourite_count":435,"genre_id":11,"hype":null,"id":2348607,"language_id":3,"nsfw":false,"offset":0,"play_count":118018,"preview_url":"https:\/\/b.ppy.sh\/preview\/2348607.mp3","source":"","spotlight":false,"status":"ranked","title":"Shinbatsu o Tadori Kyoukotsu ni Itaru","title_unicode":"\u795e\u7f70\u3092\u8fbf\u308a\u72c2\u9aa8\u306b\u81f3\u308b","track_id":8223,"user_id":11322604,"video":false,"bpm":250,"can_be_hyped":false,"deleted_at":null,"discussion_enabled":true,"discussion_locked":false,"is_scoreable":true,"last_updated":"2026-03-26T20:54:46Z","legacy_thread_url":"https:\/\/osu.ppy.sh\/community\/forums\/topics\/2061550","nominations_summary":{"current":3,"eligible_main_rulesets":["osu"],"required_meta":{"main_ruleset":2,"non_main_ruleset":1}},"ranked":1,"ranked_date":"2026-04-12T17:43:03Z","rating":9.11539,"storyboard":false,"submitted_date":"2025-04-02T11:30:44Z","tags":"maaadbot rhythmnoodles ciiyus icdd sinyus20 oomf chan ciyus miapah fort danilmaz1 mekadon -aly arthro roupus julie maiev worthlessnut9 frawog mithew m1ts shoyeu take amats sprixx chocomilk \u9ec4\u6cc9\u3088\u308a\u8074\u3053\u3086\u3001\u7687\u56fd\u306e\u71c8\u3068\u7114\u306e\u5c11\u5973\u3002 japanese male vocals symphonic melodic black death metal doujin hull kim rib:y(uhki) rib yuhki shuhei js jumpstream hs handstream stream stamina jack chordjack rice fa featured artist","availability":{"download_disabled":false,"more_information":null},"ratings":[0,7,0,0,1,0,1,1,3,10,81]},"current_user_playcount":0,"failtimes":{"fail":[0,0,0,18,72,54,36,36,18,9,9,9,0,0,0,9,63,18,0,0,0,0,0,0,9,0,0,0,9,9,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,18,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0],"exit":[0,0,9,243,90,198,216,126,18,81,63,36,9,0,18,135,45,27,27,18,18,9,0,0,0,0,9,9,18,27,36,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,27,18,9,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,9,0,27,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,9,9,0]},"max_combo":5113,"owners":[{"id":16018038,"username":"frawog"},{"id":23384715,"username":"Worthlessnut9"}]}
]
@@ -13,7 +13,6 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Textures;
using osu.Framework.Testing;
using osu.Game.Audio;
using osu.Game.Configuration;
using osu.Game.Rulesets.Osu;
using osu.Game.Skinning;
using osu.Game.Tests.Beatmaps;
@@ -26,17 +25,15 @@ namespace osu.Game.Tests.Skins
public partial class TestSceneBeatmapSkinLookupDisables : OsuTestScene
{
private UserSkinSource userSource;
private BeatmapSkinProvidingContainer beatmapSkinProvider;
private BeatmapSkinSource beatmapSource;
private SkinRequester requester;
[Resolved]
private OsuConfigManager config { get; set; }
[SetUp]
public void SetUp() => Schedule(() =>
{
Add(new SkinProvidingContainer(userSource = new UserSkinSource())
.WithChild(new BeatmapSkinProvidingContainer(beatmapSource = new BeatmapSkinSource())
.WithChild(beatmapSkinProvider = new BeatmapSkinProvidingContainer(beatmapSource = new BeatmapSkinSource())
.WithChild(requester = new SkinRequester())));
});
@@ -44,7 +41,7 @@ namespace osu.Game.Tests.Skins
[TestCase(true)]
public void TestDrawableLookup(bool allowBeatmapLookups)
{
AddStep($"Set beatmap skin enabled to {allowBeatmapLookups}", () => config.SetValue(OsuSetting.BeatmapSkins, allowBeatmapLookups));
AddStep($"Set beatmap skin enabled to {allowBeatmapLookups}", () => beatmapSkinProvider.BeatmapSkins.Value = allowBeatmapLookups);
string expected = allowBeatmapLookups ? "beatmap" : "user";
@@ -55,7 +52,7 @@ namespace osu.Game.Tests.Skins
[TestCase(true)]
public void TestProviderLookup(bool allowBeatmapLookups)
{
AddStep($"Set beatmap skin enabled to {allowBeatmapLookups}", () => config.SetValue(OsuSetting.BeatmapSkins, allowBeatmapLookups));
AddStep($"Set beatmap skin enabled to {allowBeatmapLookups}", () => beatmapSkinProvider.BeatmapSkins.Value = allowBeatmapLookups);
ISkin expected() => allowBeatmapLookups ? beatmapSource : userSource;
@@ -100,8 +100,10 @@ namespace osu.Game.Tests.Visual.Editing
[Test]
public void TestPlacementOfConcurrentObjectWithDuration()
{
AddStep("seek to timing point", () => EditorClock.Seek(2170));
AddStep("add hit circle", () => EditorBeatmap.Add(createHitCircle(2170, Vector2.Zero)));
const double spinner_start_time = 2170;
const double spinner_end_seek_time = 2500;
AddStep("seek to timing point", () => EditorClock.Seek(spinner_start_time));
AddStep("choose spinner placement tool", () =>
{
@@ -116,10 +118,15 @@ namespace osu.Game.Tests.Visual.Editing
});
AddStep("end placing spinner", () =>
{
EditorClock.Seek(2500);
EditorClock.Seek(spinner_end_seek_time);
InputManager.Click(MouseButton.Right);
});
AddStep("add hit circle mid-spinner", () =>
{
EditorBeatmap.Add(createHitCircle((spinner_start_time + spinner_end_seek_time) / 2, Vector2.Zero));
});
AddAssert("two timeline blueprints present", () => Editor.ChildrenOfType<TimelineHitObjectBlueprint>().Count() == 2);
}
@@ -5,6 +5,7 @@ using System.Linq;
using NUnit.Framework;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
@@ -12,10 +13,11 @@ using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Edit.Components.TernaryButtons;
using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Tests.Beatmaps;
@@ -51,6 +53,90 @@ namespace osu.Game.Tests.Visual.Editing
AddAssert("circle removed", () => EditorBeatmap.HitObjects, () => Is.Empty);
}
[Test]
public void TestPlacementReplacesObjectAtSameStartTime()
{
HitCircle existing = null!;
var existingPosition = new Vector2(128, 160);
var replacementPosition = new Vector2(400, 280);
Playfield playfield = null!;
AddStep("add existing circle", () =>
{
EditorBeatmap.Add(existing = new HitCircle
{
StartTime = 500,
Position = existingPosition,
});
});
AddStep("seek to same time", () => EditorClock.Seek(500));
AddStep("select circle placement tool", () => InputManager.Key(Key.Number2));
AddStep("grab playfield", () => playfield = this.ChildrenOfType<Playfield>().Single());
AddStep("move mouse to replacement coordinates", () => InputManager.MoveMouseTo(playfield.GamefieldToScreenSpace(replacementPosition)));
AddStep("place circle", () => InputManager.Click(MouseButton.Left));
AddAssert("only one hit object", () => EditorBeatmap.HitObjects.Count, () => Is.EqualTo(1));
AddAssert("original instance removed from beatmap", () => EditorBeatmap.HitObjects.Single(), () => Is.Not.SameAs(existing));
AddAssert("start time unchanged", () => Precision.AlmostEquals(EditorBeatmap.HitObjects.Single().StartTime, 500));
AddAssert("circle at new coordinates", () =>
{
var circle = (HitCircle)EditorBeatmap.HitObjects.Single();
return circle != null
&& Precision.AlmostEquals(circle.Position.X, replacementPosition.X)
&& Precision.AlmostEquals(circle.Position.Y, replacementPosition.Y);
});
}
[Test]
public void TestPlacementOnSliderBodyDoesNotRemoveSlider()
{
Slider originalSlider = null!;
AddStep("add slider", () =>
{
EditorBeatmap.Add(originalSlider = new Slider
{
StartTime = 0,
Position = new Vector2(256, 192),
Path = new SliderPath(new[]
{
new PathControlPoint(Vector2.Zero),
new PathControlPoint(new Vector2(256, 0)),
}),
});
});
AddUntilStep("slider duration resolved", () => originalSlider.EndTime > originalSlider.StartTime + 1);
double midTime = 0;
double endTime = 0;
AddStep("capture slider times", () =>
{
midTime = originalSlider.StartTime + originalSlider.Duration / 2;
endTime = originalSlider.EndTime;
});
Playfield playfield = null!;
AddStep("seek to slider mid", () => EditorClock.Seek(midTime));
AddStep("select circle placement tool", () => InputManager.Key(Key.Number2));
AddStep("grab playfield", () => playfield = this.ChildrenOfType<Playfield>().Single());
AddStep("move mouse for mid placement", () => InputManager.MoveMouseTo(playfield.GamefieldToScreenSpace(new Vector2(300, 200))));
AddStep("place circle at slider mid time", () => InputManager.Click(MouseButton.Left));
AddAssert("slider preserved after mid placement", () => EditorBeatmap.HitObjects.Contains(originalSlider));
AddAssert("one circle after mid placement", () => EditorBeatmap.HitObjects.Count(h => h is HitCircle), () => Is.EqualTo(1));
AddStep("seek to slider end", () => EditorClock.Seek(endTime));
AddStep("place circle at slider end time", () => InputManager.Click(MouseButton.Left));
AddAssert("slider still preserved", () => EditorBeatmap.HitObjects.Contains(originalSlider));
AddAssert("three hit objects total", () => EditorBeatmap.HitObjects.Count, () => Is.EqualTo(3));
AddAssert("two circles placed", () => EditorBeatmap.HitObjects.Count(h => h is HitCircle), () => Is.EqualTo(2));
}
[Test]
public void TestTimingLost()
{
@@ -91,7 +91,7 @@ namespace osu.Game.Tests.Visual.Gameplay
AddRepeatStep("Add judgement", () => applyOneJudgement(HitResult.LargeTickHit), 2);
AddAssert("Check value added whilst hidden", () => hiddenCount() == 2);
AddStep("Show all judgements", () => counterDisplay.Mode.Value = ArgonJudgementCounterDisplay.DisplayMode.All);
AddStep("Show all judgements", () => counterDisplay.Mode.Value = JudgementCounterDisplay.DisplayMode.All);
}
[Test]
@@ -156,7 +156,7 @@ namespace osu.Game.Tests.Visual.Gameplay
AddStep("create counter", () => Child = counterDisplay = new TestArgonJudgementCounterDisplay());
AddStep("Set max judgement to hide itself", () => counterDisplay.ShowMaxJudgement.Value = false);
AddStep("Show all judgements", () => counterDisplay.Mode.Value = ArgonJudgementCounterDisplay.DisplayMode.All);
AddStep("Show all judgements", () => counterDisplay.Mode.Value = JudgementCounterDisplay.DisplayMode.All);
AddWaitStep("wait some", 2);
AddAssert("Assert max judgement hidden", () => counterDisplay.CounterFlow.ChildrenOfType<ArgonJudgementCounter>().First().Alpha == 0);
}
@@ -165,7 +165,7 @@ namespace osu.Game.Tests.Visual.Gameplay
public void TestNoDuplicates()
{
AddStep("create counter", () => Child = counterDisplay = new TestArgonJudgementCounterDisplay());
AddStep("Show all judgements", () => counterDisplay.Mode.Value = ArgonJudgementCounterDisplay.DisplayMode.All);
AddStep("Show all judgements", () => counterDisplay.Mode.Value = JudgementCounterDisplay.DisplayMode.All);
AddAssert("Check no duplicates",
() => counterDisplay.CounterFlow.ChildrenOfType<ArgonJudgementCounter>().Count(),
() => Is.EqualTo(counterDisplay.CounterFlow.ChildrenOfType<ArgonJudgementCounter>().Select(c => c.Result.DisplayName).Distinct().Count()));
@@ -176,11 +176,11 @@ namespace osu.Game.Tests.Visual.Gameplay
{
AddStep("create counter", () => Child = counterDisplay = new TestArgonJudgementCounterDisplay());
AddStep("Show basic judgements", () => counterDisplay.Mode.Value = ArgonJudgementCounterDisplay.DisplayMode.Simple);
AddStep("Show basic judgements", () => counterDisplay.Mode.Value = JudgementCounterDisplay.DisplayMode.Simple);
AddWaitStep("wait some", 2);
AddAssert("Check only basic", () => counterDisplay.CounterFlow.ChildrenOfType<ArgonJudgementCounter>().Last().Alpha == 0);
AddStep("Show normal judgements", () => counterDisplay.Mode.Value = ArgonJudgementCounterDisplay.DisplayMode.Normal);
AddStep("Show all judgements", () => counterDisplay.Mode.Value = ArgonJudgementCounterDisplay.DisplayMode.All);
AddStep("Show normal judgements", () => counterDisplay.Mode.Value = JudgementCounterDisplay.DisplayMode.Normal);
AddStep("Show all judgements", () => counterDisplay.Mode.Value = JudgementCounterDisplay.DisplayMode.All);
AddWaitStep("wait some", 2);
AddAssert("Check all visible", () => counterDisplay.CounterFlow.ChildrenOfType<ArgonJudgementCounter>().Last().Alpha == 1);
AddToggleStep("toggle wireframe display", t => counterDisplay.WireframeOpacity.Value = t ? 0.3f : 0);
@@ -73,6 +73,15 @@ namespace osu.Game.Tests.Visual.Gameplay
iteration++;
}
[Test]
public void TestDisplayModes()
{
AddStep("create counter", () => Child = counterDisplay = new TestJudgementCounterDisplay());
foreach (JudgementCounterDisplay.DisplayMode mode in Enum.GetValues<JudgementCounterDisplay.DisplayMode>())
AddStep($"Change mode to {mode}", () => counterDisplay.Mode.Value = mode);
}
[Test]
public void TestAddJudgementsToCounters()
{
@@ -71,6 +71,22 @@ namespace osu.Game.Tests.Visual.Matchmaking
AddStep("change state to in room", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.InRoom));
}
[Test]
public void TestDelayedRoomScreenPushDoesNotRunIfRoomIsLeftPrematurely()
{
AddStep("change state to in room then immediately leave room", () =>
{
queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.InRoom);
MultiplayerClient.LeaveRoom();
});
// the queue screen waits 2 seconds between transitioning to `InRoom` state and actually pushing the relevant screen.
// if the room goes to `null` in that time, things die very hard.
// therefore the wait here is to check that things don't die very hard.
// if they do the test will throw an exception and fail.
AddWaitStep("wait a little bit", 10);
}
private static double generateCount(double x, double mean, double stdDev, double amplitude)
{
return amplitude * Math.Exp(-Math.Pow(x - mean, 2) / (2 * Math.Pow(stdDev, 2))) + Random.Shared.Next(300);
@@ -48,7 +48,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
allowResponseCallback.Wait(10000);
allowResponseCallback.Reset();
Schedule(() => d?.Invoke("Incorrect password", new InvalidPasswordException()));
Schedule(() => d("Incorrect password", new InvalidPasswordException()));
});
});
@@ -330,6 +330,33 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("score multiplier = 1.20", () => this.ChildrenOfType<RankingInformationDisplay>().Single().ModMultiplier.Value, () => Is.EqualTo(1.2).Within(0.01));
}
[Test]
public void TestModSelectOverlayNonDefaultSettings()
{
AddStep("add playlist item", () =>
{
room.Playlist =
[
new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo)
{
RulesetID = new OsuRuleset().RulesetInfo.OnlineID,
RequiredMods =
[
new APIMod(new OsuModSuddenDeath { FailOnSliderTail = { Value = true } }),
],
AllowedMods = [],
Freestyle = true
}
];
});
ClickButtonWhenEnabled<MultiplayerMatchSettingsOverlay.CreateOrUpdateButton>();
AddUntilStep("wait for join", () => RoomJoined);
ClickButtonWhenEnabled<UserModSelectButton>();
AddAssert("sudden death not visible", () => this.ChildrenOfType<MultiplayerUserModSelectOverlay>().Single().ChildrenOfType<ModPanel>().Single(m => m.Mod is ModSuddenDeath).Visible == false);
}
[Test]
public void TestChangeSettingsButtonVisibleForHost()
{
@@ -144,7 +144,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("4 rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 4);
AddStep("filter one room", () => container.Filter.Value = new FilterCriteria { SearchString = rooms.First().Name });
AddStep("filter one room", () => container.Filter.Value = new LoungeFilterCriteria { SearchString = rooms.First().Name });
AddUntilStep("1 rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 1);
@@ -160,13 +160,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("add rooms", () => rooms.AddRange(GenerateRooms(3, new CatchRuleset().RulesetInfo)));
// Todo: What even is this case...?
AddStep("set empty filter criteria", () => container.Filter.Value = new FilterCriteria());
AddStep("set empty filter criteria", () => container.Filter.Value = new LoungeFilterCriteria());
AddUntilStep("5 rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 5);
AddStep("filter osu! rooms", () => container.Filter.Value = new FilterCriteria { Ruleset = new OsuRuleset().RulesetInfo });
AddStep("filter osu! rooms", () => container.Filter.Value = new LoungeFilterCriteria { Ruleset = new OsuRuleset().RulesetInfo });
AddUntilStep("2 rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 2);
AddStep("filter catch rooms", () => container.Filter.Value = new FilterCriteria { Ruleset = new CatchRuleset().RulesetInfo });
AddStep("filter catch rooms", () => container.Filter.Value = new LoungeFilterCriteria { Ruleset = new CatchRuleset().RulesetInfo });
AddUntilStep("3 rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 3);
}
@@ -183,11 +183,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("both rooms visible", () => container.DrawableRooms.Count(r => r.IsPresent) == 2);
AddStep("filter public rooms", () => container.Filter.Value = new FilterCriteria { Permissions = RoomPermissionsFilter.Public });
AddStep("filter public rooms", () => container.Filter.Value = new LoungeFilterCriteria { Permissions = RoomPermissionsFilter.Public });
AddUntilStep("private room hidden", () => container.DrawableRooms.All(r => !r.Room.HasPassword));
AddStep("filter private rooms", () => container.Filter.Value = new FilterCriteria { Permissions = RoomPermissionsFilter.Private });
AddStep("filter private rooms", () => container.Filter.Value = new LoungeFilterCriteria { Permissions = RoomPermissionsFilter.Private });
AddUntilStep("public room hidden", () => container.DrawableRooms.All(r => r.Room.HasPassword));
}
@@ -114,6 +114,27 @@ namespace osu.Game.Tests.Visual.Navigation
&& songSelect.Beatmap.Value is DummyWorkingBeatmap);
}
[Test]
public void TestEditorRestoresModeOnReload()
{
prepareBeatmap();
openEditor();
makeMetadataChange(commit: false);
Editor editor1 = null!;
AddStep("store current editor", () => editor1 = getEditor());
AddStep("reload", () => getEditor().SwitchToDifficulty(getEditorBeatmap().BeatmapInfo));
AddUntilStep("save dialog displayed", () => Game.ChildrenOfType<DialogOverlay>().SingleOrDefault()?.CurrentDialog is PromptForSaveDialog);
AddStep("confirm", () => InputManager.Key(Key.Number1));
AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse);
AddAssert("editor is new instance", () => getEditor() != editor1);
AddAssert("mode is still song setup", () => getEditor().Mode.Value == EditorScreenMode.SongSetup);
}
[Test]
public void TestChangeMetadataExitWhileTextboxFocusedPromptsSave()
{
@@ -253,6 +253,11 @@ namespace osu.Game.Tests.Visual.Online
InputManager.MoveMouseTo(btn);
InputManager.Click(MouseButton.Left);
});
AddStep("Set reason to other", () =>
{
var reason = this.ChildrenOfType<OsuEnumDropdown<CommentReportReason>>().Single();
reason.Current.Value = CommentReportReason.Other;
});
AddStep("Try to report", () =>
{
var btn = this.ChildrenOfType<ReportCommentPopover>().Single().ChildrenOfType<RoundedButton>().Single();
@@ -261,12 +266,10 @@ namespace osu.Game.Tests.Visual.Online
});
AddWaitStep("Wait", 3);
AddAssert("Nothing happened", () => this.ChildrenOfType<ReportCommentPopover>().Any());
AddStep("Set report data", () =>
AddStep("Add comment", () =>
{
var field = this.ChildrenOfType<ReportCommentPopover>().Single().ChildrenOfType<OsuTextBox>().First();
field.Current.Value = report_text;
var reason = this.ChildrenOfType<OsuEnumDropdown<CommentReportReason>>().Single();
reason.Current.Value = CommentReportReason.Other;
});
AddStep("Try to report", () =>
{
@@ -56,6 +56,17 @@ namespace osu.Game.Tests.Visual.Online
{
Username = @"flyte",
Id = 3103765,
Rank = new APIUser.GlobalRank
{
Rank = null,
},
Team = new APITeam
{
Id = 2,
Name = "mom?",
ShortName = "MOM",
FlagUrl = "https://assets.ppy.sh/teams/flag/1/b46fb10dbfd8a35dc50e6c00296c0dc6172dffc3ed3d3a4b379277ba498399fe.png",
},
CountryCode = CountryCode.JP,
CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg",
WasRecentlyOnline = true
@@ -64,6 +75,17 @@ namespace osu.Game.Tests.Visual.Online
{
Username = @"peppy",
Id = 2,
Rank = new APIUser.GlobalRank
{
Rank = 9999999,
},
Team = new APITeam
{
Id = 2,
Name = "mom?",
ShortName = "MOM",
FlagUrl = "https://assets.ppy.sh/teams/flag/1/b46fb10dbfd8a35dc50e6c00296c0dc6172dffc3ed3d3a4b379277ba498399fe.png",
},
CountryCode = CountryCode.AU,
CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg",
IsSupporter = true,
@@ -72,7 +94,18 @@ namespace osu.Game.Tests.Visual.Online
new OnlineUserListPanel(new APIUser
{
Username = @"flyte",
Rank = new APIUser.GlobalRank
{
Rank = null,
},
Id = 3103765,
Team = new APITeam
{
Id = 2,
Name = "mom?",
ShortName = "MOM",
FlagUrl = "https://assets.ppy.sh/teams/flag/1/b46fb10dbfd8a35dc50e6c00296c0dc6172dffc3ed3d3a4b379277ba498399fe.png",
},
CountryCode = CountryCode.JP,
CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg",
WasRecentlyOnline = true
@@ -81,6 +114,17 @@ namespace osu.Game.Tests.Visual.Online
{
Username = @"peppy",
Id = 2,
Rank = new APIUser.GlobalRank
{
Rank = 9999999,
},
Team = new APITeam
{
Id = 2,
Name = "mom?",
ShortName = "MOM",
FlagUrl = "https://assets.ppy.sh/teams/flag/1/b46fb10dbfd8a35dc50e6c00296c0dc6172dffc3ed3d3a4b379277ba498399fe.png",
},
CountryCode = CountryCode.AU,
CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg",
LastVisit = DateTimeOffset.Now
@@ -0,0 +1,118 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using System.Net.Http;
using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Testing;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Overlays.Chat;
namespace osu.Game.Tests.Visual.Online
{
public partial class TestSceneReportPopover : OsuTestScene
{
private DummyAPIAccess dummyAPI => (DummyAPIAccess)API;
private ReportPopoverContainer popover = null!;
[SetUpSteps]
public void SetUp()
{
AddStep("create popover", () =>
{
Child = new PopoverContainer
{
RelativeSizeAxes = Axes.Both,
Child = popover = new ReportPopoverContainer(),
};
});
}
[Test]
public void TestSuccess()
{
ChatReportRequest pendingRequest = null!;
AddStep("setup request handling", () =>
{
dummyAPI.HandleRequest += request =>
{
if (request is ChatReportRequest chatReportRequest)
{
pendingRequest = chatReportRequest;
return true;
}
return false;
};
});
AddStep("show popover", () => popover.ShowPopover());
AddStep("input reason", () => this.ChildrenOfType<OsuTextBox>().First().Text = "reason");
AddStep("send report", () => this.ChildrenOfType<Button>().First().TriggerClick());
AddUntilStep("wait for loading layer to hide", () => this.ChildrenOfType<LoadingLayer>().First().IsPresent, () => Is.True);
AddWaitStep("wait some", 3);
AddStep("complete request", () => pendingRequest.TriggerSuccess());
AddUntilStep("wait for loading layer to hide", () => this.ChildrenOfType<LoadingLayer>().First().IsPresent, () => Is.False);
AddAssert("ensure form is not present", () => this.ChildrenOfType<ReverseChildIDFillFlowContainer<Drawable>>().First().IsPresent, () => Is.False);
AddAssert("ensure confirmation is present", () => this.ChildrenOfType<ReportPopover<ChatReportReason>.ReportConfirmation>().First().IsPresent, () => Is.True);
AddUntilStep("wait for popover to hide", () => this.ChildrenOfType<ReportPopoverContainer.TestReportPopover>().First().IsPresent, () => Is.False);
}
[Test]
public void TestFailure()
{
ChatReportRequest pendingRequest = null!;
AddStep("setup request handling", () =>
{
dummyAPI.HandleRequest += request =>
{
if (request is ChatReportRequest chatReportRequest)
{
pendingRequest = chatReportRequest;
return true;
}
return false;
};
});
AddStep("show popover", () => popover.ShowPopover());
AddStep("input reason", () => this.ChildrenOfType<OsuTextBox>().First().Text = "reason");
AddStep("send report", () => this.ChildrenOfType<Button>().First().TriggerClick());
AddUntilStep("wait for loading layer to hide", () => this.ChildrenOfType<LoadingLayer>().First().IsPresent, () => Is.True);
AddWaitStep("wait some", 3);
AddStep("fail request", () => pendingRequest.TriggerFailure(new APIException("test error", new HttpRequestException("test error"))));
AddUntilStep("wait for loading layer to hide", () => this.ChildrenOfType<LoadingLayer>().First().IsPresent, () => Is.False);
AddAssert("ensure form is present", () => this.ChildrenOfType<ReverseChildIDFillFlowContainer<Drawable>>().First().IsPresent, () => Is.True);
AddAssert("ensure error is present", () => this.ChildrenOfType<ErrorTextFlowContainer>().First().IsPresent, () => Is.True);
AddAssert("ensure confirmation is not present", () => this.ChildrenOfType<ReportPopover<ChatReportReason>.ReportConfirmation>().First().IsPresent, () => Is.False);
}
protected partial class ReportPopoverContainer : Drawable, IHasPopover
{
public Popover GetPopover() => new TestReportPopover("test");
public partial class TestReportPopover : ReportPopover<ChatReportReason>
{
private IAPIProvider api { get; set; } = null!;
public TestReportPopover(string name)
: base($"Report {name}?")
{
}
protected override APIRequest GetRequest(ChatReportReason reason, string comment) => new ChatReportRequest(1, reason, comment);
}
}
}
}
@@ -9,6 +9,7 @@ using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay;
using osu.Game.Tests.Resources;
using osu.Game.Tests.Visual.Multiplayer;
@@ -18,14 +19,26 @@ namespace osu.Game.Tests.Visual.RankedPlay
public abstract partial class RankedPlayTestScene : MultiplayerTestScene
{
/// <summary>
/// Returns 5 sample <see cref="APIBeatmap"/>s.
/// Returns 5 sample of the chosen ruleset <see cref="APIBeatmap"/>s.
/// </summary>
protected static APIBeatmap[] GetSampleBeatmaps()
protected static APIBeatmap[] GetSampleBeatmaps(RulesetInfo ruleset)
{
using var resourceStream = TestResources.OpenResource("Requests/api-beatmaps-rankedplay.json");
using var reader = new StreamReader(resourceStream);
switch (ruleset.OnlineID)
{
case 3:
{
using var resourceStream = TestResources.OpenResource("Requests/api-beatmaps-rankedplay-mania4k.json");
using var reader = new StreamReader(resourceStream);
return JsonConvert.DeserializeObject<APIBeatmap[]>(reader.ReadToEnd())!;
}
return JsonConvert.DeserializeObject<APIBeatmap[]>(reader.ReadToEnd())!;
default:
{
using var resourceStream = TestResources.OpenResource("Requests/api-beatmaps-rankedplay.json");
using var reader = new StreamReader(resourceStream);
return JsonConvert.DeserializeObject<APIBeatmap[]>(reader.ReadToEnd())!;
}
}
}
/// <summary>
@@ -33,7 +46,12 @@ namespace osu.Game.Tests.Visual.RankedPlay
/// </summary>
public class BeatmapRequestHandler
{
public readonly APIBeatmap[] Beatmaps = GetSampleBeatmaps();
public APIBeatmap[] Beatmaps;
public BeatmapRequestHandler(RulesetInfo ruleset)
{
Beatmaps = GetSampleBeatmaps(ruleset);
}
public bool HandleRequest(APIRequest request)
{
@@ -6,6 +6,7 @@ using osu.Game.Online.API;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay;
namespace osu.Game.Tests.Visual.RankedPlay
@@ -26,7 +27,8 @@ namespace osu.Game.Tests.Visual.RankedPlay
AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!)));
AddUntilStep("screen loaded", () => screen.IsLoaded);
var requestHandler = new BeatmapRequestHandler();
BeatmapRequestHandler requestHandler = null!;
AddStep("setup ruleset", () => requestHandler = new BeatmapRequestHandler(new OsuRuleset().RulesetInfo));
AddStep("setup request handler", () => ((DummyAPIAccess)API).HandleRequest = requestHandler.HandleRequest);
@@ -6,6 +6,7 @@ using osu.Game.Online.API;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay;
namespace osu.Game.Tests.Visual.RankedPlay
@@ -26,7 +27,8 @@ namespace osu.Game.Tests.Visual.RankedPlay
AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!)));
AddUntilStep("screen loaded", () => screen.IsLoaded);
var requestHandler = new BeatmapRequestHandler();
BeatmapRequestHandler requestHandler = null!;
AddStep("setup ruleset", () => requestHandler = new BeatmapRequestHandler(new OsuRuleset().RulesetInfo));
AddStep("setup request handler", () => ((DummyAPIAccess)API).HandleRequest = requestHandler.HandleRequest);
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Testing;
using osu.Game.Graphics.UserInterface;
@@ -9,6 +10,8 @@ using osu.Game.Online.API;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Mania;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand;
using osuTK.Input;
@@ -30,8 +33,63 @@ namespace osu.Game.Tests.Visual.RankedPlay
AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!)));
AddUntilStep("screen loaded", () => screen.IsLoaded);
}
var requestHandler = new BeatmapRequestHandler();
[Test]
public void TestOsu()
{
BeatmapRequestHandler requestHandler = null!;
AddStep("setup ruleset", () => requestHandler = new BeatmapRequestHandler(new OsuRuleset().RulesetInfo));
AddStep("setup request handler", () => ((DummyAPIAccess)API).HandleRequest = requestHandler.HandleRequest);
AddStep("set pick state", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = API.LocalUser.Value.OnlineID).WaitSafely());
AddWaitStep("wait some", 5);
AddStep("reveal cards", () =>
{
for (int i = 0; i < 5; i++)
{
int i2 = i;
MultiplayerClient.RankedPlayRevealCard(hand => hand[i2], new MultiplayerPlaylistItem
{
ID = i2,
BeatmapID = requestHandler.Beatmaps[i2].OnlineID
}).WaitSafely();
}
});
for (int i = 0; i < 3; i++)
{
int i2 = i;
AddStep($"click card {i2}", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<PlayerHandOfCards.PlayerHandCard>().ElementAt(i2));
InputManager.Click(MouseButton.Left);
});
}
AddWaitStep("wait", 3);
AddStep("click play button", () =>
{
var button = screen
.ChildrenOfType<PlayerHandOfCards.PlayerHandCard>()
.First(it => it.Selected)
.ChildrenOfType<ShearedButton>()
.First();
InputManager.MoveMouseTo(button);
InputManager.Click(MouseButton.Left);
});
}
[Test]
public void TestMania()
{
BeatmapRequestHandler requestHandler = null!;
AddStep("setup ruleset", () => requestHandler = new BeatmapRequestHandler(new ManiaRuleset().RulesetInfo));
AddStep("setup request handler", () => ((DummyAPIAccess)API).HandleRequest = requestHandler.HandleRequest);
@@ -56,14 +56,14 @@ namespace osu.Game.Tests.Visual.RankedPlay
});
AddStep("single selection mode", () => handOfCards.SelectionMode = HandSelectionMode.Single);
AddStep("click first card", () => handOfCards.Cards.First().TriggerClick());
AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.First().Item]));
AddStep("click first card", () => handOfCards.GetCardsInDisplayOrder()[0].TriggerClick());
AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[0].Item]));
AddStep("click second card", () => handOfCards.Cards.ElementAt(1).TriggerClick());
AddAssert("second card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(1).Item]));
AddStep("click second card", () => handOfCards.GetCardsInDisplayOrder()[1].TriggerClick());
AddAssert("second card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[1].Item]));
AddStep("click second card again", () => handOfCards.Cards.ElementAt(1).TriggerClick());
AddAssert("second card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(1).Item]));
AddStep("click second card again", () => handOfCards.GetCardsInDisplayOrder()[1].TriggerClick());
AddAssert("second card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[1].Item]));
}
[Test]
@@ -76,14 +76,14 @@ namespace osu.Game.Tests.Visual.RankedPlay
});
AddStep("multi selection mode", () => handOfCards.SelectionMode = HandSelectionMode.Multiple);
AddStep("click first card", () => handOfCards.Cards.First().TriggerClick());
AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.First().Item]));
AddStep("click first card", () => handOfCards.GetCardsInDisplayOrder().First().TriggerClick());
AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder().First().Item]));
AddStep("click second card", () => handOfCards.Cards.ElementAt(1).TriggerClick());
AddAssert("both cards selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(0).Item, handOfCards.Cards.ElementAt(1).Item]));
AddStep("click second card", () => handOfCards.GetCardsInDisplayOrder()[1].TriggerClick());
AddAssert("both cards selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[0].Item, handOfCards.GetCardsInDisplayOrder()[1].Item]));
AddStep("click second card again", () => handOfCards.Cards.ElementAt(1).TriggerClick());
AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(0).Item]));
AddStep("click second card again", () => handOfCards.GetCardsInDisplayOrder()[1].TriggerClick());
AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[0].Item]));
}
[Test]
@@ -131,20 +131,20 @@ namespace osu.Game.Tests.Visual.RankedPlay
Key key = Key.Number1 + i;
AddStep($"key {i + 1}", () => InputManager.Key(key));
AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(i1).Item]));
AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[i1].Item]));
}
AddStep("right arrow", () => InputManager.Key(Key.Right));
AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(0).Item]));
AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[0].Item]));
AddStep("right arrow", () => InputManager.Key(Key.Right));
AddAssert("second card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(1).Item]));
AddAssert("second card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[1].Item]));
AddStep("left arrow", () => InputManager.Key(Key.Left));
AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(0).Item]));
AddAssert("first card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[0].Item]));
AddStep("left arrow", () => InputManager.Key(Key.Left));
AddAssert("last card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.Cards.ElementAt(^1).Item]));
AddAssert("last card selected", () => handOfCards.Selection.SequenceEqual([handOfCards.GetCardsInDisplayOrder()[^1].Item]));
AddStep("space", () => InputManager.Key(Key.Space));
AddAssert("play action triggered", () => playActionTriggered);
@@ -166,11 +166,11 @@ namespace osu.Game.Tests.Visual.RankedPlay
Key key = Key.Number1 + i;
AddStep($"key {i + 1}", () => InputManager.Key(key));
AddAssert("card hovered", () => handOfCards.Cards.ElementAt(i1).CardHovered);
AddAssert("card hovered", () => handOfCards.GetCardsInDisplayOrder()[i1].CardHovered);
AddAssert("card not selected", () => !handOfCards.Selection.Contains(handOfCards.Cards.ElementAt(i1).Card.Item));
AddAssert("card not selected", () => !handOfCards.Selection.Contains(handOfCards.GetCardsInDisplayOrder()[i1].Card.Item));
AddStep("space", () => InputManager.Key(Key.Space));
AddAssert("card selected", () => handOfCards.Selection.Contains(handOfCards.Cards.ElementAt(i1).Card.Item));
AddAssert("card selected", () => handOfCards.Selection.Contains(handOfCards.GetCardsInDisplayOrder()[i1].Card.Item));
}
}
@@ -201,9 +201,9 @@ namespace osu.Game.Tests.Visual.RankedPlay
for (int i = 0; i < 5; i++)
handOfCards.AddCard(new RankedPlayCardWithPlaylistItem(new RankedPlayCardItem()));
});
AddStep("hover card", () => InputManager.MoveMouseTo(handOfCards.Cards.First()));
AddStep("hover card", () => InputManager.MoveMouseTo(handOfCards.GetCardsInDisplayOrder()[0]));
AddStep("start drag", () => InputManager.PressButton(MouseButton.Left));
AddStep("move card", () => InputManager.MoveMouseTo(handOfCards.Cards[3]));
AddStep("move card", () => InputManager.MoveMouseTo(handOfCards.GetCardsInDisplayOrder()[3]));
AddStep("remove cards", () =>
{
foreach (var card in handOfCards.Cards.ToArray())
@@ -211,5 +211,13 @@ namespace osu.Game.Tests.Visual.RankedPlay
});
AddStep("release mouse", () => InputManager.ReleaseButton(MouseButton.Left));
}
[Test]
public void TestKeyboardSelectionWithoutCards()
{
AddAssert("no cards", () => !handOfCards.Cards.Any());
AddStep("right arrow", () => InputManager.Key(Key.Right));
AddStep("left arrow", () => InputManager.Key(Key.Left));
}
}
}
@@ -0,0 +1,53 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Screens.OnlinePlay.Matchmaking;
using osuTK.Graphics;
namespace osu.Game.Tests.Visual.RankedPlay
{
public partial class TestSceneRankedPlayBottomOrnament : OsuTestScene
{
private readonly TestOrnament ornament;
public TestSceneRankedPlayBottomOrnament()
{
Child = new Container
{
Width = 400,
Height = 24,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Color4.Gray,
},
ornament = new TestOrnament(),
}
};
}
[Test]
public void TestAnimations()
{
AddStep("hide", () => ornament.Hide());
AddStep("show", () => ornament.Show());
AddSliderStep("Progress", 0f, 1f, 0f, p => ornament.Progress = p);
}
private partial class TestOrnament : RankedPlayBottomOrnament
{
public new float Progress
{
set => base.Progress = value;
}
}
}
}
@@ -12,6 +12,10 @@ using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Catch;
using osu.Game.Rulesets.Mania;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Taiko;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand;
using osuTK;
@@ -31,8 +35,6 @@ namespace osu.Game.Tests.Visual.RankedPlay
[Cached]
private readonly SongPreviewParticleContainer particleContainer;
private readonly BeatmapRequestHandler requestHandler = new BeatmapRequestHandler();
public TestSceneRankedPlayCard()
{
base.Content.AddRange(new Drawable[]
@@ -121,6 +123,8 @@ namespace osu.Game.Tests.Visual.RankedPlay
[Test]
public void TestCardHand()
{
BeatmapRequestHandler requestHandler = null!;
AddStep("setup ruleset", () => requestHandler = new BeatmapRequestHandler(new OsuRuleset().RulesetInfo));
AddStep("setup request handler", () => ((DummyAPIAccess)API).HandleRequest = requestHandler.HandleRequest);
AddStep("add cards", () =>
@@ -143,13 +147,16 @@ namespace osu.Game.Tests.Visual.RankedPlay
});
}
[Resolved]
private RulesetStore rulesetStore { get; set; } = null!;
[Test]
public void TestRulesets()
{
var rulesets = rulesetStore.AvailableRulesets.Where(it => it.OnlineID >= 0);
RulesetInfo[] rulesets =
[
new OsuRuleset().RulesetInfo,
new TaikoRuleset().RulesetInfo,
new CatchRuleset().RulesetInfo,
new ManiaRuleset().RulesetInfo
];
foreach (var ruleset in rulesets)
{
@@ -8,9 +8,12 @@ using osu.Framework.Testing;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand;
using osuTK.Input;
@@ -26,21 +29,35 @@ namespace osu.Game.Tests.Visual.RankedPlay
AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.RankedPlay)));
WaitForJoined();
AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 }));
AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!)));
}
[Test]
public void TestIntroStage()
{
AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 }));
AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!)));
AddStep("set round warmup phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.RoundWarmup, s => s.StarRating = 6.3f).WaitSafely());
}
[Test]
public void TestUnresolvedUser()
{
AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = TestUserLookupCache.UNRESOLVED_USER_ID }));
AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!)));
AddStep("set round warmup phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.RoundWarmup, s => s.StarRating = 6.3f).WaitSafely());
}
[Test]
public void TestDiscardCardsStage()
{
AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 }));
AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!)));
AddStep("set discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely());
AddWaitStep("wait", 3);
@@ -72,6 +89,10 @@ namespace osu.Game.Tests.Visual.RankedPlay
[Test]
public void TestAddRemoveCards()
{
AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 }));
AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!)));
AddStep("set discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely());
for (int i = 0; i < 3; i++)
@@ -84,7 +105,11 @@ namespace osu.Game.Tests.Visual.RankedPlay
[Test]
public void TestRevealCards()
{
var requestHandler = new BeatmapRequestHandler();
AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 }));
AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!)));
var requestHandler = new BeatmapRequestHandler(Ruleset.Value);
AddStep("setup request handler", () => ((DummyAPIAccess)API).HandleRequest = requestHandler.HandleRequest);
@@ -104,6 +129,10 @@ namespace osu.Game.Tests.Visual.RankedPlay
[Test]
public void TestPlayCardDirect()
{
AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 }));
AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!)));
AddStep("set play phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = API.LocalUser.Value.OnlineID).WaitSafely());
AddWaitStep("wait", 3);
AddStep("play card", () => MultiplayerClient.PlayCard(hand => hand[0]).WaitSafely());
@@ -112,6 +141,10 @@ namespace osu.Game.Tests.Visual.RankedPlay
[Test]
public void TestDiscardCardsDirect()
{
AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 }));
AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!)));
AddStep("set discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely());
AddWaitStep("wait", 3);
AddStep("discard cards", () => MultiplayerClient.DiscardCards(hand => hand.Take(3)).WaitSafely());
@@ -122,6 +155,10 @@ namespace osu.Game.Tests.Visual.RankedPlay
[Test]
public void TestPlayStage()
{
AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 }));
AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!)));
AddStep("set play phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = API.LocalUser.Value.OnlineID).WaitSafely());
AddUntilStep("wait until cards are present", () => this.ChildrenOfType<PlayerHandOfCards.PlayerHandCard>().Count() == 5);
@@ -153,6 +190,10 @@ namespace osu.Game.Tests.Visual.RankedPlay
[Test]
public void TestOtherPlaysCard()
{
AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 }));
AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!)));
AddStep("set play phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = 2).WaitSafely());
AddWaitStep("wait", 5);
AddStep("play beatmap", () => MultiplayerClient.PlayUserCard(2, hand => hand[0]).WaitSafely());
@@ -166,11 +207,52 @@ namespace osu.Game.Tests.Visual.RankedPlay
[Test]
public void TestHealthChange()
{
AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 }));
AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!)));
AddStep("set play phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = 2).WaitSafely());
AddWaitStep("wait", 5);
AddStep("change player 1 health", () => MultiplayerClient.RankedPlayChangeUserState(MultiplayerClient.LocalUser!.UserID, state => state.Life = 250_000).WaitSafely());
AddWaitStep("wait", 5);
AddStep("change player 2 health", () => MultiplayerClient.RankedPlayChangeUserState(2, state => state.Life = 250_000).WaitSafely());
}
[Test]
public void TestPreviewStopsOnEnteringGameplay()
{
AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 2 }));
AddStep("load screen", () => LoadScreen(screen = new RankedPlayScreen(MultiplayerClient.ClientRoom!)));
BeatmapRequestHandler requestHandler = null!;
AddStep("setup ruleset", () => requestHandler = new BeatmapRequestHandler(new OsuRuleset().RulesetInfo));
AddStep("setup request handler", () => ((DummyAPIAccess)API).HandleRequest = requestHandler.HandleRequest);
AddStep("set play phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardPlay, state => state.ActiveUserId = 1001).WaitSafely());
for (int i = 0; i < 3; i++)
{
int i2 = i;
AddStep("reveal card", () => MultiplayerClient.RankedPlayRevealCard(hand => hand[i2], new MultiplayerPlaylistItem
{
ID = i2,
BeatmapID = requestHandler.Beatmaps[i2].OnlineID
}).WaitSafely());
}
AddStep("hover first card", () => InputManager.MoveMouseTo(this.ChildrenOfType<PlayerHandOfCards>().Single().Cards.First()));
AddUntilStep("preview playing", () => this.ChildrenOfType<RankedPlayCard.SongPreviewContainer>().Any(p => p.IsRunning), () => Is.True);
AddWaitStep("wait", 1);
AddStep("play beatmap", () => MultiplayerClient.PlayUserCard(1001, hand => hand[0]).WaitSafely());
AddStep("set warmup", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.GameplayWarmup).WaitSafely());
AddUntilStep("preview running", () => this.ChildrenOfType<RankedPlayCard.SongPreviewContainer>().Any(p => p.IsRunning), () => Is.True);
AddStep("load requested", () => ((IMultiplayerClient)MultiplayerClient).LoadRequested());
AddUntilStep("preview stopped", () => this.ChildrenOfType<RankedPlayCard.SongPreviewContainer>().Any(p => p.IsRunning), () => Is.False);
}
}
}
@@ -7,6 +7,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Online.API;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Hand;
using osuTK;
@@ -17,18 +18,18 @@ namespace osu.Game.Tests.Visual.RankedPlay
{
private readonly Bindable<bool> previewEnabled = new BindableBool(true);
private readonly BeatmapRequestHandler requestHandler = new BeatmapRequestHandler();
public override void SetUpSteps()
{
base.SetUpSteps();
BeatmapRequestHandler requestHandler = null!;
AddStep("setup ruleset", () => requestHandler = new BeatmapRequestHandler(new OsuRuleset().RulesetInfo));
AddStep("setup request handler", () => ((DummyAPIAccess)API).HandleRequest = requestHandler.HandleRequest);
AddStep("add cards", () =>
{
PlayerHandOfCards handOfCards;
Child = handOfCards = new PlayerHandOfCards
{
RelativeSizeAxes = Axes.Both,
@@ -76,6 +76,12 @@ namespace osu.Game.Tests.Visual.SongSelect
[Test]
public void TestLocalRank()
{
AddStep("set null rank", () => this.ChildrenOfType<UpdateableRank>().ForEach(p =>
{
p.Hide();
p.Rank = null;
}));
foreach (var rank in Enum.GetValues<ScoreRank>())
{
AddStep($"set {rank.GetDescription()} rank", () => this.ChildrenOfType<UpdateableRank>().ForEach(p =>
+4 -6
View File
@@ -20,6 +20,7 @@ using osu.Game.Beatmaps.Formats;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.IO.Archives;
using osu.Game.Localisation;
using osu.Game.Models;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
@@ -372,7 +373,6 @@ namespace osu.Game.Beatmaps
public void ResetAllOffsets()
{
const string reset_complete_message = "All offsets have been reset!";
Realm.Write(r =>
{
var items = r.All<BeatmapInfo>();
@@ -383,7 +383,7 @@ namespace osu.Game.Beatmaps
beatmap.UserSettings.Offset = 0;
}
PostNotification?.Invoke(new ProgressCompletionNotification { Text = reset_complete_message });
PostNotification?.Invoke(new ProgressCompletionNotification { Text = MaintenanceSettingsStrings.AllOffsetsReset });
});
}
@@ -435,12 +435,10 @@ namespace osu.Game.Beatmaps
/// </summary>
public void DeleteVideos(List<BeatmapSetInfo> items, bool silent = false)
{
const string no_videos_message = "No videos found to delete!";
if (items.Count == 0)
{
if (!silent)
PostNotification?.Invoke(new ProgressCompletionNotification { Text = no_videos_message });
PostNotification?.Invoke(new ProgressCompletionNotification { Text = MaintenanceSettingsStrings.NoVideosFoundToDelete });
return;
}
@@ -448,7 +446,7 @@ namespace osu.Game.Beatmaps
{
Progress = 0,
Text = $"Preparing to delete all {HumanisedModelName} videos...",
CompletionText = no_videos_message,
CompletionText = MaintenanceSettingsStrings.NoVideosFoundToDelete,
State = ProgressNotificationState.Active,
};
@@ -21,7 +21,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards
{
public abstract partial class BeatmapCard : OsuClickableContainer, IHasContextMenu
{
public const float TRANSITION_DURATION = 340;
public const float TRANSITION_DURATION = 360;
public const float CORNER_RADIUS = 8;
public const float WIDTH = 345;
@@ -174,6 +174,8 @@ namespace osu.Game.Beatmaps.Drawables.Cards
// By limiting the width we avoid this box showing up as an outline around the drawables that are on top of it.
background.ResizeWidthTo(buttonAreaWidth + BeatmapCard.CORNER_RADIUS, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
if (ButtonsCollapsedWidth == 0)
background.FadeTo(ShowDetails.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
background.FadeColour(downloadTracker.State.Value == DownloadState.LocallyAvailable ? colours.Lime0 : colourProvider.Background3, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
buttons.FadeTo(ShowDetails.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
@@ -9,8 +9,8 @@ namespace osu.Game.Configuration
{
protected override string Filename => base.Filename.Replace(".ini", ".dev.ini");
public DevelopmentOsuConfigManager(Storage storage)
: base(storage)
public DevelopmentOsuConfigManager(Storage storage, GameHost? host = null)
: base(storage, host)
{
}
}
+19 -1
View File
@@ -2,12 +2,15 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Framework;
using osu.Framework.Bindables;
using osu.Framework.Configuration;
using osu.Framework.Configuration.Tracking;
using osu.Framework.Extensions;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Input.Handlers.Mouse;
using osu.Framework.Input.Handlers.Pen;
using osu.Framework.Localisation;
using osu.Framework.Platform;
using osu.Game.Beatmaps.Drawables.Cards;
@@ -30,9 +33,13 @@ namespace osu.Game.Configuration
{
public class OsuConfigManager : IniConfigManager<OsuSetting>, IGameplaySettings
{
public OsuConfigManager(Storage storage)
private readonly GameHost? host;
public OsuConfigManager(Storage storage, GameHost? host = null)
: base(storage)
{
this.host = host;
Migrate();
}
@@ -274,6 +281,17 @@ namespace osu.Game.Configuration
if (RuntimeInfo.IsMobile)
GetBindable<float>(OsuSetting.UIScale).SetDefault();
}
if (combined < 20250428)
{
// Pen tablet sensitivity is now separated from cursor sensitivity.
// Most users will want the default to be what they already had set on cursor sensitivity so let's transfer it.
var mouseHandler = host?.AvailableInputHandlers.OfType<MouseHandler>().SingleOrDefault();
var penHandler = host?.AvailableInputHandlers.OfType<PenHandler>().SingleOrDefault();
if (penHandler != null && mouseHandler != null && penHandler.Sensitivity.IsDefault)
penHandler.Sensitivity.Value = mouseHandler.Sensitivity.Value;
}
}
public override TrackedSettings CreateTrackedSettings()
@@ -661,23 +661,22 @@ namespace osu.Game.Database
return;
}
Logger.Log(@"Querying for beatmaps that do not have user tags");
Logger.Log(@"Updating user tags");
// it is not an abnormal situation for a map not to have user tags.
// while this is constrained to run every month or so (every time a new online.db cache is retrieved), there's some chance that this will still run much too often and be annoying to users.
// if that turns out to be the case we may need a better way to debounce this (or just delete the backpopulation logic after some time has passed?)
HashSet<Guid> beatmapIds = realmAccess.Run(r => new HashSet<Guid>(
r.All<BeatmapInfo>()
.Filter($"{nameof(BeatmapInfo.Metadata)}.{nameof(BeatmapMetadata.UserTags)}.@count == 0 AND {nameof(BeatmapInfo.StatusInt)} IN {{ 1,2,4 }}")
.Filter($"{nameof(BeatmapInfo.StatusInt)} IN {{ 1,2,4 }}")
.AsEnumerable()
.Select(b => b.ID)));
if (beatmapIds.Count == 0)
return;
Logger.Log($@"Found {beatmapIds.Count} beatmaps with missing user tags.");
Logger.Log($@"Checking for tag updates for {beatmapIds.Count} beatmaps.");
var notification = showProgressNotification(beatmapIds.Count, @"Populating missing user tags",
var notification = showProgressNotification(beatmapIds.Count, @"Updating user tags",
@"beatmaps have had their tags updated. This runs once a month to allow searching user tags.");
int processedCount = 0;
+2
View File
@@ -12,6 +12,8 @@ namespace osu.Game.Database
{
public partial class BeatmapLookupCache : OnlineLookupCache<int, APIBeatmap, GetBeatmapsRequest>
{
protected override bool CacheNullValues => false;
/// <summary>
/// Perform an API lookup on the specified beatmap, populating a <see cref="APIBeatmap"/> model.
/// </summary>
@@ -37,7 +37,7 @@ namespace osu.Game.Extensions
return floatValue.ToString($@"0.{new string('0', Math.Max(0, significantDigits - 2))}%", CultureInfo.CurrentCulture);
}
string negativeSign = Math.Round(floatValue, significantDigits) < 0 ? "-" : string.Empty;
string negativeSign = Math.Round(floatValue, significantDigits) < 0 ? CultureInfo.CurrentCulture.NumberFormat.NegativeSign : string.Empty;
return $"{negativeSign}{Math.Abs(floatValue).ToString($"N{significantDigits}", CultureInfo.CurrentCulture)}";
}
@@ -3,8 +3,10 @@
using System;
using osu.Framework.Allocation;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Localisation;
using osu.Game.Screens.Footer;
namespace osu.Game.Graphics.UserInterface
@@ -24,7 +26,7 @@ namespace osu.Game.Graphics.UserInterface
{
Anchor = Anchor.TopLeft,
Origin = Anchor.TopLeft,
Text = @"back",
Text = CommonStrings.Back.ToLower(),
Icon = OsuIcon.LeftCircle,
Action = () => Action?.Invoke()
};
@@ -7,6 +7,7 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Game.Localisation;
using osu.Game.Online;
using osuTK;
@@ -49,19 +50,19 @@ namespace osu.Game.Graphics.UserInterface
Background.FadeColour(colours.Gray4, 500, Easing.InOutExpo);
Icon.MoveToX(0, 500, Easing.InOutExpo);
checkmark.ScaleTo(Vector2.Zero, 500, Easing.InOutExpo);
TooltipText = "Download";
TooltipText = CommonStrings.Download;
break;
case DownloadState.Downloading:
Background.FadeColour(colours.Blue, 500, Easing.InOutExpo);
Icon.MoveToX(0, 500, Easing.InOutExpo);
checkmark.ScaleTo(Vector2.Zero, 500, Easing.InOutExpo);
TooltipText = "Downloading...";
TooltipText = CommonStrings.Downloading;
break;
case DownloadState.Importing:
Background.FadeColour(colours.Yellow, 500, Easing.InOutExpo);
TooltipText = "Importing";
TooltipText = CommonStrings.Importing;
break;
case DownloadState.LocallyAvailable:
@@ -10,6 +10,7 @@ using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
@@ -161,7 +162,7 @@ namespace osu.Game.Graphics.UserInterface
set => bouncingIcon.Icon = value;
}
public string Text
public LocalisableString Text
{
set => text.Text = value;
}
@@ -11,6 +11,7 @@ using osu.Framework.Localisation;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Resources.Localisation.Web;
using osuTK;
@@ -23,36 +24,223 @@ namespace osu.Game.Graphics.UserInterfaceV2
public abstract partial class ReportPopover<TReportReason> : OsuPopover
where TReportReason : struct, Enum
{
/// <summary>
/// The action to run when the report is finalised.
/// The arguments to this action are: the reason for the report, and an optional additional comment.
/// </summary>
public Action<TReportReason, string>? Action;
[Resolved]
private IAPIProvider api { get; set; } = null!;
/// <summary>
/// The action to run when the report is submitted.
/// </summary>
public event Action? Submitted;
/// <summary>
/// The action to run when the report is submitted successfully.
/// </summary>
public event Action? Success;
/// <summary>
/// The action to run when the report failed to submit.
/// </summary>
public event Action? Failure;
private ReverseChildIDFillFlowContainer<Drawable> form = null!;
private ReportConfirmation confirmation = null!;
private OsuEnumDropdown<TReportReason> reasonDropdown = null!;
private OsuTextBox commentsTextBox = null!;
private ErrorTextFlowContainer errorMessage = null!;
private RoundedButton submitButton = null!;
private LoadingLayer loadingLayer = null!;
private readonly LocalisableString header;
private readonly bool showConfirmation;
/// <summary>
/// Creates a new <see cref="ReportPopover{TReportReason}"/>.
/// </summary>
/// <param name="headerString">The text to display in the header of the popover.</param>
protected ReportPopover(LocalisableString headerString)
/// <param name="showConfirmation">
/// Whether the popover should show a generic "Thank you for your report" confirmation message.
/// Set this to `true` if you're displaying a custom message outside of this popover.
/// </param>
protected ReportPopover(LocalisableString headerString, bool showConfirmation = true)
: base(false)
{
header = headerString;
this.showConfirmation = showConfirmation;
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
Child = new ReverseChildIDFillFlowContainer<Drawable>
Content.AutoSizeAxes = Axes.Y;
Content.Width = 500;
Children = new Drawable[]
{
Direction = FillDirection.Vertical,
Width = 500,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(7),
form = new ReverseChildIDFillFlowContainer<Drawable>
{
Direction = FillDirection.Vertical,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(7),
Padding = new MarginPadding(20),
Children = new Drawable[]
{
new SpriteIcon
{
Origin = Anchor.TopCentre,
Anchor = Anchor.TopCentre,
Icon = FontAwesome.Solid.ExclamationTriangle,
Size = new Vector2(36),
},
new OsuSpriteText
{
Origin = Anchor.TopCentre,
Anchor = Anchor.TopCentre,
Text = header,
Font = OsuFont.Torus.With(size: 25),
Margin = new MarginPadding { Bottom = 10 }
},
new OsuSpriteText
{
Origin = Anchor.TopCentre,
Anchor = Anchor.TopCentre,
Text = UsersStrings.ReportReason,
},
new Container
{
RelativeSizeAxes = Axes.X,
Height = 40,
Child = reasonDropdown = new OsuEnumDropdown<TReportReason>
{
RelativeSizeAxes = Axes.X
}
},
new OsuSpriteText
{
Origin = Anchor.TopCentre,
Anchor = Anchor.TopCentre,
Text = UsersStrings.ReportComments,
},
commentsTextBox = new OsuTextBox
{
RelativeSizeAxes = Axes.X,
PlaceholderText = UsersStrings.ReportPlaceholder,
},
errorMessage = new ErrorTextFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
},
submitButton = new RoundedButton
{
Origin = Anchor.TopCentre,
Anchor = Anchor.TopCentre,
Width = 200,
BackgroundColour = colours.Red3,
Text = UsersStrings.ReportActionsSend,
Action = () =>
{
if (showConfirmation)
loadingLayer.Show();
// we don't want size easing to mess up any transforms that are happening
// when the popover is appearing, hence easing is only enabled after
// the report is submitted
Content.AutoSizeEasing = Easing.OutQuint;
Content.AutoSizeDuration = 500F;
Submitted?.Invoke();
performRequest();
if (!showConfirmation)
this.HidePopover();
},
Margin = new MarginPadding { Bottom = 5, Top = 10 },
},
},
},
confirmation = new ReportConfirmation(),
loadingLayer = new LoadingLayer(true)
{
RelativeSizeAxes = Axes.Both,
},
};
commentsTextBox.Current.BindValueChanged(_ => updateStatus());
reasonDropdown.Current.BindValueChanged(_ => updateStatus());
updateStatus();
}
private void performRequest()
{
var request = GetRequest(reasonDropdown.Current.Value, commentsTextBox.Text);
request.Success += handleSuccess;
request.Failure += handleFailure;
api.Queue(request);
}
private void handleSuccess()
{
if (showConfirmation)
{
Schedule(() =>
{
form.Hide();
confirmation.Show();
loadingLayer.Hide();
Scheduler.AddDelayed(this.HidePopover, 2000);
});
}
Success?.Invoke();
}
private void handleFailure(Exception e)
{
if (showConfirmation)
{
Schedule(() => errorMessage.AddErrors([e.Message]));
loadingLayer.Hide();
}
Failure?.Invoke();
}
private void updateStatus()
{
submitButton.Enabled.Value = !string.IsNullOrWhiteSpace(commentsTextBox.Current.Value) || !IsCommentRequired(reasonDropdown.Current.Value);
}
/// <summary>
/// Returns the API request responsible for submitting this report.
/// </summary>
/// <param name="reason">The reason for this report.</param>
/// <param name="comments">An optional comment explaining the report.</param>
/// <returns></returns>
protected abstract APIRequest GetRequest(TReportReason reason, string comments);
/// <summary>
/// Determines whether an additional comment is required for submitting the report with the supplied <paramref name="reason"/>.
/// </summary>
protected virtual bool IsCommentRequired(TReportReason reason) => true;
public partial class ReportConfirmation : FillFlowContainer
{
public ReportConfirmation()
{
Direction = FillDirection.Vertical;
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
Spacing = new Vector2(7);
Padding = new MarginPadding(20);
Alpha = 0;
Children = new Drawable[]
{
new SpriteIcon
@@ -66,68 +254,12 @@ namespace osu.Game.Graphics.UserInterfaceV2
{
Origin = Anchor.TopCentre,
Anchor = Anchor.TopCentre,
Text = header,
Text = UsersStrings.ReportThanks,
Font = OsuFont.Torus.With(size: 25),
Margin = new MarginPadding { Bottom = 10 }
},
new OsuSpriteText
{
Origin = Anchor.TopCentre,
Anchor = Anchor.TopCentre,
Text = UsersStrings.ReportReason,
},
new Container
{
RelativeSizeAxes = Axes.X,
Height = 40,
Child = reasonDropdown = new OsuEnumDropdown<TReportReason>
{
RelativeSizeAxes = Axes.X
}
},
new OsuSpriteText
{
Origin = Anchor.TopCentre,
Anchor = Anchor.TopCentre,
Text = UsersStrings.ReportComments,
},
commentsTextBox = new OsuTextBox
{
RelativeSizeAxes = Axes.X,
PlaceholderText = UsersStrings.ReportPlaceholder,
},
submitButton = new RoundedButton
{
Origin = Anchor.TopCentre,
Anchor = Anchor.TopCentre,
Width = 200,
BackgroundColour = colours.Red3,
Text = UsersStrings.ReportActionsSend,
Action = () =>
{
Action?.Invoke(reasonDropdown.Current.Value, commentsTextBox.Text);
this.HidePopover();
},
Margin = new MarginPadding { Bottom = 5, Top = 10 },
}
}
};
commentsTextBox.Current.BindValueChanged(_ => updateStatus());
reasonDropdown.Current.BindValueChanged(_ => updateStatus());
updateStatus();
};
}
}
private void updateStatus()
{
submitButton.Enabled.Value = !string.IsNullOrWhiteSpace(commentsTextBox.Current.Value) || !IsCommentRequired(reasonDropdown.Current.Value);
}
/// <summary>
/// Determines whether an additional comment is required for submitting the report with the supplied <paramref name="reason"/>.
/// </summary>
protected virtual bool IsCommentRequired(TReportReason reason) => true;
}
}
+169
View File
@@ -0,0 +1,169 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace osu.Game.IPC
{
/// <summary>
/// Represents a WebSocket-based communication channel.
/// Only supports UTF-8 string-based messages, of maximum size of <see cref="max_message_size"/> bytes.
/// </summary>
public sealed class WebSocketChannel : IDisposable
{
public event Action<string>? MessageReceived;
public event Action? ClosedPrematurely;
private const int max_message_size = 4096; // bytes
private readonly byte[] receiveBuffer = new byte[max_message_size];
private int currentBufferPosition;
private readonly WebSocket webSocket;
private Task? readWriteTask;
private readonly CancellationTokenSource runningTokenSource = new CancellationTokenSource();
private bool isDisposed;
public WebSocketChannel(WebSocket webSocket)
{
this.webSocket = webSocket;
}
/// <summary>
/// Starts the channel.
/// </summary>
/// <param name="cancellationToken">Use this to abort the start.</param>
public void Start(CancellationToken cancellationToken)
{
if (readWriteTask?.Status >= TaskStatus.Running)
throw new InvalidOperationException($@"Cannot {nameof(Start)} more than once.");
readWriteTask = Task.Run(readWriteLoop, cancellationToken);
}
private async Task readWriteLoop()
{
var token = runningTokenSource.Token;
while (!token.IsCancellationRequested)
{
ValueWebSocketReceiveResult result;
try
{
result = await webSocket.ReceiveAsync(receiveBuffer.AsMemory(currentBufferPosition), token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// normal when `token` is cancelled.
// at this point the websocket will have entered `Aborted` state on its own, so no further clean-up can be done.
return;
}
catch (Exception)
{
// could throw something like `WebSocketException`s from the other side hard-aborting.
ClosedPrematurely?.Invoke();
return;
}
currentBufferPosition += result.Count;
if (webSocket.State > WebSocketState.Open)
{
if (webSocket.State == WebSocketState.CloseReceived)
{
try
{
// attempt to complete the close handshake nicely.
await webSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, @"Received close request", token).ConfigureAwait(false);
}
catch
{
// an attempt was made, and failed. bail.
}
}
ClosedPrematurely?.Invoke();
return;
}
if (result.MessageType == WebSocketMessageType.Binary)
{
// see https://github.com/dotnet/runtime/issues/81762#issuecomment-1421029475 for difference between `CloseAsync()` and `CloseOutputAsync()`.
// there is basically no incentive to use `CloseAsync()` in these error scenarios. the point is to drop the errant peer on the floor immediately.
await webSocket.CloseOutputAsync(WebSocketCloseStatus.InvalidMessageType, @"Binary messages are not supported.", token).ConfigureAwait(false);
ClosedPrematurely?.Invoke();
return;
}
if (currentBufferPosition >= max_message_size)
{
await webSocket.CloseOutputAsync(WebSocketCloseStatus.MessageTooBig, $@"Exceeded maximum message size of {max_message_size} bytes.", token).ConfigureAwait(false);
ClosedPrematurely?.Invoke();
return;
}
if (result.EndOfMessage)
{
string message;
try
{
message = Encoding.UTF8.GetString(receiveBuffer, 0, currentBufferPosition);
}
catch (ArgumentException)
{
await webSocket.CloseOutputAsync(WebSocketCloseStatus.InvalidPayloadData, @"UTF-8 encoded strings expected.", token).ConfigureAwait(false);
ClosedPrematurely?.Invoke();
return;
}
MessageReceived?.Invoke(message);
Array.Fill(receiveBuffer, (byte)0, 0, currentBufferPosition);
currentBufferPosition = 0;
}
}
}
public async Task SendAsync(string message)
{
if (readWriteTask == null)
throw new InvalidOperationException($@"Must {nameof(Start)} first.");
byte[] bytes = Encoding.UTF8.GetBytes(message);
await webSocket.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None).ConfigureAwait(false);
}
/// <summary>
/// Stops the channel.
/// </summary>
/// <param name="stoppingToken">Cancel this to transition from a graceful shutdown to a forced shutdown.</param>
public async Task StopAsync(CancellationToken stoppingToken)
{
if (isDisposed)
return;
await runningTokenSource.CancelAsync().ConfigureAwait(false);
if (readWriteTask != null)
await readWriteTask.WaitAsync(stoppingToken).ConfigureAwait(false);
if (stoppingToken.IsCancellationRequested)
webSocket.Abort();
}
public void Dispose()
{
if (isDisposed)
return;
isDisposed = true;
webSocket.Dispose();
runningTokenSource.Dispose();
}
}
}
+280
View File
@@ -0,0 +1,280 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Logging;
namespace osu.Game.IPC
{
/// <summary>
/// Implements a WebSocket server to be used for external integrations such as streaming overlays.
/// The server can only listen on <c>localhost</c>, on the port given in the constructor.
/// Only UTF-8 string-based messages are supported. Binary messages are not supported.
/// String-based messages must not exceed <see cref="WebSocketChannel.max_message_size"/> bytes.
/// </summary>
/// <remarks>
/// This implementation uses <see cref="HttpListener"/> internally.
/// This is a frozen .NET API as per https://github.com/dotnet/runtime/issues/63941#issuecomment-1205259894.
/// The reason of using this API instead of ASP.NET directly via frameworks like SignalR are as follows:
/// <list type="bullet">
/// <item>
/// This is intended to be a <b>simple</b> server.
/// There are no reliability guarantees, no delivery guarantees, no authorisation.
/// The operation of this server is <b>best-effort</b>.
/// Due to this, ASP.NET is surplus to requirements.
/// </item>
/// <item>Including ASP.NET wholesale would have a negative impact on binary size.</item>
/// <item>
/// Using ASP.NET could expose end users' PCs to having things enabled that shouldn't be enabled via little-known configuration toggles.
/// One pertinent example is the <c>ASPNETCORE_URLS</c> environment variable which silently changes which endpoints an ASP.NET service listens on.
/// </item>
/// <item>
/// ASP.NET does not generally fit into the paradigm of being <i>part</i> of an application.
/// The way ASP.NET apps are structured, is that they generally <i>take over</i> the functioning of an application.
/// Therefore, there is not necessarily a given that ASP.NET bundled inside the client will fully stop functioning even when explicitly asked.
/// </item>
/// </list>
/// </remarks>
public sealed class WebSocketServer : IDisposable
{
/// <summary>
/// Whether the server is currently running and listening for connection requests.
/// </summary>
public bool IsRunning => handleRequestTask != null && !runningTokenSource.IsCancellationRequested;
/// <summary>
/// Invoked when a client is connected.
/// The argument is the assigned ID of the client.
/// </summary>
public event Action<int>? ClientConnected;
/// <summary>
/// Invoked when a message is received.
/// The first argument is the ID of the sender; the second is the content of the received message.
/// </summary>
public event Action<int, string>? MessageReceived;
private readonly object syncRoot = new object();
private readonly string prefix;
private readonly Logger logger;
private HttpListener? listener;
private readonly ManualResetEventSlim contextResetEvent = new ManualResetEventSlim();
private Task? handleRequestTask;
private int channelCounter;
private readonly ConcurrentDictionary<int, WebSocketChannel> channels = new ConcurrentDictionary<int, WebSocketChannel>();
private readonly CancellationTokenSource runningTokenSource = new CancellationTokenSource();
private bool isDisposed;
public WebSocketServer(int port)
{
// Restricting to only providing a port is intentional for several reasons:
// - Use of HTTP (no efforts are taken to make HTTPS work).
// - Attack surface reduction (doesn't accidentally listen on all interfaces, potentially getting hit by something external).
// Some users with setups that use a second "streaming PC" or similar will complain. They can set up proxies at their own peril.
prefix = $@"http://localhost:{port}/";
logger = Logger.GetLogger(@"websocket");
}
/// <summary>
/// Starts the server.
/// </summary>
/// <param name="cancellationToken">Use this to cancel start-up.</param>
public Task StartAsync(CancellationToken cancellationToken = default) => Task.Run(() =>
{
lock (syncRoot)
{
if (listener != null)
throw new InvalidOperationException($@"Cannot call {nameof(StartAsync)} multiple times.");
listener = new HttpListener();
listener.Prefixes.Add(prefix);
listener.Start();
handleRequestTask = Task.Run(handleRequests, cancellationToken);
logger.Add($@"Listening on {prefix}.");
}
}, cancellationToken);
private async Task handleRequests()
{
Debug.Assert(listener != null);
while (!runningTokenSource.IsCancellationRequested)
{
HttpListenerContext? context = null;
// `listener.GetContextAsync()` exists but is unusable here without ugly hacks.
// as per source inspection, it is a thin wrapper over `{Begin,End}GetContext()`.
// the problem with that is that the method is *hard-blocking* and *does not accept cancellation*.
// therefore, if it's called in a processing loop like this
// that we are expecting to be able to cut short at any moment's notice to shut things down,
// it's not going to yield and will keep waiting forever.
// a `listener.Stop()` from another thread does cut the call short, but also ends up in an unclean termination.
// what "unclean termination" means here depends on the OS we're running on
// (different exceptions are observed on macOS and Windows, at least).
// therefore use the old asynchronous paradigm with manual signalling when the context is available.
contextResetEvent.Reset();
listener.BeginGetContext(iar =>
{
try
{
context = ((HttpListener)iar.AsyncState!).EndGetContext(iar);
contextResetEvent.Set();
}
catch (HttpListenerException ex) when (ex.ErrorCode == 995)
{
// occurs on Windows when the listener is stopped.
}
}, listener);
WaitHandle.WaitAny([contextResetEvent.WaitHandle, runningTokenSource.Token.WaitHandle]);
// either we have a context to use, or the cancellation fired.
// if it's the latter, terminate processing loop.
if (runningTokenSource.IsCancellationRequested)
return;
Debug.Assert(context != null);
var request = context.Request;
var response = context.Response;
if (!request.IsWebSocketRequest)
{
logger.Add($@"Received non-websocket request from {request.RemoteEndPoint}. Requesting upgrade.");
response.StatusCode = (int)HttpStatusCode.UpgradeRequired;
response.Headers.Add(HttpRequestHeader.Upgrade, @"websocket");
response.Close();
continue;
}
HttpListenerWebSocketContext wsContext;
try
{
wsContext = await context.AcceptWebSocketAsync(null).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.Add($@"Failed to accept websocket connection from {request.RemoteEndPoint}.", LogLevel.Error, ex);
continue;
}
int channelId = Interlocked.Increment(ref channelCounter);
var wsChannel = new WebSocketChannel(wsContext.WebSocket);
channels[channelId] = wsChannel;
wsChannel.MessageReceived += msg => MessageReceived?.Invoke(channelId, msg);
wsChannel.ClosedPrematurely += () => onChannelClosed(channelId);
wsChannel.Start(runningTokenSource.Token);
logger.Add($@"Accepted websocket connection from {request.RemoteEndPoint} as client #{channelId}.");
ClientConnected?.Invoke(channelId);
}
}
private void onChannelClosed(int channelId)
{
if (channels.TryRemove(channelId, out var channel))
channel.Dispose();
logger.Add($@"Connection with client #{channelId} closed.");
}
/// <summary>
/// Sends <paramref name="message"/> to the specific client with the given <paramref name="clientId"/>.
/// </summary>
/// <exception cref="ArgumentException"><paramref name="clientId"/> is not known.</exception>
public async Task SendAsync(int clientId, string message)
{
if (!channels.TryGetValue(clientId, out var channel))
throw new ArgumentException($@"Client {clientId} is not known.");
logger.Add($@"Sending to client {clientId}: {message}");
await channel.SendAsync(message).ConfigureAwait(false);
}
/// <summary>
/// Sends <paramref name="message"/> to all connected clients.
/// </summary>
public Task BroadcastAsync(string message)
{
logger.Add($@"Broadcasting to all clients: {message}");
return Task.WhenAll(channels.Values.Select(ch => ch.SendAsync(message)).ToArray());
}
/// <summary>
/// Stops the server.
/// </summary>
/// <param name="stoppingToken">Cancel this to transition from a graceful shutdown to a forced shutdown.</param>
public Task StopAsync(CancellationToken stoppingToken = default) => Task.Run(async () =>
{
if (isDisposed)
return;
logger.Add(@"Stopping websocket server...");
// of note, ordering here is important - the token is supposed to be cancelled *before* the listener is stopped.
// see `readWriteTask()` and the treatment of early cancellation for answer why.
await runningTokenSource.CancelAsync().ConfigureAwait(false);
if (handleRequestTask != null)
{
try
{
await handleRequestTask.WaitAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// has to be caught manually because outer task isn't accepting `stoppingToken`.
}
}
try
{
listener?.Stop();
}
catch (ObjectDisposedException)
{
// observed to intermittently fire on unices in unclear circumstances. tragic, but also irrelevant at this point. the point is to stop.
}
try
{
await Task.WhenAll(channels.Values.Select(ch => ch.StopAsync(stoppingToken)).ToArray()).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// has to be caught manually because outer task isn't accepting `stoppingToken`.
}
logger.Add(@"Websocket server stopped.");
}, CancellationToken.None); // we always want this task to start running. passing `stoppingToken` here would mean potentially never even scheduling it for execution.
public void Dispose()
{
if (isDisposed)
return;
isDisposed = true;
// no clue why this isn't accessible without casting.
// sidebar: `Stop()` unregisters addresses on Windows, but `Abort()` doesn't!
// this `Dispose()` implementation calls the former.
(listener as IDisposable)?.Dispose();
foreach (var channel in channels.Values)
channel.Dispose();
runningTokenSource.Dispose();
contextResetEvent.Dispose();
}
}
}
+5
View File
@@ -59,6 +59,11 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString Height => new TranslatableString(getKey(@"height"), @"Height");
/// <summary>
/// "Download"
/// </summary>
public static LocalisableString Download => new TranslatableString(getKey(@"download"), @"Download");
/// <summary>
/// "Downloading..."
/// </summary>
@@ -24,6 +24,51 @@ Tomorrow's challenge is now being prepared and will appear soon.");
/// </summary>
public static LocalisableString ChallengeLiveNotification => new TranslatableString(getKey(@"todays_daily_challenge_is_now"), @"Today's daily challenge is now live! Click here to play.");
/// <summary>
/// "Today&#39;s Challenge"
/// </summary>
public static LocalisableString TodaysChallenge => new TranslatableString(getKey(@"todays_challenge"), @"Today's Challenge");
/// <summary>
/// "Difficulty: {0}"
/// </summary>
public static LocalisableString DifficultyInfo(string difficultyName) => new TranslatableString(getKey(@"difficulty_info"), @"Difficulty: {0}", difficultyName);
/// <summary>
/// "Time remaining"
/// </summary>
public static LocalisableString SectionTimeRemaining => new TranslatableString(getKey(@"section_time_remaining"), @"Time remaining");
/// <summary>
/// "Score breakdown"
/// </summary>
public static LocalisableString SectionScoreBreakdown => new TranslatableString(getKey(@"section_score_breakdown"), @"Score breakdown");
/// <summary>
/// "Total pass count"
/// </summary>
public static LocalisableString SectionTotalPasses => new TranslatableString(getKey(@"section_total_passes"), @"Total pass count");
/// <summary>
/// "Cumulative total score"
/// </summary>
public static LocalisableString SectionCumulativeScore => new TranslatableString(getKey(@"section_cumulative_score"), @"Cumulative total score");
/// <summary>
/// "Events"
/// </summary>
public static LocalisableString SectionEvents => new TranslatableString(getKey(@"section_events"), @"Events");
/// <summary>
/// "You"
/// </summary>
public static LocalisableString You => new TranslatableString(getKey(@"you"), @"You");
/// <summary>
/// "{0:N0} passes in {1:N0} - {2:N0} range"
/// </summary>
public static LocalisableString ScoreBreakdownBarTooltip(long passesCount, int minScore, int maxScore) => new TranslatableString(getKey(@"score_breakdown_bar_tooltip"), @"{0:N0} passes in {1:N0} - {2:N0} range", passesCount, minScore, maxScore);
private static string getKey(string key) => $@"{prefix}:{key}";
}
}
@@ -74,6 +74,11 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString DeleteDifficultyDetails(string difficultyName, int objectCount) => new TranslatableString(getKey(@"delete_difficulty_details"), @"Difficulty ""{0}"" with {1} objects", difficultyName, objectCount);
/// <summary>
/// "This overwrites artist, title, source, and tags on all other difficulties. This cannot be undone."
/// </summary>
public static LocalisableString SyncMetadataConfirmationBody => new TranslatableString(getKey(@"sync_metadata_confirmation_body"), @"This overwrites artist, title, source, and tags on all other difficulties. This cannot be undone.");
/// <summary>
/// "All Bookmarks"
/// </summary>
@@ -218,6 +218,18 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString ApplyToThisDifficulty => new TranslatableString(getKey(@"apply_to_this_difficulty"), @"Only apply to this difficulty");
/// <summary>
/// "Sync metadata with all difficulties"
/// </summary>
public static LocalisableString SyncMetadataWithAllDifficulties =>
new TranslatableString(getKey(@"sync_metadata_with_all_difficulties"), @"Sync metadata with all difficulties");
/// <summary>
/// "Copies artist, title, source, and tags to all difficulties."
/// </summary>
public static LocalisableString SyncMetadataWithAllDifficultiesTooltip => new TranslatableString(getKey(@"sync_metadata_with_all_difficulties_tooltip"),
@"Copies artist, title, source, and tags to all difficulties.");
/// <summary>
/// "Ruleset ({0})"
/// </summary>
+5
View File
@@ -229,6 +229,11 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString CheckEntireBeatmapSet => new TranslatableString(getKey(@"check_entire_beatmap_set"), @"Entire beatmap set");
/// <summary>
/// "Saving is not supported for this ruleset yet, sorry!"
/// </summary>
public static LocalisableString RulesetNotSupportSaving => new TranslatableString(getKey(@"ruleset_not_support_saving"), @"Saving is not supported for this ruleset yet, sorry!");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}
@@ -12,38 +12,43 @@ namespace osu.Game.Localisation.HUD
/// <summary>
/// "Display mode"
/// </summary>
public static LocalisableString JudgementDisplayMode => new TranslatableString(getKey(@"judgement_display_mode"), "Display mode");
public static LocalisableString JudgementDisplayMode => new TranslatableString(getKey(@"judgement_display_mode"), @"Display mode");
/// <summary>
/// "Counter direction"
/// </summary>
public static LocalisableString FlowDirection => new TranslatableString(getKey(@"flow_direction"), "Counter direction");
public static LocalisableString FlowDirection => new TranslatableString(getKey(@"flow_direction"), @"Counter direction");
/// <summary>
/// "Show judgement names"
/// </summary>
public static LocalisableString ShowJudgementNames => new TranslatableString(getKey(@"show_judgement_names"), "Show judgement names");
public static LocalisableString ShowJudgementNames => new TranslatableString(getKey(@"show_judgement_names"), @"Show judgement names");
/// <summary>
/// "Show max judgement"
/// </summary>
public static LocalisableString ShowMaxJudgement => new TranslatableString(getKey(@"show_max_judgement"), "Show max judgement");
public static LocalisableString ShowMaxJudgement => new TranslatableString(getKey(@"show_max_judgement"), @"Show max judgement");
/// <summary>
/// "Simple"
/// </summary>
public static LocalisableString JudgementDisplayModeSimple => new TranslatableString(getKey(@"judgement_display_mode_simple"), "Simple");
public static LocalisableString JudgementDisplayModeSimple => new TranslatableString(getKey(@"judgement_display_mode_simple"), @"Simple");
/// <summary>
/// "Misses only"
/// </summary>
public static LocalisableString JudgementDisplayModeMissesOnly => new TranslatableString(getKey(@"judgement_display_mode_misses_only"), @"Misses only");
/// <summary>
/// "Normal"
/// </summary>
public static LocalisableString JudgementDisplayModeNormal => new TranslatableString(getKey(@"judgement_display_mode_normal"), "Normal");
public static LocalisableString JudgementDisplayModeNormal => new TranslatableString(getKey(@"judgement_display_mode_normal"), @"Normal");
/// <summary>
/// "All"
/// </summary>
public static LocalisableString JudgementDisplayModeAll => new TranslatableString(getKey(@"judgement_display_mode_all"), "All");
public static LocalisableString JudgementDisplayModeAll => new TranslatableString(getKey(@"judgement_display_mode_all"), @"All");
private static string getKey(string key) => $"{prefix}:{key}";
private static string getKey(string key) => $@"{prefix}:{key}";
}
}
@@ -129,6 +129,16 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString StableDirectorySelectHeader => new TranslatableString(getKey(@"stable_directory_select_header"), @"Please select your osu!stable install location");
/// <summary>
/// "All offsets have been reset!"
/// </summary>
public static LocalisableString AllOffsetsReset => new TranslatableString(getKey(@"all_offsets_reset"), @"All offsets have been reset!");
/// <summary>
/// "No videos found to delete!"
/// </summary>
public static LocalisableString NoVideosFoundToDelete => new TranslatableString(getKey(@"no_videos_found_to_delete"), @"No videos found to delete!");
private static string getKey(string key) => $"{prefix}:{key}";
}
}
@@ -79,6 +79,12 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString NeverConfine => new TranslatableString(getKey(@"never_confine"), @"Never");
/// <summary>
/// "Looking to change your pen tablet&#39;s sensitivity? Search for pen sensitivity instead."
/// </summary>
public static LocalisableString CursorSensitivityForTabletsElsewhere => new TranslatableString(getKey(@"cursor_sensitivity_for_tablets_elsewhere"),
@"Looking to change your pen tablet's sensitivity? Search for pen sensitivity instead.");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}
@@ -39,6 +39,16 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString FreestyleButtonTooltip => new TranslatableString(getKey(@"freestyle_button_tooltip"), @"Each player can choose their preferred difficulty, ruleset and mods.");
/// <summary>
/// "Searching for opponents..."
/// </summary>
public static LocalisableString SearchingForOpponents => new TranslatableString(getKey(@"searching_for_opponents"), @"Searching for opponents...");
/// <summary>
/// "Your match is ready! Click to join."
/// </summary>
public static LocalisableString MatchIsReady => new TranslatableString(getKey(@"match_is_ready"), @"Your match is ready! Click to join.");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}
@@ -135,6 +135,11 @@ Click to see what's new!", version);
/// </summary>
public static LocalisableString Mention => new TranslatableString(getKey(@"mention"), @"Mention");
/// <summary>
/// "{0} in {1}"
/// </summary>
public static LocalisableString MentionDetails(string user, string channelName) => new TranslatableString(getKey(@"mention_details"), @"{0} in {1}", user, channelName);
/// <summary>
/// "Online: {0}"
/// </summary>
@@ -0,0 +1,24 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Localisation;
namespace osu.Game.Localisation
{
public static class PenSettingsStrings
{
private const string prefix = @"osu.Game.Resources.Localisation.PenSettings";
/// <summary>
/// "Tablet (External)"
/// </summary>
public static LocalisableString TabletExternal => new TranslatableString(getKey(@"tablet_external"), @"Tablet (External)");
/// <summary>
/// "Pen sensitivity"
/// </summary>
public static LocalisableString PenSensitivity => new TranslatableString(getKey(@"pen_sensitivity"), @"Pen sensitivity");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}
@@ -79,6 +79,16 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString TimingBasedColouring => new TranslatableString(getKey(@"Timing_based_colouring"), @"Timing-based note colouring");
/// <summary>
/// "Rate-adjusted hit animations"
/// </summary>
public static LocalisableString RateAdjustedHitAnimation => new TranslatableString(getKey(@"rate_adjusted_hit_animation"), @"Rate-adjusted hit animations");
/// <summary>
/// "Hits will fly faster or slower when beatmap rate is adjusted via mods."
/// </summary>
public static LocalisableString RateAdjustedHitAnimationTooltip => new TranslatableString(getKey(@"rate_adjusted_hit_animation_tooltip"), @"Hits will fly faster or slower when beatmap rate is adjusted via mods.");
/// <summary>
/// "{0}ms (speed {1:N1})"
/// </summary>
@@ -312,6 +312,12 @@ namespace osu.Game.Online.API.Requests.Responses
Colour = @"9c0101",
};
public static APIUser UnknownUser(int userId) => new APIUser
{
Id = userId,
Username = "Unknown user",
};
public int OnlineID => Id;
public bool Equals(APIUser other) => this.MatchesOnlineID(other);
@@ -0,0 +1,38 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Net.Http;
using osu.Framework.IO.Network;
using osu.Game.Overlays.Profile;
namespace osu.Game.Online.API.Requests
{
public class UserReportRequest : APIRequest
{
public readonly int UserID;
public readonly UserReportReason Reason;
public readonly string Comment;
public UserReportRequest(int userID, UserReportReason reason, string comment)
{
UserID = userID;
Reason = reason;
Comment = comment;
}
protected override WebRequest CreateWebRequest()
{
var request = base.CreateWebRequest();
request.Method = HttpMethod.Post;
request.AddParameter(@"reportable_type", @"user");
request.AddParameter(@"reportable_id", $"{UserID}");
request.AddParameter(@"reason", Reason.ToString());
request.AddParameter(@"comments", Comment);
return request;
}
protected override string Target => @"reports";
}
}
+1 -1
View File
@@ -218,7 +218,7 @@ namespace osu.Game.Online.Chat
{
TextFlow.AddText(Localisation.NotificationsStrings.Mention.ToUpper(), s => s.Font = OsuFont.Style.Caption2.With(weight: FontWeight.Bold));
TextFlow.NewLine();
TextFlow.AddText($"{message.Sender.Username} in {channel.Name}", s =>
TextFlow.AddText(Localisation.NotificationsStrings.MentionDetails(message.Sender.Username, channel.Name), s =>
{
s.Font = OsuFont.Style.Caption2.With(weight: FontWeight.SemiBold);
s.Colour = colourProvider.Content2;
+13
View File
@@ -26,5 +26,18 @@ namespace osu.Game.Online
/// </list>
/// </remarks>
Task DisconnectRequested();
/// <summary>
/// Invoked when server begins a shutdown sequence.
/// </summary>
/// <remarks>
/// Server shutdowns are graceful.
///
/// This will fire with hours of notice for clients to do what they need to and subsequently
/// disconnect. It's in the client's best interest to switch over to the new hubs as soon as
/// it can, so that the user can be on the same server as the majority of others (and avoid a
/// "server split" scenario where users are split across multiple shutting-down hubs).
/// </remarks>
Task ServerShuttingDown();
}
}
@@ -38,6 +38,12 @@ namespace osu.Game.Online.Matchmaking
/// </summary>
Task MatchmakingRoomInvitedWithParams(MatchmakingRoomInvitationParams invitation);
/// <summary>
/// Signals that the user has been issued a duel by another user.
/// </summary>
/// <param name="issue">Contains the parameters for the duel.</param>
Task MatchmakingDuelIssued(MatchmakingDuelIssuedParams issue);
/// <summary>
/// Signals that the matchmaking room is ready to be opened.
/// </summary>
@@ -40,6 +40,18 @@ namespace osu.Game.Online.Matchmaking
/// </summary>
Task MatchmakingAcceptInvitation();
/// <summary>
/// Issues a matchmaking duel.
/// </summary>
/// <param name="request">Describes the duel.</param>
Task<MatchmakingIssueDuelResponse> MatchmakingIssueDuel(MatchmakingIssueDuelRequest request);
/// <summary>
/// Accepts a matchmaking duel invitation.
/// </summary>
/// <param name="request">Describes the duel.</param>
Task<MatchmakingAcceptDuelResponse> MatchmakingAcceptDuel(MatchmakingAcceptDuelRequest request);
/// <summary>
/// Declines a matchmaking room invitation.
/// </summary>
@@ -0,0 +1,22 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using MessagePack;
namespace osu.Game.Online.Matchmaking
{
[MessagePackObject]
[Serializable]
public class MatchmakingDuelIssuedParams
{
[Key(0)]
public Guid Id { get; set; }
[Key(1)]
public int UserId { get; set; }
[Key(2)]
public MatchmakingPool Pool { get; set; } = new MatchmakingPool();
}
}
@@ -26,6 +26,31 @@ namespace osu.Game.Online.Matchmaking
[Key(4)]
public MatchmakingPoolType Type { get; set; } = MatchmakingPoolType.QuickPlay;
[IgnoreMember]
public string DisplayName
{
get
{
switch (RulesetId)
{
case 0:
return $"osu! ({Name})";
case 1:
return $"osu!taiko ({Name})";
case 2:
return $"osu!catch ({Name})";
case 3:
return $"osu!mania {Variant}K ({Name})";
default:
return Name;
}
}
}
public bool Equals(MatchmakingPool? other)
=> other != null
&& Id == other.Id
@@ -0,0 +1,16 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using MessagePack;
namespace osu.Game.Online.Matchmaking.Requests
{
[MessagePackObject]
[Serializable]
public class MatchmakingAcceptDuelRequest
{
[Key(0)]
public Guid Id { get; set; }
}
}
@@ -0,0 +1,19 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using MessagePack;
namespace osu.Game.Online.Matchmaking.Requests
{
[MessagePackObject]
[Serializable]
public class MatchmakingIssueDuelRequest
{
[Key(0)]
public int UserId { get; set; }
[Key(1)]
public int PoolId { get; set; }
}
}
@@ -0,0 +1,14 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using MessagePack;
namespace osu.Game.Online.Matchmaking.Responses
{
[MessagePackObject]
[Serializable]
public class MatchmakingAcceptDuelResponse
{
}
}
@@ -0,0 +1,14 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using MessagePack;
namespace osu.Game.Online.Matchmaking.Responses
{
[MessagePackObject]
[Serializable]
public class MatchmakingIssueDuelResponse
{
}
}
+25 -2
View File
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -19,6 +20,11 @@ namespace osu.Game.Online.Metadata
{
public abstract IBindable<bool> IsConnected { get; }
/// <summary>
/// A list of all watched multiplayer rooms (see <see cref="BeginWatchingMultiplayerRoom"/>).
/// </summary>
protected readonly HashSet<long> WatchedRooms = new HashSet<long>();
[Resolved]
private IAPIProvider api { get; set; } = null!;
@@ -179,11 +185,28 @@ namespace osu.Game.Online.Metadata
#region Disconnection handling
/// <summary>
/// Invoked just prior to disconnection.
/// </summary>
public event Action? Disconnecting;
public virtual Task DisconnectRequested()
public abstract Task Reconnect();
protected abstract Task DisconnectInternal();
Task IStatefulUserHubClient.DisconnectRequested()
{
Schedule(() => Disconnecting?.Invoke());
Schedule(() =>
{
Disconnecting?.Invoke();
DisconnectInternal().FireAndForget();
});
return Task.CompletedTask;
}
Task IStatefulUserHubClient.ServerShuttingDown()
{
this.ReconnectWhenReady(IsConnected, () => WatchedRooms.Count == 0, Reconnect);
return Task.CompletedTask;
}
@@ -69,7 +69,8 @@ namespace osu.Game.Online.Metadata
connection.On<int, UserPresence?>(nameof(IMetadataClient.FriendPresenceUpdated), ((IMetadataClient)this).FriendPresenceUpdated);
connection.On<DailyChallengeInfo?>(nameof(IMetadataClient.DailyChallengeUpdated), ((IMetadataClient)this).DailyChallengeUpdated);
connection.On<MultiplayerRoomScoreSetEvent>(nameof(IMetadataClient.MultiplayerRoomScoreSet), ((IMetadataClient)this).MultiplayerRoomScoreSet);
connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IMetadataClient)this).DisconnectRequested);
connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IStatefulUserHubClient)this).DisconnectRequested);
connection.On(nameof(IStatefulUserHubClient.ServerShuttingDown), ((IStatefulUserHubClient)this).ServerShuttingDown);
};
IsConnected.BindTo(connector.IsConnected);
@@ -109,6 +110,7 @@ namespace osu.Game.Online.Metadata
{
userPresences.Clear();
friendPresences.Clear();
WatchedRooms.Clear();
dailyChallengeInfo.Value = null;
localUserPresence = default;
});
@@ -272,6 +274,8 @@ namespace osu.Game.Online.Metadata
if (connector?.IsConnected.Value != true)
throw new OperationCanceledException();
WatchedRooms.Add(id);
Debug.Assert(connection != null);
var result = await connection.InvokeAsync<MultiplayerPlaylistItemStats[]>(nameof(IMetadataServer.BeginWatchingMultiplayerRoom), id).ConfigureAwait(false);
Logger.Log($@"{nameof(OnlineMetadataClient)} began watching multiplayer room with ID {id}", LoggingTarget.Network);
@@ -283,6 +287,8 @@ namespace osu.Game.Online.Metadata
if (connector?.IsConnected.Value != true)
throw new OperationCanceledException();
WatchedRooms.Remove(id);
Debug.Assert(connection != null);
await connection.InvokeAsync(nameof(IMetadataServer.EndWatchingMultiplayerRoom), id).ConfigureAwait(false);
Logger.Log($@"{nameof(OnlineMetadataClient)} stopped watching multiplayer room with ID {id}", LoggingTarget.Network);
@@ -297,17 +303,22 @@ namespace osu.Game.Online.Metadata
await connection.InvokeAsync(nameof(IMetadataServer.RefreshFriends)).ConfigureAwait(false);
}
public override async Task DisconnectRequested()
{
await base.DisconnectRequested().ConfigureAwait(false);
if (connector != null)
await connector.Disconnect().ConfigureAwait(false);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
connector?.Dispose();
}
public override async Task Reconnect()
{
if (connector != null)
await connector.Reconnect().ConfigureAwait(false);
}
protected override async Task DisconnectInternal()
{
if (connector != null)
await connector.Disconnect().ConfigureAwait(false);
}
}
}
@@ -11,9 +11,7 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Development;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Game.Database;
using osu.Game.Localisation;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
@@ -117,11 +115,6 @@ namespace osu.Game.Online.Multiplayer
/// </summary>
public event Action? ResultsReady;
/// <summary>
/// Invoked just prior to disconnection requested by the server via <see cref="IStatefulUserHubClient.DisconnectRequested"/>.
/// </summary>
public event Action? Disconnecting;
public event Action<MultiplayerCountdown>? CountdownStarted;
public event Action<MultiplayerCountdown>? CountdownStopped;
@@ -133,6 +126,7 @@ namespace osu.Game.Online.Multiplayer
public event Action? MatchmakingQueueJoined;
public event Action? MatchmakingQueueLeft;
public event Action<MatchmakingRoomInvitationParams>? MatchmakingRoomInvited;
public event Action<MatchmakingDuelIssuedParams>? MatchmakingDuelIssued;
public event Action<long, string>? MatchmakingRoomReady;
public event Action<MatchmakingLobbyStatus>? MatchmakingLobbyStatusChanged;
public event Action<MatchmakingQueueStatus>? MatchmakingQueueStatusChanged;
@@ -489,8 +483,6 @@ namespace osu.Game.Online.Multiplayer
public abstract Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability);
public abstract Task DisconnectInternal();
public abstract Task ChangeUserStyle(int? beatmapId, int? rulesetId);
/// <summary>
@@ -1081,15 +1073,7 @@ namespace osu.Game.Online.Multiplayer
});
}
Task IStatefulUserHubClient.DisconnectRequested()
{
Schedule(() =>
{
Disconnecting?.Invoke();
DisconnectInternal();
});
return Task.CompletedTask;
}
#region Matchmaking / Ranked Play
Task IMatchmakingClient.MatchmakingQueueJoined()
{
@@ -1115,6 +1099,12 @@ namespace osu.Game.Online.Multiplayer
return Task.CompletedTask;
}
Task IMatchmakingClient.MatchmakingDuelIssued(MatchmakingDuelIssuedParams issue)
{
Scheduler.Add(() => MatchmakingDuelIssued?.Invoke(issue));
return Task.CompletedTask;
}
Task IMatchmakingClient.MatchmakingRoomReady(long roomId, string password)
{
Scheduler.Add(() => MatchmakingRoomReady?.Invoke(roomId, password));
@@ -1223,20 +1213,45 @@ namespace osu.Game.Online.Multiplayer
public abstract Task MatchmakingAcceptInvitation();
public abstract Task<MatchmakingIssueDuelResponse> MatchmakingIssueDuel(MatchmakingIssueDuelRequest request);
public abstract Task<MatchmakingAcceptDuelResponse> MatchmakingAcceptDuel(MatchmakingAcceptDuelRequest request);
public abstract Task MatchmakingDeclineInvitation();
public abstract Task MatchmakingToggleSelection(long playlistItemId);
public abstract Task MatchmakingSkipToNextStage();
private partial class MultiplayerInvitationNotification : UserAvatarNotification
{
protected override IconUsage CloseButtonIcon => FontAwesome.Solid.Times;
#endregion
public MultiplayerInvitationNotification(APIUser user, Room room)
: base(user, NotificationsStrings.InvitedYouToTheMultiplayer(user.Username, room.Name))
#region Disconnection handling
/// <summary>
/// Invoked just prior to disconnection.
/// </summary>
public event Action? Disconnecting;
protected abstract Task DisconnectInternal();
public abstract Task Reconnect();
Task IStatefulUserHubClient.DisconnectRequested()
{
Schedule(() =>
{
}
Disconnecting?.Invoke();
DisconnectInternal().FireAndForget();
});
return Task.CompletedTask;
}
Task IStatefulUserHubClient.ServerShuttingDown()
{
this.ReconnectWhenReady(IsConnected, () => room == null, Reconnect);
return Task.CompletedTask;
}
#endregion
}
}
@@ -5,7 +5,9 @@ using System;
using System.Diagnostics;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ExceptionExtensions;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Logging;
using osu.Game.Utils;
@@ -43,6 +45,43 @@ namespace osu.Game.Online.Multiplayer
}
});
/// <summary>
/// Start a background process to disconnect/reconnect as soon as a specific condition is met.
/// </summary>
/// <remarks>
/// If a reconnect happens via another means, this will abort attempts.
/// We only want to reconnect once.
/// </remarks>
/// <param name="client">The client to operate on.</param>
/// <param name="isConnected">Connected state of client.</param>
/// <param name="readyFunction">The condition which should be <c>true</c> to continue with the shutdown.</param>
/// <param name="reconnectFunction">The method to run to perform the reconnect.</param>
public static void ReconnectWhenReady(this IStatefulUserHubClient client, IBindable<bool> isConnected, Func<bool> readyFunction, Func<Task> reconnectFunction)
{
Task.Run(async () =>
{
bool didReconnect = false;
var connected = isConnected.GetBoundCopy();
connected.ValueChanged += _ => didReconnect = true;
string clientName = client.GetType().ReadableName();
Logger.Log($"{clientName} has signalled shutdown");
while (!readyFunction())
{
Logger.Log($"{clientName} shutdown waiting for idle conditions...");
await Task.Delay(10000).ConfigureAwait(false);
}
Logger.Log($"{clientName} disconnecting due to shutdown signal");
if (!didReconnect)
await reconnectFunction().ConfigureAwait(false);
connected.UnbindAll();
}).FireAndForget();
}
public static string? GetHubExceptionMessage(this Exception exception)
{
if (exception is HubException hubException)
@@ -0,0 +1,21 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics.Sprites;
using osu.Game.Localisation;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
using osu.Game.Overlays.Notifications;
namespace osu.Game.Online.Multiplayer
{
public partial class MultiplayerInvitationNotification : UserAvatarNotification
{
protected override IconUsage CloseButtonIcon => FontAwesome.Solid.Times;
public MultiplayerInvitationNotification(APIUser user, Room room)
: base(user, NotificationsStrings.InvitedYouToTheMultiplayer(user.Username, room.Name))
{
}
}
}
@@ -10,15 +10,15 @@ using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR.Client;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Game.Online.API;
using osu.Game.Online.Rooms;
using osu.Game.Overlays.Notifications;
using osu.Game.Localisation;
using osu.Game.Online.API;
using osu.Game.Online.Matchmaking;
using osu.Game.Online.Matchmaking.Requests;
using osu.Game.Online.Matchmaking.Responses;
using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay;
using osu.Game.Online.RankedPlay;
using osu.Game.Online.Rooms;
using osu.Game.Overlays.Notifications;
namespace osu.Game.Online.Multiplayer
{
@@ -80,6 +80,7 @@ namespace osu.Game.Online.Multiplayer
connection.On(nameof(IMatchmakingClient.MatchmakingQueueJoined), ((IMatchmakingClient)this).MatchmakingQueueJoined);
connection.On(nameof(IMatchmakingClient.MatchmakingQueueLeft), ((IMatchmakingClient)this).MatchmakingQueueLeft);
connection.On<MatchmakingRoomInvitationParams>(nameof(IMatchmakingClient.MatchmakingRoomInvitedWithParams), ((IMatchmakingClient)this).MatchmakingRoomInvitedWithParams);
connection.On<MatchmakingDuelIssuedParams>(nameof(IMatchmakingClient.MatchmakingDuelIssued), ((IMatchmakingClient)this).MatchmakingDuelIssued);
connection.On<long, string>(nameof(IMatchmakingClient.MatchmakingRoomReady), ((IMatchmakingClient)this).MatchmakingRoomReady);
connection.On<MatchmakingLobbyStatus>(nameof(IMatchmakingClient.MatchmakingLobbyStatusChanged), ((IMatchmakingClient)this).MatchmakingLobbyStatusChanged);
connection.On<MatchmakingQueueStatus>(nameof(IMatchmakingClient.MatchmakingQueueStatusChanged), ((IMatchmakingClient)this).MatchmakingQueueStatusChanged);
@@ -91,7 +92,8 @@ namespace osu.Game.Online.Multiplayer
connection.On<RankedPlayCardItem, MultiplayerPlaylistItem>(nameof(IRankedPlayClient.RankedPlayCardRevealed), ((IRankedPlayClient)this).RankedPlayCardRevealed);
connection.On<RankedPlayCardItem>(nameof(IRankedPlayClient.RankedPlayCardPlayed), ((IRankedPlayClient)this).RankedPlayCardPlayed);
connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IMultiplayerClient)this).DisconnectRequested);
connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IStatefulUserHubClient)this).DisconnectRequested);
connection.On(nameof(IStatefulUserHubClient.ServerShuttingDown), ((IStatefulUserHubClient)this).ServerShuttingDown);
};
IsConnected.BindTo(connector.IsConnected);
@@ -334,14 +336,6 @@ namespace osu.Game.Online.Multiplayer
return connection.InvokeAsync(nameof(IMultiplayerServer.VoteToSkipIntro));
}
public override Task DisconnectInternal()
{
if (connector == null)
return Task.CompletedTask;
return connector.Disconnect();
}
public override Task DiscardCards(RankedPlayCardItem[] cards)
{
if (!IsConnected.Value)
@@ -416,6 +410,24 @@ namespace osu.Game.Online.Multiplayer
return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingAcceptInvitation));
}
public override Task<MatchmakingIssueDuelResponse> MatchmakingIssueDuel(MatchmakingIssueDuelRequest request)
{
if (!IsConnected.Value)
return Task.FromResult(new MatchmakingIssueDuelResponse());
Debug.Assert(connection != null);
return connection.InvokeAsync<MatchmakingIssueDuelResponse>(nameof(IMatchmakingServer.MatchmakingIssueDuel), request);
}
public override Task<MatchmakingAcceptDuelResponse> MatchmakingAcceptDuel(MatchmakingAcceptDuelRequest request)
{
if (!IsConnected.Value)
return Task.FromResult(new MatchmakingAcceptDuelResponse());
Debug.Assert(connection != null);
return connection.InvokeAsync<MatchmakingAcceptDuelResponse>(nameof(IMatchmakingServer.MatchmakingAcceptDuel), request);
}
public override Task MatchmakingDeclineInvitation()
{
if (!IsConnected.Value)
@@ -448,5 +460,17 @@ namespace osu.Game.Online.Multiplayer
base.Dispose(isDisposing);
connector?.Dispose();
}
public override async Task Reconnect()
{
if (connector != null)
await connector.Reconnect().ConfigureAwait(false);
}
protected override async Task DisconnectInternal()
{
if (connector != null)
await connector.Disconnect().ConfigureAwait(false);
}
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ namespace osu.Game.Online.Rooms
private readonly RoomStatusFilter? status;
private readonly string category;
public GetRoomsRequest(FilterCriteria filterCriteria)
public GetRoomsRequest(LoungeFilterCriteria filterCriteria)
{
mode = filterCriteria.Mode;
category = filterCriteria.Category;
@@ -46,6 +46,7 @@ namespace osu.Game.Online.Spectator
connection.On<SpectatorUser[]>(nameof(ISpectatorClient.UserStartedWatching), ((ISpectatorClient)this).UserStartedWatching);
connection.On<int>(nameof(ISpectatorClient.UserEndedWatching), ((ISpectatorClient)this).UserEndedWatching);
connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IStatefulUserHubClient)this).DisconnectRequested);
connection.On(nameof(IStatefulUserHubClient.ServerShuttingDown), ((IStatefulUserHubClient)this).ServerShuttingDown);
};
IsConnected.BindTo(connector.IsConnected);
@@ -122,14 +123,16 @@ namespace osu.Game.Online.Spectator
return connection.InvokeAsync(nameof(ISpectatorServer.EndWatchingUser), userId);
}
public override async Task Reconnect()
{
if (connector != null)
await connector.Reconnect().ConfigureAwait(false);
}
protected override async Task DisconnectInternal()
{
await base.DisconnectInternal().ConfigureAwait(false);
if (connector == null)
return;
await connector.Disconnect().ConfigureAwait(false);
if (connector != null)
await connector.Disconnect().ConfigureAwait(false);
}
}
}

Some files were not shown because too many files have changed in this diff Show More