mirror of
https://github.com/ppy/osu.git
synced 2025-01-13 19:52:55 +08:00
Merge branch 'master' into non-concurrent-sample-playback
This commit is contained in:
commit
c6ed3efa4a
@ -18,6 +18,7 @@ using osu.Framework.Screens;
|
|||||||
using osu.Game.Screens.Menu;
|
using osu.Game.Screens.Menu;
|
||||||
using osu.Game.Updater;
|
using osu.Game.Updater;
|
||||||
using osu.Desktop.Windows;
|
using osu.Desktop.Windows;
|
||||||
|
using osu.Game.IO;
|
||||||
|
|
||||||
namespace osu.Desktop
|
namespace osu.Desktop
|
||||||
{
|
{
|
||||||
@ -32,7 +33,7 @@ namespace osu.Desktop
|
|||||||
noVersionOverlay = args?.Any(a => a == "--no-version-overlay") ?? false;
|
noVersionOverlay = args?.Any(a => a == "--no-version-overlay") ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Storage GetStorageForStableInstall()
|
public override StableStorage GetStorageForStableInstall()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -40,7 +41,7 @@ namespace osu.Desktop
|
|||||||
{
|
{
|
||||||
string stablePath = getStableInstallPath();
|
string stablePath = getStableInstallPath();
|
||||||
if (!string.IsNullOrEmpty(stablePath))
|
if (!string.IsNullOrEmpty(stablePath))
|
||||||
return new DesktopStorage(stablePath, desktopHost);
|
return new StableStorage(stablePath, desktopHost);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
|
@ -21,15 +21,17 @@ namespace osu.Game.Tests.Chat
|
|||||||
Assert.AreEqual(36, result.Links[0].Length);
|
Assert.AreEqual(36, result.Links[0].Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
[TestCase(LinkAction.OpenBeatmap, "456", "https://osu.ppy.sh/beatmapsets/123#osu/456")]
|
[TestCase(LinkAction.OpenBeatmap, "456", "https://dev.ppy.sh/beatmapsets/123#osu/456")]
|
||||||
[TestCase(LinkAction.OpenBeatmap, "456", "https://osu.ppy.sh/beatmapsets/123#osu/456?whatever")]
|
[TestCase(LinkAction.OpenBeatmap, "456", "https://dev.ppy.sh/beatmapsets/123#osu/456?whatever")]
|
||||||
[TestCase(LinkAction.OpenBeatmap, "456", "https://osu.ppy.sh/beatmapsets/123/456")]
|
[TestCase(LinkAction.OpenBeatmap, "456", "https://dev.ppy.sh/beatmapsets/123/456")]
|
||||||
[TestCase(LinkAction.External, null, "https://osu.ppy.sh/beatmapsets/abc/def")]
|
[TestCase(LinkAction.External, null, "https://dev.ppy.sh/beatmapsets/abc/def")]
|
||||||
[TestCase(LinkAction.OpenBeatmapSet, "123", "https://osu.ppy.sh/beatmapsets/123")]
|
[TestCase(LinkAction.OpenBeatmapSet, "123", "https://dev.ppy.sh/beatmapsets/123")]
|
||||||
[TestCase(LinkAction.OpenBeatmapSet, "123", "https://osu.ppy.sh/beatmapsets/123/whatever")]
|
[TestCase(LinkAction.OpenBeatmapSet, "123", "https://dev.ppy.sh/beatmapsets/123/whatever")]
|
||||||
[TestCase(LinkAction.External, null, "https://osu.ppy.sh/beatmapsets/abc")]
|
[TestCase(LinkAction.External, null, "https://dev.ppy.sh/beatmapsets/abc")]
|
||||||
public void TestBeatmapLinks(LinkAction expectedAction, string expectedArg, string link)
|
public void TestBeatmapLinks(LinkAction expectedAction, string expectedArg, string link)
|
||||||
{
|
{
|
||||||
|
MessageFormatter.WebsiteRootUrl = "dev.ppy.sh";
|
||||||
|
|
||||||
Message result = MessageFormatter.FormatMessage(new Message { Content = link });
|
Message result = MessageFormatter.FormatMessage(new Message { Content = link });
|
||||||
|
|
||||||
Assert.AreEqual(result.Content, result.DisplayContent);
|
Assert.AreEqual(result.Content, result.DisplayContent);
|
||||||
|
@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
using osu.Framework.Allocation;
|
using osu.Framework.Allocation;
|
||||||
using osu.Framework.Bindables;
|
using osu.Framework.Bindables;
|
||||||
@ -13,6 +12,7 @@ using osu.Framework.Testing;
|
|||||||
using osu.Framework.Utils;
|
using osu.Framework.Utils;
|
||||||
using osu.Game.Beatmaps;
|
using osu.Game.Beatmaps;
|
||||||
using osu.Game.Online;
|
using osu.Game.Online;
|
||||||
|
using osu.Game.Online.API;
|
||||||
using osu.Game.Online.Spectator;
|
using osu.Game.Online.Spectator;
|
||||||
using osu.Game.Replays.Legacy;
|
using osu.Game.Replays.Legacy;
|
||||||
using osu.Game.Rulesets.Osu;
|
using osu.Game.Rulesets.Osu;
|
||||||
@ -244,10 +244,7 @@ namespace osu.Game.Tests.Visual.Gameplay
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override Task Connect()
|
protected override HubClientConnector CreateConnector(string name, string endpoint, IAPIProvider api) => null;
|
||||||
{
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void StartPlay(int beatmapId)
|
public void StartPlay(int beatmapId)
|
||||||
{
|
{
|
||||||
|
@ -4,7 +4,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
using osu.Framework.Allocation;
|
using osu.Framework.Allocation;
|
||||||
using osu.Framework.Bindables;
|
using osu.Framework.Bindables;
|
||||||
@ -14,6 +13,7 @@ using osu.Framework.Testing;
|
|||||||
using osu.Framework.Utils;
|
using osu.Framework.Utils;
|
||||||
using osu.Game.Database;
|
using osu.Game.Database;
|
||||||
using osu.Game.Online;
|
using osu.Game.Online;
|
||||||
|
using osu.Game.Online.API;
|
||||||
using osu.Game.Online.Spectator;
|
using osu.Game.Online.Spectator;
|
||||||
using osu.Game.Replays.Legacy;
|
using osu.Game.Replays.Legacy;
|
||||||
using osu.Game.Rulesets.Osu.Scoring;
|
using osu.Game.Rulesets.Osu.Scoring;
|
||||||
@ -106,6 +106,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
|||||||
this.totalUsers = totalUsers;
|
this.totalUsers = totalUsers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override HubClientConnector CreateConnector(string name, string endpoint, IAPIProvider api) => null;
|
||||||
|
|
||||||
public void Start(int beatmapId)
|
public void Start(int beatmapId)
|
||||||
{
|
{
|
||||||
for (int i = 0; i < totalUsers; i++)
|
for (int i = 0; i < totalUsers; i++)
|
||||||
@ -163,8 +165,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
|||||||
((ISpectatorClient)this).UserSentFrames(userId, new FrameDataBundle(header, Array.Empty<LegacyReplayFrame>()));
|
((ISpectatorClient)this).UserSentFrames(userId, new FrameDataBundle(header, Array.Empty<LegacyReplayFrame>()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override Task Connect() => Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,8 @@ namespace osu.Game.Tournament.Tests
|
|||||||
{
|
{
|
||||||
base.LoadComplete();
|
base.LoadComplete();
|
||||||
|
|
||||||
|
BracketLoadTask.ContinueWith(_ => Schedule(() =>
|
||||||
|
{
|
||||||
LoadComponentAsync(new Background("Menu/menu-background-0")
|
LoadComponentAsync(new Background("Menu/menu-background-0")
|
||||||
{
|
{
|
||||||
Colour = OsuColour.Gray(0.5f),
|
Colour = OsuColour.Gray(0.5f),
|
||||||
@ -22,6 +24,7 @@ namespace osu.Game.Tournament.Tests
|
|||||||
// Have to construct this here, rather than in the constructor, because
|
// Have to construct this here, rather than in the constructor, because
|
||||||
// we depend on some dependencies to be loaded within OsuGameBase.load().
|
// we depend on some dependencies to be loaded within OsuGameBase.load().
|
||||||
Add(new TestBrowser());
|
Add(new TestBrowser());
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -153,11 +153,14 @@ namespace osu.Game.Tournament.Tests
|
|||||||
private TestSceneTestRunner.TestRunner runner;
|
private TestSceneTestRunner.TestRunner runner;
|
||||||
|
|
||||||
protected override void LoadAsyncComplete()
|
protected override void LoadAsyncComplete()
|
||||||
|
{
|
||||||
|
BracketLoadTask.ContinueWith(_ => Schedule(() =>
|
||||||
{
|
{
|
||||||
// this has to be run here rather than LoadComplete because
|
// this has to be run here rather than LoadComplete because
|
||||||
// TestScene.cs is checking the IsLoaded state (on another thread) and expects
|
// TestScene.cs is checking the IsLoaded state (on another thread) and expects
|
||||||
// the runner to be loaded at that point.
|
// the runner to be loaded at that point.
|
||||||
Add(runner = new TestSceneTestRunner.TestRunner());
|
Add(runner = new TestSceneTestRunner.TestRunner());
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RunTestBlocking(TestScene test) => runner.RunTestBlocking(test);
|
public void RunTestBlocking(TestScene test) => runner.RunTestBlocking(test);
|
||||||
|
@ -13,6 +13,7 @@ using osu.Framework.Graphics.Colour;
|
|||||||
using osu.Game.Graphics.Cursor;
|
using osu.Game.Graphics.Cursor;
|
||||||
using osu.Game.Tournament.Models;
|
using osu.Game.Tournament.Models;
|
||||||
using osu.Game.Graphics;
|
using osu.Game.Graphics;
|
||||||
|
using osu.Game.Graphics.UserInterface;
|
||||||
using osuTK;
|
using osuTK;
|
||||||
using osuTK.Graphics;
|
using osuTK.Graphics;
|
||||||
|
|
||||||
@ -32,25 +33,24 @@ namespace osu.Game.Tournament
|
|||||||
private Drawable heightWarning;
|
private Drawable heightWarning;
|
||||||
private Bindable<Size> windowSize;
|
private Bindable<Size> windowSize;
|
||||||
private Bindable<WindowMode> windowMode;
|
private Bindable<WindowMode> windowMode;
|
||||||
|
private LoadingSpinner loadingSpinner;
|
||||||
|
|
||||||
[BackgroundDependencyLoader]
|
[BackgroundDependencyLoader]
|
||||||
private void load(FrameworkConfigManager frameworkConfig)
|
private void load(FrameworkConfigManager frameworkConfig)
|
||||||
{
|
{
|
||||||
windowSize = frameworkConfig.GetBindable<Size>(FrameworkSetting.WindowedSize);
|
windowSize = frameworkConfig.GetBindable<Size>(FrameworkSetting.WindowedSize);
|
||||||
windowSize.BindValueChanged(size => ScheduleAfterChildren(() =>
|
|
||||||
{
|
|
||||||
var minWidth = (int)(size.NewValue.Height / 768f * TournamentSceneManager.REQUIRED_WIDTH) - 1;
|
|
||||||
|
|
||||||
heightWarning.Alpha = size.NewValue.Width < minWidth ? 1 : 0;
|
|
||||||
}), true);
|
|
||||||
|
|
||||||
windowMode = frameworkConfig.GetBindable<WindowMode>(FrameworkSetting.WindowMode);
|
windowMode = frameworkConfig.GetBindable<WindowMode>(FrameworkSetting.WindowMode);
|
||||||
windowMode.BindValueChanged(mode => ScheduleAfterChildren(() =>
|
|
||||||
{
|
|
||||||
windowMode.Value = WindowMode.Windowed;
|
|
||||||
}), true);
|
|
||||||
|
|
||||||
AddRange(new[]
|
Add(loadingSpinner = new LoadingSpinner(true, true)
|
||||||
|
{
|
||||||
|
Anchor = Anchor.BottomRight,
|
||||||
|
Origin = Anchor.BottomRight,
|
||||||
|
Margin = new MarginPadding(40),
|
||||||
|
});
|
||||||
|
|
||||||
|
loadingSpinner.Show();
|
||||||
|
|
||||||
|
BracketLoadTask.ContinueWith(_ => LoadComponentsAsync(new[]
|
||||||
{
|
{
|
||||||
new Container
|
new Container
|
||||||
{
|
{
|
||||||
@ -93,7 +93,24 @@ namespace osu.Game.Tournament
|
|||||||
RelativeSizeAxes = Axes.Both,
|
RelativeSizeAxes = Axes.Both,
|
||||||
Child = new TournamentSceneManager()
|
Child = new TournamentSceneManager()
|
||||||
}
|
}
|
||||||
});
|
}, drawables =>
|
||||||
|
{
|
||||||
|
loadingSpinner.Hide();
|
||||||
|
loadingSpinner.Expire();
|
||||||
|
|
||||||
|
AddRange(drawables);
|
||||||
|
|
||||||
|
windowSize.BindValueChanged(size => ScheduleAfterChildren(() =>
|
||||||
|
{
|
||||||
|
var minWidth = (int)(size.NewValue.Height / 768f * TournamentSceneManager.REQUIRED_WIDTH) - 1;
|
||||||
|
heightWarning.Alpha = size.NewValue.Width < minWidth ? 1 : 0;
|
||||||
|
}), true);
|
||||||
|
|
||||||
|
windowMode.BindValueChanged(mode => ScheduleAfterChildren(() =>
|
||||||
|
{
|
||||||
|
windowMode.Value = WindowMode.Windowed;
|
||||||
|
}), true);
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using osu.Framework.Allocation;
|
using osu.Framework.Allocation;
|
||||||
using osu.Framework.Graphics.Textures;
|
using osu.Framework.Graphics.Textures;
|
||||||
@ -29,6 +30,10 @@ namespace osu.Game.Tournament
|
|||||||
private DependencyContainer dependencies;
|
private DependencyContainer dependencies;
|
||||||
private FileBasedIPC ipc;
|
private FileBasedIPC ipc;
|
||||||
|
|
||||||
|
protected Task BracketLoadTask => taskCompletionSource.Task;
|
||||||
|
|
||||||
|
private readonly TaskCompletionSource<bool> taskCompletionSource = new TaskCompletionSource<bool>();
|
||||||
|
|
||||||
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
|
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
|
||||||
{
|
{
|
||||||
return dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
|
return dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
|
||||||
@ -46,14 +51,9 @@ namespace osu.Game.Tournament
|
|||||||
|
|
||||||
Textures.AddStore(new TextureLoaderStore(new StorageBackedResourceStore(storage)));
|
Textures.AddStore(new TextureLoaderStore(new StorageBackedResourceStore(storage)));
|
||||||
|
|
||||||
readBracket();
|
|
||||||
|
|
||||||
ladder.CurrentMatch.Value = ladder.Matches.FirstOrDefault(p => p.Current.Value);
|
|
||||||
|
|
||||||
dependencies.CacheAs(new StableInfo(storage));
|
dependencies.CacheAs(new StableInfo(storage));
|
||||||
|
|
||||||
dependencies.CacheAs<MatchIPCInfo>(ipc = new FileBasedIPC());
|
Task.Run(readBracket);
|
||||||
Add(ipc);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void readBracket()
|
private void readBracket()
|
||||||
@ -68,10 +68,6 @@ namespace osu.Game.Tournament
|
|||||||
ladder ??= new LadderInfo();
|
ladder ??= new LadderInfo();
|
||||||
ladder.Ruleset.Value ??= RulesetStore.AvailableRulesets.First();
|
ladder.Ruleset.Value ??= RulesetStore.AvailableRulesets.First();
|
||||||
|
|
||||||
Ruleset.BindTo(ladder.Ruleset);
|
|
||||||
|
|
||||||
dependencies.Cache(ladder);
|
|
||||||
|
|
||||||
bool addedInfo = false;
|
bool addedInfo = false;
|
||||||
|
|
||||||
// assign teams
|
// assign teams
|
||||||
@ -127,6 +123,19 @@ namespace osu.Game.Tournament
|
|||||||
|
|
||||||
if (addedInfo)
|
if (addedInfo)
|
||||||
SaveChanges();
|
SaveChanges();
|
||||||
|
|
||||||
|
ladder.CurrentMatch.Value = ladder.Matches.FirstOrDefault(p => p.Current.Value);
|
||||||
|
|
||||||
|
Schedule(() =>
|
||||||
|
{
|
||||||
|
Ruleset.BindTo(ladder.Ruleset);
|
||||||
|
|
||||||
|
dependencies.Cache(ladder);
|
||||||
|
dependencies.CacheAs<MatchIPCInfo>(ipc = new FileBasedIPC());
|
||||||
|
Add(ipc);
|
||||||
|
|
||||||
|
taskCompletionSource.SetResult(true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -64,7 +64,9 @@ namespace osu.Game.Beatmaps
|
|||||||
|
|
||||||
protected override string[] HashableFileTypes => new[] { ".osu" };
|
protected override string[] HashableFileTypes => new[] { ".osu" };
|
||||||
|
|
||||||
protected override string ImportFromStablePath => "Songs";
|
protected override string ImportFromStablePath => ".";
|
||||||
|
|
||||||
|
protected override Storage PrepareStableStorage(StableStorage stableStorage) => stableStorage.GetSongStorage();
|
||||||
|
|
||||||
private readonly RulesetStore rulesets;
|
private readonly RulesetStore rulesets;
|
||||||
private readonly BeatmapStore beatmaps;
|
private readonly BeatmapStore beatmaps;
|
||||||
|
@ -625,7 +625,7 @@ namespace osu.Game.Database
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Set a storage with access to an osu-stable install for import purposes.
|
/// Set a storage with access to an osu-stable install for import purposes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Func<Storage> GetStableStorage { private get; set; }
|
public Func<StableStorage> GetStableStorage { private get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Denotes whether an osu-stable installation is present to perform automated imports from.
|
/// Denotes whether an osu-stable installation is present to perform automated imports from.
|
||||||
@ -638,9 +638,10 @@ namespace osu.Game.Database
|
|||||||
protected virtual string ImportFromStablePath => null;
|
protected virtual string ImportFromStablePath => null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Select paths to import from stable. Default implementation iterates all directories in <see cref="ImportFromStablePath"/>.
|
/// Select paths to import from stable where all paths should be absolute. Default implementation iterates all directories in <see cref="ImportFromStablePath"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
protected virtual IEnumerable<string> GetStableImportPaths(Storage stableStoage) => stableStoage.GetDirectories(ImportFromStablePath);
|
protected virtual IEnumerable<string> GetStableImportPaths(Storage storage) => storage.GetDirectories(ImportFromStablePath)
|
||||||
|
.Select(path => storage.GetFullPath(path));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether this specified path should be removed after successful import.
|
/// Whether this specified path should be removed after successful import.
|
||||||
@ -654,24 +655,33 @@ namespace osu.Game.Database
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Task ImportFromStableAsync()
|
public Task ImportFromStableAsync()
|
||||||
{
|
{
|
||||||
var stable = GetStableStorage?.Invoke();
|
var stableStorage = GetStableStorage?.Invoke();
|
||||||
|
|
||||||
if (stable == null)
|
if (stableStorage == null)
|
||||||
{
|
{
|
||||||
Logger.Log("No osu!stable installation available!", LoggingTarget.Information, LogLevel.Error);
|
Logger.Log("No osu!stable installation available!", LoggingTarget.Information, LogLevel.Error);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stable.ExistsDirectory(ImportFromStablePath))
|
var storage = PrepareStableStorage(stableStorage);
|
||||||
|
|
||||||
|
if (!storage.ExistsDirectory(ImportFromStablePath))
|
||||||
{
|
{
|
||||||
// This handles situations like when the user does not have a Skins folder
|
// This handles situations like when the user does not have a Skins folder
|
||||||
Logger.Log($"No {ImportFromStablePath} folder available in osu!stable installation", LoggingTarget.Information, LogLevel.Error);
|
Logger.Log($"No {ImportFromStablePath} folder available in osu!stable installation", LoggingTarget.Information, LogLevel.Error);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.Run(async () => await Import(GetStableImportPaths(GetStableStorage()).Select(f => stable.GetFullPath(f)).ToArray()));
|
return Task.Run(async () => await Import(GetStableImportPaths(storage).ToArray()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Run any required traversal operations on the stable storage location before performing operations.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stableStorage">The stable storage.</param>
|
||||||
|
/// <returns>The usable storage. Return the unchanged <paramref name="stableStorage"/> if no traversal is required.</returns>
|
||||||
|
protected virtual Storage PrepareStableStorage(StableStorage stableStorage) => stableStorage;
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
62
osu.Game/IO/StableStorage.cs
Normal file
62
osu.Game/IO/StableStorage.cs
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
// 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.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using osu.Framework.Platform;
|
||||||
|
|
||||||
|
namespace osu.Game.IO
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A storage pointing to an osu-stable installation.
|
||||||
|
/// Provides methods for handling installations with a custom Song folder location.
|
||||||
|
/// </summary>
|
||||||
|
public class StableStorage : DesktopStorage
|
||||||
|
{
|
||||||
|
private const string stable_default_songs_path = "Songs";
|
||||||
|
|
||||||
|
private readonly DesktopGameHost host;
|
||||||
|
private readonly Lazy<string> songsPath;
|
||||||
|
|
||||||
|
public StableStorage(string path, DesktopGameHost host)
|
||||||
|
: base(path, host)
|
||||||
|
{
|
||||||
|
this.host = host;
|
||||||
|
|
||||||
|
songsPath = new Lazy<string>(locateSongsDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a <see cref="Storage"/> pointing to the osu-stable Songs directory.
|
||||||
|
/// </summary>
|
||||||
|
public Storage GetSongStorage() => new DesktopStorage(songsPath.Value, host);
|
||||||
|
|
||||||
|
private string locateSongsDirectory()
|
||||||
|
{
|
||||||
|
var configFile = GetFiles(".", $"osu!.{Environment.UserName}.cfg").SingleOrDefault();
|
||||||
|
|
||||||
|
if (configFile != null)
|
||||||
|
{
|
||||||
|
using (var stream = GetStream(configFile))
|
||||||
|
using (var textReader = new StreamReader(stream))
|
||||||
|
{
|
||||||
|
string line;
|
||||||
|
|
||||||
|
while ((line = textReader.ReadLine()) != null)
|
||||||
|
{
|
||||||
|
if (!line.StartsWith("BeatmapDirectory", StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
|
||||||
|
var customDirectory = line.Split('=').LastOrDefault()?.Trim();
|
||||||
|
if (customDirectory != null && Path.IsPathFullyQualified(customDirectory))
|
||||||
|
return customDirectory;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetFullPath(stable_default_songs_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
204
osu.Game/Online/HubClientConnector.cs
Normal file
204
osu.Game/Online/HubClientConnector.cs
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using osu.Framework;
|
||||||
|
using osu.Framework.Bindables;
|
||||||
|
using osu.Framework.Logging;
|
||||||
|
using osu.Game.Online.API;
|
||||||
|
|
||||||
|
namespace osu.Game.Online
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A component that manages the life cycle of a connection to a SignalR Hub.
|
||||||
|
/// </summary>
|
||||||
|
public class HubClientConnector : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Invoked whenever a new hub connection is built, to configure it before it's started.
|
||||||
|
/// </summary>
|
||||||
|
public Action<HubConnection>? ConfigureConnection;
|
||||||
|
|
||||||
|
private readonly string clientName;
|
||||||
|
private readonly string endpoint;
|
||||||
|
private readonly IAPIProvider api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The current connection opened by this connector.
|
||||||
|
/// </summary>
|
||||||
|
public HubConnection? CurrentConnection { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this is connected to the hub, use <see cref="CurrentConnection"/> to access the connection, if this is <c>true</c>.
|
||||||
|
/// </summary>
|
||||||
|
public IBindable<bool> IsConnected => isConnected;
|
||||||
|
|
||||||
|
private readonly Bindable<bool> isConnected = new Bindable<bool>();
|
||||||
|
private readonly SemaphoreSlim connectionLock = new SemaphoreSlim(1);
|
||||||
|
private CancellationTokenSource connectCancelSource = new CancellationTokenSource();
|
||||||
|
|
||||||
|
private readonly IBindable<APIState> apiState = new Bindable<APIState>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constructs a new <see cref="HubClientConnector"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="clientName">The name of the client this connector connects for, used for logging.</param>
|
||||||
|
/// <param name="endpoint">The endpoint to the hub.</param>
|
||||||
|
/// <param name="api"> An API provider used to react to connection state changes.</param>
|
||||||
|
public HubClientConnector(string clientName, string endpoint, IAPIProvider api)
|
||||||
|
{
|
||||||
|
this.clientName = clientName;
|
||||||
|
this.endpoint = endpoint;
|
||||||
|
this.api = api;
|
||||||
|
|
||||||
|
apiState.BindTo(api.State);
|
||||||
|
apiState.BindValueChanged(state =>
|
||||||
|
{
|
||||||
|
switch (state.NewValue)
|
||||||
|
{
|
||||||
|
case APIState.Failing:
|
||||||
|
case APIState.Offline:
|
||||||
|
Task.Run(() => disconnect(true));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case APIState.Online:
|
||||||
|
Task.Run(connect);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task connect()
|
||||||
|
{
|
||||||
|
cancelExistingConnect();
|
||||||
|
|
||||||
|
if (!await connectionLock.WaitAsync(10000))
|
||||||
|
throw new TimeoutException("Could not obtain a lock to connect. A previous attempt is likely stuck.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (apiState.Value == APIState.Online)
|
||||||
|
{
|
||||||
|
// ensure any previous connection was disposed.
|
||||||
|
// this will also create a new cancellation token source.
|
||||||
|
await disconnect(false);
|
||||||
|
|
||||||
|
// this token will be valid for the scope of this connection.
|
||||||
|
// if cancelled, we can be sure that a disconnect or reconnect is handled elsewhere.
|
||||||
|
var cancellationToken = connectCancelSource.Token;
|
||||||
|
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
Logger.Log($"{clientName} connecting...", LoggingTarget.Network);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// importantly, rebuild the connection each attempt to get an updated access token.
|
||||||
|
CurrentConnection = buildConnection(cancellationToken);
|
||||||
|
|
||||||
|
await CurrentConnection.StartAsync(cancellationToken);
|
||||||
|
|
||||||
|
Logger.Log($"{clientName} connected!", LoggingTarget.Network);
|
||||||
|
isConnected.Value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
//connection process was cancelled.
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Logger.Log($"{clientName} connection error: {e}", LoggingTarget.Network);
|
||||||
|
|
||||||
|
// retry on any failure.
|
||||||
|
await Task.Delay(5000, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
connectionLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private HubConnection buildConnection(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var builder = new HubConnectionBuilder()
|
||||||
|
.WithUrl(endpoint, options => { options.Headers.Add("Authorization", $"Bearer {api.AccessToken}"); });
|
||||||
|
|
||||||
|
if (RuntimeInfo.SupportsJIT)
|
||||||
|
builder.AddMessagePackProtocol();
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// eventually we will precompile resolvers for messagepack, but this isn't working currently
|
||||||
|
// see https://github.com/neuecc/MessagePack-CSharp/issues/780#issuecomment-768794308.
|
||||||
|
builder.AddNewtonsoftJsonProtocol(options => { options.PayloadSerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; });
|
||||||
|
}
|
||||||
|
|
||||||
|
var newConnection = builder.Build();
|
||||||
|
|
||||||
|
ConfigureConnection?.Invoke(newConnection);
|
||||||
|
|
||||||
|
newConnection.Closed += ex => onConnectionClosed(ex, cancellationToken);
|
||||||
|
return newConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task onConnectionClosed(Exception? ex, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
isConnected.Value = false;
|
||||||
|
|
||||||
|
Logger.Log(ex != null ? $"{clientName} lost connection: {ex}" : $"{clientName} disconnected", LoggingTarget.Network);
|
||||||
|
|
||||||
|
// make sure a disconnect wasn't triggered (and this is still the active connection).
|
||||||
|
if (!cancellationToken.IsCancellationRequested)
|
||||||
|
Task.Run(connect, default);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task disconnect(bool takeLock)
|
||||||
|
{
|
||||||
|
cancelExistingConnect();
|
||||||
|
|
||||||
|
if (takeLock)
|
||||||
|
{
|
||||||
|
if (!await connectionLock.WaitAsync(10000))
|
||||||
|
throw new TimeoutException("Could not obtain a lock to disconnect. A previous attempt is likely stuck.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (CurrentConnection != null)
|
||||||
|
await CurrentConnection.DisposeAsync();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CurrentConnection = null;
|
||||||
|
if (takeLock)
|
||||||
|
connectionLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cancelExistingConnect()
|
||||||
|
{
|
||||||
|
connectCancelSource.Cancel();
|
||||||
|
connectCancelSource = new CancellationTokenSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => $"Connector for {clientName} ({(IsConnected.Value ? "connected" : "not connected")}";
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
apiState.UnbindAll();
|
||||||
|
cancelExistingConnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -3,17 +3,12 @@
|
|||||||
|
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.SignalR.Client;
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Newtonsoft.Json;
|
|
||||||
using osu.Framework;
|
|
||||||
using osu.Framework.Allocation;
|
using osu.Framework.Allocation;
|
||||||
using osu.Framework.Bindables;
|
using osu.Framework.Bindables;
|
||||||
using osu.Framework.Logging;
|
|
||||||
using osu.Game.Online.API;
|
using osu.Game.Online.API;
|
||||||
using osu.Game.Online.Rooms;
|
using osu.Game.Online.Rooms;
|
||||||
|
|
||||||
@ -21,21 +16,12 @@ namespace osu.Game.Online.Multiplayer
|
|||||||
{
|
{
|
||||||
public class MultiplayerClient : StatefulMultiplayerClient
|
public class MultiplayerClient : StatefulMultiplayerClient
|
||||||
{
|
{
|
||||||
public override IBindable<bool> IsConnected => isConnected;
|
|
||||||
|
|
||||||
private readonly Bindable<bool> isConnected = new Bindable<bool>();
|
|
||||||
private readonly IBindable<APIState> apiState = new Bindable<APIState>();
|
|
||||||
|
|
||||||
private readonly SemaphoreSlim connectionLock = new SemaphoreSlim(1);
|
|
||||||
|
|
||||||
[Resolved]
|
|
||||||
private IAPIProvider api { get; set; } = null!;
|
|
||||||
|
|
||||||
private HubConnection? connection;
|
|
||||||
|
|
||||||
private CancellationTokenSource connectCancelSource = new CancellationTokenSource();
|
|
||||||
|
|
||||||
private readonly string endpoint;
|
private readonly string endpoint;
|
||||||
|
private HubClientConnector? connector;
|
||||||
|
|
||||||
|
public override IBindable<bool> IsConnected { get; } = new BindableBool();
|
||||||
|
|
||||||
|
private HubConnection? connection => connector?.CurrentConnection;
|
||||||
|
|
||||||
public MultiplayerClient(EndpointConfiguration endpoints)
|
public MultiplayerClient(EndpointConfiguration endpoints)
|
||||||
{
|
{
|
||||||
@ -43,84 +29,34 @@ namespace osu.Game.Online.Multiplayer
|
|||||||
}
|
}
|
||||||
|
|
||||||
[BackgroundDependencyLoader]
|
[BackgroundDependencyLoader]
|
||||||
private void load()
|
private void load(IAPIProvider api)
|
||||||
{
|
{
|
||||||
apiState.BindTo(api.State);
|
connector = new HubClientConnector(nameof(MultiplayerClient), endpoint, api)
|
||||||
apiState.BindValueChanged(apiStateChanged, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void apiStateChanged(ValueChangedEvent<APIState> state)
|
|
||||||
{
|
{
|
||||||
switch (state.NewValue)
|
ConfigureConnection = connection =>
|
||||||
{
|
{
|
||||||
case APIState.Failing:
|
// this is kind of SILLY
|
||||||
case APIState.Offline:
|
// https://github.com/dotnet/aspnetcore/issues/15198
|
||||||
Task.Run(() => disconnect(true));
|
connection.On<MultiplayerRoomState>(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged);
|
||||||
break;
|
connection.On<MultiplayerRoomUser>(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined);
|
||||||
|
connection.On<MultiplayerRoomUser>(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft);
|
||||||
|
connection.On<int>(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged);
|
||||||
|
connection.On<MultiplayerRoomSettings>(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged);
|
||||||
|
connection.On<int, MultiplayerUserState>(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged);
|
||||||
|
connection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested);
|
||||||
|
connection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted);
|
||||||
|
connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady);
|
||||||
|
connection.On<int, IEnumerable<APIMod>>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged);
|
||||||
|
connection.On<int, BeatmapAvailability>(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
case APIState.Online:
|
IsConnected.BindTo(connector.IsConnected);
|
||||||
Task.Run(connect);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task connect()
|
|
||||||
{
|
|
||||||
cancelExistingConnect();
|
|
||||||
|
|
||||||
if (!await connectionLock.WaitAsync(10000))
|
|
||||||
throw new TimeoutException("Could not obtain a lock to connect. A previous attempt is likely stuck.");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (api.State.Value == APIState.Online)
|
|
||||||
{
|
|
||||||
// ensure any previous connection was disposed.
|
|
||||||
// this will also create a new cancellation token source.
|
|
||||||
await disconnect(false);
|
|
||||||
|
|
||||||
// this token will be valid for the scope of this connection.
|
|
||||||
// if cancelled, we can be sure that a disconnect or reconnect is handled elsewhere.
|
|
||||||
var cancellationToken = connectCancelSource.Token;
|
|
||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
Logger.Log("Multiplayer client connecting...", LoggingTarget.Network);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// importantly, rebuild the connection each attempt to get an updated access token.
|
|
||||||
connection = createConnection(cancellationToken);
|
|
||||||
|
|
||||||
await connection.StartAsync(cancellationToken);
|
|
||||||
|
|
||||||
Logger.Log("Multiplayer client connected!", LoggingTarget.Network);
|
|
||||||
isConnected.Value = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
//connection process was cancelled.
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Logger.Log($"Multiplayer client connection error: {e}", LoggingTarget.Network);
|
|
||||||
|
|
||||||
// retry on any failure.
|
|
||||||
await Task.Delay(5000, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
connectionLock.Release();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override Task<MultiplayerRoom> JoinRoom(long roomId)
|
protected override Task<MultiplayerRoom> JoinRoom(long roomId)
|
||||||
{
|
{
|
||||||
if (!isConnected.Value)
|
if (!IsConnected.Value)
|
||||||
return Task.FromCanceled<MultiplayerRoom>(new CancellationToken(true));
|
return Task.FromCanceled<MultiplayerRoom>(new CancellationToken(true));
|
||||||
|
|
||||||
return connection.InvokeAsync<MultiplayerRoom>(nameof(IMultiplayerServer.JoinRoom), roomId);
|
return connection.InvokeAsync<MultiplayerRoom>(nameof(IMultiplayerServer.JoinRoom), roomId);
|
||||||
@ -128,7 +64,7 @@ namespace osu.Game.Online.Multiplayer
|
|||||||
|
|
||||||
protected override Task LeaveRoomInternal()
|
protected override Task LeaveRoomInternal()
|
||||||
{
|
{
|
||||||
if (!isConnected.Value)
|
if (!IsConnected.Value)
|
||||||
return Task.FromCanceled(new CancellationToken(true));
|
return Task.FromCanceled(new CancellationToken(true));
|
||||||
|
|
||||||
return connection.InvokeAsync(nameof(IMultiplayerServer.LeaveRoom));
|
return connection.InvokeAsync(nameof(IMultiplayerServer.LeaveRoom));
|
||||||
@ -136,7 +72,7 @@ namespace osu.Game.Online.Multiplayer
|
|||||||
|
|
||||||
public override Task TransferHost(int userId)
|
public override Task TransferHost(int userId)
|
||||||
{
|
{
|
||||||
if (!isConnected.Value)
|
if (!IsConnected.Value)
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|
||||||
return connection.InvokeAsync(nameof(IMultiplayerServer.TransferHost), userId);
|
return connection.InvokeAsync(nameof(IMultiplayerServer.TransferHost), userId);
|
||||||
@ -144,7 +80,7 @@ namespace osu.Game.Online.Multiplayer
|
|||||||
|
|
||||||
public override Task ChangeSettings(MultiplayerRoomSettings settings)
|
public override Task ChangeSettings(MultiplayerRoomSettings settings)
|
||||||
{
|
{
|
||||||
if (!isConnected.Value)
|
if (!IsConnected.Value)
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|
||||||
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeSettings), settings);
|
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeSettings), settings);
|
||||||
@ -152,7 +88,7 @@ namespace osu.Game.Online.Multiplayer
|
|||||||
|
|
||||||
public override Task ChangeState(MultiplayerUserState newState)
|
public override Task ChangeState(MultiplayerUserState newState)
|
||||||
{
|
{
|
||||||
if (!isConnected.Value)
|
if (!IsConnected.Value)
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|
||||||
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState);
|
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState);
|
||||||
@ -160,7 +96,7 @@ namespace osu.Game.Online.Multiplayer
|
|||||||
|
|
||||||
public override Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability)
|
public override Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability)
|
||||||
{
|
{
|
||||||
if (!isConnected.Value)
|
if (!IsConnected.Value)
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|
||||||
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability);
|
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability);
|
||||||
@ -168,7 +104,7 @@ namespace osu.Game.Online.Multiplayer
|
|||||||
|
|
||||||
public override Task ChangeUserMods(IEnumerable<APIMod> newMods)
|
public override Task ChangeUserMods(IEnumerable<APIMod> newMods)
|
||||||
{
|
{
|
||||||
if (!isConnected.Value)
|
if (!IsConnected.Value)
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|
||||||
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserMods), newMods);
|
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserMods), newMods);
|
||||||
@ -176,91 +112,16 @@ namespace osu.Game.Online.Multiplayer
|
|||||||
|
|
||||||
public override Task StartMatch()
|
public override Task StartMatch()
|
||||||
{
|
{
|
||||||
if (!isConnected.Value)
|
if (!IsConnected.Value)
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|
||||||
return connection.InvokeAsync(nameof(IMultiplayerServer.StartMatch));
|
return connection.InvokeAsync(nameof(IMultiplayerServer.StartMatch));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task disconnect(bool takeLock)
|
|
||||||
{
|
|
||||||
cancelExistingConnect();
|
|
||||||
|
|
||||||
if (takeLock)
|
|
||||||
{
|
|
||||||
if (!await connectionLock.WaitAsync(10000))
|
|
||||||
throw new TimeoutException("Could not obtain a lock to disconnect. A previous attempt is likely stuck.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (connection != null)
|
|
||||||
await connection.DisposeAsync();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
connection = null;
|
|
||||||
if (takeLock)
|
|
||||||
connectionLock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void cancelExistingConnect()
|
|
||||||
{
|
|
||||||
connectCancelSource.Cancel();
|
|
||||||
connectCancelSource = new CancellationTokenSource();
|
|
||||||
}
|
|
||||||
|
|
||||||
private HubConnection createConnection(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var builder = new HubConnectionBuilder()
|
|
||||||
.WithUrl(endpoint, options => { options.Headers.Add("Authorization", $"Bearer {api.AccessToken}"); });
|
|
||||||
|
|
||||||
if (RuntimeInfo.SupportsJIT)
|
|
||||||
builder.AddMessagePackProtocol();
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// eventually we will precompile resolvers for messagepack, but this isn't working currently
|
|
||||||
// see https://github.com/neuecc/MessagePack-CSharp/issues/780#issuecomment-768794308.
|
|
||||||
builder.AddNewtonsoftJsonProtocol(options => { options.PayloadSerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; });
|
|
||||||
}
|
|
||||||
|
|
||||||
var newConnection = builder.Build();
|
|
||||||
|
|
||||||
// this is kind of SILLY
|
|
||||||
// https://github.com/dotnet/aspnetcore/issues/15198
|
|
||||||
newConnection.On<MultiplayerRoomState>(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged);
|
|
||||||
newConnection.On<MultiplayerRoomUser>(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined);
|
|
||||||
newConnection.On<MultiplayerRoomUser>(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft);
|
|
||||||
newConnection.On<int>(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged);
|
|
||||||
newConnection.On<MultiplayerRoomSettings>(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged);
|
|
||||||
newConnection.On<int, MultiplayerUserState>(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged);
|
|
||||||
newConnection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested);
|
|
||||||
newConnection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted);
|
|
||||||
newConnection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady);
|
|
||||||
newConnection.On<int, IEnumerable<APIMod>>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged);
|
|
||||||
newConnection.On<int, BeatmapAvailability>(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged);
|
|
||||||
|
|
||||||
newConnection.Closed += ex =>
|
|
||||||
{
|
|
||||||
isConnected.Value = false;
|
|
||||||
|
|
||||||
Logger.Log(ex != null ? $"Multiplayer client lost connection: {ex}" : "Multiplayer client disconnected", LoggingTarget.Network);
|
|
||||||
|
|
||||||
// make sure a disconnect wasn't triggered (and this is still the active connection).
|
|
||||||
if (!cancellationToken.IsCancellationRequested)
|
|
||||||
Task.Run(connect, default);
|
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
};
|
|
||||||
return newConnection;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Dispose(bool isDisposing)
|
protected override void Dispose(bool isDisposing)
|
||||||
{
|
{
|
||||||
base.Dispose(isDisposing);
|
base.Dispose(isDisposing);
|
||||||
|
connector?.Dispose();
|
||||||
cancelExistingConnect();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -97,7 +97,8 @@ namespace osu.Game.Online.Multiplayer
|
|||||||
// Todo: This is temporary, until the multiplayer server returns the item id on match start or otherwise.
|
// Todo: This is temporary, until the multiplayer server returns the item id on match start or otherwise.
|
||||||
private int playlistItemId;
|
private int playlistItemId;
|
||||||
|
|
||||||
protected StatefulMultiplayerClient()
|
[BackgroundDependencyLoader]
|
||||||
|
private void load()
|
||||||
{
|
{
|
||||||
IsConnected.BindValueChanged(connected =>
|
IsConnected.BindValueChanged(connected =>
|
||||||
{
|
{
|
||||||
|
@ -8,13 +8,9 @@ using System.Linq;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using JetBrains.Annotations;
|
using JetBrains.Annotations;
|
||||||
using Microsoft.AspNetCore.SignalR.Client;
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Newtonsoft.Json;
|
|
||||||
using osu.Framework;
|
|
||||||
using osu.Framework.Allocation;
|
using osu.Framework.Allocation;
|
||||||
using osu.Framework.Bindables;
|
using osu.Framework.Bindables;
|
||||||
using osu.Framework.Graphics;
|
using osu.Framework.Graphics;
|
||||||
using osu.Framework.Logging;
|
|
||||||
using osu.Game.Beatmaps;
|
using osu.Game.Beatmaps;
|
||||||
using osu.Game.Online.API;
|
using osu.Game.Online.API;
|
||||||
using osu.Game.Replays.Legacy;
|
using osu.Game.Replays.Legacy;
|
||||||
@ -34,7 +30,14 @@ namespace osu.Game.Online.Spectator
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const double TIME_BETWEEN_SENDS = 200;
|
public const double TIME_BETWEEN_SENDS = 200;
|
||||||
|
|
||||||
private HubConnection connection;
|
private readonly string endpoint;
|
||||||
|
|
||||||
|
[CanBeNull]
|
||||||
|
private HubClientConnector connector;
|
||||||
|
|
||||||
|
private readonly IBindable<bool> isConnected = new BindableBool();
|
||||||
|
|
||||||
|
private HubConnection connection => connector?.CurrentConnection;
|
||||||
|
|
||||||
private readonly List<int> watchingUsers = new List<int>();
|
private readonly List<int> watchingUsers = new List<int>();
|
||||||
|
|
||||||
@ -44,13 +47,6 @@ namespace osu.Game.Online.Spectator
|
|||||||
|
|
||||||
private readonly BindableList<int> playingUsers = new BindableList<int>();
|
private readonly BindableList<int> playingUsers = new BindableList<int>();
|
||||||
|
|
||||||
private readonly IBindable<APIState> apiState = new Bindable<APIState>();
|
|
||||||
|
|
||||||
private bool isConnected;
|
|
||||||
|
|
||||||
[Resolved]
|
|
||||||
private IAPIProvider api { get; set; }
|
|
||||||
|
|
||||||
[CanBeNull]
|
[CanBeNull]
|
||||||
private IBeatmap currentBeatmap;
|
private IBeatmap currentBeatmap;
|
||||||
|
|
||||||
@ -82,85 +78,32 @@ namespace osu.Game.Online.Spectator
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<int, SpectatorState> OnUserFinishedPlaying;
|
public event Action<int, SpectatorState> OnUserFinishedPlaying;
|
||||||
|
|
||||||
private readonly string endpoint;
|
|
||||||
|
|
||||||
public SpectatorStreamingClient(EndpointConfiguration endpoints)
|
public SpectatorStreamingClient(EndpointConfiguration endpoints)
|
||||||
{
|
{
|
||||||
endpoint = endpoints.SpectatorEndpointUrl;
|
endpoint = endpoints.SpectatorEndpointUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
[BackgroundDependencyLoader]
|
[BackgroundDependencyLoader]
|
||||||
private void load()
|
private void load(IAPIProvider api)
|
||||||
{
|
{
|
||||||
apiState.BindTo(api.State);
|
connector = CreateConnector(nameof(SpectatorStreamingClient), endpoint, api);
|
||||||
apiState.BindValueChanged(apiStateChanged, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void apiStateChanged(ValueChangedEvent<APIState> state)
|
if (connector != null)
|
||||||
{
|
{
|
||||||
switch (state.NewValue)
|
connector.ConfigureConnection = connection =>
|
||||||
{
|
{
|
||||||
case APIState.Failing:
|
// until strong typed client support is added, each method must be manually bound
|
||||||
case APIState.Offline:
|
// (see https://github.com/dotnet/aspnetcore/issues/15198)
|
||||||
connection?.StopAsync();
|
|
||||||
connection = null;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case APIState.Online:
|
|
||||||
Task.Run(Connect);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected virtual async Task Connect()
|
|
||||||
{
|
|
||||||
if (connection != null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var builder = new HubConnectionBuilder()
|
|
||||||
.WithUrl(endpoint, options => { options.Headers.Add("Authorization", $"Bearer {api.AccessToken}"); });
|
|
||||||
|
|
||||||
if (RuntimeInfo.SupportsJIT)
|
|
||||||
builder.AddMessagePackProtocol();
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// eventually we will precompile resolvers for messagepack, but this isn't working currently
|
|
||||||
// see https://github.com/neuecc/MessagePack-CSharp/issues/780#issuecomment-768794308.
|
|
||||||
builder.AddNewtonsoftJsonProtocol(options => { options.PayloadSerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; });
|
|
||||||
}
|
|
||||||
|
|
||||||
connection = builder.Build();
|
|
||||||
// until strong typed client support is added, each method must be manually bound (see https://github.com/dotnet/aspnetcore/issues/15198)
|
|
||||||
connection.On<int, SpectatorState>(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying);
|
connection.On<int, SpectatorState>(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying);
|
||||||
connection.On<int, FrameDataBundle>(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames);
|
connection.On<int, FrameDataBundle>(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames);
|
||||||
connection.On<int, SpectatorState>(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying);
|
connection.On<int, SpectatorState>(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying);
|
||||||
|
|
||||||
connection.Closed += async ex =>
|
|
||||||
{
|
|
||||||
isConnected = false;
|
|
||||||
playingUsers.Clear();
|
|
||||||
|
|
||||||
if (ex != null)
|
|
||||||
{
|
|
||||||
Logger.Log($"Spectator client lost connection: {ex}", LoggingTarget.Network);
|
|
||||||
await tryUntilConnected();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await tryUntilConnected();
|
isConnected.BindTo(connector.IsConnected);
|
||||||
|
isConnected.BindValueChanged(connected =>
|
||||||
async Task tryUntilConnected()
|
|
||||||
{
|
{
|
||||||
Logger.Log("Spectator client connecting...", LoggingTarget.Network);
|
if (connected.NewValue)
|
||||||
|
|
||||||
while (api.State.Value == APIState.Online)
|
|
||||||
{
|
{
|
||||||
try
|
|
||||||
{
|
|
||||||
// reconnect on any failure
|
|
||||||
await connection.StartAsync();
|
|
||||||
Logger.Log("Spectator client connected!", LoggingTarget.Network);
|
|
||||||
|
|
||||||
// get all the users that were previously being watched
|
// get all the users that were previously being watched
|
||||||
int[] users;
|
int[] users;
|
||||||
|
|
||||||
@ -170,28 +113,24 @@ namespace osu.Game.Online.Spectator
|
|||||||
watchingUsers.Clear();
|
watchingUsers.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// success
|
// resubscribe to watched users.
|
||||||
isConnected = true;
|
|
||||||
|
|
||||||
// resubscribe to watched users
|
|
||||||
foreach (var userId in users)
|
foreach (var userId in users)
|
||||||
WatchUser(userId);
|
WatchUser(userId);
|
||||||
|
|
||||||
// re-send state in case it wasn't received
|
// re-send state in case it wasn't received
|
||||||
if (isPlaying)
|
if (isPlaying)
|
||||||
beginPlaying();
|
beginPlaying();
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
else
|
||||||
{
|
{
|
||||||
Logger.Log($"Spectator client connection error: {e}", LoggingTarget.Network);
|
playingUsers.Clear();
|
||||||
await Task.Delay(5000);
|
}
|
||||||
}
|
}, true);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected virtual HubClientConnector CreateConnector(string name, string endpoint, IAPIProvider api) => new HubClientConnector(name, endpoint, api);
|
||||||
|
|
||||||
Task ISpectatorClient.UserBeganPlaying(int userId, SpectatorState state)
|
Task ISpectatorClient.UserBeganPlaying(int userId, SpectatorState state)
|
||||||
{
|
{
|
||||||
if (!playingUsers.Contains(userId))
|
if (!playingUsers.Contains(userId))
|
||||||
@ -240,14 +179,14 @@ namespace osu.Game.Online.Spectator
|
|||||||
{
|
{
|
||||||
Debug.Assert(isPlaying);
|
Debug.Assert(isPlaying);
|
||||||
|
|
||||||
if (!isConnected) return;
|
if (!isConnected.Value) return;
|
||||||
|
|
||||||
connection.SendAsync(nameof(ISpectatorServer.BeginPlaySession), currentState);
|
connection.SendAsync(nameof(ISpectatorServer.BeginPlaySession), currentState);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SendFrames(FrameDataBundle data)
|
public void SendFrames(FrameDataBundle data)
|
||||||
{
|
{
|
||||||
if (!isConnected) return;
|
if (!isConnected.Value) return;
|
||||||
|
|
||||||
lastSend = connection.SendAsync(nameof(ISpectatorServer.SendFrameData), data);
|
lastSend = connection.SendAsync(nameof(ISpectatorServer.SendFrameData), data);
|
||||||
}
|
}
|
||||||
@ -257,7 +196,7 @@ namespace osu.Game.Online.Spectator
|
|||||||
isPlaying = false;
|
isPlaying = false;
|
||||||
currentBeatmap = null;
|
currentBeatmap = null;
|
||||||
|
|
||||||
if (!isConnected) return;
|
if (!isConnected.Value) return;
|
||||||
|
|
||||||
connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), currentState);
|
connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), currentState);
|
||||||
}
|
}
|
||||||
@ -271,7 +210,7 @@ namespace osu.Game.Online.Spectator
|
|||||||
|
|
||||||
watchingUsers.Add(userId);
|
watchingUsers.Add(userId);
|
||||||
|
|
||||||
if (!isConnected)
|
if (!isConnected.Value)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -284,7 +223,7 @@ namespace osu.Game.Online.Spectator
|
|||||||
{
|
{
|
||||||
watchingUsers.Remove(userId);
|
watchingUsers.Remove(userId);
|
||||||
|
|
||||||
if (!isConnected)
|
if (!isConnected.Value)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,7 +28,6 @@ using osu.Framework.Graphics.Sprites;
|
|||||||
using osu.Framework.Input;
|
using osu.Framework.Input;
|
||||||
using osu.Framework.Input.Bindings;
|
using osu.Framework.Input.Bindings;
|
||||||
using osu.Framework.Input.Events;
|
using osu.Framework.Input.Events;
|
||||||
using osu.Framework.Platform;
|
|
||||||
using osu.Framework.Threading;
|
using osu.Framework.Threading;
|
||||||
using osu.Game.Beatmaps;
|
using osu.Game.Beatmaps;
|
||||||
using osu.Game.Collections;
|
using osu.Game.Collections;
|
||||||
@ -52,6 +51,7 @@ using osu.Game.Updater;
|
|||||||
using osu.Game.Utils;
|
using osu.Game.Utils;
|
||||||
using LogLevel = osu.Framework.Logging.LogLevel;
|
using LogLevel = osu.Framework.Logging.LogLevel;
|
||||||
using osu.Game.Database;
|
using osu.Game.Database;
|
||||||
|
using osu.Game.IO;
|
||||||
|
|
||||||
namespace osu.Game
|
namespace osu.Game
|
||||||
{
|
{
|
||||||
@ -88,7 +88,7 @@ namespace osu.Game
|
|||||||
|
|
||||||
protected SentryLogger SentryLogger;
|
protected SentryLogger SentryLogger;
|
||||||
|
|
||||||
public virtual Storage GetStorageForStableInstall() => null;
|
public virtual StableStorage GetStorageForStableInstall() => null;
|
||||||
|
|
||||||
public float ToolbarOffset => (Toolbar?.Position.Y ?? 0) + (Toolbar?.DrawHeight ?? 0);
|
public float ToolbarOffset => (Toolbar?.Position.Y ?? 0) + (Toolbar?.DrawHeight ?? 0);
|
||||||
|
|
||||||
|
@ -52,6 +52,7 @@ namespace osu.Game.Overlays.Mods
|
|||||||
new OsuScrollContainer
|
new OsuScrollContainer
|
||||||
{
|
{
|
||||||
RelativeSizeAxes = Axes.Both,
|
RelativeSizeAxes = Axes.Both,
|
||||||
|
ScrollbarVisible = false,
|
||||||
Child = modSettingsContent = new FillFlowContainer<ModControlSection>
|
Child = modSettingsContent = new FillFlowContainer<ModControlSection>
|
||||||
{
|
{
|
||||||
Anchor = Anchor.TopCentre,
|
Anchor = Anchor.TopCentre,
|
||||||
|
@ -71,8 +71,9 @@ namespace osu.Game.Scoring
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override IEnumerable<string> GetStableImportPaths(Storage stableStorage)
|
protected override IEnumerable<string> GetStableImportPaths(Storage storage)
|
||||||
=> stableStorage.GetFiles(ImportFromStablePath).Where(p => HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false));
|
=> storage.GetFiles(ImportFromStablePath).Where(p => HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false))
|
||||||
|
.Select(path => storage.GetFullPath(path));
|
||||||
|
|
||||||
public Score GetScore(ScoreInfo score) => new LegacyDatabasedScore(score, rulesets, beatmaps(), Files.Store);
|
public Score GetScore(ScoreInfo score) => new LegacyDatabasedScore(score, rulesets, beatmaps(), Files.Store);
|
||||||
|
|
||||||
|
@ -68,6 +68,10 @@ namespace osu.Game.Screens.Menu
|
|||||||
{
|
{
|
||||||
StartTrack();
|
StartTrack();
|
||||||
|
|
||||||
|
// this classic intro loops forever.
|
||||||
|
if (UsingThemedIntro)
|
||||||
|
Track.Looping = true;
|
||||||
|
|
||||||
const float fade_in_time = 200;
|
const float fade_in_time = 200;
|
||||||
|
|
||||||
logo.ScaleTo(1);
|
logo.ScaleTo(1);
|
||||||
|
@ -77,7 +77,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
|
|||||||
RelativeSizeAxes = Axes.Both,
|
RelativeSizeAxes = Axes.Both,
|
||||||
Padding = new MarginPadding
|
Padding = new MarginPadding
|
||||||
{
|
{
|
||||||
Horizontal = 105,
|
Horizontal = HORIZONTAL_OVERFLOW_PADDING + 55,
|
||||||
Vertical = 20
|
Vertical = 20
|
||||||
},
|
},
|
||||||
Child = new GridContainer
|
Child = new GridContainer
|
||||||
@ -237,6 +237,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
|
|||||||
Origin = Anchor.BottomLeft,
|
Origin = Anchor.BottomLeft,
|
||||||
RelativeSizeAxes = Axes.Both,
|
RelativeSizeAxes = Axes.Both,
|
||||||
Height = 0.5f,
|
Height = 0.5f,
|
||||||
|
Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING },
|
||||||
Child = userModsSelectOverlay = new UserModSelectOverlay
|
Child = userModsSelectOverlay = new UserModSelectOverlay
|
||||||
{
|
{
|
||||||
SelectedMods = { BindTarget = UserMods },
|
SelectedMods = { BindTarget = UserMods },
|
||||||
|
Loading…
Reference in New Issue
Block a user