diff --git a/.idea/.idea.ServerBrowser.dir/.idea/.name b/.idea/.idea.ServerBrowser.dir/.idea/.name deleted file mode 100644 index b066421..0000000 --- a/.idea/.idea.ServerBrowser.dir/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -ServerBrowser \ No newline at end of file diff --git a/.idea/.idea.ServerBrowser.dir/.idea/discord.xml b/.idea/.idea.ServerBrowser.dir/.idea/discord.xml new file mode 100644 index 0000000..d8e9561 --- /dev/null +++ b/.idea/.idea.ServerBrowser.dir/.idea/discord.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/.idea/.idea.ServerBrowserMod/.idea/.gitignore b/.idea/.idea.ServerBrowserMod/.idea/.gitignore index 96b47c6..8819879 100644 --- a/.idea/.idea.ServerBrowserMod/.idea/.gitignore +++ b/.idea/.idea.ServerBrowserMod/.idea/.gitignore @@ -11,3 +11,5 @@ /dataSources.local.xml # Editor-based HTTP Client requests /httpRequests/ +# GitHub Copilot persisted chat sessions +/copilot/chatSessions diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md deleted file mode 100644 index 6f357d9..0000000 --- a/ATTRIBUTION.md +++ /dev/null @@ -1,53 +0,0 @@ -## Attribution for assets - -### RemixIcon - Media icon pack - -- `Assets/Sprites/Announce.png` - -[Icons by RemixICon](https://www.iconfinder.com/iconsets/remixicon-media), licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). - -### Users. Android L (Lollipop) icon pack by Ivan Boyko - -- `Assets/Sprites/Crown.png` -- `Assets/Sprites/Person.png` - -[Icons by Ivan Boyko](https://www.iconfinder.com/iconsets/users-android-l-lollipop), licensed under [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/). - -### Octicons icon set by Github - -- `Assets/Sprites/Pencil.png` - -MIT License - -Copyright (c) 2020 GitHub Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -### Technology - Straight Line icon set by designforeat - -- `Assets/Sprites/Portal.png` -- `Assets/Sprites/PortalUser.png` - -[Icons by designforeat](https://www.iconfinder.com/iconsets/technology-straight-line), licensed under [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/). - -### Fluent Solid 24px vol.1 icon pack by Microsoft - -- `Assets/Sprites/Robot.png` - -[Icons by Microsoft](https://www.iconfinder.com/iconsets/fluent-solid-24px-vol-1), licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). diff --git a/Assets/Sprites/BSSB.png b/Assets/BSSB.png similarity index 100% rename from Assets/Sprites/BSSB.png rename to Assets/BSSB.png diff --git a/Assets/Sprites.cs b/Assets/Sprites.cs index d8b212f..de8ee11 100644 --- a/Assets/Sprites.cs +++ b/Assets/Sprites.cs @@ -1,134 +1,60 @@ -using System.Linq; -using System.Reflection; +using System.Collections.Generic; +using System.Threading.Tasks; +using BeatSaberMarkupLanguage; using UnityEngine; +using UnityEngine.UI; namespace ServerBrowser.Assets { - /// - /// Utilities for using embedded and downloaded sprite assets in the UI. - /// - /// - /// Helper code taken from BeatSaverDownloader - /// Copyright (c) 2018 andruzzzhka (MIT Licensed) - /// internal static class Sprites { - /// Announce icon - /// Icon by RemixIcon (https://www.iconfinder.com/iconsets/remixicon-media) - /// Licensed under CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/) - public static Sprite? Announce; - /// Announce icon, padded version - /// Icon by RemixIcon (https://www.iconfinder.com/iconsets/remixicon-media) - /// Licensed under CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/) - public static Sprite? AnnouncePadded; - - /// BSSB logo - public static Sprite? ServerBrowserLogo; - - /// BeatSaver logo - public static Sprite? BeatSaverLogo; - - /// Crown icon - /// Icon by Ivan Boyko (https://www.iconfinder.com/visualpharm) - /// Licensed under CC BY 3.0 (https://creativecommons.org/licenses/by/3.0/) - public static Sprite? Crown; - - /// Pencil icon - /// Icon by Github, MIT Licensed - /// Copyright (c) 2020 GitHub Inc - public static Sprite? Pencil; - - /// Person icon - /// Icon by Ivan Boyko (https://www.iconfinder.com/visualpharm) - /// Licensed under CC BY 3.0 (https://creativecommons.org/licenses/by/3.0/) - public static Sprite? Person; - - /// Portal icon - /// Straight Line icon set by designforeat (https://www.iconfinder.com/iconsets/technology-straight-line) - /// Licensed under CC BY 3.0 (https://creativecommons.org/licenses/by/3.0/) - public static Sprite? Portal; - - /// Portal user icon - /// Straight Line icon set by designforeat (https://www.iconfinder.com/iconsets/technology-straight-line) - /// Licensed under CC BY 3.0 (https://creativecommons.org/licenses/by/3.0/) - public static Sprite? PortalUser; - - /// Robot icon - /// Fluent Solid 24px vol.1 icon pack by Microsoft (https://www.iconfinder.com/iconsets/fluent-solid-24px-vol-1) - /// Licensed under CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/) - public static Sprite? Robot; - - public static bool IsInitialized { get; private set; } - - public static void Initialize() - { - IsInitialized = true; - - Announce = LoadSpriteFromResources("ServerBrowser.Assets.Sprites.Announce.png"); - AnnouncePadded = LoadSpriteFromResources("ServerBrowser.Assets.Sprites.AnnouncePadded.png"); - ServerBrowserLogo = LoadSpriteFromResources("ServerBrowser.Assets.Sprites.BSSB.png"); - BeatSaverLogo = LoadSpriteFromResources("ServerBrowser.Assets.Sprites.BeatSaver.png"); - Crown = LoadSpriteFromResources("ServerBrowser.Assets.Sprites.Crown.png"); - Pencil = LoadSpriteFromResources("ServerBrowser.Assets.Sprites.Pencil.png"); - Person = LoadSpriteFromResources("ServerBrowser.Assets.Sprites.Person.png"); - Portal = LoadSpriteFromResources("ServerBrowser.Assets.Sprites.Portal.png"); - PortalUser = LoadSpriteFromResources("ServerBrowser.Assets.Sprites.PortalUser.png"); - Robot = LoadSpriteFromResources("ServerBrowser.Assets.Sprites.Robot.png"); - } - - private static Sprite? LoadSpriteFromResources(string resourcePath, float pixelsPerUnit = 100.0f) + private const string ResourcePrefix = "ServerBrowser.Assets.Sprites."; + + internal const string Avatar = "Avatar"; + internal const string Checkmark = "Checkmark"; + internal const string CircleDot = "CircleDot"; + internal const string ComboCrown = "ComboCrown"; + internal const string Crown = "Crown"; + internal const string EnergyBolt = "EnergyBolt"; + internal const string Friends = "Friends"; + internal const string Ghost = "Ghost"; + internal const string Global = "Global"; + internal const string Lock = "Lock"; + internal const string PlaceholderAvatar = "PlaceholderAvatar"; + internal const string PlaceholderSabers = "PlaceholderSabers"; + internal const string Player = "Player"; + internal const string Plus = "Plus"; + internal const string Random = "Random"; + internal const string SaberClash = "SaberClash"; + internal const string SaberUp = "SaberUp"; + internal const string Search = "Search"; + internal const string Spectator = "Spectator"; + internal const string SuperFast = "SuperFast"; + + private static readonly Dictionary _loadedSprites = new(); + + internal static async Task LoadAsync(string spriteName) { - var rawData = GetResource(Assembly.GetCallingAssembly(), resourcePath); - - if (rawData is null) - return null; - - var sprite = LoadSpriteRaw(rawData, pixelsPerUnit); - - if (sprite is null) - return null; - - sprite.name = resourcePath; + var spritePath = $"{ResourcePrefix}{spriteName}.png"; + + if (_loadedSprites.TryGetValue(spritePath, out var cachedSprite)) + if (cachedSprite != null) + return cachedSprite; + + var sprite = await Utilities.LoadSpriteFromAssemblyAsync(spritePath); + if (sprite != null) + _loadedSprites[spritePath] = sprite; return sprite; } - private static byte[]? GetResource(Assembly asm, string resourceName) - { - var stream = asm.GetManifestResourceStream(resourceName); - - if (stream is null) - return null; - - var data = new byte[stream.Length]; - stream.Read(data, 0, (int) stream.Length); - return data; - } - - internal static Sprite? LoadSpriteRaw(byte[] image, float pixelsPerUnit = 100.0f, - SpriteMeshType spriteMeshType = SpriteMeshType.Tight) + internal static async Task SetAssetSpriteAsync(this Image image, string spriteName) { - var texture = LoadTextureRaw(image); - - if (texture is null) - return null; + var sprite = await LoadAsync(spriteName); - return LoadSpriteFromTexture(texture, pixelsPerUnit, spriteMeshType); - } - - private static Texture2D? LoadTextureRaw(byte[] file) - { - if (!file.Any()) - return null; - - var texture = new Texture2D(2, 2); - return texture.LoadImage(file) ? texture : null; - } - - private static Sprite LoadSpriteFromTexture(Texture2D spriteTexture, float pixelsPerUnit = 100.0f, - SpriteMeshType spriteMeshType = SpriteMeshType.Tight) - { - return Sprite.Create(spriteTexture, new Rect(0, 0, spriteTexture.width, spriteTexture.height), - new Vector2(0, 0), pixelsPerUnit, 0, spriteMeshType); + if (sprite == null || image == null) + return; + + image.sprite = sprite; } } } \ No newline at end of file diff --git a/Assets/Sprites/Announce.png b/Assets/Sprites/Announce.png deleted file mode 100644 index 3f46533..0000000 Binary files a/Assets/Sprites/Announce.png and /dev/null differ diff --git a/Assets/Sprites/AnnouncePadded.png b/Assets/Sprites/AnnouncePadded.png deleted file mode 100644 index 6fcca7b..0000000 Binary files a/Assets/Sprites/AnnouncePadded.png and /dev/null differ diff --git a/Assets/Sprites/Avatar.png b/Assets/Sprites/Avatar.png new file mode 100644 index 0000000..ecb9e86 Binary files /dev/null and b/Assets/Sprites/Avatar.png differ diff --git a/Assets/Sprites/BeatSaver.png b/Assets/Sprites/BeatSaver.png deleted file mode 100644 index 6f15c01..0000000 Binary files a/Assets/Sprites/BeatSaver.png and /dev/null differ diff --git a/Assets/Sprites/Checkmark.png b/Assets/Sprites/Checkmark.png new file mode 100644 index 0000000..38aaef9 Binary files /dev/null and b/Assets/Sprites/Checkmark.png differ diff --git a/Assets/Sprites/CircleDot.png b/Assets/Sprites/CircleDot.png new file mode 100644 index 0000000..9a0c942 Binary files /dev/null and b/Assets/Sprites/CircleDot.png differ diff --git a/Assets/Sprites/ComboCrown.png b/Assets/Sprites/ComboCrown.png new file mode 100644 index 0000000..be154a5 Binary files /dev/null and b/Assets/Sprites/ComboCrown.png differ diff --git a/Assets/Sprites/Crown.png b/Assets/Sprites/Crown.png index 693d435..9740991 100644 Binary files a/Assets/Sprites/Crown.png and b/Assets/Sprites/Crown.png differ diff --git a/Assets/Sprites/EnergyBolt.png b/Assets/Sprites/EnergyBolt.png new file mode 100644 index 0000000..a25b7e2 Binary files /dev/null and b/Assets/Sprites/EnergyBolt.png differ diff --git a/Assets/Sprites/Friends.png b/Assets/Sprites/Friends.png new file mode 100644 index 0000000..0ab1fd5 Binary files /dev/null and b/Assets/Sprites/Friends.png differ diff --git a/Assets/Sprites/Ghost.png b/Assets/Sprites/Ghost.png new file mode 100644 index 0000000..cc2bc6a Binary files /dev/null and b/Assets/Sprites/Ghost.png differ diff --git a/Assets/Sprites/Global.png b/Assets/Sprites/Global.png new file mode 100644 index 0000000..6f3b20d Binary files /dev/null and b/Assets/Sprites/Global.png differ diff --git a/Assets/Sprites/Lock.png b/Assets/Sprites/Lock.png new file mode 100644 index 0000000..2046bf4 Binary files /dev/null and b/Assets/Sprites/Lock.png differ diff --git a/Assets/Sprites/Pencil.png b/Assets/Sprites/Pencil.png deleted file mode 100644 index e4f4b21..0000000 Binary files a/Assets/Sprites/Pencil.png and /dev/null differ diff --git a/Assets/Sprites/Person.png b/Assets/Sprites/Person.png deleted file mode 100644 index 7f370cc..0000000 Binary files a/Assets/Sprites/Person.png and /dev/null differ diff --git a/Assets/Sprites/PlaceholderAvatar.png b/Assets/Sprites/PlaceholderAvatar.png new file mode 100644 index 0000000..51e7cb3 Binary files /dev/null and b/Assets/Sprites/PlaceholderAvatar.png differ diff --git a/Assets/Sprites/PlaceholderSabers.png b/Assets/Sprites/PlaceholderSabers.png new file mode 100644 index 0000000..d937e9f Binary files /dev/null and b/Assets/Sprites/PlaceholderSabers.png differ diff --git a/Assets/Sprites/Player.png b/Assets/Sprites/Player.png new file mode 100644 index 0000000..407f1fa Binary files /dev/null and b/Assets/Sprites/Player.png differ diff --git a/Assets/Sprites/Plus.png b/Assets/Sprites/Plus.png new file mode 100644 index 0000000..691ca41 Binary files /dev/null and b/Assets/Sprites/Plus.png differ diff --git a/Assets/Sprites/Portal.png b/Assets/Sprites/Portal.png deleted file mode 100644 index ad9bf28..0000000 Binary files a/Assets/Sprites/Portal.png and /dev/null differ diff --git a/Assets/Sprites/PortalUser.png b/Assets/Sprites/PortalUser.png deleted file mode 100644 index 8c77b0b..0000000 Binary files a/Assets/Sprites/PortalUser.png and /dev/null differ diff --git a/Assets/Sprites/Random.png b/Assets/Sprites/Random.png new file mode 100644 index 0000000..3089ca7 Binary files /dev/null and b/Assets/Sprites/Random.png differ diff --git a/Assets/Sprites/Robot.png b/Assets/Sprites/Robot.png deleted file mode 100644 index a748310..0000000 Binary files a/Assets/Sprites/Robot.png and /dev/null differ diff --git a/Assets/Sprites/SaberClash.png b/Assets/Sprites/SaberClash.png new file mode 100644 index 0000000..6db3d91 Binary files /dev/null and b/Assets/Sprites/SaberClash.png differ diff --git a/Assets/Sprites/SaberUp.png b/Assets/Sprites/SaberUp.png new file mode 100644 index 0000000..e62af31 Binary files /dev/null and b/Assets/Sprites/SaberUp.png differ diff --git a/Assets/Sprites/Search.png b/Assets/Sprites/Search.png new file mode 100644 index 0000000..c6661b7 Binary files /dev/null and b/Assets/Sprites/Search.png differ diff --git a/Assets/Sprites/Spectator.png b/Assets/Sprites/Spectator.png new file mode 100644 index 0000000..f9d1655 Binary files /dev/null and b/Assets/Sprites/Spectator.png differ diff --git a/Assets/Sprites/SuperFast.png b/Assets/Sprites/SuperFast.png new file mode 100644 index 0000000..c91b04f Binary files /dev/null and b/Assets/Sprites/SuperFast.png differ diff --git a/Core/BssbApiClient.cs b/Core/BssbApiClient.cs deleted file mode 100644 index 1946a83..0000000 --- a/Core/BssbApiClient.cs +++ /dev/null @@ -1,192 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Reflection; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using System.Web; -using ServerBrowser.Models; -using ServerBrowser.Models.Requests; -using ServerBrowser.Models.Responses; -using SiraUtil.Logging; -using Zenject; - -namespace ServerBrowser.Core -{ - /// - /// HTTP client utility for the Server Browser API. - /// - public class BssbApiClient : IInitializable - { - public static string UserAgent - { - get - { - var assemblyVersion = Assembly.GetExecutingAssembly().GetName().Version; - var assemblyVersionStr = $"{assemblyVersion.Major}.{assemblyVersion.Minor}.{assemblyVersion.Build}"; - - var bsVersion = IPA.Utilities.UnityGame.GameVersion.ToString(); - - return $"ServerBrowser/{assemblyVersionStr} (BeatSaber/{bsVersion})"; - } - } - - [Inject] private readonly SiraLog _log = null!; - [Inject] private readonly PluginConfig _config = null!; - - private readonly HttpClient _httpClient; - - public BssbApiClient() - { - _httpClient = new(); - } - - public void Initialize() - { - _httpClient.BaseAddress = new Uri(_config.ApiServerUrl); - _httpClient.DefaultRequestHeaders.Add("User-Agent", UserAgent); - _httpClient.DefaultRequestHeaders.Add("X-BSSB", "✔"); - - _log.Debug($"Initialized API client [{_config.ApiServerUrl}, {UserAgent}]"); - } - - public async Task Announce(BssbServer announceData) - { - try - { -#if DEBUG - var rawJson = announceData.ToJson(); - _log.Info($"Sending announce payload: {rawJson}"); - var requestContent = new StringContent(rawJson, Encoding.UTF8, "application/json"); -#else - var requestContent = announceData.ToRequestContent(); -#endif - - var response = await _httpClient.PostAsync($"/api/v1/announce", requestContent); - - response.EnsureSuccessStatusCode(); - - var responseBody = await response.Content.ReadAsStringAsync(); - return AnnounceResponse.FromJson(responseBody); - } - catch (Exception ex) - { - LogApiException(ex); - return null; - } - } - - public async Task AnnounceResults(AnnounceResultsData resultsData) - { - try - { -#if DEBUG - var rawJson = resultsData.ToJson(); - _log.Info($"Sending results announce payload: {rawJson}"); - var requestContent = new StringContent(rawJson, Encoding.UTF8, "application/json"); -#else - var requestContent = resultsData.ToRequestContent(); -#endif - - var response = await _httpClient.PostAsync($"/api/v1/announce_results", requestContent); - - response.EnsureSuccessStatusCode(); - return true; - } - catch (Exception ex) - { - LogApiException(ex); - return false; - } - } - - public async Task UnAnnounce(UnAnnounceParams unannounceData) - { - try - { - var response = await _httpClient.PostAsync($"/api/v2/unannounce", - unannounceData.ToRequestContent()); - - response.EnsureSuccessStatusCode(); - - var responseBody = await response.Content.ReadAsStringAsync(); - return UnAnnounceResponse.FromJson(responseBody); - } - catch (Exception ex) - { - LogApiException(ex); - return null; - } - } - - public async Task Browse(BrowseQueryParams queryParams, CancellationToken cancellationToken) - { - try - { - var response = await _httpClient.GetAsync($"/api/v1/browse?{queryParams.ToQueryString()}", - cancellationToken); - - response.EnsureSuccessStatusCode(); - - var responseBody = await response.Content.ReadAsStringAsync(); - return BrowseResponse.FromJson(responseBody); - } - catch (Exception ex) - { - LogApiException(ex); - return null; - } - } - - public async Task BrowseDetail(string key, CancellationToken cancellationToken) - { - try - { - var response = await _httpClient.GetAsync($"/api/v1/browse/{key}", cancellationToken); - - response.EnsureSuccessStatusCode(); - - var responseBody = await response.Content.ReadAsStringAsync(); - return BssbServerDetail.FromJson(responseBody); - } - catch (Exception ex) - { - LogApiException(ex); - return null; - } - } - - private void LogApiException(Exception ex) - { - // Try to reduce verbosity of simple network errors in the log - if (ex is TaskCanceledException) - { - _log.Warn($"HTTP request was cancelled"); - return; - } - - if (ex is HttpException or HttpRequestException) - { - if (ex.InnerException is WebException) - { - ex = ex.InnerException; - } - else - { - _log.Error($"HTTP request failed: {ex.Message}"); - return; - } - } - - if (ex is WebException) - { - _log.Error($"Web request failed: {ex.Message}"); - return; - } - - // Fallback for unexpected errors - _log.Error(ex); - } - } -} \ No newline at end of file diff --git a/Core/BssbBrowser.cs b/Core/BssbBrowser.cs deleted file mode 100644 index 4e2bbff..0000000 --- a/Core/BssbBrowser.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using ServerBrowser.Models.Requests; -using ServerBrowser.Models.Responses; -using SiraUtil.Logging; -using Zenject; - -namespace ServerBrowser.Core -{ - /// - /// Utility for browsing paginated lobbies on the Server Browser API. - /// - // ReSharper disable once ClassNeverInstantiated.Global - public class BssbBrowser - { - public const int DefaultPageSize = 6; - - [Inject] private readonly SiraLog _log = null!; - [Inject] private readonly BssbApiClient _apiClient = null!; - - public BrowseQueryParams QueryParams = new(); - public BrowseResponse? PageData { get; private set; } - - /// - /// This event is raised when loading a page has finished or failed. - /// - public event EventHandler? UpdateEvent; - - private CancellationTokenSource? _loadingCts; - private int _pageIndex; - - public bool IsLoading { get; private set; } - public bool LoadingErrored { get; private set; } - - public async Task ResetRefresh() - { - CancelLoading(); - _pageIndex = 0; - await Refresh(); - } - - public async Task Refresh() - { - CancelLoading(); - TriggerUpdate(true); - - // Calculate pagination offset - var pageSize = PageData?.PageSize ?? DefaultPageSize; - QueryParams.Offset = (_pageIndex * pageSize); - - // Query API (load page) - try - { - PageData = await _apiClient.Browse(QueryParams, _loadingCts!.Token); - } - catch (TaskCanceledException) - { - PageData = null; - } - - if (PageData is not null) - _log.Debug($"BrowseData loaded page (Index={_pageIndex}, TotalCount={PageData.TotalResultCount}, " + - $"Limit={PageData.PageSize}, LobbiesCount={PageData.Servers?.Count ?? 0}, " + - $"MOTD={PageData.MessageOfTheDay})"); - else - _log.Error($"BrowseData page load failed - received null response (Index={_pageIndex})"); - - // Trigger update - TriggerUpdate(false, (PageData == null)); - } - - public async Task PageUp() - { - if (_pageIndex <= 0) - return; - - _pageIndex--; - await Refresh(); - } - - public async Task PageDown() - { - _pageIndex++; - await Refresh(); - } - - public void CancelLoading() - { - _loadingCts?.Cancel(); - _loadingCts?.Dispose(); - - _loadingCts = new(); - - if (IsLoading) - { - TriggerUpdate(false, true); - } - } - - private void TriggerUpdate(bool isLoading, bool didError = false) - { - IsLoading = isLoading; - LoadingErrored = didError; - - UpdateEvent?.Invoke(this, EventArgs.Empty); - } - } -} \ No newline at end of file diff --git a/Core/BssbDataCollector.cs b/Core/BssbDataCollector.cs deleted file mode 100644 index a56ef48..0000000 --- a/Core/BssbDataCollector.cs +++ /dev/null @@ -1,330 +0,0 @@ -using System; -using System.Linq; -using IgnoranceCore; -using IPA.Utilities; -using MultiplayerCore.Players; -using ServerBrowser.Models; -using ServerBrowser.Models.Enums; -using SiraUtil.Affinity; -using SiraUtil.Logging; -using Zenject; - -namespace ServerBrowser.Core -{ - /// - /// Collects server and lobby data from the multiplayer session, so it can be relayed to the server browser. - /// - // ReSharper disable once ClassNeverInstantiated.Global - public class BssbDataCollector : IInitializable, IDisposable, IAffinity - { - [Inject] private readonly SiraLog _log = null!; - [Inject] private readonly IMultiplayerSessionManager _multiplayerSession = null!; - [Inject] private readonly ServerBrowserClient _serverBrowserClient = null!; - [Inject] private readonly MpPlayerManager _mpPlayerManager = null!; - - public bool SessionActive { get; private set; } - public BssbServerDetail Current { get; private set; } = new(); - public MultiplayerResultsData? LastResults { get; private set; } = null; - - public event EventHandler? DataChanged; - public event EventHandler? SessionEstablished; - public event EventHandler? SessionEnded; - - public void Initialize() - { - _multiplayerSession.connectedEvent += HandleSessionConnected; - _multiplayerSession.disconnectedEvent += HandleSessionDisconnected; - _multiplayerSession.playerConnectedEvent += HandlePlayerConnected; - _multiplayerSession.playerDisconnectedEvent += HandlePlayerDisconnected; - _mpPlayerManager.PlayerConnectedEvent += HandleExtendedPlayerConnected; - } - - public void Dispose() - { - _multiplayerSession.connectedEvent -= HandleSessionConnected; - _multiplayerSession.disconnectedEvent -= HandleSessionDisconnected; - _multiplayerSession.playerConnectedEvent -= HandlePlayerConnected; - _multiplayerSession.playerDisconnectedEvent -= HandlePlayerDisconnected; - _mpPlayerManager.PlayerConnectedEvent -= HandleExtendedPlayerConnected; - } - - internal void TriggerDataChanged() - { - DataChanged?.Invoke(this, EventArgs.Empty); - } - - private void HandleSessionConnected() - { - _log.Info($"Multiplayer session connected (syncTime={_multiplayerSession.syncTime})"); - - SessionActive = true; - - if (IsPartyLeader) - Current.Name = _serverBrowserClient.PreferredServerName; - - HandlePlayerConnected(_multiplayerSession.connectionOwner); - HandlePlayerConnected(_multiplayerSession.localPlayer); - - foreach (var connectedPlayer in _multiplayerSession.connectedPlayers) - { - if (!connectedPlayer.isConnected || connectedPlayer.isKicked) - continue; - - HandlePlayerConnected(connectedPlayer); - } - - if (Current.IsBeatTogetherHost) - _log.Debug("Detected a BeatTogether host"); - else if (Current.IsBeatUpServerHost) - _log.Debug("Detected a BeatUpServer host"); - else if (Current.IsBeatDediHost) - _log.Debug("Detected a BeatDedi host"); - else if (Current.IsAwsGameLiftHost) - _log.Debug("Detected an Amazon GameLift host"); - - Current.ServerTypeCode = DetermineServerType(); - - SessionEstablished?.Invoke(this, Current); - } - - private void HandleSessionDisconnected(DisconnectedReason reason) - { - if (!SessionActive) - return; - - _log.Info($"Multiplayer session disconnected (reason={reason})"); - - SessionActive = false; - - SessionEnded?.Invoke(this, EventArgs.Empty); - } - - private void HandlePlayerConnected(IConnectedPlayer player) - { - if (ContainsPlayer(player.userId)) - return; - - _log.Info($"Player connected (sortIndex={player.sortIndex}, userId={player.userId}, " - + $"userName={player.userName}, isMe={player.isMe}, isConnectionOwner={player.isConnectionOwner}, " - + $"currentLatency={player.currentLatency})"); - - var bssbServerPlayer = BssbServerPlayer.FromConnectedPlayer(player); - bssbServerPlayer.IsPartyLeader = (Current.ManagerId == player.userId); - - if (bssbServerPlayer.IsMe) - { - bssbServerPlayer.PlatformType = _serverBrowserClient.PlatformKey; - bssbServerPlayer.PlatformUserId = _serverBrowserClient.PlatformUserInfo?.platformUserId; - } - else - { - var extendedPlayerInfo = _mpPlayerManager.GetPlayer(bssbServerPlayer.UserId!); - - if (extendedPlayerInfo != null) - { - bssbServerPlayer.PlatformType = extendedPlayerInfo.Platform.ToString(); - bssbServerPlayer.PlatformUserId = extendedPlayerInfo.PlatformId; - } - } - - Current.Players.Add(bssbServerPlayer); - - DataChanged?.Invoke(this, EventArgs.Empty); - } - - private void HandlePlayerDisconnected(IConnectedPlayer player) - { - _log.Info($"Player disconnected (userId={player.userId}, userName={player.userName})"); - - var playerToRemove = Current.Players.FirstOrDefault(p => p.UserId == player.userId); - - if (playerToRemove == null) - return; - - Current.Players.Remove(playerToRemove); - - DataChanged?.Invoke(this, EventArgs.Empty); - } - - private void HandleExtendedPlayerConnected(IConnectedPlayer basePlayer, MpPlayerData extendedPlayerInfo) - { - var dataPlayer = Current.Players.FirstOrDefault(p => p.UserId == basePlayer.userId); - - if (dataPlayer is null) - return; - - dataPlayer.PlatformType = extendedPlayerInfo.Platform.ToString(); - dataPlayer.PlatformUserId = extendedPlayerInfo.PlatformId; - } - - private string DetermineServerType() - { - if (Current.IsOfficial) - return Current.IsQuickPlay ? "vanilla_quickplay" : "vanilla_dedicated"; - - if (Current.IsBeatTogetherHost) - return Current.IsQuickPlay ? "beattogether_quickplay" : "beattogether_dedicated"; - - if (Current.IsBeatUpServerHost) - return Current.IsQuickPlay ? "beatupserver_quickplay" : "beatupserver_dedicated"; - - if (Current.IsBeatDediHost) - return Current.IsQuickPlay ? "beatdedi_quickplay" : "beatdedi_custom"; - - return "unknown"; - } - - public bool ContainsPlayer(string userId) => Current.Players.Any(p => p.UserId == userId); - - public bool IsPartyLeader => - _multiplayerSession.localPlayer.userId == Current.ManagerId; - - [AffinityPostfix] - [AffinityPatch(typeof(GameLiftConnectionManager), "HandleConnectToServerSuccess")] - private void HandleGameLiftPreConnect(string playerSessionId, string hostName, int port, string gameSessionId, - string secret, string code, BeatmapLevelSelectionMask selectionMask, - GameplayServerConfiguration configuration) - { - // nb: HandleConnectToServerSuccess means handshake is complete, and we are about to reconnect to the - // dedicated server for the actual multiplayer session - we're not yet actually successfully connected. - - _log.Info($"Game will connect to GameLift session (playerSessionId={playerSessionId}, " - + $"hostName={hostName}, port={port}, gameSessionId={gameSessionId}, secret={secret}, code={code}, " - + $"maxPlayerCount={configuration.maxPlayerCount}, " - + $"discoveryPolicy={configuration.discoveryPolicy}, " - + $"gameplayServerMode={configuration.gameplayServerMode}, " - + $"songSelectionMode={configuration.songSelectionMode})"); - - // https://docs.aws.amazon.com/gamelift/latest/apireference/API_PlayerSession.html - - // playerSessionId is "psess-{GUID}" - // A unique identifier for a player session. - - // gameSessionId is an AWS identifier (ARN) starting with "arn:aws:gamelift:" and equals the dedi's user id - // A unique identifier for the game session that the player session is connected to. - - Current.ServerCode = code; - Current.RemoteUserId = gameSessionId; - Current.RemoteUserName = null; - Current.HostSecret = (string.IsNullOrEmpty(secret) ? gameSessionId : secret); - Current.ManagerId = null; - Current.PlayerLimit = configuration.maxPlayerCount; - Current.GameplayMode = configuration.gameplayServerMode; - Current.MasterGraphUrl = _serverBrowserClient.MasterGraphUrl; - Current.MasterStatusUrl = _serverBrowserClient.MasterStatusUrl; - Current.EndPoint = new DnsEndPoint(hostName, port); - Current.LobbyDifficulty = selectionMask.difficulties.ToBssbDifficulty(); - - Current.Key = null; - Current.Name = null; - Current.LobbyState = null; - Current.MultiplayerCoreVersion = _serverBrowserClient.MultiplayerCoreVersion; - Current.MultiplayerExtensionsVersion = _serverBrowserClient.MultiplayerExtensionsVersion; - - Current.Level = null; - Current.Players.Clear(); - - Current.ServerTypeCode = DetermineServerType(); - - DataChanged?.Invoke(this, EventArgs.Empty); - } - - [AffinityPostfix] - [AffinityPatch(typeof(LobbyGameStateController), "StartMultiplayerLevel")] - private void HandleStartMultiplayerLevel(ILevelGameplaySetupData gameplaySetupData, - IDifficultyBeatmap? difficultyBeatmap, Action beforeSceneSwitchCallback) - { - var previewBeatmapLevel = gameplaySetupData.beatmapLevel.beatmapLevel; - var beatmapDifficulty = gameplaySetupData.beatmapLevel.beatmapDifficulty; - var beatmapCharacteristic = gameplaySetupData.beatmapLevel.beatmapCharacteristic; - var gameplayModifiers = gameplaySetupData.gameplayModifiers; - - _log.Info($"Starting multiplayer level (levelID={previewBeatmapLevel.levelID}, " + - $"songName={previewBeatmapLevel.songName}, songSubName={previewBeatmapLevel.songSubName}, " + - $"songAuthorName={previewBeatmapLevel.songAuthorName}, " + - $"levelAuthorName={previewBeatmapLevel.levelAuthorName}, " + - $"difficulty={beatmapDifficulty}, characteristic={beatmapCharacteristic}, " + - $"modifiers={gameplayModifiers})"); - - Current.Level = BssbServerLevel.FromLevelStartData(previewBeatmapLevel, beatmapDifficulty, difficultyBeatmap, - gameplayModifiers, beatmapCharacteristic.serializedName); - - if (Current.Level.Difficulty.HasValue && Current.LobbyDifficulty != BssbDifficulty.All) - Current.LobbyDifficulty = Current.Level.Difficulty.Value.ToBssbDifficulty(); - - Current.LevelDifficulty = beatmapDifficulty.ToBssbDifficulty(); - - DataChanged?.Invoke(this, EventArgs.Empty); - } - - [AffinityPrefix] - [AffinityPatch(typeof(IgnoranceClient), "ThreadWorker")] - private void PrefixIgnoranceClientThread(IgnoranceClient __instance) - { - if (__instance.UseSsl) - { - _log.Info("Ignorance connection encryption enabled (enet_dtls)"); - Current.EncryptionMode = "enet_dtls"; - } - else - { - _log.Info("Ignorance connection encryption is disabled"); - Current.EncryptionMode = "none"; - } - } - - [AffinityPostfix] - [AffinityPatch(typeof(LobbyPlayersDataModel), "SetPlayerIsPartyOwner")] - private void HandleSetPlayerIsPartyOwner(string userId, bool isPartyOwner) - { - if (!isPartyOwner) - return; - - Current.ManagerId = userId; - - foreach (var player in Current.Players) - { - var wasPartyLeader = player.IsPartyLeader; - player.IsPartyLeader = (player.UserId == userId); - - if (!wasPartyLeader && player.IsPartyLeader) - { - var isLocalPlayer = _multiplayerSession.localPlayer.userId == player.UserId; - - _log.Info($"Party leader changed to (userId={player.UserId}, " + - $"userName={player.UserName}, isMe={isLocalPlayer})"); - - if (isLocalPlayer && String.IsNullOrEmpty(Current.Name)) - // Set server name if it wasn't set at session start (workaround for BeatTogether hosts) - Current.Name = _serverBrowserClient.PreferredServerName; - } - } - - DataChanged?.Invoke(this, EventArgs.Empty); - } - - [AffinityPostfix] - [AffinityPatch(typeof(MultiplayerController), "PerformSongStartSync")] - private void HandleSongStartSync(MultiplayerPlayerStartState localPlayerSyncState, - MultiplayerController __instance) - { - var sessionGameId = __instance.GetField("_sessionGameId"); - - _log.Info($"Multiplayer song started (sessionGameId={sessionGameId}, " + - $"localPlayerSyncState={localPlayerSyncState})"); - - if (Current.Level is not null) - Current.Level.SessionGameId = sessionGameId; - - DataChanged?.Invoke(this, EventArgs.Empty); - } - - [AffinityPostfix] - [AffinityPatch(typeof(MultiplayerLevelScenesTransitionSetupDataSO), "Finish")] - private void HandleMultiplayerLevelFinish(MultiplayerResultsData resultsData) - { - LastResults = resultsData; - TriggerDataChanged(); - } - } -} \ No newline at end of file diff --git a/Core/BssbMenuDataCollector.cs b/Core/BssbMenuDataCollector.cs deleted file mode 100644 index 72aa92e..0000000 --- a/Core/BssbMenuDataCollector.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using SiraUtil.Logging; -using Zenject; - -namespace ServerBrowser.Core -{ - /// - /// Extends the BssbDataCollector with data that is only available in menu scope. - /// - // ReSharper disable once ClassNeverInstantiated.Global - public class BssbMenuDataCollector : IInitializable, IDisposable - { - [Inject] private SiraLog _log = null!; - [Inject] private BssbDataCollector _dataCollector = null!; - [Inject] private ILobbyGameStateController _lobbyGameStateController = null!; - - public void Initialize() - { - _lobbyGameStateController.lobbyStateChangedEvent += HandleLobbyStateChanged; - } - - public void Dispose() - { - _lobbyGameStateController.lobbyStateChangedEvent -= HandleLobbyStateChanged; - } - - private void HandleLobbyStateChanged(MultiplayerLobbyState newState) - { - if (_dataCollector.Current.LobbyState == newState) - return; - - _log.Debug($"Lobby state changed to: {newState}"); - - _dataCollector.Current.LobbyState = newState; - _dataCollector.TriggerDataChanged(); - } - } -} \ No newline at end of file diff --git a/Core/BssbServerAnnouncer.cs b/Core/BssbServerAnnouncer.cs deleted file mode 100644 index d3a30b9..0000000 --- a/Core/BssbServerAnnouncer.cs +++ /dev/null @@ -1,387 +0,0 @@ -using System; -using System.Threading.Tasks; -using ServerBrowser.Models; -using ServerBrowser.Models.Requests; -using ServerBrowser.Models.Responses; -using SiraUtil.Logging; -using UnityEngine; -using Zenject; - -namespace ServerBrowser.Core -{ - /// - /// Handles sending announce/unannounce requests to the server browser API. - /// - public class BssbServerAnnouncer : MonoBehaviour, IInitializable - { - private const float AnnounceIntervalSeconds = 30f; - private const int MaxConsecutiveErrorsForUnannounce = 3; - - [Inject] private readonly SiraLog _log = null!; - [Inject] private readonly PluginConfig _config = null!; - [Inject] private readonly BssbDataCollector _dataCollector = null!; - [Inject] private readonly BssbApiClient _apiClient = null!; - [Inject] private readonly ServerBrowserClient _serverBrowserClient = null!; - - private bool _sessionEstablished; - private bool _dirtyFlag; - private bool _havePendingRequest; - private float? _lastAnnounceTime; - private AnnounceResponse? _lastSuccessResponse; - private int _consecutiveErrors = 0; - private string? _lastResultsSessionId; - - public BssbServerDetail Data => _dataCollector.Current; - - public enum AnnouncerState : byte - { - /// - /// Announcing has not yet begun, or unannounce has completed. - /// May transition to Announcing if the session starts, and we are allowed to announce. - /// - NotAnnouncing, - - /// - /// Session was established, and we are now actively sending periodic announces. - /// - Announcing, - - /// - /// Session has ended, and we are unannouncing the game. - /// Will transition to NotAnnouncing once the unannounce is confirmed. - /// - Unannouncing - } - - private AnnouncerState _stateBackingField = AnnouncerState.NotAnnouncing; - - public AnnouncerState State - { - get => _stateBackingField; - set - { - if (_stateBackingField == value) - return; - - _stateBackingField = value; - _lastSuccessResponse = null; - _dirtyFlag = true; - - OnStateChange?.Invoke(this, value); - } - } - - public bool HaveAnnounceSuccess => State == AnnouncerState.Announcing && _lastSuccessResponse is not null && - _lastSuccessResponse.Success; - - public string? AnnounceServerMessage => State == AnnouncerState.Announcing - ? _lastSuccessResponse?.ServerMessage : null; - - public event EventHandler? OnAnnounceResult; - public event EventHandler? OnUnAnnounceResult; - public event EventHandler? OnStateChange; - - #region Lifecycle - - public void Initialize() - { - _dataCollector.SessionEstablished += HandleDataSessionEstablished; - _dataCollector.DataChanged += HandleDataChanged; - _dataCollector.SessionEnded += HandleDataSessionEnded; - } - - public void OnEnable() - { - InvokeRepeating(nameof(RepeatingTick), 3f, 3f); - - _sessionEstablished = false; - _dirtyFlag = true; - _havePendingRequest = false; - _lastAnnounceTime = null; - _lastSuccessResponse = null; - _consecutiveErrors = 0; - } - - public void OnDisable() - { - CancelInvoke(nameof(RepeatingTick)); - } - - #endregion - - #region Logic - - private bool GetShouldAnnounce() - { - if (Data.IsDirectConnect || Data.IsBeatDediHost) - // Direct connect hosts handle their own announcements and may be intentionally private - return false; - - if (Data.IsQuickPlay) - return _config.AnnounceQuickPlay; // Quick Play: All players should announce, if not disabled - - return _config.AnnounceParty && _dataCollector.IsPartyLeader; - } - - public async void RefreshPreferences() - { - var isAnnouncing = State is AnnouncerState.Announcing; - var shouldAnnounce = GetShouldAnnounce(); - - if (_sessionEstablished) - { - if (shouldAnnounce && !isAnnouncing) - { - _log.Debug($"User preferences updated: starting announce"); - State = AnnouncerState.Announcing; - } - else if (!shouldAnnounce && isAnnouncing) - { - _log.Debug($"User preferences updated: starting unannounce"); - State = AnnouncerState.Unannouncing; - } - - var prefName = _serverBrowserClient.PreferredServerName; - - if ((Data.LocalPlayer?.IsPartyLeader ?? false) && Data.Name != prefName) - { - _log.Debug($"User preferences updated: set game name to {prefName}"); - Data.Name = prefName; - } - } - - await RepeatingTick(); - } - - private async Task RepeatingTick() - { - if (State == AnnouncerState.NotAnnouncing) - // Not enabled - return; - - if (_havePendingRequest) - // Currently awaiting (un)announce request, do nothing - return; - - if (_dirtyFlag) - { - // Have dirty flag, we should send next message now - if (State == AnnouncerState.Announcing) - { - await SendAnnounceNow(); - } - else if (State == AnnouncerState.Unannouncing) - { - if (await SendUnannounceNow()) - { - State = AnnouncerState.NotAnnouncing; - } - else if (_consecutiveErrors >= MaxConsecutiveErrorsForUnannounce) - { - _log.Warn($"Unannounce aborted: {_consecutiveErrors} consecutive errors"); - State = AnnouncerState.NotAnnouncing; - } - } - } - else - { - // Do not have dirty flag, check timing - var timeNow = Time.realtimeSinceStartup; - - if (_lastAnnounceTime == null || (timeNow - _lastAnnounceTime) >= AnnounceIntervalSeconds) - { - // Interval time reached, mark update needed - _dirtyFlag = true; - } - } - } - - #endregion - - #region API - - private async Task SendAnnounceNow() - { - if (Data.LobbyState == MultiplayerLobbyState.None) - // Not in a lobby (anymore); do not announce - return false; - - try - { - _dirtyFlag = false; - _havePendingRequest = true; - - var response = await _apiClient.Announce(_dataCollector.Current); - - if (response?.Success ?? false) - { - _consecutiveErrors = 0; - _log.Info($"Announce OK (ServerKey={response.Key}, ServerMessage={response.ServerMessage})"); - - _lastAnnounceTime = Time.realtimeSinceStartup; - _lastSuccessResponse = response; - _dataCollector.Current.Key = response.Key; - - await SendAnnounceResultsIfNeededNow(); - - OnAnnounceResult?.Invoke(this, response); - return true; - } - else - { - _consecutiveErrors++; - _log.Warn($"Announce failed (ServerMessage={response?.ServerMessage})"); - - _dirtyFlag = true; - - OnAnnounceResult?.Invoke(this, null); - return false; - } - } - finally - { - _havePendingRequest = false; - } - } - - private async Task SendAnnounceResultsIfNeededNow() - { - if (_dataCollector.LastResults is null) - // No results available to announce - return false; - - if (string.IsNullOrEmpty(_dataCollector.LastResults.gameId)) - // Gameplay Session ID (GUID) sometimes doesn't get set, which means it cannot be announced - return false; - - if (_lastResultsSessionId == _dataCollector.LastResults.gameId) - // These results were already successfully announced - return false; - - try - { - var resultsData = AnnounceResultsData.FromMultiplayerResultsData(_dataCollector.LastResults); - var responseOk = await _apiClient.AnnounceResults(resultsData); - - if (responseOk) - { - _log.Info($"Level results announced OK (SessionGameId={resultsData.SessionGameId})"); - _lastResultsSessionId = resultsData.SessionGameId; - return true; - } - else - { - _log.Warn($"Level results announce failed"); - return false; - } - } - finally - { - _havePendingRequest = false; - } - } - - private async Task SendUnannounceNow() - { - var unAnnounceRequest = new UnAnnounceParams() - { - SelfUserId = _dataCollector.Current?.LocalPlayer?.UserId, - HostSecret = _dataCollector.Current?.HostSecret, - HostUserId = _dataCollector.Current?.RemoteUserId, - }; - - if (!unAnnounceRequest.IsComplete) - return false; - - try - { - _dirtyFlag = false; - _havePendingRequest = true; - - var response = await _apiClient.UnAnnounce(unAnnounceRequest); - - if (response?.IsOk ?? false) - { - _consecutiveErrors = 0; - _log.Info("Unannounce OK"); - - OnUnAnnounceResult?.Invoke(this, true); - return true; - } - else - { - _consecutiveErrors++; - _log.Warn("Unannounce failed"); - - if (response is null || response.CanRetry) - { - _dirtyFlag = true; - } - - OnUnAnnounceResult?.Invoke(this, false); - return false; - } - } - finally - { - _havePendingRequest = false; - } - } - - #endregion - - #region Data collector events - - private void HandleDataSessionEstablished(object sender, BssbServerDetail e) - { - // Data established for a new session; begin announcing if appropriate for lobby and config - _sessionEstablished = true; - - if (!GetShouldAnnounce()) - return; - - _log.Info("Starting announcing (session established)"); - State = AnnouncerState.Announcing; - } - - private void HandleDataChanged(object sender, EventArgs e) - { - if (!_sessionEstablished) - // We never announce until session is fully established - return; - - if (State != AnnouncerState.Announcing) - { - // We are NOT already announcing; we can start now if allowed (e.g. after host migration) - if (!GetShouldAnnounce()) - return; - - _log.Info("Starting announcing (late/host migration)"); - State = AnnouncerState.Announcing; - } - - _dirtyFlag = true; - } - - private async void HandleDataSessionEnded(object sender, EventArgs e) - { - _sessionEstablished = false; - - if (State != AnnouncerState.Announcing) - // We are not announcing - return; - - if (!HaveAnnounceSuccess) - { - // We do not have a successful announce to cancel - State = AnnouncerState.NotAnnouncing; - return; - } - - State = AnnouncerState.Unannouncing; - await SendUnannounceNow(); - } - - #endregion - } -} \ No newline at end of file diff --git a/Core/BssbSessionNotifier.cs b/Core/BssbSessionNotifier.cs deleted file mode 100644 index c632ff7..0000000 --- a/Core/BssbSessionNotifier.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using ServerBrowser.Assets; -using ServerBrowser.UI.Components; -using Zenject; - -namespace ServerBrowser.Core -{ - // ReSharper disable once ClassNeverInstantiated.Global - public class BssbSessionNotifier : IInitializable - { - [Inject] private readonly PluginConfig _config = null!; - [Inject] private readonly IMultiplayerSessionManager _sessionManager = null!; - [Inject] private readonly BssbFloatingAlert _floatingAlert = null!; - - private bool _isConnected = false; - private DateTime? _connectedSince = null; - - private TimeSpan TimeSinceConnected => - _connectedSince.HasValue ? (DateTime.Now - _connectedSince.Value) : new TimeSpan(0); - - public void Initialize() - { - _sessionManager.connectedEvent += HandleSessionConnected; - _sessionManager.disconnectedEvent += HandleSessionDisconnected; - _sessionManager.playerConnectedEvent += HandlePlayerConnected; - _sessionManager.playerDisconnectedEvent += HandlePlayerDisconnected; - } - - private void HandleSessionConnected() - { - _isConnected = true; - _connectedSince = DateTime.Now; - - _floatingAlert.DismissAllImmediate(); - } - - private void HandleSessionDisconnected(DisconnectedReason reason) - { - _isConnected = false; - _connectedSince = null; - - _floatingAlert.DismissAllImmediate(); - } - - private void HandlePlayerConnected(IConnectedPlayer player) - { - if (!_config.EnableJoinNotifications) - return; - - if (!_isConnected || TimeSinceConnected.TotalSeconds <= 3) - // On join, "player connected" is raised for all players; don't notify unless we've been connected awhile - return; - - _floatingAlert.PresentNotification(new BssbFloatingAlert.NotificationData - ( - Sprites.PortalUser, - $"{player.userName} joined!", - $"{_sessionManager.connectedPlayerCount + 1} players connected", - BssbLevelBarClone.BackgroundStyle.SolidBlue - )); - } - - private void HandlePlayerDisconnected(IConnectedPlayer player) - { - if (!_config.EnableJoinNotifications) - return; - - if (!_isConnected) - // On disconnect, "player disconnected" is raised for all players; don't notify unless connected - return; - - _floatingAlert.PresentNotification(new BssbFloatingAlert.NotificationData - ( - Sprites.Portal, - $"{player.userName} disconnected", - _sessionManager.connectedPlayerCount >= 1 - ? $"{_sessionManager.connectedPlayerCount + 1} players remaining" - : "You're all alone", - BssbLevelBarClone.BackgroundStyle.SolidCerise - )); - } - } -} \ No newline at end of file diff --git a/Core/DirectConnectionPatcher.cs b/Core/DirectConnectionPatcher.cs deleted file mode 100644 index 146d008..0000000 --- a/Core/DirectConnectionPatcher.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System; -using System.Threading; -using IPA.Utilities; -using MultiplayerCore.Patchers; -using ServerBrowser.Models; -using SiraUtil.Affinity; -using SiraUtil.Logging; -using Zenject; - -namespace ServerBrowser.Core -{ - // ReSharper disable once ClassNeverInstantiated.Global - public class DirectConnectionPatcher : IAffinity - { - public BssbServer? TargetServer { get; private set; } - public bool Enabled { get; private set; } - - [Inject] private readonly SiraLog _log = null!; - [Inject] private readonly NetworkConfigPatcher _networkConfigPatcher = null!; - - #region Control - - public void Enable(BssbServer server) - { - if (!server.IsDirectConnect) - throw new ArgumentException("Must be direct-connect server"); - - if (Enabled && TargetServer == server) - return; - - TargetServer = server; - Enabled = true; - - _networkConfigPatcher.DisableSsl = !server.UseENetSSL; - - _log.Info($"Enable direct connect mode (endPoint={TargetServer.EndPoint})"); - } - - public void Disable() - { - if (!Enabled) - return; - - TargetServer = null; - Enabled = false; - - _networkConfigPatcher.DisableSsl = false; - - _log.Info("Disable direct connect mode"); - } - - #endregion - - #region Patch - Connection - - [AffinityPrefix] - [AffinityPatch(typeof(GameLiftConnectionManager), "GameLiftConnectToServer")] - private bool PrefixGameLiftConnectToServer(string secret, string code, CancellationToken cancellationToken, - GameLiftConnectionManager __instance) - { - if (!Enabled) - return true; - - // This patch will allow us to completely bypass GameLift API calls + authentication - - __instance.InvokeMethod("HandleConnectToServerSuccess", new object[] - { - // string playerSessionId - "DirectConnect", - // string hostName - TargetServer!.EndPoint!.hostName, - // int port - TargetServer!.EndPoint!.port, - // string gameSessionId, - TargetServer!.RemoteUserId ?? "DirectConnect", - // string secret - TargetServer!.HostSecret ?? "DirectConnect", - // string code - TargetServer!.ServerCode ?? "DirectConnect", - // BeatmapLevelSelectionMask selectionMask - new BeatmapLevelSelectionMask(BeatmapDifficultyMask.All, GameplayModifierMask.All, - SongPackMask.all), - // GameplayServerConfiguration configuration - new GameplayServerConfiguration(TargetServer.PlayerLimit!.Value, DiscoveryPolicy.WithCode, - InvitePolicy.AnyoneCanInvite, TargetServer.LogicalGameplayServerMode, - TargetServer.LogicalSongSelectionMode, GameplayServerControlSettings.All) - }); - - return false; - } - - #endregion - - #region Patch - Custom Songs - - [AffinityPrefix] - [AffinityPatch(typeof(MultiplayerLevelSelectionFlowCoordinator), "enableCustomLevels", AffinityMethodType.Getter)] - [AffinityAfter("com.goobwabber.multiplayercore.affinity")] - [AffinityPriority(1)] - private bool PrefixCustomLevelsEnabled(ref bool __result, SongPackMask ____songPackMask) - { - // MultiplayerCore requires an override API server to be set for custom songs to be enabled - // We have to take over that job here if direct connecting - - if (!Enabled) - return true; - - __result = ____songPackMask.Contains(new SongPackMask("custom_levelpack_CustomLevels"));; - return false; - } - - #endregion - } -} \ No newline at end of file diff --git a/Core/ServerBrowserClient.cs b/Core/ServerBrowserClient.cs deleted file mode 100644 index f4dba0c..0000000 --- a/Core/ServerBrowserClient.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System; -using System.Threading.Tasks; -using ServerBrowser.Utils; -using SiraUtil.Logging; -using Zenject; -using Version = Hive.Versioning.Version; - -namespace ServerBrowser.Core -{ - /// - /// Integrates server browser ops with the game client and MultiplayerCore. - /// - // ReSharper disable once ClassNeverInstantiated.Global - public class ServerBrowserClient : IInitializable - { - [Inject] private readonly SiraLog _log = null!; - [Inject] private readonly PluginConfig _config = null!; - [Inject] private readonly IPlatformUserModel _platformUserModel = null!; - [Inject] private readonly INetworkConfig _networkConfig = null!; - [Inject] private readonly BssbDataCollector _dataCollector = null!; - - public async void Initialize() - { - LoadModdedStatus(); - - await LoadPlatformUserInfo(); - } - - #region Game Version - - public string GameVersionRaw => IPA.Utilities.UnityGame.GameVersion.ToString(); - - public string GameVersionNoSuffix - { - get - { - var raw = GameVersionRaw; - var separatorIdx = raw.IndexOf('_'); - return separatorIdx > 0 ? raw.Substring(0, separatorIdx) : raw; - } - } - - #endregion - - #region Mod Status - - public Version? MultiplayerCoreVersion { get; private set; } = null; - public Version? MultiplayerExtensionsVersion { get; private set; } = null; - - private void LoadModdedStatus() - { - MultiplayerCoreVersion = ModCheck.MultiplayerCore.InstalledVersion; - MultiplayerExtensionsVersion = ModCheck.MultiplayerExtensions.InstalledVersion; - - _log.Debug($"Checked related mods (multiplayerCoreVersion={MultiplayerCoreVersion}, " + - $"multiplayerExtensionsVersion={MultiplayerExtensionsVersion?.ToString() ?? "Not installed"})"); - } - - #endregion - - #region Master Server - - public string MasterGraphUrl => _networkConfig.graphUrl; - - public string MasterGraphHostname - { - get - { - try - { - var uri = new Uri(MasterGraphUrl); - return uri.Host; - } - catch (UriFormatException) - { - return MasterGraphUrl; - } - } - } - - public bool UsingOfficialMaster => MasterGraphHostname.EndsWith(".oculus.com"); - - public bool UsingBeatTogetherMaster => MasterGraphHostname.EndsWith(".beattogether.systems"); - - public string MasterStatusUrl => _networkConfig.multiplayerStatusUrl; - - #endregion - - #region Server Name - - public string PreferredServerName => (!string.IsNullOrWhiteSpace(_config.ServerName) - ? _config.ServerName - : DefaultServerName)!; - - public string DefaultServerName => PlatformUserInfo is not null - ? $"{PlatformUserInfo.userName}'s game" - : "Untitled Beat Game"; - - #endregion - - #region UserInfo - - public UserInfo? PlatformUserInfo { get; private set; } = null!; - public UserInfo.Platform? Platform => PlatformUserInfo?.platform; - public string PlatformKey => Platform?.ToString() ?? "unknown"; - public bool IsSteam => Platform == UserInfo.Platform.Steam; - public bool IsOculus => Platform == UserInfo.Platform.Oculus; - - private async Task LoadPlatformUserInfo() - { - PlatformUserInfo = await _platformUserModel.GetUserInfo(); - - if (PlatformUserInfo == null) - { - _log.Warn("Failed to load platform user info!"); - return; - } - - _log.Debug($"Loaded platform user info (platform={PlatformUserInfo.platform}, " + - $"userName={PlatformUserInfo.userName}, platformUserId={PlatformUserInfo.platformUserId})"); - - _dataCollector.Current.ReportingPlatformKey = PlatformKey; - } - - #endregion - } -} \ No newline at end of file diff --git a/Data/BssbApi.cs b/Data/BssbApi.cs new file mode 100644 index 0000000..ccf6283 --- /dev/null +++ b/Data/BssbApi.cs @@ -0,0 +1,89 @@ +using System; +using System.Net.Http; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Newtonsoft.Json; +using ServerBrowser.Models; +using ServerBrowser.Models.Responses; +using ServerBrowser.Requests; +using SiraUtil.Logging; +using Zenject; + +namespace ServerBrowser.Data +{ + [UsedImplicitly] + public class BssbApi : IInitializable + { + [Inject] private readonly SiraLog _log = null!; + [Inject] private readonly BssbConfig _config = null!; + + public const int TimeoutSeconds = 10; + + public static string UserAgent + { + get + { + var assemblyVersion = Assembly.GetExecutingAssembly().GetName().Version; + var assemblyVersionStr = $"{assemblyVersion.Major}.{assemblyVersion.Minor}.{assemblyVersion.Build}"; + + var bsVersion = IPA.Utilities.UnityGame.GameVersion.ToString(); + + return $"ServerBrowser/{assemblyVersionStr} (BeatSaber/{bsVersion})"; + } + } + + private readonly HttpClient _httpClient; + + public BssbApi() + { + _httpClient = new(); + } + + public void Initialize() + { + _httpClient.BaseAddress = new Uri(_config.BssbApiUrl); + _httpClient.DefaultRequestHeaders.Add("User-Agent", UserAgent); + _httpClient.DefaultRequestHeaders.Add("X-BSSB", "✔"); + _httpClient.Timeout = TimeSpan.FromSeconds(TimeoutSeconds); + + _log.Debug($"Initialized API client [{_config.BssbApiUrl}, {UserAgent}]"); + } + + private async Task Post(string path, TRequest request) + { + try + { + var requestJson = JsonConvert.SerializeObject(request); + var requestContent = new StringContent(requestJson, Encoding.UTF8, "application/json"); + + _log.Debug($"[Request] POST {path}"); + if (request is not BssbLoginRequest or BssbEmptyRequest) // never log user auth / empties + _log.Debug(requestJson); + + var response = await _httpClient.PostAsync(path, requestContent); + var responseBody = await response.Content.ReadAsStringAsync(); + + response.EnsureSuccessStatusCode(); + + return JsonConvert.DeserializeObject(responseBody); + } + catch (Exception ex) + { + _log.Error($"Request failed: POST {path}: {ex.Message}"); + _log.Debug(ex.StackTrace); + return default; + } + } + + public async Task SendConfigRequest() + => await Post("/api/v2/config", new()); + + public async Task SendLoginRequest(BssbLoginRequest request) + => await Post("/api/v2/login", request); + + public async Task SendBrowseRequest() + => await Post("/api/v2/browse", new()); + } +} \ No newline at end of file diff --git a/Data/BssbSession.cs b/Data/BssbSession.cs new file mode 100644 index 0000000..5bf7fa5 --- /dev/null +++ b/Data/BssbSession.cs @@ -0,0 +1,221 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using ServerBrowser.Models; +using ServerBrowser.Requests; +using SiraUtil.Logging; +using UnityEngine; +using Zenject; + +namespace ServerBrowser.Data +{ + [UsedImplicitly] + public class BssbSession : IInitializable, IDisposable, ITickable + { + [Inject] private readonly SiraLog _log = null!; + [Inject] private readonly BssbConfig _config = null!; + [Inject] private readonly BssbApi _api = null!; + [Inject] private readonly IPlatformUserModel _platformUserModel = null!; + + public UserInfo? LocalUserInfo { get; private set; } + public bool IsLoggedIn { get; private set; } + public string? AvatarUrl { get; private set; } + + private CancellationTokenSource _ctsUserInfo = new(); + private float? _nextLoginRetry = null; + private int _loginAttempts = 0; + private bool _loginRequested = false; + private bool _isLoggingIn = false; + private string? _cachedAuthToken = null; + private float? _cachedAuthTokenTime = null; + + private PlatformAuthenticationTokenProvider? _tokenProvider = null; + + public bool AttemptingLogin => _isLoggingIn || (!IsLoggedIn && _loginRequested); + + public event Action? LocalUserInfoChangedEvent; + public event Action? AvatarUrlChangedEvent; + public event Action? LoginStatusChangedEvent; + + public void Initialize() + { + _ = LoadLocalUserInfo(); + } + + public void Dispose() + { + _ctsUserInfo.Cancel(); + _ctsUserInfo.Dispose(); + } + + public void Tick() + { + if (!_loginRequested || IsLoggedIn) + return; + + if (_nextLoginRetry.HasValue && Time.realtimeSinceStartup >= _nextLoginRetry.Value) + _ = EnsureLoggedIn(); + } + + private async Task LoadLocalUserInfo() + { + try + { + LocalUserInfo = await _platformUserModel.GetUserInfo(_ctsUserInfo.Token); + + if (LocalUserInfo != null) + { + LocalUserInfoChangedEvent?.Invoke(LocalUserInfo); + + if (_loginRequested) + _ = EnsureLoggedIn(); + } + } + catch (OperationCanceledException) + { + } + catch (Exception e) + { + _log.Error($"Error loading local user info: {e}"); + } + } + + private async Task GetPlatformAuthToken(bool allowCached = true) + { + if (LocalUserInfo == null) + return null; + + if (allowCached && _cachedAuthToken != null && _cachedAuthTokenTime.HasValue && + Time.realtimeSinceStartup - _cachedAuthTokenTime.Value < CachedAuthTokenMaxAge) + { + return _cachedAuthToken; + } + + try + { + _tokenProvider ??= new PlatformAuthenticationTokenProvider(_platformUserModel, LocalUserInfo); + + var result = LocalUserInfo.platform switch + { + UserInfo.Platform.Steam => (await _tokenProvider.GetAuthenticationToken()).sessionToken, + _ => (await _tokenProvider.GetXPlatformAccessToken(CancellationToken.None)).token, + }; + + _cachedAuthToken = result; + _cachedAuthTokenTime = Time.realtimeSinceStartup; + + return result; + } + catch (Exception ex) + { + _log.Error($"Error getting platform auth token: {ex}"); + return null; + } + } + + private async Task Login() + { + if (_isLoggingIn) + // Try to prevent multiple login attempts at the same time + return false; + + if (!_config.AnyPrivacyDisclaimerAccepted) + // Refuse to login if the user has not accepted the privacy disclaimer + return false; + + if (LocalUserInfo == null) + // Required info not loaded yet or not available + return false; + + _isLoggingIn = true; + _nextLoginRetry = null; + _loginAttempts++; + + try + { + var loginResponse = await _api.SendLoginRequest(new BssbLoginRequest() + { + UserInfo = LocalUserInfo, + AuthenticationToken = await GetPlatformAuthToken() + }); + + if (loginResponse == null) + { + _log.Error($"Login failed: Network error or server unavailable"); + IsLoggedIn = false; + LoginStatusChangedEvent?.Invoke(false); + return false; + } + + if (!loginResponse.Success) + { + // Our player profile does not exist on BSSB (and we were not able to authenticate to auto-register) + var logError = loginResponse?.ErrorMessage ?? "Unknown error"; + _log.Error($"Profile login failed: {logError}"); + IsLoggedIn = false; + LoginStatusChangedEvent?.Invoke(false); + return false; + } + + if (AvatarUrl != loginResponse.AvatarUrl) + { + AvatarUrl = loginResponse.AvatarUrl; + AvatarUrlChangedEvent?.Invoke(AvatarUrl); + } + + if (!loginResponse.Authenticated) + { + // Even though the login request was valid, the user has not authenticated themselves with the platform + // This means that authentication with Steam/Oculus/etc. failed, so we are not fully logged in + var logError = loginResponse?.ErrorMessage ?? "Unknown error"; + _log.Error($"Login authentication failed: {logError}"); + IsLoggedIn = false; + LoginStatusChangedEvent?.Invoke(false); + return false; + } + + IsLoggedIn = true; + _loginAttempts = 0; + _nextLoginRetry = null; + _log.Info($"Logged in successfully as {LocalUserInfo.userName} ({LocalUserInfo.platform})"); + LoginStatusChangedEvent?.Invoke(true); + return true; + } + finally + { + _isLoggingIn = false; + if (!IsLoggedIn && _loginRequested) + ScheduleLoginRetry(); + } + } + + public async Task EnsureLoggedIn() + { + _loginRequested = true; + + if (IsLoggedIn) + return true; + + if (_isLoggingIn) + return false; + + return await Login(); + } + + private void ScheduleLoginRetry() + { + var retryDelay = Mathf.Clamp(Mathf.Pow(2f, _loginAttempts), 2f, 128f); + _nextLoginRetry = Time.realtimeSinceStartup + retryDelay; + } + + public void StopLoginRetries() + { + _loginRequested = false; + _nextLoginRetry = null; + _loginAttempts = 0; + } + + public const float CachedAuthTokenMaxAge = 60f; + } +} \ No newline at end of file diff --git a/Data/Discovery/BssbApiServerDiscovery.cs b/Data/Discovery/BssbApiServerDiscovery.cs new file mode 100644 index 0000000..47b6e60 --- /dev/null +++ b/Data/Discovery/BssbApiServerDiscovery.cs @@ -0,0 +1,89 @@ +using System.Net; +using System.Threading.Tasks; +using BGNet.Core; +using ServerBrowser.UI.Browser; +using UnityEngine; +using Zenject; + +namespace ServerBrowser.Data.Discovery +{ + public class BssbApiServerDiscovery : ServerRepository.ServerDiscovery + { + [Inject] private readonly BssbApi _api = null!; + + private float? _lastRefreshTime = null; + public const float RefreshInterval = 5f; + + public override async Task Refresh(ServerRepository repository) + { + if (_lastRefreshTime.HasValue && (Time.time - _lastRefreshTime.Value) < RefreshInterval) + return; + + var browseResponse = await _api.SendBrowseRequest(); + _lastRefreshTime = Time.time; + + if (browseResponse != null) + { + foreach (var server in browseResponse.Lobbies) + { + var connectionMethod = ServerRepository.ConnectionMethod.GameLiftModded; + IPEndPoint? dedicatedEndPoint = null; + BeatmapLevelSelectionMask? beatmapLevelSelectionMask = null; + GameplayServerConfiguration? gameplayServerConfiguration = null; + + if (server.IsDirectConnect) + { + connectionMethod = ServerRepository.ConnectionMethod.DirectConnect; + dedicatedEndPoint = await server.EndPoint!.GetEndPointAsync(DefaultTaskUtility.instance); + beatmapLevelSelectionMask = BrowserFlowCoordinator.DefaultLevelSelectionMask; + gameplayServerConfiguration = new GameplayServerConfiguration( + server.PlayerLimit ?? 5, + DiscoveryPolicy.Public, + InvitePolicy.AnyoneCanInvite, + server.GameplayMode ?? GameplayServerMode.Managed, + server.SongSelectionMode, + GameplayServerControlSettings.All + ); + } + else if (server.IsOfficial) + { + connectionMethod = ServerRepository.ConnectionMethod.GameLiftOfficial; + dedicatedEndPoint = null; + } + else if (server.MasterGraphUrl == null) + { + // Invalid server: not direct connect, not official, but also no master server? shouldn't happen + continue; + } + + repository.DiscoverServer(new ServerRepository.ServerInfo() + { + Key = server.Key!, + ImageUrl = server.Level?.CoverArtUrl, + ServerName = server.Name!, + GameModeName = server.GameModeDescription, + ServerTypeName = server.ServerTypeText, + PlayerCount = server.ReadOnlyPlayerCount ?? 0, + PlayerLimit = server.PlayerLimit ?? 5, + LobbyState = server.LobbyState ?? MultiplayerLobbyState.LobbySetup, + ConnectionMethod = connectionMethod, + ServerEndPoint = dedicatedEndPoint, + MasterServerGraphUrl = server.MasterGraphUrl, + MasterServerStatusUrl = server.MasterStatusUrl, + ServerCode = server.ServerCode, + ServerSecret = server.HostSecret, + ServerUserId = server.RemoteUserId, + BeatmapLevelSelectionMask = beatmapLevelSelectionMask, + GameplayServerConfiguration = gameplayServerConfiguration + }); + } + } + } + + public override Task Stop() + { + _lastRefreshTime = null; + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/Data/Discovery/LocalNetworkServerDiscovery.cs b/Data/Discovery/LocalNetworkServerDiscovery.cs new file mode 100644 index 0000000..5672d39 --- /dev/null +++ b/Data/Discovery/LocalNetworkServerDiscovery.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ServerBrowser.Network.Discovery; +using SiraUtil.Logging; +using Zenject; + +namespace ServerBrowser.Data.Discovery +{ + public class LocalNetworkServerDiscovery : ServerRepository.ServerDiscovery + { + [Inject] private readonly SiraLog _log = null!; + [Inject] private readonly DiscoveryClient _discoveryClient = null!; + + private readonly List _endpointsSeen = new(); + + public override Task Refresh(ServerRepository repository) + { + if (!_discoveryClient.IsActive) + _discoveryClient.StartBroadcast(); + + while (_discoveryClient.ReceivedResponses.Count > 0) + { + var response = _discoveryClient.ReceivedResponses.Dequeue(); + + var endPointStr = response.ServerEndPoint.ToString(); + if (!_endpointsSeen.Contains(endPointStr)) + { + _log.Info($"Discovered local network server: {endPointStr}, {response.ServerName}"); + _endpointsSeen.Add(endPointStr); + } + + repository.DiscoverServer(new ServerRepository.ServerInfo() + { + Key = response.ServerEndPoint.ToString(), + ImageUrl = null, + ServerName = response.ServerName, + GameModeName = response.GameModeName, + ServerTypeName = response.ServerTypeName ?? "LAN Server", + PlayerCount = response.PlayerCount, + PlayerLimit = response.GameplayServerConfiguration.maxPlayerCount, + LobbyState = MultiplayerLobbyState.None, + ConnectionMethod = ServerRepository.ConnectionMethod.DirectConnect, + ServerEndPoint = response.ServerEndPoint, + ServerCode = null, + ServerSecret = null, + ServerUserId = response.ServerUserId, + WasLocallyDiscovered = true, + BeatmapLevelSelectionMask = response.BeatmapLevelSelectionMask, + GameplayServerConfiguration = response.GameplayServerConfiguration + }); + } + + return Task.CompletedTask; + } + + public override Task Stop() + { + if (_discoveryClient.IsActive) + _discoveryClient.StopBroadcast(); + + _endpointsSeen.Clear(); + + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/Data/MasterServerRepository.cs b/Data/MasterServerRepository.cs new file mode 100644 index 0000000..8130d5a --- /dev/null +++ b/Data/MasterServerRepository.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JetBrains.Annotations; +using MultiplayerCore.Models; +using Newtonsoft.Json; +using ServerBrowser.Models; +using SiraUtil.Logging; +using Zenject; + +namespace ServerBrowser.Data +{ + [UsedImplicitly] + public class MasterServerRepository : IInitializable, IDisposable + { + [Inject] private readonly SiraLog _log = null!; + [Inject] private readonly BssbConfig _bssbConfig = null!; + [Inject] private readonly BssbApi _bssbApi = null!; + + private readonly Dictionary _masterServers = new(); + private MasterServerInfo? _selectedServer = null; + + public MasterServerInfo SelectedMasterServer + { + get + { + if (_selectedServer != null) + return _selectedServer; + + return new MasterServerInfo() + { + GraphUrl = "https://graph.oculus.com", + StatusUrl = "https://graph.oculus.com/beat_saber_multiplayer_status", + Name = "Official Servers", + MaxPlayers = 5, + UseSsl = true + }; + } + set + { + _selectedServer = !value.IsOfficial ? value : null; + SelectedMasterServerChangedEvent?.Invoke(SelectedMasterServer); + } + } + + public event Action? SelectedMasterServerChangedEvent; + + private bool _remoteUpdateInProgress; + private bool _remoteUpdateSuccess; + + public void Initialize() + { + _bssbConfig.ReloadedEvent += HandleConfigReloaded; + + HandleConfigReloaded(); + } + + public void Dispose() + { + _bssbConfig.ReloadedEvent -= HandleConfigReloaded; + } + + public MasterServerInfo? GetMasterServerInfo(string graphUrl) => + _masterServers.TryGetValue(graphUrl, out var masterServer) ? masterServer : null; + + private void HandleConfigReloaded() + { + // Apply previously selected master server + if (_bssbConfig.SelectedMasterServer != null) + if (_masterServers.TryGetValue(_bssbConfig.SelectedMasterServer, out var selectedServer)) + _selectedServer = selectedServer; + + // Load list; assume config is latest; if we had updates we would have written them back to the config + foreach (var cfgMaster in _bssbConfig.MasterServers) + { + if (cfgMaster.IsOfficial) + continue; + + cfgMaster.LastUpdated ??= DateTime.Now; + + _masterServers[cfgMaster.GraphUrl] = cfgMaster; + } + } + + /// + /// Attempts to update the master server list from the BSSB API. + /// This is a no-op if remote updates are disabled, already in progress, or completed in this session. + /// + public async Task TryRemoteUpdate() + { + if (!_bssbConfig.RemoteUpdateMasterServerList || _remoteUpdateInProgress || _remoteUpdateSuccess) + // Remote updates are not enabled / already in progress / already completed successfully + return; + + _remoteUpdateInProgress = true; + try + { + var configResponse = await _bssbApi.SendConfigRequest(); + if (configResponse == null) + { + _log.Warn("Failed to update master server list from BSSB API"); + _remoteUpdateSuccess = false; + return; + } + + foreach (var remoteServer in configResponse.MasterServers) + { + var exists = _masterServers.TryGetValue(remoteServer.GraphUrl, out var existingServer); + if (exists && existingServer!.LastUpdated >= remoteServer.LastUpdated) + // Client knows this server and has fresher data + continue; + + if (remoteServer.IsOfficial) + continue; + + _masterServers[remoteServer.GraphUrl] = remoteServer; + } + + _remoteUpdateSuccess = true; + + WriteConfig(); + } + finally + { + _remoteUpdateInProgress = false; + } + } + + public void ProvideStatusInfo(string graphUrl, string statusUrl, MpStatusData statusData) + { + var exists = _masterServers.TryGetValue(graphUrl, out var masterServerInfo); + + if (!exists) + { + masterServerInfo = new MasterServerInfo() + { + GraphUrl = graphUrl + }; + } + + masterServerInfo!.StatusUrl = statusUrl; + masterServerInfo.UseSsl = statusData.useSsl; + masterServerInfo.Name = statusData.name; + masterServerInfo.Description = statusData.description; + masterServerInfo.ImageUrl = statusData.imageUrl; + masterServerInfo.MaxPlayers = statusData.maxPlayers; + masterServerInfo.LastUpdated = DateTime.Now; + + _masterServers[graphUrl] = masterServerInfo; + + WriteConfig(); + } + + private void WriteConfig() => + _bssbConfig.MasterServers = _masterServers.Values.ToList(); + + public class MasterServerInfo + { + /// + /// Graph API URL for the master server. + /// + [JsonProperty("graphUrl")] + public string GraphUrl { get; init; } + + /// + /// Status API URL for the master server. + /// + [JsonProperty("statusUrl")] + public string? StatusUrl { get; set; } + + /// + /// Indicates whether dedicated servers hosted on this master server use SSL / DTLS encrypted connections. + /// + [JsonProperty("useSsl")] + public bool UseSsl { get; set; } + + /// + /// Display name for the master server (from extended status info). + /// + [JsonProperty("name")] + public string? Name { get; set; } + + /// + /// Description for the master server (from extended status info). + /// + [JsonProperty("description")] + public string? Description { get; set; } + + /// + /// Image URL for the master server (from extended status info). + /// + [JsonProperty("imageUrl")] + public string? ImageUrl { get; set; } + + /// + /// Maximum number of players for lobbies created on this master server. + /// + [JsonProperty("maxPlayers")] + public int? MaxPlayers { get; set; } + + /// + /// Indicates when this data was last updated (from a status request, either by the client or the BSSB API). + /// + public DateTime? LastUpdated { get; set; } + + /// + /// Modded feature: per-player modifiers. + /// + public bool SupportsPpModifiers { get; set; } + + /// + /// Modded feature: per-player difficulties. + /// + public bool SupportsPpDifficulties { get; set; } + + /// + /// Modded feature: per-player maps. + /// + public bool SupportsPpMaps { get; set; } + + /// + /// Indicates if this is the official master server (Oculus GameLift API). + /// + public bool IsOfficial => GraphUrl == "https://graph.oculus.com"; + } + } +} \ No newline at end of file diff --git a/Data/MultiplayerConfigManager.cs b/Data/MultiplayerConfigManager.cs new file mode 100644 index 0000000..623579c --- /dev/null +++ b/Data/MultiplayerConfigManager.cs @@ -0,0 +1,97 @@ +using System.Threading; +using System.Threading.Tasks; +using BGLib.Polyglot; +using JetBrains.Annotations; +using MultiplayerCore.Models; +using MultiplayerCore.Patchers; +using MultiplayerCore.Repositories; +using SiraUtil.Logging; +using Zenject; + +namespace ServerBrowser.Data +{ + [UsedImplicitly] + public class MultiplayerConfigManager + { + [Inject] private readonly SiraLog _log = null!; + [Inject] private readonly IMultiplayerStatusModel _multiplayerStatusModel = null!; + [Inject] private readonly NetworkConfigPatcher _mpCoreNetworkConfig = null!; + [Inject] private readonly MpStatusRepository _mpCoreStatusRepository = null!; + [Inject] private readonly MasterServerRepository _masterServerRepository = null!; + + public void ConfigureOfficialGamelift() + { + _log.Info("Apply network config: Official GameLift"); + _mpCoreNetworkConfig.UseOfficialServer(); + } + + public async Task ConfigureCustomMasterServer(string graphUrl, string? statusUrl, + CancellationToken cancellationToken) + { + var masterServerInfo = _masterServerRepository.GetMasterServerInfo(graphUrl); + + if (statusUrl == null && masterServerInfo is { StatusUrl: not null }) + // Use status URL from known master server + statusUrl = masterServerInfo.StatusUrl; + + if (statusUrl == null) + { + // No status URL known at all, we can only use basic config + _log.Info($"Apply network config: Modded Master Server (no status URL), {graphUrl}"); + _mpCoreNetworkConfig.UseCustomApiServer(graphUrl, null); + return new ConfigResult(); + } + + var serverStatus = _mpCoreStatusRepository.GetStatusForUrl(statusUrl); + + // Prefer to get fresh master server status + _log.Info($"Checking master server status: {statusUrl}"); + + _mpCoreNetworkConfig.UseCustomApiServer(graphUrl, statusUrl); // set temporary basic config for status check + + var status = await _multiplayerStatusModel.GetMultiplayerStatusAsync(cancellationToken); + var statusIsFresh = false; + + if (status is MpStatusData mpStatusData) + { + serverStatus = mpStatusData; + statusIsFresh = true; + } + else + { + _log.Warn($"Failed to get updated master server status for: {statusUrl}"); + } + + // Apply network config with the best available data + _log.Info($"Apply network config: Modded Master Server, {graphUrl}"); + + var playerLimit = serverStatus?.maxPlayers ?? masterServerInfo?.MaxPlayers ?? 5; + var useSsl = serverStatus?.useSsl ?? masterServerInfo?.UseSsl ?? false; + + _mpCoreNetworkConfig.UseCustomApiServer(graphUrl, statusUrl, playerLimit, null, useSsl); + + // Provide fresh status info to our list of master servers + if (serverStatus != null && statusIsFresh) + _masterServerRepository.ProvideStatusInfo(graphUrl, statusUrl, serverStatus); + + // Check server status (base game or MultiplayerCore may raise an error based on the status response) + var isUnavailable = MultiplayerUnavailableReasonMethods.TryGetMultiplayerUnavailableReason(serverStatus, + out var multiplayerUnavailableReason); + + // Combined result + return new ConfigResult() + { + MpStatusData = serverStatus, + UnavailableReason = isUnavailable ? multiplayerUnavailableReason : null, + LocalizedMessage = serverStatus?.GetLocalizedMessage(Localization.Instance.SelectedLanguage) + }; + } + + public class ConfigResult + { + public MpStatusData? MpStatusData { get; init; } + public MultiplayerUnavailableReason? UnavailableReason { get; init; } + public string? LocalizedMessage { get; init; } + } + } +} \ No newline at end of file diff --git a/Data/ServerRepository.cs b/Data/ServerRepository.cs new file mode 100644 index 0000000..eb36644 --- /dev/null +++ b/Data/ServerRepository.cs @@ -0,0 +1,365 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using JetBrains.Annotations; +using ServerBrowser.Data.Discovery; +using ServerBrowser.Models; +using SiraUtil.Logging; +using UnityEngine; +using Zenject; + +namespace ServerBrowser.Data +{ + [UsedImplicitly] + public class ServerRepository : IInitializable, ITickable + { + [Inject] private readonly SiraLog _log = null!; + [Inject] private readonly BssbConfig _config = null!; + [Inject] private readonly DiContainer _container = null!; + + private readonly ConcurrentDictionary _servers = new(); + + public IReadOnlyList FilteredServers = Array.Empty(); + + public bool NoResults => FilteredServers.Count == 0; + + private bool _discoveryEnabled = false; + private List _discoveryMethods = new(); + private float? _nextDiscoveryTime; + private bool _serverListDirty; + + public event Action>? ServersUpdatedEvent; + public event Action? RefreshFinishedEvent; + + public void Initialize() + { + _discoveryMethods = new List(3); + _discoveryMethods.Add(new BssbApiServerDiscovery()); + if (_config.EnableLocalNetworkDiscovery) + _discoveryMethods.Add(new LocalNetworkServerDiscovery()); + + foreach (var discoveryMethod in _discoveryMethods) + _container.Inject(discoveryMethod); + + _servers.Clear(); + _serverListDirty = true; + } + + public void StartDiscovery() + { + if (_discoveryEnabled) + return; + + _discoveryEnabled = true; + _log.Debug("Starting server discovery"); + _ = RefreshDiscovery(); + } + + private async Task RefreshDiscovery() + { + _nextDiscoveryTime = null; + + foreach (var discoveryMethod in _discoveryMethods) + { + await discoveryMethod.Refresh(this); + + if (_serverListDirty) + { + _serverListDirty = false; + RaiseServersUpdated(); + } + } + + _nextDiscoveryTime = Time.realtimeSinceStartup + DiscoveryTickInterval; + RefreshFinishedEvent?.Invoke(); + } + + public void StopDiscovery() + { + if (!_discoveryEnabled) + return; + + _discoveryEnabled = false; + _log.Debug("Stopping server discovery"); + + foreach (var discoveryMethod in _discoveryMethods) + _ = discoveryMethod.Stop(); + } + + public void DiscoverServer(ServerInfo serverInfo) + { + var entry = _servers.AddOrUpdate(serverInfo.Key, serverInfo, (_, _) => serverInfo); + entry.LastDiscovered = DateTime.Now; + _serverListDirty = true; + } + + public void RemoveServer(string key) + { + _servers.TryRemove(key, out _); + _serverListDirty = true; + } + + public void Tick() + { + if (!_discoveryEnabled || !_nextDiscoveryTime.HasValue) + return; + + if (Time.realtimeSinceStartup >= _nextDiscoveryTime) + _ = RefreshDiscovery(); + } + + public string? FilterText { get; set; } = null; + public ServerFilterParams? FilterParams { get; set; } = null; + + public void SetFilterText(string? filterText) + { + if (FilterText == filterText) + return; + + FilterText = filterText; + RaiseServersUpdated(); + } + + public void SetFilterParams(ServerFilterParams? filterParams) + { + FilterParams = filterParams; + RaiseServersUpdated(); + } + + private IReadOnlyList GetFilteredServers() + { + var servers = _servers.Values.ToList() + .OrderByDescending(x => x.SortPriority) + .Select(x => x); + + if (!string.IsNullOrEmpty(FilterText)) + { + var filterChars = FilterText.Split(Array.Empty(), + StringSplitOptions.RemoveEmptyEntries); + var matchedResults = new List>(); + + foreach (var server in servers) + { + var matchScore = GetTextMatchScore(server.ServerName, filterChars); + if (matchScore > 0) + matchedResults.Add((matchScore, server)); + } + + servers = matchedResults + .OrderByDescending(x => x.Item1) + .Select(x => x.Item2); + } + + if (FilterParams != null) + { + if (FilterParams.GetValue(ServerFilterParams.HidePlayingLevel)) + servers = servers.Where(x => !x.InGameplay); + if (FilterParams.GetValue(ServerFilterParams.HideFull)) + servers = servers.Where(x => !x.IsFull); + if (FilterParams.GetValue(ServerFilterParams.HideEmpty)) + servers = servers.Where(x => x.PlayerCount > 0); + if (FilterParams.GetValue(ServerFilterParams.HideOfficial)) + servers = servers.Where(x => x.ConnectionMethod != ConnectionMethod.GameLiftOfficial); + if (FilterParams.GetValue(ServerFilterParams.HideQuickPlay)) + servers = servers.Where(x => !x.GameModeName.Contains("Quick Play")); + } + + return servers.ToList(); + } + + private static int GetTextMatchScore(string input, string[] searchTerms) + { + var totalScore = 0; + + foreach (var searchTerm in searchTerms) + { + var matchIdx = input.IndexOf(searchTerm, StringComparison.CurrentCultureIgnoreCase); + + if (matchIdx < 0) + continue; + + totalScore += (matchIdx == 0 || char.IsWhiteSpace(input[matchIdx - 1]) ? 1 : 0) + 50 * searchTerm.Length; + } + + return totalScore; + } + + private void RaiseServersUpdated() + { + // Remove servers past the staleness threshold + var staleThreshold = DateTime.Now - ServerRepository.StaleServerThreshold; + foreach (var server in _servers.Values.Where(x => x.LastDiscovered < staleThreshold).ToList()) + _servers.TryRemove(server.Key, out _); + + // Update filtered set and raise event + FilteredServers = GetFilteredServers(); + ServersUpdatedEvent?.Invoke(FilteredServers); + } + + public abstract class ServerDiscovery + { + public abstract Task Refresh(ServerRepository repository); + + public abstract Task Stop(); + } + + public class ServerInfo + { + /// + /// Globally unique key. Identifies a single server within the repository. + /// + public string Key { get; init; } + /// + /// Remote image URL for the lobby. Usually the covert art URL. + /// + public string? ImageUrl { get; init; } + /// + /// Public server name. + /// + public string ServerName { get; init; } = ""; + /// + /// Description of the server's current game mode (e.g. "Quick Play", "Battle Royale", etc). + /// + public string GameModeName { get; init; } = ""; + /// + /// Description of the server type (e.g. "Official", "BeatTogether", etc.) + /// + public string? ServerTypeName { get; init; } + /// + /// Current player count. + /// + public int PlayerCount { get; init; } = 0; + /// + /// Maximum player limit / capacity. + /// + public int PlayerLimit { get; init; } = 5; + /// + /// Current lobby state. + /// + public MultiplayerLobbyState LobbyState { get; init; } = MultiplayerLobbyState.None; + /// + /// Connection method the Server Browser should use to connect to this server. + /// + public ConnectionMethod ConnectionMethod { get; init; } = ConnectionMethod.GameLiftOfficial; + /// + /// Dedicated server endpoint. + /// Required for direct connections. + /// + public IPEndPoint? ServerEndPoint { get; init; } + /// + /// Master server API URL (GameLift). + /// + public string? MasterServerGraphUrl { get; init; } + /// + /// Master server status API URL (multiplayer status check). + /// + public string? MasterServerStatusUrl { get; init; } + /// + /// Server code. Required for public games that connect via GameLift / modded master servers. + /// May be null in case of direct connections and password-protected servers. + /// + public string? ServerCode { get; init; } + /// + /// Server secret. Also known as "gameSessionId" in GameLift context. + /// Required for public games that connect via GameLift / modded master servers. + /// May be null in case of direct connections and password-protected servers. + /// + public string? ServerSecret { get; init; } + /// + /// User ID of the host player (if any). + /// Required for direct connections. + /// + public string? ServerUserId { get; init; } + /// + /// Flags whether the server was discovered on the local network. + /// If set, the server will be boosted in the server list and highlighted. + /// + public bool WasLocallyDiscovered { get; init; } + /// + /// Beatmap level selection mask. + /// Should be set for direct connections to ensure the client shows the appropriate UI. + /// + public BeatmapLevelSelectionMask? BeatmapLevelSelectionMask { get; init; } + /// + /// Gameplay server configuration. + /// Should be set for direct connections to ensure the client shows the appropriate UI. + /// + public GameplayServerConfiguration? GameplayServerConfiguration { get; init; } + /// + /// Timestamp of the last discovery event. + /// Set automatically by the repository upon discovery call. + /// + public DateTime LastDiscovered { get; set; } + + public bool IsFull => PlayerCount >= PlayerLimit; + + public bool InGameplay => + LobbyState is MultiplayerLobbyState.GameRunning or MultiplayerLobbyState.GameStarting; + + public int SortPriority + { + get + { + var sortPoints = 0; + if (WasLocallyDiscovered) + // Boost: LAN server - always show these first + sortPoints += 100; + if (PlayerCount >= PlayerLimit) + // Drop: server is full + sortPoints -= 10; + if (PlayerCount == 1) + // Boost: lonely player + sortPoints++; + if (PlayerLimit > 2) + // Boost: bigger lobby + sortPoints++; + if (!InGameplay) + // Boost: In lobby + sortPoints++; + return sortPoints; + } + } + + public string LobbyStateText + { + get + { + return LobbyState switch + { + MultiplayerLobbyState.LobbySetup => "In lobby (setup)", + MultiplayerLobbyState.LobbyCountdown => "In lobby (countdown)", + MultiplayerLobbyState.GameStarting => "Game starting", + MultiplayerLobbyState.GameRunning => "Game running", + MultiplayerLobbyState.Error => "Error", + _ => "Unknown" + }; + } + } + + public bool UseDtlsEncryption => + ConnectionMethod is ConnectionMethod.GameLiftOfficial or ConnectionMethod.GameLiftModded; + } + + public enum ConnectionMethod + { + /// + /// GameLift connection for official / vanilla servers. + /// + GameLiftOfficial = 1, + /// + /// Modded/custom master servers emulating the GameLift API. + /// + GameLiftModded = 2, + /// + /// Modded servers using direct connect (no encryption). + /// + DirectConnect = 3 + } + + public const float DiscoveryTickInterval = 1f; + public static readonly TimeSpan StaleServerThreshold = TimeSpan.FromSeconds(15); + } +} \ No newline at end of file diff --git a/Installers/BssbAppInstaller.cs b/Installers/BssbAppInstaller.cs index 96d8791..fa2a810 100644 --- a/Installers/BssbAppInstaller.cs +++ b/Installers/BssbAppInstaller.cs @@ -1,25 +1,26 @@ -using ServerBrowser.Core; -using ServerBrowser.UI.Components; +using ServerBrowser.Data; +using ServerBrowser.Models; +using ServerBrowser.Network.Discovery; +using ServerBrowser.UI.Toolkit; using Zenject; namespace ServerBrowser.Installers { - // ReSharper disable once ClassNeverInstantiated.Global - public class BssbAppInstaller : Installer + public class BssbAppInstaller : MonoInstaller { public override void InstallBindings() { - Container.Bind().FromInstance(Plugin.Config).AsSingle(); + Container.Bind().FromInstance(Plugin.Config).AsSingle(); - Container.BindInterfacesAndSelfTo().AsSingle(); - Container.BindInterfacesAndSelfTo().AsSingle(); - Container.BindInterfacesAndSelfTo().FromNewComponentOnNewGameObject().AsSingle(); - Container.BindInterfacesAndSelfTo().AsSingle(); - Container.BindInterfacesAndSelfTo().AsSingle(); + Container.BindInterfacesAndSelfTo().AsSingle(); + Container.BindInterfacesAndSelfTo().AsSingle(); + Container.BindInterfacesAndSelfTo().AsSingle(); + Container.BindInterfacesAndSelfTo().AsSingle(); + Container.BindInterfacesAndSelfTo().AsSingle(); - Container.BindInterfacesAndSelfTo().AsSingle(); + Container.BindInterfacesAndSelfTo().AsSingle(); - Container.BindInterfacesAndSelfTo().FromNewComponentOnNewGameObject().AsSingle(); + Container.BindInterfacesAndSelfTo().FromNewComponentOnNewGameObject().AsSingle(); } } } \ No newline at end of file diff --git a/Installers/BssbMenuInstaller.cs b/Installers/BssbMenuInstaller.cs index 60bb2b9..be59a30 100644 --- a/Installers/BssbMenuInstaller.cs +++ b/Installers/BssbMenuInstaller.cs @@ -1,44 +1,31 @@ -using ServerBrowser.Core; using ServerBrowser.UI; -using ServerBrowser.UI.Components; -using ServerBrowser.UI.Lobby; -using ServerBrowser.UI.Utils; -using ServerBrowser.UI.Views; +using ServerBrowser.UI.Browser; +using ServerBrowser.UI.Browser.Views; +using ServerBrowser.UI.Forms; +using ServerBrowser.UI.Toolkit; using Zenject; namespace ServerBrowser.Installers { - // ReSharper disable once ClassNeverInstantiated.Global - public class BssbMenuInstaller : Installer + public class BssbMenuInstaller : MonoInstaller { public override void InstallBindings() { - // BSSB Core - Container.BindInterfacesAndSelfTo().AsSingle(); - Container.BindInterfacesAndSelfTo().AsSingle(); + Container.Bind().AsTransient(); - // UI Core - Container.BindInterfacesAndSelfTo().AsSingle(); - Container.BindInterfacesAndSelfTo().AsSingle(); - Container.BindInterfacesAndSelfTo().FromNewComponentOnNewGameObject().AsSingle(); - - // UI Views - Container.Bind().FromNewComponentAsViewController().AsSingle(); - Container.Bind().FromNewComponentAsViewController().AsSingle(); - Container.Bind().FromNewComponentOnNewGameObject().AsSingle(); - - // Helpers - Container.BindInterfacesAndSelfTo().AsSingle(); + Container.Bind().AsSingle(); + Container.Bind().AsSingle(); + + Container.BindInterfacesAndSelfTo().AsSingle(); + Container.BindInterfacesAndSelfTo().AsSingle(); + Container.BindInterfacesAndSelfTo().AsSingle(); - // Inject LobbyConfigPanel dependencies - Container.Inject(LobbyConfigPanel.instance); - LobbyConfigPanel.instance.Initialize(); - - // UI Extras - if (Plugin.Config.EnableJoiningLobbyExtender) - { - Container.BindInterfacesAndSelfTo().AsSingle(); - } + Container.BindInterfacesAndSelfTo().FromNewComponentAsViewController().AsSingle(); + Container.BindInterfacesAndSelfTo().FromNewComponentAsViewController().AsSingle(); + Container.BindInterfacesAndSelfTo().FromNewComponentAsViewController().AsSingle(); + Container.BindInterfacesAndSelfTo().FromNewComponentOnNewGameObject().AsSingle(); + + Container.BindInterfacesAndSelfTo().AsSingle(); } } } \ No newline at end of file diff --git a/Models/BssbConfig.cs b/Models/BssbConfig.cs new file mode 100644 index 0000000..de45042 --- /dev/null +++ b/Models/BssbConfig.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using IPA.Config.Stores.Attributes; +using IPA.Config.Stores.Converters; +using JetBrains.Annotations; +using ServerBrowser.Data; + +namespace ServerBrowser.Models +{ + [UsedImplicitly] + public class BssbConfig + { + /// + /// The base URL for the BSSB API. + /// + public virtual string BssbApiUrl { get; set; } = "https://bssb.app/"; + + /// + /// Toggles whether LAN discovery of servers is enabled. + /// + public virtual bool EnableLocalNetworkDiscovery { get; set; } = true; + + /// + /// A list of master servers that can be selected by the user. + /// + [NonNullable, UseConverter(typeof(CollectionConverter>))] + public virtual List MasterServers { get; set; } = new(); + + /// + /// Graph URL for the selected master server. + /// If set to null, or if no matching master server info is known, will use official server. + /// + public virtual string? SelectedMasterServer { get; set; } = null; + + /// + /// Controls whether the master server list should be populated / updated from the BSSB API. + /// + public virtual bool RemoteUpdateMasterServerList { get; set; } = true; + + /// + /// The version of the privacy disclaimer that the user has accepted. + /// + public virtual uint AcceptedPrivacyDisclaimerVersion { get; set; } = 0; + + /// + /// Toggle state: whether to announce the server to the BSSB API. + /// + public virtual bool ToggleAnnounce { get; set; } = true; + + /// + /// Toggle state: per-player modifiers. + /// + public virtual bool TogglePpModifiers { get; set; } = false; + + /// + /// Toggle state: per-player difficulties. + /// + public virtual bool TogglePpDifficulties { get; set; } = false; + + /// + /// Toggle state: per-player maps. + /// + public virtual bool TogglePpMaps { get; set; } = false; + + internal bool AnyPrivacyDisclaimerAccepted => AcceptedPrivacyDisclaimerVersion > 0; + + public event Action? ReloadedEvent; + + [UsedImplicitly] + public virtual void OnReload() => + ReloadedEvent?.Invoke(); + } +} \ No newline at end of file diff --git a/Models/BssbLevel.cs b/Models/BssbLevel.cs index 4b456af..6ec5c8e 100644 --- a/Models/BssbLevel.cs +++ b/Models/BssbLevel.cs @@ -1,38 +1,41 @@ -using System.Text; +using System.Text.RegularExpressions; using Newtonsoft.Json; -using ServerBrowser.Models.Utils; namespace ServerBrowser.Models { - public class BssbLevel : JsonObject + public class BssbLevel { [JsonProperty("LevelId")] public string? LevelId; [JsonProperty("SongName")] public string? SongName; [JsonProperty("SongSubName")] public string? SongSubName; [JsonProperty("SongAuthorName")] public string? SongAuthorName; [JsonProperty("LevelAuthorName")] public string? LevelAuthorName; + /// /// HTTP URL for the cover art associated with the level, if any. /// Provided by the API when querying lobbies. /// [JsonProperty("CoverUrl")] public string? CoverArtUrl; + [JsonProperty("SessionGameId")] public string? SessionGameId; + [JsonProperty("Difficulty")] public BeatmapDifficulty? Difficulty; + [JsonProperty("Modifiers")] public GameplayModifiers? Modifiers; + + /// + /// Serialized name of the Beatmap characteristic. + /// + [JsonProperty("Characteristic")] public string? Characteristic; + [JsonIgnore] - public string ListDescription + public string CharacteristicText { get { - if (SongName is null) - return "Unknown"; - - var text = new StringBuilder(); - if (SongAuthorName != null) - { - text.Append(SongAuthorName); - text.Append(" - "); - } - text.Append(SongName); - return text.ToString(); + if (string.IsNullOrWhiteSpace(Characteristic)) + return "Standard"; + + // To string with spaces + return Regex.Replace(Characteristic!, "(\\B[A-Z])", " $1"); } } } diff --git a/Models/BssbLobby.cs b/Models/BssbLobby.cs new file mode 100644 index 0000000..33af62e --- /dev/null +++ b/Models/BssbLobby.cs @@ -0,0 +1,177 @@ +using System; +using Newtonsoft.Json; +using ServerBrowser.Util; +using ServerBrowser.Util.Serialization; + +namespace ServerBrowser.Models +{ + public class BssbLobby + { + /// + /// Server side identifier (hash key) for this lobby instance. + /// + [JsonProperty("Key")] public string? Key; + + /// + /// Unique 5 character code assigned to the lobby by the master server. + /// + [JsonProperty("ServerCode")] public string? ServerCode; + + /// + /// User ID for the lobby host (unique per dedicated server). + /// + [JsonProperty("OwnerId")] public string? RemoteUserId; + + /// + /// User name for the lobby host (unique per dedicated server). + /// + [JsonProperty("OwnerName")] public string? RemoteUserName; + + /// + /// Unique lobby secret. Can be used to access specific Quick Play lobbies via matchmaking. + /// + [JsonProperty("HostSecret")] public string? HostSecret; + + /// + /// User ID for the party leader (player in charge of the lobby). + /// + [JsonProperty("ManagerId")] public string? ManagerId; + + /// + /// The current amount of non-ghost players in this lobby. + /// + /// + /// This field is not filled by the mod/client. + /// The BSSB API derives it from the "players" list in the detailed announce. + /// + [JsonProperty("PlayerCount")] public int? ReadOnlyPlayerCount; + + /// + /// The maximum amount of players permitted in this lobby. + /// + [JsonProperty("PlayerLimit")] public int? PlayerLimit; + + /// + /// Server configuration mode, indicates what kind of lobby this is. + /// • Countdown: Quick Play lobby with vote-based levels and automatic countdown + /// • Managed: Custom game where one player, the party leader, is in control. + /// • QuickStartOneSong: Currently unused. Most likely a Quick Play mode for specific levels. + /// + [JsonProperty("GameplayMode")] public GameplayServerMode? GameplayMode; + + /// + /// Server name as set by the lobby creator. + /// + [JsonProperty("GameName")] public string? Name; + + /// + /// Current state of the lobby. + /// + [JsonProperty("LobbyState")] public MultiplayerLobbyState? LobbyState; + + /// + /// The announcing player's platform key (e.g. "steam", "oculus"). + /// + [JsonProperty("Platform")] public string? ReportingPlatformKey; + + /// + /// Information for the current or most recently completed level. + /// + [JsonProperty("Level")] public BssbLevel? Level; + + /// + /// Lobby difficulty mask (for Quick Play lobbies). + /// + [JsonProperty("DifficultyMask")] public BeatmapDifficultyMask? DifficultyMask; + + /// + /// Current or last played level difficulty. + /// + [JsonProperty("BeatmapDifficulty")] public BeatmapDifficulty? BeatmapDifficulty; + + /// + /// Identifies what type of server this is for announce messages. + /// + [JsonProperty("ServerType")] public string? ServerTypeCode; + + /// + /// The Graph API URL for the master server (1.29+). + /// + [JsonProperty("MasterGraphUrl")] public string? MasterGraphUrl; + + /// + /// The multiplayer status check URL associated with the master server. + /// + [JsonProperty("MasterStatusUrl")] public string? MasterStatusUrl; + + /// + /// The endpoint for the dedicated server instance this lobby is hosted on. + /// + [JsonProperty("Endpoint")] [JsonConverter(typeof(DnsEndPointConverter))] + public DnsEndPoint? EndPoint; + + /// + /// The announcer's game version, or a server's compatible game version. + /// + /// + /// This field is not filled by the mod/client. + /// The BSSB API derives it from the user agent. + /// + [JsonProperty("GameVersion")] [JsonConverter(typeof(HiveVersionJsonConverter))] + public Hive.Versioning.Version? GameVersion; + + /// + /// The announcer's installed or compatibility version of MultiplayerCore. + /// MultiplayerCore is required (for custom songs, and for this mod). + /// + [JsonProperty("MpCoreVersion")] [JsonConverter(typeof(HiveVersionJsonConverter))] + public Hive.Versioning.Version? MultiplayerCoreVersion; + + /// + /// Server type description, as generated by the API. + /// + /// + /// This field is not filled by the mod/client. + /// The BSSB API derives it from announce data. + /// + [JsonProperty("ServerTypeText")] public string? ServerTypeText; + + /// + /// Master server description, as generated by the API. + /// + /// + /// This field is not filled by the mod/client. + /// The BSSB API derives it from announce data. + /// + [JsonProperty("MasterServerText")] public string? MasterServerText; + + [JsonProperty("FirstSeen")] public DateTime? ReadOnlyFirstSeen; + + [JsonProperty("LastUpdate")] public DateTime? ReadOnlyLastSeen; + + [JsonProperty("EncryptionMode")] public string? EncryptionMode; + + [JsonIgnore] + public string GameModeDescription + { + get + { + if (GameplayMode is GameplayServerMode.Managed) + return "Custom Game"; + + var difficultyText = DifficultyMask != null ? DifficultyMask.ToString().AddSpacesToCamelCase() : "All"; + return $"Quick Play ({difficultyText})"; + } + } + + [JsonIgnore] + public SongSelectionMode SongSelectionMode => + GameplayMode is GameplayServerMode.Countdown ? SongSelectionMode.Vote : SongSelectionMode.OwnerPicks; + + [JsonIgnore] + public bool IsDirectConnect => MasterGraphUrl == null && EndPoint != null; + + [JsonIgnore] + public bool IsOfficial => MasterGraphUrl?.StartsWith("https://graph.oculus.com") ?? false; + } +} \ No newline at end of file diff --git a/Models/BssbPlayer.cs b/Models/BssbPlayer.cs deleted file mode 100644 index 6b38982..0000000 --- a/Models/BssbPlayer.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Newtonsoft.Json; -using ServerBrowser.Models.Utils; - -namespace ServerBrowser.Models -{ - /// - /// Persistent player information that is the same between games. - /// - /// Extended model - public class BssbPlayer : JsonObject - { - [JsonProperty("UserId")] public string? UserId; - [JsonProperty("UserName")] public string? UserName; - [JsonProperty("PlatformType")] public string? PlatformType; - [JsonProperty("PlatformUserId")] public string? PlatformUserId; - [JsonProperty("AvatarData")] public MultiplayerAvatarData? AvatarData; - } -} \ No newline at end of file diff --git a/Models/BssbServer.cs b/Models/BssbServer.cs deleted file mode 100644 index 8a2536f..0000000 --- a/Models/BssbServer.cs +++ /dev/null @@ -1,289 +0,0 @@ -using System; -using Newtonsoft.Json; -using ServerBrowser.Models.Enums; -using ServerBrowser.Models.JsonConverters; -using ServerBrowser.Models.Utils; -using ServerBrowser.Utils; -using Version = Hive.Versioning.Version; - -namespace ServerBrowser.Models -{ - /// - /// Basic server information. Represents a single lobby instance. - /// - /// Extended model - public class BssbServer : JsonObject - { - /// - /// Server side identifier (hash key) for this lobby instance. - /// - [JsonProperty("Key")] public string? Key; - - /// - /// Unique 5 character code assigned to the lobby by the master server. - /// - [JsonProperty("ServerCode")] public string? ServerCode; - - /// - /// User ID for the lobby host (unique per dedicated server). - /// - [JsonProperty("OwnerId")] public string? RemoteUserId; - - /// - /// User name for the lobby host (unique per dedicated server). - /// - [JsonProperty("OwnerName")] public string? RemoteUserName; - - /// - /// Unique lobby secret. Can be used to access specific Quick Play lobbies via matchmaking. - /// - [JsonProperty("HostSecret")] public string? HostSecret; - - /// - /// User ID for the party leader (player in charge of the lobby). - /// - [JsonProperty("ManagerId")] public string? ManagerId; - - /// - /// The current amount of non-ghost players in this lobby. - /// - /// - /// This field is not filled by the mod/client. - /// The BSSB API derives it from the "players" list in the detailed announce. - /// - [JsonProperty("PlayerCount")] public int? ReadOnlyPlayerCount; - - /// - /// The maximum amount of players permitted in this lobby. - /// - [JsonProperty("PlayerLimit")] public int? PlayerLimit; - - /// - /// Server configuration mode, indicates what kind of lobby this is. - /// • Countdown: Quick Play lobby with vote-based levels and automatic countdown - /// • Managed: Custom game where one player, the party leader, is in control. - /// • QuickStartOneSong: Currently unused. Most likely a Quick Play mode for specific levels. - /// - [JsonProperty("GameplayMode")] public GameplayServerMode? GameplayMode; - - /// - /// Server name as set by the lobby creator. - /// - [JsonProperty("GameName")] public string? Name; - - /// - /// Current state of the lobby. - /// - [JsonProperty("LobbyState")] public MultiplayerLobbyState? LobbyState; - - /// - /// The announcing player's platform key (e.g. "steam", "oculus"). - /// - [JsonProperty("Platform")] public string? ReportingPlatformKey; - - /// - /// Information for the current or most recently completed level. - /// - [JsonProperty("Level")] public BssbServerLevel? Level; - - /// - /// Lobby difficulty (for Quick Play), or last played level difficulty. - /// - [JsonProperty("Difficulty")] public BssbDifficulty? LobbyDifficulty; - - /// - /// Current or last played level difficulty. - /// - [JsonProperty("LevelDifficulty")] public BssbDifficulty? LevelDifficulty; - - /// - /// Identifies what type of server this is for announce messages. - /// - [JsonProperty("ServerType")] public string? ServerTypeCode; - - /// - /// The Graph API URL for the master server (1.29+). - /// - [JsonProperty("MasterGraphUrl")] public string? MasterGraphUrl; - - /// - /// The multiplayer status check URL associated with the master server. - /// - [JsonProperty("MasterStatusUrl")] public string? MasterStatusUrl; - - /// - /// The endpoint for the dedicated server instance this lobby is hosted on. - /// - [JsonProperty("Endpoint")] [JsonConverter(typeof(DnsEndPointConverter))] - public DnsEndPoint? EndPoint; - - /// - /// The announcer's game version, or a server's compatible game version. - /// - /// - /// This field is not filled by the mod/client. - /// The BSSB API derives it from the user agent. - /// - [JsonProperty("GameVersion")] [JsonConverter(typeof(HiveVersionJsonConverter))] - public Version? GameVersion; - - /// - /// The announcer's installed or compatibility version of MultiplayerCore. - /// MultiplayerCore is required (for custom songs, and for this mod). - /// - [JsonProperty("MpCoreVersion")] [JsonConverter(typeof(HiveVersionJsonConverter))] - public Version? MultiplayerCoreVersion; - - /// - /// The announcer's installed or compatibility version of MultiplayerExtensions. - /// MultiplayerExtensions is optional. - /// - [JsonProperty("MpExVersion")] [JsonConverter(typeof(HiveVersionJsonConverter))] - public Version? MultiplayerExtensionsVersion; - - /// - /// Server type description, as generated by the API. - /// - /// - /// This field is not filled by the mod/client. - /// The BSSB API derives it from announce data. - /// - [JsonProperty("ServerTypeText")] - public string? ServerTypeText; - - /// - /// Master server description, as generated by the API. - /// - /// - /// This field is not filled by the mod/client. - /// The BSSB API derives it from announce data. - /// - [JsonProperty("MasterServerText")] - public string? MasterServerText; - - [JsonProperty("FirstSeen")] - public DateTime? ReadOnlyFirstSeen; - - [JsonProperty("LastUpdate")] - public DateTime? ReadOnlyLastSeen; - - [JsonProperty("EncryptionMode")] - public string? EncryptionMode; - - #region Local - - [JsonIgnore] public bool IsQuickPlay => GameplayMode == GameplayServerMode.Countdown; - - [JsonIgnore] public bool IsOfficial => IsAwsGameLiftHost; - - [JsonIgnore] public bool IsBeatTogetherHost => RemoteUserId == "ziuMSceapEuNN7wRGQXrZg"; - [JsonIgnore] public bool IsBeatUpServerHost => RemoteUserId?.StartsWith("beatupserver:", - StringComparison.OrdinalIgnoreCase) ?? false; - [JsonIgnore] public bool IsBeatDediHost => RemoteUserId?.StartsWith("beatdedi:", - StringComparison.OrdinalIgnoreCase) ?? false; - [JsonIgnore] public bool IsAwsGameLiftHost => RemoteUserId?.StartsWith("arn:aws:gamelift:") ?? false; - - [JsonIgnore] public bool IsDirectConnect => !IsOfficial && MasterGraphUrl is null && EndPoint is not null; - - [JsonIgnore] - public string LobbyStateText - { - get - { - return LobbyState switch - { - MultiplayerLobbyState.None => "None", - MultiplayerLobbyState.LobbySetup => "In lobby (setup)", - MultiplayerLobbyState.LobbyCountdown => "In lobby (countdown)", - MultiplayerLobbyState.GameStarting => "Level starting", - MultiplayerLobbyState.GameRunning => "Playing level", - MultiplayerLobbyState.Error => "Error", - _ => "Unknown" - }; - } - } - - [JsonIgnore] - public string DualDifficultyFormatted - { - get - { - if (LobbyDifficulty is null) - return "New lobby"; - - if (LobbyDifficulty.HasValue && LevelDifficulty.HasValue && LevelDifficulty != LobbyDifficulty) - // Have both lobby and level difficulty, but they diverge (happens with "All" difficulty lobbies) - return $"{LobbyDifficulty.Value.ToFormattedText()} ({LevelDifficulty.Value.ToFormattedText()})"; - - // Have only basic lobby difficulty - return $"{LobbyDifficulty.Value.ToFormattedText()}"; - } - } - - [JsonIgnore] - public string BrowserDetailTextWithDifficulty - { - get - { - if (LobbyDifficulty is null) - return BrowserDetailText; - - if (IsInLobby || LevelDifficulty is null) - return $"{DualDifficultyFormatted} : {BrowserDetailText}"; - else - return $"{DualDifficultyFormatted} : {BrowserDetailText}"; - } - } - - [JsonIgnore] - public string BrowserDetailText => (IsInGameplay && Level != null) - ? $"Playing {Level.ListDescription}" - : LobbyStateText; - - [JsonIgnore] - public bool IsInLobby => LobbyState is MultiplayerLobbyState.None or MultiplayerLobbyState.Error - or MultiplayerLobbyState.LobbyCountdown or MultiplayerLobbyState.LobbySetup; - - [JsonIgnore] - public bool IsInGameplay => - LobbyState is MultiplayerLobbyState.GameStarting or MultiplayerLobbyState.GameRunning; - - [JsonIgnore] - public TimeSpan? LobbyLifetime => ReadOnlyFirstSeen != null ? DateTime.Now - ReadOnlyFirstSeen : null; - - [JsonIgnore] - public string LobbyLifetimeText => LobbyLifetime?.ToReadableString() ?? "Unknown"; - - [JsonIgnore] - public GameplayServerMode LogicalGameplayServerMode - { - get - { - if (GameplayMode is not null) - return GameplayMode.Value; - - return IsQuickPlay ? GameplayServerMode.Countdown : GameplayServerMode.Managed; - } - } - - [JsonIgnore] - public SongSelectionMode LogicalSongSelectionMode - { - get - { - if (GameplayMode == GameplayServerMode.Countdown || IsQuickPlay) - return SongSelectionMode.Vote; - - return SongSelectionMode.OwnerPicks; - } - } - - /// - /// Gets whether ENet SSL (DTLS) should be used for this server. - /// - [JsonIgnore] - public bool UseENetSSL => EncryptionMode == "enet_dtls"; - - #endregion - } -} \ No newline at end of file diff --git a/Models/BssbServerDetail.cs b/Models/BssbServerDetail.cs deleted file mode 100644 index 45227ee..0000000 --- a/Models/BssbServerDetail.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Newtonsoft.Json; - -namespace ServerBrowser.Models -{ - public class BssbServerDetail : BssbServer - { - [JsonProperty("Players")] public List Players = new(); - [JsonProperty("PlayerCount")] public int PlayerCount => Players.Count(p => !p.IsGhost); - [JsonProperty("LevelHistory")] public List LevelHistory = new(); - - [JsonIgnore] public BssbServerPlayer? LocalPlayer => Players.FirstOrDefault(p => p.IsMe); - [JsonIgnore] public BssbServerPlayer? HostPlayer => Players.FirstOrDefault(p => p.IsHost); - [JsonIgnore] public BssbServerPlayer? PartyLeaderPlayer => Players.FirstOrDefault(p => p.IsPartyLeader); - } -} \ No newline at end of file diff --git a/Models/BssbServerLevel.cs b/Models/BssbServerLevel.cs deleted file mode 100644 index f850a26..0000000 --- a/Models/BssbServerLevel.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Text.RegularExpressions; -using Newtonsoft.Json; - -namespace ServerBrowser.Models -{ - public class BssbServerLevel : BssbLevel - { - [JsonProperty("SessionGameId")] public string? SessionGameId; - [JsonProperty("Difficulty")] public BeatmapDifficulty? Difficulty; - [JsonProperty("Modifiers")] public GameplayModifiers? Modifiers; - /// - /// Serialized name of the Beatmap characteristic. - /// - [JsonProperty("Characteristic")] public string? Characteristic; - - [JsonIgnore] - public string CharacteristicText - { - get - { - if (string.IsNullOrWhiteSpace(Characteristic)) - return "Standard"; - - // To string with spaces - return Regex.Replace(Characteristic!, "(\\B[A-Z])", " $1"); - } - } - - public static BssbServerLevel FromLevelStartData(IPreviewBeatmapLevel previewBeatmapLevel, - BeatmapDifficulty difficulty, IDifficultyBeatmap? db = null, GameplayModifiers? modifiers = null, - string? characteristic = null) - { - if (db != null) - { - return new BssbServerLevel() - { - LevelId = db.level.levelID, - SongName = db.level.songName, - SongSubName = db.level.songSubName, - SongAuthorName = db.level.songAuthorName, - LevelAuthorName = db.level.levelAuthorName, - Difficulty = db.difficulty, - Modifiers = modifiers, - Characteristic = characteristic - }; - } - - return new BssbServerLevel() - { - LevelId = previewBeatmapLevel.levelID, - SongName = previewBeatmapLevel.songName, - SongSubName = previewBeatmapLevel.songSubName, - SongAuthorName = previewBeatmapLevel.songAuthorName, - LevelAuthorName = previewBeatmapLevel.levelAuthorName, - Difficulty = difficulty, - Modifiers = modifiers, - Characteristic = characteristic - }; - } - } -} \ No newline at end of file diff --git a/Models/BssbServerPlayer.cs b/Models/BssbServerPlayer.cs deleted file mode 100644 index 2678c5e..0000000 --- a/Models/BssbServerPlayer.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Newtonsoft.Json; - -namespace ServerBrowser.Models -{ - /// - /// Extended player information, relative to an active server session. - /// - public class BssbServerPlayer : BssbPlayer - { - [JsonProperty("SortIndex")] public int SortIndex; - [JsonProperty("IsMe")] public bool IsMe; - [JsonProperty("IsHost")] public bool IsHost; - [JsonProperty("IsPartyLeader")] public bool IsPartyLeader; - [JsonProperty("IsAnnouncer")] public bool IsAnnouncing; - [JsonProperty("Latency")] public float CurrentLatency; - - /// - /// Indicates whether this player is invisible in the lobby. - /// Invisible/ghost players do not take up a slot count. - /// - [JsonIgnore] - public bool IsGhost => SortIndex < 0; - - /// - /// Extra text shown on the player list in the detail view. - /// - [JsonIgnore] - public string ListText - { - get - { - if (IsHost) - return "Server Host"; - if (IsPartyLeader) - return "Party Leader"; - if (IsAnnouncing) - return "Announcer"; - return $"Player"; - } - } - - public static BssbServerPlayer FromConnectedPlayer(IConnectedPlayer player, bool isPartyLeader = false) - { - return new BssbServerPlayer() - { - UserId = player.userId, - UserName = player.userName, - AvatarData = player.multiplayerAvatarData, - SortIndex = player.sortIndex, - IsMe = player.isMe, - IsHost = player.isConnectionOwner, - IsPartyLeader = false, - IsAnnouncing = false, - CurrentLatency = player.currentLatency - }; - } - } -} \ No newline at end of file diff --git a/Models/Enums/BssbDifficulty.cs b/Models/Enums/BssbDifficulty.cs deleted file mode 100644 index 8c3f7b9..0000000 --- a/Models/Enums/BssbDifficulty.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace ServerBrowser.Models.Enums -{ - public enum BssbDifficulty : int - { - All = -1, - Easy = 0, - Normal = 1, - Hard = 2, - Expert = 3, - ExpertPlus = 4, - } - - public static class DifficultyUtils - { - public static BssbDifficulty ToBssbDifficulty(this BeatmapDifficultyMask mask) => mask switch - { - BeatmapDifficultyMask.All => BssbDifficulty.All, - BeatmapDifficultyMask.Easy => BssbDifficulty.Easy, - BeatmapDifficultyMask.Normal => BssbDifficulty.Normal, - BeatmapDifficultyMask.Hard => BssbDifficulty.Hard, - BeatmapDifficultyMask.Expert => BssbDifficulty.Expert, - BeatmapDifficultyMask.ExpertPlus => BssbDifficulty.ExpertPlus, - _ => BssbDifficulty.All - }; - - public static BssbDifficulty ToBssbDifficulty(this BeatmapDifficulty mask) => mask switch - { - BeatmapDifficulty.Easy => BssbDifficulty.Easy, - BeatmapDifficulty.Normal => BssbDifficulty.Normal, - BeatmapDifficulty.Hard => BssbDifficulty.Hard, - BeatmapDifficulty.Expert => BssbDifficulty.Expert, - BeatmapDifficulty.ExpertPlus => BssbDifficulty.ExpertPlus, - _ => BssbDifficulty.All - }; - - public static string ToFormattedText(this BssbDifficulty difficulty) => difficulty switch - { - BssbDifficulty.All => "All", - BssbDifficulty.Easy => "Easy", - BssbDifficulty.Normal => "Normal", - BssbDifficulty.Hard => "Hard", - BssbDifficulty.Expert => "Expert", - BssbDifficulty.ExpertPlus => "Expert+", - _ => "Unknown" - }; - } -} \ No newline at end of file diff --git a/Models/JsonConverters/IPEndPointConverter.cs b/Models/JsonConverters/IPEndPointConverter.cs deleted file mode 100644 index b09cc4f..0000000 --- a/Models/JsonConverters/IPEndPointConverter.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; -using System.Net; -using System.Net.Sockets; -using Newtonsoft.Json; - -namespace ServerBrowser.Models.JsonConverters -{ - internal class IPEndPointJsonConverter : JsonConverter - { - public override void WriteJson(JsonWriter writer, IPEndPoint? value, JsonSerializer serializer) - { - if (value is null) - { - writer.WriteNull(); - return; - } - - writer.WriteValue(value.AddressFamily == AddressFamily.InterNetworkV6 - ? $"[{value.Address}]:{value.Port}" - : $"{value.Address}:{value.Port}"); - } - - public override IPEndPoint? ReadJson(JsonReader reader, Type objectType, IPEndPoint? existingValue, - bool hasExistingValue, JsonSerializer serializer) - { - if (reader.Value is null) - { - reader.Skip(); - return null; - } - - var valueStr = reader.Value.ToString(); - - string? ipPart = null; - string? portPart = null; - - if (valueStr.StartsWith("[") && valueStr.Contains("]:")) - { - // Unwrap IPv6 brackets - var endBracketIdx = valueStr.LastIndexOf("]:"); - - ipPart = valueStr.Substring(1, endBracketIdx - 1); - - if (valueStr.Length >= endBracketIdx + 2) - portPart = valueStr.Substring(endBracketIdx + 2); - } - else - { - // Regular IPv4 notation (ip:port) - var valueParts = valueStr.Split(':'); - - if (valueParts.Length == 2) - { - ipPart = valueParts[0]; - portPart = valueParts[1]; - } - } - - if (ipPart != null - && IPAddress.TryParse(ipPart, out var ipAddress) - && int.TryParse(portPart, out var port)) - { - return new IPEndPoint(ipAddress, port); - } - - return null; - } - } -} \ No newline at end of file diff --git a/Models/Requests/AnnounceResultsData.cs b/Models/Requests/AnnounceResultsData.cs deleted file mode 100644 index ce5b4cf..0000000 --- a/Models/Requests/AnnounceResultsData.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Collections.Generic; -using ServerBrowser.Models.Utils; - -namespace ServerBrowser.Models.Requests -{ - public class AnnounceResultsData : JsonObject - { - public string? SessionGameId; - public List? Results; - - public static AnnounceResultsData FromMultiplayerResultsData(MultiplayerResultsData data) - { - var p = new AnnounceResultsData(); - p.SessionGameId = data.gameId; - p.Results = new(); - - foreach (var playerResults in data.allPlayersSortedData) - { - p.Results.Add(PlayerResultsItem.FromMultiplayerPlayerResultsData(playerResults)); - } - - return p; - } - - public class PlayerResultsItem - { - public string? UserId; - public PlayerResultsItemBadge? Badge; - public MultiplayerLevelCompletionResults.MultiplayerPlayerLevelEndState? LevelEndState; - public MultiplayerLevelCompletionResults.MultiplayerPlayerLevelEndReason? LevelEndReason; - public int? MultipliedScore; - public int? ModifiedScore; - public int? Rank; - public int? GoodCuts; - public int? BadCuts; - public int? MissCount; - public bool? FullCombo; - public int? MaxCombo; - - public static PlayerResultsItem FromMultiplayerPlayerResultsData(MultiplayerPlayerResultsData data) - { - var item = new PlayerResultsItem(); - item.UserId = data.connectedPlayer.userId; - - if (data.badge is not null) - item.Badge = PlayerResultsItemBadge.FromMultiplayerBadgeAwardData(data.badge); - - var outerResults = data.multiplayerLevelCompletionResults; - if (outerResults != null) - { - item.LevelEndState = outerResults.playerLevelEndState; - item.LevelEndReason = outerResults.playerLevelEndReason; - - var innerResults = outerResults.levelCompletionResults; - if (innerResults != null) - { - item.MultipliedScore = innerResults.multipliedScore; - item.ModifiedScore = innerResults.modifiedScore; - item.Rank = (int)innerResults.rank; - item.GoodCuts = innerResults.goodCutsCount; - item.BadCuts = innerResults.badCutsCount; - item.MissCount = innerResults.missedCount; - item.FullCombo = innerResults.fullCombo; - item.MaxCombo = innerResults.maxCombo; - } - } - - return item; - } - } - - public class PlayerResultsItemBadge - { - public string? Key; - public string? Title; - public string? Subtitle; - - public static PlayerResultsItemBadge FromMultiplayerBadgeAwardData(MultiplayerBadgeAwardData data) - { - var badge = new PlayerResultsItemBadge(); - badge.Key = data.titleLocalizationKey; - badge.Title = data.title; - badge.Subtitle = data.subtitle; - return badge; - } - } - } -} \ No newline at end of file diff --git a/Models/Requests/BrowseQueryParams.cs b/Models/Requests/BrowseQueryParams.cs deleted file mode 100644 index dbae6ca..0000000 --- a/Models/Requests/BrowseQueryParams.cs +++ /dev/null @@ -1,62 +0,0 @@ -using ServerBrowser.Utils; - -namespace ServerBrowser.Models.Requests -{ - public class BrowseQueryParams - { - public int Offset; - public string? TextSearch; - public bool HideFullGames; - public bool HideModdedGames; - public bool HideVanillaGames; - public bool HideInProgressGames; - public bool HideQuickPlay; - - /// - /// Indicates whether any filters have been activated. - /// - public bool AnyFiltersActive => - !string.IsNullOrEmpty(TextSearch) || HideFullGames || HideModdedGames || HideVanillaGames - || HideInProgressGames || HideQuickPlay; - - public void Reset() - { - TextSearch = null; - HideFullGames = false; - HideModdedGames = false; - HideVanillaGames = false; - HideInProgressGames = false; - HideQuickPlay = false; - } - - public string ToQueryString() - { - var queryString = new QueryString(); - - queryString.Set("limit", 7.ToString()); - queryString.Set("includeLevel", "1"); - - if (Offset > 0) - queryString.Set("offset", Offset.ToString()); - - if (!string.IsNullOrEmpty(TextSearch)) - queryString.Set("query", TextSearch); - - if (HideFullGames) - queryString.Set("filterFull", "1"); - - if (HideModdedGames) - queryString.Set("filterModded", "1"); - else if (HideVanillaGames) - queryString.Set("filterVanilla", "1"); - - if (HideInProgressGames) - queryString.Set("filterInProgress", "1"); - - if (HideQuickPlay) - queryString.Set("filterQuickPlay", "1"); - - return queryString.ToString(); - } - } -} \ No newline at end of file diff --git a/Models/Requests/BssbEmptyRequest.cs b/Models/Requests/BssbEmptyRequest.cs new file mode 100644 index 0000000..56d7ebf --- /dev/null +++ b/Models/Requests/BssbEmptyRequest.cs @@ -0,0 +1,6 @@ +namespace ServerBrowser.Requests +{ + public class BssbEmptyRequest + { + } +} \ No newline at end of file diff --git a/Models/Requests/BssbLoginRequest.cs b/Models/Requests/BssbLoginRequest.cs new file mode 100644 index 0000000..1630a20 --- /dev/null +++ b/Models/Requests/BssbLoginRequest.cs @@ -0,0 +1,8 @@ +namespace ServerBrowser.Requests +{ + public class BssbLoginRequest + { + public UserInfo? UserInfo { get; set; } + public string? AuthenticationToken { get; set; } + } +} \ No newline at end of file diff --git a/Models/Requests/UnAnnounceParams.cs b/Models/Requests/UnAnnounceParams.cs deleted file mode 100644 index 7a66908..0000000 --- a/Models/Requests/UnAnnounceParams.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Newtonsoft.Json; -using ServerBrowser.Models.Utils; - -namespace ServerBrowser.Models.Requests -{ - public class UnAnnounceParams : JsonObject - { - [JsonProperty("SelfUserId")] public string? SelfUserId; - [JsonProperty("HostUserId")] public string? HostUserId; - [JsonProperty("HostSecret")] public string? HostSecret; - - [JsonIgnore] public bool IsComplete => SelfUserId != null && HostUserId != null && HostSecret != null; - } -} \ No newline at end of file diff --git a/Models/Responses/AnnounceResponse.cs b/Models/Responses/AnnounceResponse.cs deleted file mode 100644 index 6b86545..0000000 --- a/Models/Responses/AnnounceResponse.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Newtonsoft.Json; -using ServerBrowser.Models.Utils; - -namespace ServerBrowser.Models.Responses -{ - public class AnnounceResponse : JsonObject - { - [JsonProperty("Success")] public bool Success; - [JsonProperty("Key")] public string? Key; - [JsonProperty("Message")] public string? ServerMessage; - } -} \ No newline at end of file diff --git a/Models/Responses/BrowseResponse.cs b/Models/Responses/BrowseResponse.cs deleted file mode 100644 index dacc0a7..0000000 --- a/Models/Responses/BrowseResponse.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using System.Collections.Generic; -using Newtonsoft.Json; -using ServerBrowser.Models.Utils; - -namespace ServerBrowser.Models.Responses -{ - public class BrowseResponse : JsonObject - { - /// - /// Total amount of matching lobbies for the request parameters. - /// - [JsonProperty("Count")] public int TotalResultCount; - /// - /// Current offset from the start of the lobby list, for pagination. - /// - [JsonProperty("Offset")] public int PageOffset; - /// - /// Page size. Maximum amount of lobbies returned in the response. - /// - [JsonProperty("Limit")] public int PageSize; - /// - /// List of servers. - /// - [JsonProperty("Lobbies")] public List? Servers; - /// - /// Optional message of the day set by the BSSB server. - /// - [JsonProperty("Message")] public string? MessageOfTheDay; - - [JsonIgnore] public int LowerBoundNumber => PageOffset + 1; - [JsonIgnore] public int UpperBoundNumber => PageOffset + (Servers?.Count ?? 0); - [JsonIgnore] public int PageNumber => (int) Math.Floor(PageOffset / (decimal) PageSize) + 1; - [JsonIgnore] public int PageCount => (int) Math.Ceiling(TotalResultCount / (decimal) PageSize); - } -} \ No newline at end of file diff --git a/Models/Responses/BssbBrowseResponse.cs b/Models/Responses/BssbBrowseResponse.cs new file mode 100644 index 0000000..dc92c09 --- /dev/null +++ b/Models/Responses/BssbBrowseResponse.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using JetBrains.Annotations; + +namespace ServerBrowser.Models.Responses +{ + [UsedImplicitly] + public class BssbBrowseResponse + { + public List Lobbies { get; set; } + } +} \ No newline at end of file diff --git a/Models/Responses/BssbConfigResponse.cs b/Models/Responses/BssbConfigResponse.cs new file mode 100644 index 0000000..1fed790 --- /dev/null +++ b/Models/Responses/BssbConfigResponse.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using JetBrains.Annotations; +using ServerBrowser.Data; + +namespace ServerBrowser.Models.Responses +{ + [UsedImplicitly] + public class BssbConfigResponse + { + public List MasterServers { get; set; } + } +} \ No newline at end of file diff --git a/Models/Responses/BssbLoginResponse.cs b/Models/Responses/BssbLoginResponse.cs new file mode 100644 index 0000000..447b012 --- /dev/null +++ b/Models/Responses/BssbLoginResponse.cs @@ -0,0 +1,25 @@ +using JetBrains.Annotations; + +namespace ServerBrowser.Models.Responses +{ + [UsedImplicitly] + public class BssbLoginResponse + { + /// + /// Whether the login request was valid and points to a valid player profile. + /// + public bool Success { get; set; } + /// + /// Whether the user has successfully authenticated themselves as the player via token or Steam/Oculus ticket. + /// + public bool Authenticated { get; set; } + /// + /// Server error for logging purposes, if any. + /// + public string? ErrorMessage { get; set; } + /// + /// The platform avatar URL for the player. + /// + public string? AvatarUrl { get; set; } + } +} \ No newline at end of file diff --git a/Models/Responses/UnAnnounceResponse.cs b/Models/Responses/UnAnnounceResponse.cs deleted file mode 100644 index b63d0e4..0000000 --- a/Models/Responses/UnAnnounceResponse.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Newtonsoft.Json; -using ServerBrowser.Models.Utils; - -namespace ServerBrowser.Models.Responses -{ - public class UnAnnounceResponse : JsonObject - { - public string? Result; - public bool CanRetry; - - [JsonIgnore] public bool IsOk => Result == "ok"; - } -} \ No newline at end of file diff --git a/Models/ServerFilterParams.cs b/Models/ServerFilterParams.cs new file mode 100644 index 0000000..209de1f --- /dev/null +++ b/Models/ServerFilterParams.cs @@ -0,0 +1,114 @@ +using System.Collections.Generic; +using System.Text; +using ServerBrowser.UI.Toolkit; +using ServerBrowser.UI.Toolkit.Components; + +namespace ServerBrowser.Models +{ + public class ServerFilterParams + { + #region Param def + + public class Param + { + public readonly string Key; + public readonly string Label; + public readonly bool InitialValue; + + public Param(string key, string label, bool initialInitialValue = false) + { + Key = key; + Label = label; + InitialValue = initialInitialValue; + } + + public TkToggleControl CreateControl(LayoutContainer container) + { + var control = container.AddToggleControl(); + control.SetLabel(Label); + control.SetValue(InitialValue); + return control; + } + } + + #endregion + + #region Static params list + + public static readonly List AllParams; + + public const string HidePlayingLevel = "HidePlayingLevel"; + public const string HideFull = "HideFull"; + public const string HideEmpty = "HideEmpty"; + public const string HideOfficial = "HideOfficial"; + public const string HideQuickPlay = "HideQuickPlay"; + + static ServerFilterParams() + { + AllParams = new() + { + new Param(HidePlayingLevel, "Hide Playing Level"), + new Param(HideFull, "Hide Full"), + new Param(HideEmpty, "Hide Empty"), + new Param(HideOfficial, "Hide Official"), + new Param(HideQuickPlay, "Hide Quick Play"), + }; + } + + #endregion + + #region Param values object + + private readonly List _params; + + public ServerFilterParams() + { + _params = new(); + Clear(); // set initial values + } + + public void Clear() + { + _params.Clear(); + + foreach (var param in AllParams) + if (param.InitialValue) + _params.Add(param.Key); + } + + public void SetValue(string key, bool value) + { + if (value) + { + if (!_params.Contains(key)) + _params.Add(key); + } + else + { + if (_params.Contains(key)) + _params.Remove(key); + } + } + + public bool GetValue(string key) => _params.Contains(key); + + public bool GetValue(Param param) => GetValue(param.Key); + + public string Describe() + { + var sb = new StringBuilder(); + foreach (var param in AllParams) + { + if (!GetValue(param.Key)) + continue; + + if (sb.Length > 0) + sb.Append(", "); + sb.Append(param.Label); + } + return sb.ToString(); + } + + #endregion + } +} \ No newline at end of file diff --git a/Models/Utils/JsonObject.cs b/Models/Utils/JsonObject.cs deleted file mode 100644 index 4be31e8..0000000 --- a/Models/Utils/JsonObject.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Net.Http; -using System.Text; -using Newtonsoft.Json; - -namespace ServerBrowser.Models.Utils -{ - public abstract class JsonObject - { - public string ToJson() - { - return JsonConvert.SerializeObject(this); - } - - public StringContent ToRequestContent() - { - return new StringContent(ToJson(), Encoding.UTF8, "application/json"); - } - - public static TBase FromJson(string json) - { - return JsonConvert.DeserializeObject(json)!; - } - - public static T FromJson(string json) where T : TBase - { - return JsonConvert.DeserializeObject(json)!; - } - } -} \ No newline at end of file diff --git a/Network/Discovery/DiscoveryClient.cs b/Network/Discovery/DiscoveryClient.cs new file mode 100644 index 0000000..4fb3066 --- /dev/null +++ b/Network/Discovery/DiscoveryClient.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using JetBrains.Annotations; +using LiteNetLib.Utils; +using SiraUtil.Logging; +using UnityEngine; +using Zenject; + +namespace ServerBrowser.Network.Discovery +{ + [UsedImplicitly] + public class DiscoveryClient : MonoBehaviour, IDisposable + { + [Inject] private readonly SiraLog _log = null!; + + private UdpClient? _udpClient; + private float? _lastBroadcastTime; + + private readonly NetDataReader _netDataReader = new(); + private readonly NetDataWriter _netDataWriter = new(true, 256); + + public readonly Queue ReceivedResponses = new(); + + public bool IsActive => _udpClient != null; + + public void StartBroadcast() + { + if (_udpClient != null) + StopBroadcast(); + + _udpClient = new UdpClient(new IPEndPoint(IPAddress.Any, 0)); + _udpClient.EnableBroadcast = true; + + _lastBroadcastTime = null; + + ReceivedResponses.Clear(); + + _log.Debug("Started local network discovery"); + } + + public void StopBroadcast() + { + if (_udpClient == null) + return; + + _udpClient?.Dispose(); + _udpClient = null; + + ReceivedResponses.Clear(); + + _log.Debug("Stopped local network discovery"); + } + + public void OnDisable() + { + StopBroadcast(); + } + + public void Dispose() + { + _udpClient?.Dispose(); + _udpClient = null; + + ReceivedResponses.Clear(); + } + + public void LateUpdate() + { + if (_udpClient == null) + return; + + if (_lastBroadcastTime == null || (Time.time - _lastBroadcastTime.Value) >= DiscoveryInterval) + SendBroadcast(); + + if (_udpClient.Available > 0) + HandleReceive(); + } + + private void HandleReceive() + { + var remoteEndpoint = new IPEndPoint(IPAddress.Any, 0); + var data = _udpClient!.Receive(ref remoteEndpoint); + + _netDataReader.SetSource(data); + + while (_netDataReader.AvailableBytes > 0) + { + try + { + var packet = new DiscoveryResponsePacket(); + packet.Deserialize(_netDataReader); + HandleResponse(packet); + } + catch (Exception e) + { + _log.Warn($"Failed to deserialize local discovery response: {e}"); + } + } + } + + private void HandleResponse(DiscoveryResponsePacket packet) + { + if (packet.Prefix != DiscoveryConsts.PacketPrefix) + return; + + ReceivedResponses.Enqueue(packet); + } + + private void SendBroadcast() + { + _netDataWriter.Reset(); + QueryPacket.Serialize(_netDataWriter); + + _udpClient!.Send(_netDataWriter.Data, _netDataWriter.Length, BroadcastEndpoint); + _lastBroadcastTime = Time.time; + } + + private const float DiscoveryInterval = 5f; + + private static readonly IPEndPoint BroadcastEndpoint = new(IPAddress.Broadcast, DiscoveryConsts.BroadcastPort); + + private static readonly string GameVersionNoSuffix = Application.version.Split('_')[0]; + + private static readonly DiscoveryQueryPacket QueryPacket = new() + { + Prefix = DiscoveryConsts.PacketPrefix, + ProtocolVersion = DiscoveryConsts.ProtocolVersion, + GameVersion = GameVersionNoSuffix + }; + } +} \ No newline at end of file diff --git a/Network/Discovery/DiscoveryConsts.cs b/Network/Discovery/DiscoveryConsts.cs new file mode 100644 index 0000000..7951b2b --- /dev/null +++ b/Network/Discovery/DiscoveryConsts.cs @@ -0,0 +1,9 @@ +namespace ServerBrowser.Network.Discovery +{ + public static class DiscoveryConsts + { + public const int ProtocolVersion = 1; + public const string PacketPrefix = "BssbDiscovery"; + public const int BroadcastPort = 47777; + } +} \ No newline at end of file diff --git a/Network/Discovery/DiscoveryQueryPacket.cs b/Network/Discovery/DiscoveryQueryPacket.cs new file mode 100644 index 0000000..76333ce --- /dev/null +++ b/Network/Discovery/DiscoveryQueryPacket.cs @@ -0,0 +1,28 @@ +using System; +using LiteNetLib.Utils; + +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + +namespace ServerBrowser.Network.Discovery +{ + public class DiscoveryQueryPacket : INetSerializable + { + public string Prefix; + public int ProtocolVersion; + + public string GameVersion; + + public void Serialize(NetDataWriter writer) + { + writer.Put(Prefix); + writer.Put(ProtocolVersion); + + writer.Put(GameVersion); + } + + public void Deserialize(NetDataReader reader) + { + throw new NotImplementedException("Client does not handle discovery packets"); + } + } +} \ No newline at end of file diff --git a/Network/Discovery/DiscoveryResponsePacket.cs b/Network/Discovery/DiscoveryResponsePacket.cs new file mode 100644 index 0000000..ca77ee1 --- /dev/null +++ b/Network/Discovery/DiscoveryResponsePacket.cs @@ -0,0 +1,52 @@ +using System.Net; +using LiteNetLib.Utils; + +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + +namespace ServerBrowser.Network.Discovery +{ + public class DiscoveryResponsePacket : INetSerializable + { + public string Prefix; + public int ProtocolVersion; + + public IPEndPoint ServerEndPoint; + public string ServerName; + public string ServerUserId; + public string GameModeName; + public string ServerTypeName; + public int PlayerCount; + public BeatmapLevelSelectionMask BeatmapLevelSelectionMask; + public GameplayServerConfiguration GameplayServerConfiguration; + + public void Serialize(NetDataWriter writer) + { + writer.Put(Prefix); + writer.Put(ProtocolVersion); + + writer.Put(ServerEndPoint); + writer.Put(ServerName); + writer.Put(ServerUserId); + writer.Put(GameModeName); + writer.Put(ServerTypeName); + writer.Put(PlayerCount); + BeatmapLevelSelectionMask.Serialize(writer); + GameplayServerConfiguration.Serialize(writer); + } + + public void Deserialize(NetDataReader reader) + { + Prefix = reader.GetString(); + ProtocolVersion = reader.GetInt(); + + ServerEndPoint = reader.GetNetEndPoint(); + ServerName = reader.GetString(); + ServerUserId = reader.GetString(); + GameModeName = reader.GetString(); + ServerTypeName = reader.GetString(); + PlayerCount = reader.GetInt(); + BeatmapLevelSelectionMask = BeatmapLevelSelectionMask.Deserialize(reader); + GameplayServerConfiguration = GameplayServerConfiguration.Deserialize(reader); + } + } +} \ No newline at end of file diff --git a/Plugin.cs b/Plugin.cs index 3b843a8..56d918c 100644 --- a/Plugin.cs +++ b/Plugin.cs @@ -1,8 +1,8 @@ using IPA; using IPA.Config.Stores; -using ServerBrowser.Assets; +using JetBrains.Annotations; using ServerBrowser.Installers; -using ServerBrowser.UI.Lobby; +using ServerBrowser.Models; using SiraUtil.Web.SiraSync; using SiraUtil.Zenject; using IPALogger = IPA.Logging.Logger; @@ -10,20 +10,17 @@ namespace ServerBrowser { [Plugin(RuntimeOptions.DynamicInit)] - // ReSharper disable once ClassNeverInstantiated.Global + [UsedImplicitly] public class Plugin { - // ReSharper disable once MemberCanBePrivate.Global - internal static PluginConfig Config { get; private set; } = null!; - - private IPALogger _log = null!; + internal static IPALogger Log = null!; + internal static BssbConfig Config = null!; [Init] public void Init(IPALogger logger, Zenjector zenjector, IPA.Config.Config config) { - _log = logger; - - Config = config.Generated(); + Log = logger; + Config = config.Generated(); zenjector.UseMetadataBinder(); zenjector.UseLogger(logger); @@ -37,16 +34,11 @@ public void Init(IPALogger logger, Zenjector zenjector, IPA.Config.Config config [OnEnable] public void OnEnable() { - if (!Sprites.IsInitialized) - Sprites.Initialize(); - - LobbyConfigPanel.RegisterGameplayModifierTab(); } [OnDisable] public void OnDisable() { - LobbyConfigPanel.RemoveGameplayModifierTab(); } } } \ No newline at end of file diff --git a/PluginConfig.cs b/PluginConfig.cs deleted file mode 100644 index 0023dfa..0000000 --- a/PluginConfig.cs +++ /dev/null @@ -1,44 +0,0 @@ -using ServerBrowser.Models.Requests; - -namespace ServerBrowser -{ - // ReSharper disable once ClassNeverInstantiated.Global - public class PluginConfig - { - /// - /// The base URL for the BSSB API. - /// - public virtual string ApiServerUrl { get; set; } = "https://bssb.app/"; - - /// - /// If true, send server announcements when you are the party leader. - /// - public virtual bool AnnounceParty { get; set; } = true; - - /// - /// If true, send Quick Play server announcements. - /// - public virtual bool AnnounceQuickPlay { get; set; } = true; - - /// - /// Custom server name to use with party leader announcements. - /// - public virtual string? ServerName { get; set; } = null; - - /// - /// Controls whether the join/leave notifications should be shown. - /// - public virtual bool EnableJoinNotifications { get; set; } = true; - - /// - /// Controls whether the JoiningLobbyExtender patches are applied. - /// If enabled, extended connection status is shown during connect (English only). - /// - public virtual bool EnableJoiningLobbyExtender { get; set; } = true; - - /// - /// Stores filter preferences for the main server browser view. - /// - public virtual BrowseQueryParams? FilterSet { get; set; } = new(); - } -} \ No newline at end of file diff --git a/README.md b/README.md index c6112e9..cd6c64e 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,13 @@

- Beat Saber Server Browser (PC) + Beat Saber Server Browser (BSSB)

-**Beat Saber multiplayer mod that adds a Server Browser to the Online menu, making it easy to share and join multiplayer games.** +**Easily share, browse and connect to multiplayer lobbies from the Online menu.** - - - - - - - - - - - - - -
⏬ PC Mod💚 Download latest release
🆕 Quest ModBeatSaberServerBrowserQuest
🌎 Onlinehttps://bssb.app
+### 🌐 **[https://bssb.app](https://bssb.app)** -## Installation -👉 **Server Browser is *usually* available on [ModAssistant](https://github.com/Assistant/ModAssistant/blob/master/README.md), which is the easiest way to install it!** +# Installation +🪄 **Recommended: Use [ModAssistant](https://github.com/bsmg/ModAssistant#readme) to install BSSB and all required mods with one click.** -You can also install the mod manually by following the instructions below. - -If you need help, you can ask in the [Beat Saber Modding Group](https://discord.com/invite/beatsabermods) Discord (#pc-help) or [BeatTogether Discord](https://discord.com/invite/gezGrFG4tz) (#help). - -### Requirements - -- Beat Saber 1.29+ on PC (Steam or Oculus) -- With the latest version of the following mods: - - Core mods (BSIPA and SongCore) - - SiraUtil - - BeatSaberMarkupLanguage - - MultiplayerCore - -ℹ️ **Compatibility note:** Check the GitHub releases for up-to-date information on Beat Saber compatibility and older supported versions. The mod usually doesn't require updates after a new game version drops. - -### Download -You can download the latest release directly from GitHub: - -[**Download latest release**](https://github.com/roydejong/BeatSaberServerBrowser/releases/latest) - -Extract the downloaded ZIP file to your Beat Saber installation directory. - -If the mod is installed successfully, you should see `ServerBrowser.dll` in your Beat Saber `Plugins` directory. It will only load if you also have the right dependencies like MultiplayerCore. - -## How it works - -### Joining games -Open the Online menu, then click on the "Server Browser" button. From there, you'll see all public multiplayer games. Click on a game, and then select "Connect" to jump right in. - -### Sharing games -When creating a server, you'll see the option to add it to the Server Browser. You can also set a custom name for the game if you want. - -If you're the party leader or lobby owner, you can control these settings from the Gameplay Modifiers panel as well. This is located on the left side of the lobby. - -**If you share your game on the Server Browser, anyone will be able to join! Your server code will be publicly visible on the site (https://bssb.app) as well.** - -## Custom Songs -Want to play Custom Songs in Beat Saber Multiplayer? Here's what you'll need: - -- **A modded copy of Beat Saber 1.22+** -- **[MultiplayerCore](https://github.com/Goobwabber/MultiplayerCore):** This mod makes multiplayer modding possible, and makes custom songs work. -- **[BeatTogether]()**: BeatTogether provides multiplayer servers that allow modded content to work. - -The Server Browser mod isn't required for custom songs, but it's here to help you find multiplayer lobbies. - -The [MultiplayerExtensions](https://github.com/Goobwabber/MultiplayerExtensions) mod adds additional multiplayer features and is recommended but not required anymore. - -Here's some important things you should know: -- Official servers do **NOT** allow Custom Songs at all; it doesn't matter what mods you have. -- The BeatTogether mod will let you choose which master server to play on. Make sure you choose BeatTogether or another modded master server if you want to play custom songs! - -If you need more help, you can ask in the [BeatTogether Discord](https://discord.com/invite/gezGrFG4tz) (#help). - -## Cross-play -Beat Saber has enabled cross-play for all platforms on official servers. Unofficial servers like BeatTogether always allow cross play. You'll encounter both Steam and Oculus players when you play. - -Please note that different versions of Beat Saber may not be compatible. Make sure everyone is on the same game version for the best experience. - -## Reporting issues -If you have any issues with the Server Browser mod itself, please report them via GitHub: - -[https://github.com/roydejong/BeatSaberServerBrowser/issues](https://github.com/roydejong/BeatSaberServerBrowser/issues) +Alternatively, download the [**latest release**](https://github.com/roydejong/BeatSaberServerBrowser/releases/latest) manually and extract it into your Beat Saber directory. diff --git a/ServerBrowser.csproj b/ServerBrowser.csproj index ee5a4d6..5dd08e1 100644 --- a/ServerBrowser.csproj +++ b/ServerBrowser.csproj @@ -52,15 +52,71 @@ $(BeatSaberDir)\Beat Saber_Data\Managed\BeatmapCore.dll False - + False - $(BeatSaberDir)\Beat Saber_Data\Managed\BGNet.dll + $(BeatSaberDir)\Beat Saber_Data\Managed\BeatSaber.AvatarCore.dll + False + + + False + $(BeatSaberDir)\Beat Saber_Data\Managed\BeatSaber.BeatAvatarAdapter.dll + False + True + + + False + $(BeatSaberDir)\Beat Saber_Data\Managed\BeatSaber.BeatAvatarSDK.dll + False + True + + + False + $(BeatSaberDir)\Beat Saber_Data\Managed\BGLib.AppFlow.dll + False + + + False + $(BeatSaberDir)\Beat Saber_Data\Managed\BGLib.DotnetExtension.dll + False + + + False + $(BeatSaberDir)\Beat Saber_Data\Managed\BGLib.Polyglot.dll + False + + + False + $(BeatSaberDir)\Beat Saber_Data\Managed\BGLib.UnityExtension.dll + False + + + False + $(BeatSaberDir)\Beat Saber_Data\Managed\BGNetCore.dll + False + True + + + False + $(BeatSaberDir)\Beat Saber_Data\Managed\BGNetLogging.dll False False $(BeatSaberDir)\Plugins\BSML.dll False + True + + + False + $(BeatSaberDir)\Beat Saber_Data\Managed\Colors.dll + False + True + + + False + $(BeatSaberDir)\Beat Saber_Data\Managed\DataModels.dll + False + True False @@ -80,34 +136,54 @@ False $(BeatSaberDir)\Beat Saber_Data\Managed\HMUI.dll False + True + False $(BeatSaberDir)\Beat Saber_Data\Managed\Ignorance.dll + False False $(BeatSaberDir)\Beat Saber_Data\Managed\IPA.Loader.dll False + + False + $(BeatSaberDir)\Beat Saber_Data\Managed\LiteNetLib.dll + False + False $(BeatSaberDir)\Beat Saber_Data\Managed\Main.dll False + True + False $(BeatSaberDir)\Beat Saber_Data\Managed\mscorlib.dll + False + False $(BeatSaberDir)\Plugins\MultiplayerCore.dll False + + False + $(BeatSaberDir)\Beat Saber_Data\Managed\Networking.dll + False + True + False $(BeatSaberDir)\Libs\Newtonsoft.Json.dll False - - $(BeatSaberDir)\Beat Saber_Data\Managed\Polyglot.dll + + False + $(BeatSaberDir)\Beat Saber_Data\Managed\PlatformUserModel.dll + False $(BeatSaberDir)\Plugins\SiraUtil.dll @@ -179,10 +255,16 @@ $(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.ImageConversionModule.dll False + + False + $(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.TextRenderingModule.dll + False + False $(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.UI.dll False + True False @@ -204,11 +286,21 @@ $(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.UnityWebRequestTextureModule.dll False + + False + $(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.PhysicsModule.dll + False + False $(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.VRModule.dll False + + False + $(BeatSaberDir)\Beat Saber_Data\Managed\Menu.CommonLib.dll + False + False $(BeatSaberDir)\Beat Saber_Data\Managed\VRUI.dll @@ -227,72 +319,76 @@ - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - @@ -305,27 +401,40 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + 0.4.1 + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - - - - + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + diff --git a/ServerBrowserMod.sln.DotSettings b/ServerBrowserMod.sln.DotSettings index b13b551..7cb6ddf 100644 --- a/ServerBrowserMod.sln.DotSettings +++ b/ServerBrowserMod.sln.DotSettings @@ -1,9 +1,12 @@  True + True True True True + True True + True True True True diff --git a/UI/Browser/BrowserFlowCoordinator.cs b/UI/Browser/BrowserFlowCoordinator.cs new file mode 100644 index 0000000..e0aa8cb --- /dev/null +++ b/UI/Browser/BrowserFlowCoordinator.cs @@ -0,0 +1,931 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using BeatSaber.AvatarCore; +using BGLib.Polyglot; +using HMUI; +using IgnoranceCore; +using ServerBrowser.Data; +using ServerBrowser.Models; +using ServerBrowser.UI.Browser.Views; +using ServerBrowser.UI.Forms; +using ServerBrowser.Util; +using SiraUtil.Affinity; +using SiraUtil.Logging; +using UnityEngine; +using Zenject; + +namespace ServerBrowser.UI.Browser +{ + public class BrowserFlowCoordinator : FlowCoordinator, IAffinity + { + [Inject] private readonly SiraLog _log = null!; + [Inject] private readonly BssbSession _session = null!; + [Inject] private readonly MultiplayerConfigManager _multiplayerConfigManager = null!; + [Inject] private readonly MasterServerRepository _masterServerRepository = null!; + [Inject] private readonly ServerRepository _serverRepository = null!; + + [Inject] private readonly MainFlowCoordinator _mainFlowCoordinator = null!; + [Inject] private readonly MainBrowserViewController _mainViewController = null!; + [Inject] private readonly BrowserFilterViewController _filterViewController = null!; + [Inject] private readonly MasterServerSelectViewController _masterServerSelectViewController = null!; + + [Inject] private readonly JoiningLobbyViewController _joiningLobbyViewController = null!; + [Inject] private readonly SimpleDialogPromptViewController _simpleDialogPromptViewController = null!; + [Inject] private readonly CreateServerViewController _createServerViewController = null!; + [Inject] private readonly JoinQuickPlayViewController _joinQuickPlayViewController = null!; + [Inject] private readonly ServerCodeEntryViewController _serverCodeEntryViewController = null!; + [Inject] private readonly GameServerLobbyFlowCoordinator _gameServerLobbyFlowCoordinator = null!; + + [Inject] private readonly IMultiplayerSessionManager _multiplayerSessionManager = null!; + [Inject] private readonly IUnifiedNetworkPlayerModel _unifiedNetworkPlayerModel = null!; + [Inject] private readonly ILobbyGameStateController _lobbyGameStateController = null!; + + [Inject] private readonly FadeInOutController _fadeInOutController = null!; + [Inject] private readonly MenuLightsManager _menuLightsManager = null!; + + [Inject] private readonly PlayerDataModel _playerDataModel = null!; + [Inject] private readonly LobbyDataModelsManager _lobbyDataModelsManager = null!; + [Inject] private readonly AvatarSystemCollection _avatarSystemCollection = null!; + [Inject] private readonly SongPackMasksModel _songPackMasksModel = null!; + + [Inject] private readonly CreateServerFormExtender _createServerFormExtender = null!; + [Inject] private readonly QuickPlayFormExtender _quickPlayFormExtender = null!; + [Inject] private readonly ServerCodeFormExtender _serverCodeFormExtender = null!; + + private ServerFilterParams _filterParams = new(); + private CancellationTokenSource? _joiningLobbyCancellationTokenSource; + private MultiplayerAvatarsData? _multiplayerAvatarsData; + private ServerRepository.ServerInfo? _serverInfo; + + private bool _wasEverConnected; + private DisconnectedReason? _realDisconnectReason; + private ConnectionFailedReason? _connectionFailedReason; + private MultiplayerUnavailableReason? _multiplayerUnavailableReason; + private string? _multiplayerUnavailableMessage; + private long? _multiplayerMaintenanceEndTime; + private MultiplayerModeSelectionViewController.MenuButton? _lastSelectedMode; + + #region Setup / Flow coordinator + + public override void DidActivate(bool firstActivation, bool addedToHierarchy, bool screenSystemEnabling) + { + if (addedToHierarchy) + { + _mainViewController.ServerJoinRequestedEvent += HandleServerJoinRequested; + _mainViewController.ModeSelectedEvent += HandleModeSelected; + _mainViewController.AvatarEditRequestedEvent += HandleAvatarEditRequested; + _mainViewController.FiltersClickedEvent += HandleFiltersClicked; + _mainViewController.FiltersClearedEvent += HandleFiltersCleared; + _filterViewController.FinishedEvent += HandleFiltersViewFinished; + _masterServerSelectViewController.FinishedEvent += HandleMasterServerSelectFinished; + + _joiningLobbyViewController.didCancelEvent += HandleJoinCanceled; + + _joinQuickPlayViewController.didFinishEvent += HandleQuickPlayViewFinished; + _createServerViewController.didFinishEvent += HandleCreateServerViewFinished; + _serverCodeEntryViewController.didFinishEvent += HandleServerCodeViewFinished; + + _multiplayerSessionManager.connectedEvent += HandleSessionConnected; + _multiplayerSessionManager.connectionFailedEvent += HandleSessionConnectionFailed; + _unifiedNetworkPlayerModel.connectedPlayerManagerCreatedEvent += HandleCpmCreated; + _unifiedNetworkPlayerModel.connectedPlayerManagerDestroyedEvent += HandleCpmDestroyed; + + _createServerFormExtender.MasterServerSwitchRequestedEvent += HandleMasterServerSwitchRequested; + _quickPlayFormExtender.MasterServerSwitchRequestedEvent += HandleMasterServerSwitchRequested; + _serverCodeFormExtender.MasterServerSwitchRequestedEvent += HandleMasterServerSwitchRequested; + } + + if (firstActivation) + { + showBackButton = true; + ProvideInitialViewControllers(_mainViewController); + SetTitle("Online"); + } + + _serverRepository.StartDiscovery(); + + _ = _masterServerRepository.TryRemoteUpdate(); + + _ = LoadAvatar(); + } + + public override void DidDeactivate(bool removedFromHierarchy, bool screenSystemDisabling) + { + if (removedFromHierarchy) + { + _mainViewController.ServerJoinRequestedEvent -= HandleServerJoinRequested; + _mainViewController.ModeSelectedEvent -= HandleModeSelected; + _mainViewController.AvatarEditRequestedEvent -= HandleAvatarEditRequested; + _mainViewController.FiltersClickedEvent -= HandleFiltersClicked; + _mainViewController.FiltersClearedEvent -= HandleFiltersCleared; + _filterViewController.FinishedEvent -= HandleFiltersViewFinished; + _masterServerSelectViewController.FinishedEvent -= HandleMasterServerSelectFinished; + + _joiningLobbyViewController.didCancelEvent -= HandleJoinCanceled; + + _joinQuickPlayViewController.didFinishEvent -= HandleQuickPlayViewFinished; + _createServerViewController.didFinishEvent -= HandleCreateServerViewFinished; + _serverCodeEntryViewController.didFinishEvent -= HandleServerCodeViewFinished; + + _multiplayerSessionManager.connectedEvent -= HandleSessionConnected; + _multiplayerSessionManager.connectionFailedEvent -= HandleSessionConnectionFailed; + _unifiedNetworkPlayerModel.connectedPlayerManagerCreatedEvent -= HandleCpmCreated; + + _createServerFormExtender.MasterServerSwitchRequestedEvent -= HandleMasterServerSwitchRequested; + _quickPlayFormExtender.MasterServerSwitchRequestedEvent -= HandleMasterServerSwitchRequested; + _serverCodeFormExtender.MasterServerSwitchRequestedEvent -= HandleMasterServerSwitchRequested; + } + + _serverRepository.StopDiscovery(); + + _session.StopLoginRetries(); + } + + // ReSharper disable once ParameterHidesMember + public override void BackButtonWasPressed(ViewController topViewController) + { + if (_multiplayerSessionManager.isConnectingOrConnected || topViewController == _joiningLobbyViewController) + { + // Failsafe: Back button should not be visible right now + return; + } + + if (topViewController == _joinQuickPlayViewController || + topViewController == _createServerViewController || + topViewController == _serverCodeEntryViewController || + topViewController == _filterViewController) + { + // Sub view is active, dismiss to return to top view + ShowMainView(); + return; + } + + if (topViewController == _masterServerSelectViewController) + { + HandleMasterServerSelectFinished(); + return; + } + + if (topViewController == _mainViewController) + { + // Main view is active, exit to main menu + ReturnToMainMenu(); + return; + } + } + + public void ReturnToMainMenu() + { + _mainFlowCoordinator.DismissFlowCoordinator(this); + } + + #endregion + + #region UI Events + + private void HandleAvatarEditRequested() + { + _mainFlowCoordinator._goToMultiplayerAfterAvatarCreation = true; // will trigger patch in MainMenuIntegrator + _mainFlowCoordinator._editAvatarFlowCoordinatorHelper.Show(this, true); + } + + private void HandleServerJoinRequested(ServerRepository.ServerInfo server) + { + _ = ConnectToServer(server); + } + + private void HandleServerJoinByCodeRequested(string serverCode) + { + _ = ConnectToServer(new ServerRepository.ServerInfo() + { + ServerName = "Lobby", + ConnectionMethod = ServerRepository.ConnectionMethod.GameLiftOfficial, + ServerCode = serverCode + }); + } + + private void HandleJoinCanceled() + { + _log.Info("User canceled server join"); + _connectionFailedReason = ConnectionFailedReason.ConnectionCanceled; + DisconnectFromServer(); + } + + private void HandleFiltersClicked() + { + _filterViewController.Init(_filterParams); + + showBackButton = false; + ReplaceTopViewController(_filterViewController, animationDirection: ViewController.AnimationDirection.Vertical); + SetTitle("Server Filters"); + } + + private void HandleFiltersCleared() + { + _filterParams.Clear(); + + _mainViewController.UpdateFiltersValue(_filterParams); + _serverRepository.SetFilterParams(_filterParams); + } + + private void HandleFiltersViewFinished(ServerFilterParams? obj) + { + if (obj != null) + _filterParams = obj; + + _mainViewController.UpdateFiltersValue(_filterParams); + _serverRepository.SetFilterParams(_filterParams); + + ShowMainView(); + } + + #endregion + + #region Connect / Disconnect API + + public void JoinQuickPlay(BeatmapDifficultyMask beatmapDifficultyMask, SongPackMask songPackMask, + bool allowSongSelection = true) + { + // TODO Master server select + + var selectionMask = new BeatmapLevelSelectionMask(beatmapDifficultyMask, GameplayModifierMask.NoFail, + songPackMask); + + var serverConfig = new GameplayServerConfiguration( + 5, + DiscoveryPolicy.Public, + InvitePolicy.NobodyCanInvite, + allowSongSelection ? GameplayServerMode.Countdown : GameplayServerMode.QuickStartOneSong, + allowSongSelection ? SongSelectionMode.Vote : SongSelectionMode.Random, + GameplayServerControlSettings.None + ); + + // Note: QuickStartOneSong / allowSongSelection=false is not currently used in the base game + + var serverInfo = new ServerRepository.ServerInfo() + { + Key = "quickplay", + ServerName = "Quick Play", + GameModeName = "Quick Play", + PlayerCount = 0, + PlayerLimit = serverConfig.maxPlayerCount, + ConnectionMethod = ServerRepository.ConnectionMethod.GameLiftOfficial, // TODO Custom form value + BeatmapLevelSelectionMask = selectionMask, + GameplayServerConfiguration = serverConfig, + }; + + _ = ConnectToServer(serverInfo); + } + + public void CreateServer(CreateServerFormData formData) + { + var randomSecret = NetworkUtility.GenerateId(); + + // TODO Master server select + + var selectionMask = new BeatmapLevelSelectionMask(formData.difficulties, formData.modifiers, + formData.songPacks); + + var serverConfig = new GameplayServerConfiguration( + formData.maxPlayers, + formData.netDiscoverable ? DiscoveryPolicy.Public : DiscoveryPolicy.WithCode, + formData.allowInviteOthers ? InvitePolicy.AnyoneCanInvite : InvitePolicy.OnlyConnectionOwnerCanInvite, + formData.gameplayServerMode, + formData.songSelectionMode, + formData.gameplayServerControlSettings + ); + + var serverInfo = new ServerRepository.ServerInfo() + { + Key = "create", + ServerName = "New Lobby", // TODO Custom form value + GameModeName = "Custom", // TODO Custom form value + PlayerCount = 0, + PlayerLimit = formData.maxPlayers, + ConnectionMethod = ServerRepository.ConnectionMethod.GameLiftOfficial, // TODO Custom form value + ServerSecret = randomSecret, + BeatmapLevelSelectionMask = selectionMask, + GameplayServerConfiguration = serverConfig + }; + + _ = ConnectToServer(serverInfo); + } + + public async Task ConnectToServer(ServerRepository.ServerInfo serverInfo) + { + if (_multiplayerSessionManager.isConnectingOrConnected) + { + _log.Warn("Already connecting or connected, ignoring join request"); + return; + } + + _serverInfo = serverInfo; + _joiningLobbyCancellationTokenSource = new(); + + _wasEverConnected = false; + _realDisconnectReason = null; + _connectionFailedReason = null; + _multiplayerUnavailableReason = null; + _multiplayerUnavailableMessage = null; + _multiplayerMaintenanceEndTime = null; + + ShowJoiningLobby(serverInfo); + + switch (serverInfo.ConnectionMethod) + { + case ServerRepository.ConnectionMethod.DirectConnect: + { + _log.Info($"Direct Connecting to dedicated server (serverName={serverInfo.ServerName}, " + + $"endPoint={serverInfo.ServerEndPoint})"); + break; + } + case ServerRepository.ConnectionMethod.GameLiftOfficial: + { + _log.Info($"Connecting to lobby on Official GameLift (serverName={serverInfo.ServerName}, " + + $"serverCode={serverInfo.ServerCode}, serverSecret={serverInfo.ServerSecret})"); + _multiplayerConfigManager.ConfigureOfficialGamelift(); + break; + } + case ServerRepository.ConnectionMethod.GameLiftModded: + { + _log.Info($"Connecting to lobby via custom master server (serverName={serverInfo.ServerName}, " + + $"serverCode={serverInfo.ServerCode}, serverSecret={serverInfo.ServerSecret}, " + + $"masterGraphUrl={serverInfo.MasterServerGraphUrl})"); + + var configResult = await _multiplayerConfigManager.ConfigureCustomMasterServer( + serverInfo.MasterServerGraphUrl!, serverInfo.MasterServerStatusUrl, + _joiningLobbyCancellationTokenSource.Token); + + if (configResult.UnavailableReason != null + && configResult.UnavailableReason != MultiplayerUnavailableReason.NetworkUnreachable) + { + // Multiplayer status check failed; server may be offline or in maintenance, client may not meet + // requirements, could even be a MultiplayerCore mod check failure. We ignore MUR-1 because + // who cares, we'll try to connect anyways. + _multiplayerUnavailableReason = configResult.UnavailableReason; + _multiplayerUnavailableMessage = configResult.LocalizedMessage; + _multiplayerMaintenanceEndTime = configResult.MpStatusData!.maintenanceEndTime; + DisconnectFromServer(); // will present error + return; + } + + break; + } + default: + { + throw new ArgumentOutOfRangeException(nameof(serverInfo.ConnectionMethod), + serverInfo.ConnectionMethod, "Unsupported connection method"); + } + } + + if (_joiningLobbyCancellationTokenSource.IsCancellationRequested) + return; + + _log.Info("CreatePartyConnection"); + + // We are doing the "connect by server code" flow, providing secret/code as available + // We'll set selection mask and configuration, but a master server may override it + // In case of direct connect, our GameLift patch will handle the rest + var partyConfig = new UnifiedNetworkPlayerModel.JoinMatchmakingPartyConfig() + { + selectionMask = _serverInfo.BeatmapLevelSelectionMask ?? DefaultLevelSelectionMask, + configuration = _serverInfo.GameplayServerConfiguration ?? DefaultGameplayServerConfiguration, + secret = serverInfo.ServerSecret, + code = serverInfo.ServerCode, + }; + if (!_unifiedNetworkPlayerModel.CreatePartyConnection(partyConfig)) + { + // CFR-1: Failed to create party connection - should never happen + HandleSessionConnectionFailed(ConnectionFailedReason.Unknown); + return; + } + } + + public void DisconnectFromServer(bool showError = true) + { + _joiningLobbyCancellationTokenSource?.Cancel(); + + _unifiedNetworkPlayerModel.DestroyPartyConnection(); + + if (showError) + _ = ShowConnectionError(); + } + + #endregion + + #region Mode Selection Sub-views + + private void HandleModeSelected(MultiplayerModeSelectionViewController.MenuButton mode) => + HandleModeSelected(mode, false); + + private void HandleModeSelected(MultiplayerModeSelectionViewController.MenuButton mode, bool exiting) + { + if (topViewController != _mainViewController && topViewController != _masterServerSelectViewController) + // Can only transition from main view or when finishing master server select + return; + + var multiplayerModeSettings = _playerDataModel.playerData.multiplayerModeSettings; + + ViewController? nextViewController = null; + string? nextTitle = null; + switch (mode) + { + case MultiplayerModeSelectionViewController.MenuButton.QuickPlay: + { + _joinQuickPlayViewController.Setup(new QuickPlaySetupData(), multiplayerModeSettings); + nextViewController = _joinQuickPlayViewController; + nextTitle = "Quick Play"; + break; + } + case MultiplayerModeSelectionViewController.MenuButton.CreateServer: + { + _createServerViewController.Setup(multiplayerModeSettings); + nextViewController = _createServerViewController; + nextTitle = "Create Server"; + break; + } + case MultiplayerModeSelectionViewController.MenuButton.JoinWithCode: + { + nextViewController = _serverCodeEntryViewController; + nextTitle = "Join by Code"; + break; + } + } + + if (nextViewController == null) + return; + + showBackButton = true; + ReplaceTopViewController(nextViewController, + animationDirection: exiting + ? ViewController.AnimationDirection.Vertical + : ViewController.AnimationDirection.Horizontal, + animationType: exiting ? ViewController.AnimationType.Out : ViewController.AnimationType.In); + SetTitle(nextTitle ?? "Online"); + + _lastSelectedMode = mode; + } + + private void HandleQuickPlayViewFinished(bool success) + { + if (topViewController != _joinQuickPlayViewController) + return; + + if (!success) + { + ShowMainView(); + return; + } + + var modeSettings = _joinQuickPlayViewController.multiplayerModeSettings; + + var difficultyMask = modeSettings.quickPlayBeatmapDifficulty; + var songPackMask = _songPackMasksModel.ToSongPackMask(modeSettings.quickPlaySongPackMaskSerializedName); + var allowSongSelection = modeSettings.quickPlayEnableLevelSelection; + + var logMode = allowSongSelection ? "Countdown" : "QuickStartOneSong"; + _log.Info($"Quick play join requested: {difficultyMask}, {songPackMask}, {logMode}"); + + JoinQuickPlay(difficultyMask, songPackMask, allowSongSelection); + } + + private void HandleCreateServerViewFinished(bool success, CreateServerFormData formData) + { + if (topViewController != _createServerViewController) + return; + + if (!success) + { + ShowMainView(); + return; + } + + _log.Info($"Create server requested: {formData.maxPlayers} players"); + + CreateServer(formData); + } + + private void HandleServerCodeViewFinished(bool success, string code) + { + if (topViewController != _serverCodeEntryViewController) + return; + + if (!success) + { + ShowMainView(); + return; + } + + _log.Info($"Join by code requested: {code}"); + + HandleServerJoinByCodeRequested(code); + } + + private void HandleMasterServerSwitchRequested() + { + showBackButton = false; + ReplaceTopViewController(_masterServerSelectViewController, + animationDirection: ViewController.AnimationDirection.Vertical); + SetTitle("Select Master Server"); + } + + private void HandleMasterServerSelectFinished() + { + if (_lastSelectedMode == null) + { + ShowMainView(); + return; + } + + HandleModeSelected(_lastSelectedMode.Value, true); + } + + #endregion + + #region Connection UI + + private void ShowMainView() + { + if (topViewController == _mainViewController) + return; + + var fromVerticalAnimation = topViewController is BrowserFilterViewController or + MasterServerSelectViewController or JoiningLobbyViewController or SimpleDialogPromptViewController; + var animationDirection = fromVerticalAnimation ? ViewController.AnimationDirection.Vertical + : ViewController.AnimationDirection.Horizontal; + + showBackButton = true; + ReplaceTopViewController(_mainViewController, + animationType: ViewController.AnimationType.Out, + animationDirection: animationDirection); + SetTitle("Online"); + ResetMenuLights(); + } + + private void ShowJoiningLobby(ServerRepository.ServerInfo serverInfo) + { + _joiningLobbyViewController.Init($"Joining {serverInfo.ServerName}..."); + + showBackButton = false; + ReplaceTopViewController(_joiningLobbyViewController, + animationDirection: ViewController.AnimationDirection.Vertical); + SetTitle("Online"); + SetMenuLights(MenuLightsConnectPreset); + } + + private async Task ShowConnectionError() + { + while (_joiningLobbyViewController.isInTransition) + { + // Prevent showing the error dialog while the join view is still transitioning + await Task.Delay(10); + } + + if (topViewController == _simpleDialogPromptViewController) + return; + + if (_connectionFailedReason == ConnectionFailedReason.ConnectionCanceled + || _realDisconnectReason is DisconnectedReason.UserInitiated or DisconnectedReason.ClientConnectionClosed) + { + // User canceled: return to main view + ShowMainView(); + return; + } + + _connectionFailedReason ??= ConnectionFailedReason.Unknown; + + var errTitle = Localization.Get("LABEL_CONNECTION_ERROR"); + var errKey = _connectionFailedReason.Value.LocalizedKey(); + var errCode = _connectionFailedReason.Value.ErrorCode(); + var errMsg = $"{Localization.Get(errKey)} ({errCode})"; + var btnOk = Localization.Get("BUTTON_OK"); + var btnRetry = Localization.Get("BUTTON_RETRY"); + + if (_wasEverConnected && _realDisconnectReason != null) + { + // Immediately disconnected from server (DCR) + errKey = _realDisconnectReason.Value.LocalizedKey(); + errCode = _realDisconnectReason.Value.ErrorCode(); + + // Won't use base game localized messages here because they're not helpful at all + errMsg = "Connection failed (disconnected)"; + switch (_realDisconnectReason) + { + case DisconnectedReason.Timeout: + errMsg += "
The connection timed out"; + break; + case DisconnectedReason.ServerAtCapacity: + errMsg += "
The server is full"; + break; + case DisconnectedReason.Kicked: + case DisconnectedReason.ServerConnectionClosed: // base game would say: "Server was shut down" ??? + errMsg += "
You were kicked by the server"; + break; + case DisconnectedReason.ServerTerminated: + errMsg += "
The server is shutting down"; + break; + default: + errMsg += "
Check your internet connection and try again"; + break; + } + errMsg += $" ({errCode})"; + + _log.Error($"Multiplayer connection failed with disconnect error: {errCode} ({errKey}): \"{errMsg}\""); + } + else if (_multiplayerUnavailableReason != null) + { + // Multiplayer unavailable (MUR, status check failure) + errKey = _multiplayerUnavailableReason.Value.LocalizedKey(); + errCode = _multiplayerUnavailableReason.Value.ErrorCode(); + + if (_multiplayerUnavailableMessage != null) + errMsg = _multiplayerUnavailableMessage; + else if (_multiplayerUnavailableReason == MultiplayerUnavailableReason.MaintenanceMode) + errMsg = Localization.Instance.GetFormatOrKey(errKey, (_multiplayerMaintenanceEndTime.GetValueOrDefault().AsUnixTime() - DateTime.UtcNow).ToString("h':'mm")); + else + errMsg = $"{Localization.Get(errKey)} ({errCode})"; + + _log.Error($"Multiplayer unavailable error (status check failure): {errCode} ({errKey}): \"{errMsg}\""); + } + else + { + // Normal connection failure (CFR) + _log.Error($"Multiplayer connection failed error: {errCode} ({errKey}): \"{errMsg}\""); + } + + _simpleDialogPromptViewController.Init(errTitle, errMsg, btnOk, btnRetry, + (int btnId) => + { + if (btnId == 1) + { + // Retry + _ = ConnectToServer(_serverInfo!); + } + else + { + // OK + ShowMainView(); + } + }); + + showBackButton = false; + ReplaceTopViewController(_simpleDialogPromptViewController, + animationType: ViewController.AnimationType.In, + animationDirection: ViewController.AnimationDirection.Vertical); + SetTitle(""); + SetMenuLights(MenuLightsErrorPreset); + } + + #endregion + + #region Multiplayer Connection - Base Game + + private async Task LoadAvatar() + { + _multiplayerAvatarsData = await _avatarSystemCollection.GetMultiplayerAvatarsData(_playerDataModel + .playerData + .selectedAvatarSystemTypeId); + + _unifiedNetworkPlayerModel.connectedPlayerManager?.SetLocalPlayerAvatar(_multiplayerAvatarsData.Value); + } + + private void HandleCpmCreated(INetworkPlayerModel networkPlayerModel) + { + if (_multiplayerAvatarsData != null) + networkPlayerModel.connectedPlayerManager.SetLocalPlayerAvatar(_multiplayerAvatarsData.Value); + + _multiplayerSessionManager.StartSession(MultiplayerSessionManager.SessionType.Player, + _unifiedNetworkPlayerModel.connectedPlayerManager); + } + + private void HandleCpmDestroyed(INetworkPlayerModel obj) + { + } + + private void HandleSessionConnected() + { + _log.Info("Multiplayer session connected"); + + _lobbyDataModelsManager.Activate(); + + _ = StartLobbyFlowCoordinator(); + } + + private async Task StartLobbyFlowCoordinator() + { + await _lobbyGameStateController.GetGameStateAndConfigurationAsync( + _joiningLobbyCancellationTokenSource!.Token); + + _log.Info("Lobby join successful"); + + _serverRepository.StopDiscovery(); + + _fadeInOutController.FadeOut(() => + { + _gameServerLobbyFlowCoordinator.didFinishEvent -= HandleGameServerLobbyFlowCoordinatorDidFinish; + _gameServerLobbyFlowCoordinator.didFinishEvent += HandleGameServerLobbyFlowCoordinatorDidFinish; + _gameServerLobbyFlowCoordinator.willFinishEvent -= HandleGameServerLobbyFlowCoordinatorWillFinish; + _gameServerLobbyFlowCoordinator.willFinishEvent += HandleGameServerLobbyFlowCoordinatorWillFinish; + + PresentFlowCoordinator(_gameServerLobbyFlowCoordinator, immediately: true, + replaceTopViewController: false); + + // Hack needed to setup UI properly because we're not replacing the top view controller + // TODO: Can we do better? + _gameServerLobbyFlowCoordinator.TopViewControllerWillChange(_joiningLobbyViewController, + _gameServerLobbyFlowCoordinator._lobbySetupViewController, + ViewController.AnimationType.In); + }); + } + + private void HandleGameServerLobbyFlowCoordinatorDidFinish() + { + _log.Info("Lobby has ended, returning to menu"); + + _gameServerLobbyFlowCoordinator.didFinishEvent -= HandleGameServerLobbyFlowCoordinatorDidFinish; + DismissFlowCoordinator(_gameServerLobbyFlowCoordinator, immediately: true); + _joiningLobbyViewController.HideLoading(); + DisconnectFromServer(); + _lobbyDataModelsManager.Deactivate(); + _fadeInOutController.FadeIn(); + } + + private void HandleGameServerLobbyFlowCoordinatorWillFinish() + { + _gameServerLobbyFlowCoordinator.willFinishEvent -= HandleGameServerLobbyFlowCoordinatorWillFinish; + _lobbyDataModelsManager.Deactivate(); + + if (_connectionFailedReason == null) + // Default: return to main browser view. Lobby flow coordinator will have shown its own errors. + _connectionFailedReason = ConnectionFailedReason.ConnectionCanceled; + } + + private void HandleSessionConnectionFailed(ConnectionFailedReason reason) + { + _log.Info($"Multiplayer session connection failed: {reason}"); + _connectionFailedReason = reason; + DisconnectFromServer(); + } + + public static BeatmapLevelSelectionMask DefaultLevelSelectionMask => + new(BeatmapDifficultyMask.All, GameplayModifierMask.All, SongPackMask.all); + + public static GameplayServerConfiguration DefaultGameplayServerConfiguration => + new(5, DiscoveryPolicy.WithCode, InvitePolicy.AnyoneCanInvite, GameplayServerMode.Countdown, + SongSelectionMode.Vote, GameplayServerControlSettings.All); + + #endregion + + #region Multiplayer Connection - Patches + + [AffinityPrefix] + [AffinityPatch(typeof(MultiplayerSessionManager), nameof(MultiplayerSessionManager.UpdateConnectionState))] + private void PrefixUpdateConnectionState(UpdateConnectionStateReason updateReason, + DisconnectedReason disconnectedReason, ConnectionFailedReason connectionFailedReason) + { + // Read internal session updates to get actually useful error messages + + _log.Debug($"Connection state changed: updateReason={updateReason}," + + $" disconnectedReason={disconnectedReason}, connectionFailedReason={connectionFailedReason}"); + + if (updateReason == UpdateConnectionStateReason.Connected && !_wasEverConnected) + { + _wasEverConnected = true; + _log.Info("Connected to server"); + } + + if (disconnectedReason is not DisconnectedReason.Unknown and not DisconnectedReason.UserInitiated + && _realDisconnectReason != disconnectedReason) + { + _realDisconnectReason = disconnectedReason; + _log.Info($"Disconnect reason: {disconnectedReason} ({connectionFailedReason})"); + } + + if (connectionFailedReason == ConnectionFailedReason.ServerIsTerminating) + { + // Base game message is completely useless ("server does not exist"), so push our disconnect reason instead + _realDisconnectReason = DisconnectedReason.ServerTerminated; + } + } + + [AffinityPrefix] + [AffinityPatch(typeof(IgnoranceClient), nameof(IgnoranceClient.Start))] + // ReSharper disable once InconsistentNaming + private void PrefixIgnoranceClientStart(IgnoranceClient __instance) + { + if (_serverInfo == null) + // We are not managing this connection - patch does not apply + return; + + __instance.UseSsl = _serverInfo.UseDtlsEncryption; + __instance.ValidateCertificate = _serverInfo.UseDtlsEncryption; + } + + [AffinityPrefix] + [AffinityPatch(typeof(GameLiftConnectionManager), nameof(GameLiftConnectionManager.GameLiftConnectToServer))] + // ReSharper disable once InconsistentNaming + private bool PrefixGameLiftConnectToServer(string secret, string code, GameLiftConnectionManager __instance) + { + if (_serverInfo is not { ConnectionMethod: ServerRepository.ConnectionMethod.DirectConnect }) + // We are not managing this connection, or it is a regular GameLift connection - patch does not apply + return true; + + // We will skip the entire GameLift API and move to immediate connection + __instance.HandleConnectToServerSuccess + ( + playerSessionId: "DirectConnect", + hostName: _serverInfo.ServerEndPoint!.Address.ToString(), + port: _serverInfo.ServerEndPoint.Port, + gameSessionId: _serverInfo.ServerUserId, + secret: secret, + code: "DIRECT", + selectionMask: _serverInfo.BeatmapLevelSelectionMask ?? DefaultLevelSelectionMask, + configuration: _serverInfo.GameplayServerConfiguration ?? DefaultGameplayServerConfiguration + ); + return false; + } + + [AffinityPrefix] + [AffinityPatch(typeof(GameServerLobbyFlowCoordinator), + nameof(GameServerLobbyFlowCoordinator.GetLocalizedTitle))] + // ReSharper disable once InconsistentNaming + private bool PrefixGameServerLobbyFlowCoordinatorGetLocalizedTitle(ref string __result) + { + if (_serverInfo == null) + // We are not managing this connection - patch does not apply + return true; + + __result = _serverInfo.ServerName; + return false; + } + + [AffinityPrefix] + [AffinityPatch(typeof(MultiplayerLevelSelectionFlowCoordinator), + nameof(MultiplayerLevelSelectionFlowCoordinator.enableCustomLevels), AffinityMethodType.Getter)] + [AffinityAfter("com.goobwabber.multiplayercore.affinity")] + [AffinityPriority(1)] + // ReSharper disable twice InconsistentNaming + private bool PrefixCustomLevelsEnabled(ref bool __result, SongPackMask ____songPackMask) + { + // MultiplayerCore requires an override API server to be set for custom songs to be enabled + // We have to take over that job here if direct connecting + + if (_serverInfo is not { ConnectionMethod: ServerRepository.ConnectionMethod.DirectConnect }) + // We are not managing this connection, or it is a regular GameLift connection - patch does not apply + return true; + + __result = ____songPackMask.Contains(new SongPackMask("custom_levelpack_CustomLevels")); + return false; + } + + #endregion + + #region Menu Lights + + private MenuLightsPresetSO BakeMenuLightsPreset(Color color) + { + var colorSo = ScriptableObject.CreateInstance(); + colorSo.SetColor(color); + + var presetSo = Instantiate(_menuLightsManager._defaultPreset); + presetSo._playersPlaceNeonsColor = colorSo; + foreach (var pair in presetSo._lightIdColorPairs) + { + pair.baseColor = colorSo; + pair.intensity = 1f; + } + return presetSo; + } + + private MenuLightsPresetSO? _menuLightsErrorPresetCached = null; + private MenuLightsPresetSO MenuLightsErrorPreset + { + get + { + if (_menuLightsErrorPresetCached == null) + _menuLightsErrorPresetCached = BakeMenuLightsPreset(BssbColors.FailureRed); + return _menuLightsErrorPresetCached; + } + } + + private MenuLightsPresetSO? _menuLightsConnectPresetCached = null; + private MenuLightsPresetSO MenuLightsConnectPreset + { + get + { + if (_menuLightsConnectPresetCached == null) + _menuLightsConnectPresetCached = BakeMenuLightsPreset(BssbColors.GoingToAnotherDimension); + return _menuLightsConnectPresetCached; + } + } + + private void SetMenuLights(MenuLightsPresetSO preset, bool animated = true) => + _menuLightsManager.SetColorPreset(preset, animated); + + private void ResetMenuLights(bool animated = true) => + SetMenuLights(_menuLightsManager._defaultPreset, animated); + + #endregion + } +} \ No newline at end of file diff --git a/UI/Browser/Modals/AccountModalView.cs b/UI/Browser/Modals/AccountModalView.cs new file mode 100644 index 0000000..05c0bd8 --- /dev/null +++ b/UI/Browser/Modals/AccountModalView.cs @@ -0,0 +1,70 @@ +using ServerBrowser.Assets; +using ServerBrowser.UI.Toolkit; +using ServerBrowser.UI.Toolkit.Components; +using ServerBrowser.UI.Toolkit.Modals; +using ServerBrowser.UI.Toolkit.Wrappers; +using ServerBrowser.Util; +using TMPro; +using UnityEngine; +using Zenject; + +namespace ServerBrowser.UI.Browser.Modals +{ + public class AccountModalView : TkModalView + { + [Inject] private readonly LayoutBuilder _layoutBuilder = null!; + + public override float ModalWidth => 65f; + public override float ModalHeight => 50f; + + private LayoutContainer _container = null!; + private TkText _titleText = null!; + private TkText _statusText = null!; + private TkButton _button = null!; + + public override void Initialize() + { + var wrap = new LayoutContainer(_layoutBuilder, transform, false); + + _container = wrap.AddVerticalLayoutGroup("Content", + padding: new RectOffset(4, 4, 4, 4), + expandChildWidth: true, expandChildHeight: false); + _container.SetBackground("round-rect-panel"); + + _titleText = _container.AddText("Username", textAlignment: TextAlignmentOptions.Center); + _statusText = _container.AddText("Status", textAlignment: TextAlignmentOptions.Center, fontSize: 2.8f); + + _container.InsertMargin(-1f, 4f); + + _container.AddToggleControl(); + _container.AddToggleControl(); + _container.AddToggleControl(); + // _container.AddDropdownControl(); + + _button = _container.AddButton("View profile in browser", iconName: Sprites.Spectator, iconSize: 3.2f); + } + + public void SetData(UserInfo? userInfo, bool loggedIn) + { + if (userInfo == null) + { + _titleText.SetText("Not logged in"); + _titleText.SetTextColor(BssbColors.InactiveGray); + + _statusText.SetText("No local profile is available. Are you logged out or offline on Steam / Oculus?"); + _statusText.SetTextColor(BssbColors.White); + _statusText.SetActive(true); + + _button.GameObject.SetActive(false); + return; + } + + _titleText.SetText(userInfo.userName); + _titleText.SetTextColor(BssbColors.White); + + _statusText.SetText(loggedIn ? $"Logged in ({userInfo.platform.ToString()})" : "Logging in..."); + + _button.GameObject.SetActive(true); + } + } +} \ No newline at end of file diff --git a/UI/Browser/Modals/ServerModalView.cs b/UI/Browser/Modals/ServerModalView.cs new file mode 100644 index 0000000..bdee1c0 --- /dev/null +++ b/UI/Browser/Modals/ServerModalView.cs @@ -0,0 +1,77 @@ +using System; +using ServerBrowser.Assets; +using ServerBrowser.Data; +using ServerBrowser.UI.Toolkit; +using ServerBrowser.UI.Toolkit.Components; +using ServerBrowser.UI.Toolkit.Modals; +using ServerBrowser.UI.Toolkit.Wrappers; +using TMPro; +using UnityEngine; +using Zenject; + +namespace ServerBrowser.UI.Browser.Modals +{ + public class ServerModalView : TkModalView + { + [Inject] private readonly LayoutBuilder _layoutBuilder = null!; + + public override float ModalWidth => 55f; + public override float ModalHeight => 50f; + + private ServerRepository.ServerInfo? _serverInfo = null; + + private LayoutContainer _container = null!; + private TkText _titleText = null!; + private TkButton _connectButton = null!; + + private TkTableView.TableRow _rowServerType = null!; + private TkTableView.TableRow _rowGameMode = null!; + private TkTableView.TableRow _rowPlayerCount = null!; + private TkTableView.TableRow _rowLobbyStatus = null!; + + public event Action? ConnectClickedEvent; + + public override void Initialize() + { + var wrap = new LayoutContainer(_layoutBuilder, transform, false); + + _container = wrap.AddVerticalLayoutGroup("Content", + padding: new RectOffset(4, 4, 4, 4), + expandChildWidth: true, expandChildHeight: false); + _container.SetBackground("round-rect-panel"); + + _titleText = _container.AddText("Server", textAlignment: TextAlignmentOptions.Center, fontSize: 4f); + + _container.InsertMargin(-1f, 2f); + + var tableView = _container.AddTableView(); + _rowServerType = tableView.AddRow("Server type"); + _rowGameMode = tableView.AddRow("Game mode"); + _rowPlayerCount = tableView.AddRow("Player count"); + _rowLobbyStatus = tableView.AddRow("Lobby status"); + + _container.InsertMargin(-1f, 2f); + + _connectButton = _container.AddButton("Connect", primary: true, + iconName: Sprites.Checkmark, iconSize: 4f, + preferredHeight: 8f, preferredWidth: 24f, clickAction: () => + { + if (_serverInfo != null) + ConnectClickedEvent?.Invoke(_serverInfo); + }); + _connectButton.SetOuterPadding(0, 0); + } + + public void SetData(ServerRepository.ServerInfo serverInfo) + { + _serverInfo = serverInfo; + + _titleText.SetText(serverInfo.ServerName); + + _rowServerType.Value = serverInfo.ServerTypeName ?? "Unknown"; + _rowGameMode.Value = serverInfo.GameModeName; + _rowPlayerCount.Value = $"{serverInfo.PlayerCount}/{serverInfo.PlayerLimit}"; + _rowLobbyStatus.Value = serverInfo.LobbyStateText; + } + } +} \ No newline at end of file diff --git a/UI/Browser/Views/BrowserFilterViewController.cs b/UI/Browser/Views/BrowserFilterViewController.cs new file mode 100644 index 0000000..50cbc2d --- /dev/null +++ b/UI/Browser/Views/BrowserFilterViewController.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using HMUI; +using ServerBrowser.Models; +using ServerBrowser.UI.Toolkit; +using ServerBrowser.UI.Toolkit.Components; +using ServerBrowser.Util; +using UnityEngine; +using UnityEngine.UI; +using Zenject; + +namespace ServerBrowser.UI.Browser.Views +{ + public class BrowserFilterViewController : ViewController, IInitializable + { + [Inject] private readonly DiContainer _diContainer = null!; + [Inject] private readonly LayoutBuilder _layoutBuilder = null!; + + private ServerFilterParams _filterParams = null!; + private Dictionary _toggleControls = new(); + public event Action FinishedEvent; + + public void Init(ServerFilterParams filterParams) + { + _filterParams = filterParams; + + foreach (var toggleKv in _toggleControls) + toggleKv.Value.SetValue(_filterParams.GetValue(toggleKv.Key)); + } + + public void Initialize() + { + BuildLayout(_layoutBuilder.Init(this)); + } + + private void BuildLayout(LayoutContainer root) + { + root.GameObject.TryRemoveComponent(); + root.GameObject.TryRemoveComponent(); + var rootRect = root.GameObject.GetComponent(); + rootRect.offsetMin = new Vector2(35f, -35f); + rootRect.offsetMax = new Vector2(-35f, 0f); + rootRect.sizeDelta = new Vector2(-70f, 35f); + + var verticalSplit = root.AddVerticalLayoutGroup("VerticalSplit", + expandChildHeight: true, expandChildWidth: true, childAlignment: TextAnchor.MiddleCenter); + verticalSplit.PreferredWidth = 90f; + verticalSplit.PreferredWidth = 100f; + + var scrollRoot = verticalSplit.AddVerticalLayoutScrollView(); + + var content = scrollRoot.Content!; + content.InsertMargin(-1f, 6f); + + foreach (var param in ServerFilterParams.AllParams) + { + var toggle = param.CreateControl(content); + toggle.ToggledEvent += value => + { + _filterParams.SetValue(param.Key, value); + }; + _toggleControls[param.Key] = toggle; + } + + var bottomHorizontal = verticalSplit.AddHorizontalLayoutGroup("BottomControls"); + bottomHorizontal.PreferredWidth = 90f; + bottomHorizontal.PreferredHeight = 10f; + + var applyButton = bottomHorizontal.AddButton("Apply", true, + preferredWidth: 40f, preferredHeight: 13f); + applyButton.AddClickHandler(() => + { + FinishedEvent?.Invoke(_filterParams); + }); + } + } +} \ No newline at end of file diff --git a/UI/Browser/Views/MainBrowserViewController.Layout.cs b/UI/Browser/Views/MainBrowserViewController.Layout.cs new file mode 100644 index 0000000..3178688 --- /dev/null +++ b/UI/Browser/Views/MainBrowserViewController.Layout.cs @@ -0,0 +1,99 @@ +using HMUI; +using ServerBrowser.Assets; +using ServerBrowser.UI.Toolkit; +using ServerBrowser.UI.Toolkit.Components; +using ServerBrowser.Util; +using UnityEngine; +using UnityEngine.UI; +using Zenject; + +namespace ServerBrowser.UI.Browser.Views +{ + public partial class MainBrowserViewController : ViewController, IInitializable + { + private TkTextInputField _textInputField = null!; + private TkFilterButton _filterButton = null!; + private TkScrollView _scrollView = null!; + private TkLoadingControl _loadingControl = null!; + private TkAccountTile _accountTile = null!; + + private void BuildLayout(LayoutContainer root) + { + root.InsertMargin(0f, 5f); + + var splitRoot = root.AddHorizontalLayoutGroup("BrowserSplit"); + splitRoot.PreferredHeight = 100f; + + BuildLeftLayout(splitRoot); + splitRoot.InsertMargin(5f, 0f); + BuildMainLayout(splitRoot); + } + + private void BuildLeftLayout(LayoutContainer parent) + { + var leftContainer = parent.AddVerticalLayoutGroup("BrowserLeft", expandChildWidth: true, + verticalFit: ContentSizeFitter.FitMode.PreferredSize, pivotPoint: new Vector2(0, 1f), + padding: new RectOffset(0, 0, 1, 0)); + leftContainer.PreferredWidth = 42.5f; + + _accountTile = leftContainer.AddAccountTile(); + _accountTile.ClickedEvent += HandleAccountTileClicked; + + leftContainer.InsertMargin(-1f, 2f); + + var pane = leftContainer.AddVerticalLayoutGroup("Pane", expandChildWidth: true, + verticalFit: ContentSizeFitter.FitMode.PreferredSize, pivotPoint: new Vector2(0, 1f), + padding: new RectOffset(0, 0, 3, 3)); + pane.SetBackground("panel-top"); + + pane.AddButton("Quick Play", preferredWidth: 40f, preferredHeight: 13f, + iconName: Sprites.SaberClash, iconSize: 5f, noSkew: true, highlightColor: BssbColors.HighlightBlue, + clickAction: HandleQuickPlayClicked); + pane.AddButton("Create Server", preferredWidth: 40f, preferredHeight: 13f, + iconName: Sprites.Global, iconSize: 5f, noSkew: true, highlightColor: BssbColors.HighlightBlue, + clickAction: HandleCreateServerClicked); + pane.AddButton("Join by Code", preferredWidth: 40f, preferredHeight: 13f, + iconName: Sprites.Lock, iconSize: 5f, noSkew: true, highlightColor: BssbColors.HighlightBlue, + clickAction: HandleJoinByCodeClicked); + + pane.InsertMargin(-1f, 1.5f); + pane.AddHorizontalLine(width: 35f); + pane.InsertMargin(-1f, 1.5f); + + pane.AddButton("Edit Avatar", preferredWidth: 40f, preferredHeight: 12f, + iconName: Sprites.Avatar, iconSize: 5f, noSkew: true, + clickAction: HandleEditAvatarClicked); + } + + private void BuildMainLayout(LayoutContainer parent) + { + var mainContainer = parent.AddVerticalLayoutGroup("BrowserMain", + verticalFit: ContentSizeFitter.FitMode.PreferredSize, + pivotPoint: new Vector2(0, 1f), childAlignment: TextAnchor.UpperLeft); + mainContainer.PreferredWidth = 122.5f; + + var topBar = mainContainer.AddHorizontalLayoutGroup("TopBar"); + topBar.PreferredHeight = 10f; + + _textInputField = topBar.AddTextInputField("Search Lobbies"); + _textInputField.ChangedEvent += HandleTextInputChanged; + + _filterButton = topBar.AddFilterButton("No Filters"); + _filterButton.ClickedEvent += HandleFilterButtonClicked; + _filterButton.ClearedEvent += HandleFilterButtonCleared; + + var content = mainContainer.AddHorizontalLayoutGroup("Content", expandChildWidth: true, + verticalFit: ContentSizeFitter.FitMode.PreferredSize, pivotPoint: new Vector2(0, 1f), + padding: new RectOffset(0, 0, 1, 1)); + content.PreferredHeight = 69f; + + _scrollView = content.AddScrollView(); + _scrollView.SetScrollPerCellHeight(CellHeight); + var svContent = _scrollView.Content!; + + _loadingControl = svContent.AddLoadingControl(57f); + _loadingControl.RefreshClickedEvent += HandleRefreshClicked; + _loadingControl.Hide(); + } + } +} \ No newline at end of file diff --git a/UI/Browser/Views/MainBrowserViewController.cs b/UI/Browser/Views/MainBrowserViewController.cs new file mode 100644 index 0000000..0370f24 --- /dev/null +++ b/UI/Browser/Views/MainBrowserViewController.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using HMUI; +using ServerBrowser.Data; +using ServerBrowser.Models; +using ServerBrowser.UI.Browser.Modals; +using ServerBrowser.UI.Toolkit; +using ServerBrowser.UI.Toolkit.Components; +using ServerBrowser.UI.Toolkit.Modals; +using UnityEngine; +using Zenject; + +namespace ServerBrowser.UI.Browser.Views +{ + public partial class MainBrowserViewController : ViewController, IInitializable, IDisposable + { + [Inject] private readonly DiContainer _diContainer = null!; + [Inject] private readonly BssbSession _session = null!; + [Inject] private readonly ServerRepository _serverRepository = null!; + [Inject] private readonly LayoutBuilder _layoutBuilder = null!; + + private readonly List _serverCells = new(); + private bool _completedFullRefresh = false; + private int _lastContentHeight = 0; + private AccountModalView? _accountModalView = null; + + public event Action? ServerJoinRequestedEvent; + public event Action? ModeSelectedEvent; + public event Action? AvatarEditRequestedEvent; + public event Action? FiltersClickedEvent; + public event Action? FiltersClearedEvent; + + #region Init / Deinit + + public void Initialize() + { + BuildLayout(_layoutBuilder.Init(this)); + } + + public override void DidActivate(bool firstActivation, bool addedToHierarchy, bool screenSystemEnabling) + { + base.DidActivate(firstActivation, addedToHierarchy, screenSystemEnabling); + + if (addedToHierarchy) + { + _session.LocalUserInfoChangedEvent += HandleLocalUserInfoUpdated; + _session.AvatarUrlChangedEvent += HandleAvatarUrlChanged; + _session.LoginStatusChangedEvent += HandleLoginStatusChanged; + + _serverRepository.ServersUpdatedEvent += HandleServersUpdated; + _serverRepository.RefreshFinishedEvent += HandleServersRefreshFinished; + } + + RefreshAccountStatus(); + + _serverRepository.StartDiscovery(); // ensure we restart, in case we came back from lobby / connection error + HandleServersUpdated(_serverRepository.FilteredServers); + + RefreshLoadingState(); + _completedFullRefresh = false; + } + + public override void DidDeactivate(bool removedFromHierarchy, bool screenSystemDisabling) + { + base.DidDeactivate(removedFromHierarchy, screenSystemDisabling); + + if (removedFromHierarchy) + { + _session.LocalUserInfoChangedEvent -= HandleLocalUserInfoUpdated; + _session.AvatarUrlChangedEvent -= HandleAvatarUrlChanged; + _session.LoginStatusChangedEvent -= HandleLoginStatusChanged; + + _serverRepository.ServersUpdatedEvent -= HandleServersUpdated; + _serverRepository.RefreshFinishedEvent -= HandleServersRefreshFinished; + } + + TkModalHost.CloseAnyModal(this); + } + + public void Dispose() + { + } + + #endregion + + #region Session / Account + + private void HandleLocalUserInfoUpdated(UserInfo userInfo) + { + RefreshAccountStatus(); + } + + private void HandleAvatarUrlChanged(string? avatarUrl) + { + RefreshAccountStatus(); + } + + private void HandleLoginStatusChanged(bool loggedIn) + { + RefreshAccountStatus(); + } + + private void RefreshAccountStatus() + { + if (_accountModalView != null) + _accountModalView.SetData(_session.LocalUserInfo, _session.IsLoggedIn); + + if (_session.LocalUserInfo == null) + { + _accountTile.SetNoLocalUserInfo(); + return; + } + + if (!_session.IsLoggedIn) + { + if (_session.AttemptingLogin) + _accountTile.SetLoggingIn(_session.LocalUserInfo.userName); + else + _accountTile.SetLoginFailed(_session.LocalUserInfo.userName); + return; + } + + _accountTile.SetLoggedIn(_session.LocalUserInfo.userName, _session.AvatarUrl); + } + + private void HandleAccountTileClicked() + { + _accountModalView = TkModalHost.ShowModal(this, _diContainer); + RefreshAccountStatus(); + } + + #endregion + + #region Server List + + public const float CellHeight = 20.333333f; + public const float CellsPerRow = 2; + + private float _lastCellWidth = 0; + + private void HandleServersUpdated(IReadOnlyCollection servers) + { + var container = _scrollView!.Content!; + var containerWidth = container.RectTransform.rect.width; + + var cellWidth = containerWidth / CellsPerRow; + + var targetCellCount = servers.Count; + var currentCellCount = _serverCells.Count; + var extraCellsNeeded = servers.Count - currentCellCount; + var excessCells = currentCellCount - servers.Count; + + var columnCount = Mathf.FloorToInt(containerWidth / cellWidth); + var rowCount = Mathf.CeilToInt((float)targetCellCount / (float)columnCount); + + // Unfortunately, sometimes the rect isn't fully sized out yet when we do this, so we may need to resize + var shouldResizeCells = Mathf.Approximately(cellWidth, _lastCellWidth); + _lastCellWidth = cellWidth; + + // Initialize new cells + for (var i = 0; i < extraCellsNeeded; i++) + { + var cell = container.AddServerCell(); + cell.ClickedEvent += HandleServerCellClicked; + _serverCells.Add(cell); + shouldResizeCells = true; + } + + // Sync cell contents based on server list + for (var i = 0; i < servers.Count; i++) + { + var server = servers.ElementAt(i); + var cell = _serverCells[i]; + if (shouldResizeCells || i >= currentCellCount) + { + var column = i % columnCount; + var row = i / columnCount; + cell.SetSize(cellWidth, CellHeight); + cell.SetPosition(column * cellWidth, row * -CellHeight); + } + cell.SetData(server); + cell.SetActive(true); + } + + // Disable (but do not remove) excess cells + for (var i = 0; i < excessCells; i++) + { + var cell = _serverCells[currentCellCount - i - 1]; + cell.SetActive(false); + } + + // Toggle loader + RefreshLoadingState(); + + // Manual scroll view content height + const float viewportHeight = 61f; + var newContentHeight = Mathf.CeilToInt(rowCount * CellHeight); + if (newContentHeight < viewportHeight) + newContentHeight = (int)viewportHeight; + if (newContentHeight != _lastContentHeight) + { + _scrollView!.SetContentHeight(newContentHeight - viewportHeight); // will adjust our content's delta size + _lastContentHeight = newContentHeight; + } + } + + private void HandleServerCellClicked(ServerRepository.ServerInfo serverInfo) + { + var modal = TkModalHost.ShowModal(this, _diContainer); + modal.SetData(serverInfo); + modal.ConnectClickedEvent += HandleServerConnectClicked; + } + + private void HandleServerConnectClicked(ServerRepository.ServerInfo serverInfo) + { + TkModalHost.CloseAnyModal(this); + ServerJoinRequestedEvent?.Invoke(serverInfo); + } + + private void HandleServersRefreshFinished() + { + _completedFullRefresh = true; + RefreshLoadingState(); + } + + private void HandleRefreshClicked() + { + _completedFullRefresh = false; + RefreshLoadingState(); + // So yeah this button basically does nothing, but it'll definitely spin 'til next full refresh :) + } + + private void RefreshLoadingState() + { + if (_serverRepository.NoResults) + { + if (_completedFullRefresh) + { + _loadingControl!.ShowText("No servers found", true); + } + else + { + _loadingControl!.ShowLoading("Loading Servers"); + } + } + else + { + _loadingControl!.Hide(); + } + } + + #endregion + + #region Mode Selection + + + private void HandleQuickPlayClicked() + { + ModeSelectedEvent?.Invoke(MultiplayerModeSelectionViewController.MenuButton.QuickPlay); + } + + private void HandleCreateServerClicked() + { + ModeSelectedEvent?.Invoke(MultiplayerModeSelectionViewController.MenuButton.CreateServer); + } + + private void HandleJoinByCodeClicked() + { + ModeSelectedEvent?.Invoke(MultiplayerModeSelectionViewController.MenuButton.JoinWithCode); + } + + private void HandleEditAvatarClicked() + { + AvatarEditRequestedEvent?.Invoke(); + } + + #endregion + + #region Search & Filter + + public void UpdateFiltersValue(ServerFilterParams filterParams) + { + _filterButton!.SetTextValue(filterParams.Describe()); + } + + private void HandleTextInputChanged(InputFieldView.SelectionState state, string value) + { + if (_serverRepository.FilterText == value) + return; + + _serverRepository.SetFilterText(value); + } + + private void HandleFilterButtonClicked() + { + FiltersClickedEvent?.Invoke(); + } + + private void HandleFilterButtonCleared() + { + FiltersClearedEvent?.Invoke(); + } + + #endregion + } +} \ No newline at end of file diff --git a/UI/Browser/Views/MasterServerSelectViewController.cs b/UI/Browser/Views/MasterServerSelectViewController.cs new file mode 100644 index 0000000..149144f --- /dev/null +++ b/UI/Browser/Views/MasterServerSelectViewController.cs @@ -0,0 +1,61 @@ +using System; +using HMUI; +using ServerBrowser.UI.Toolkit; +using ServerBrowser.Util; +using UnityEngine; +using UnityEngine.UI; +using Zenject; + +namespace ServerBrowser.UI.Browser.Views +{ + public class MasterServerSelectViewController : ViewController, IInitializable + { + [Inject] private readonly DiContainer _diContainer = null!; + [Inject] private readonly LayoutBuilder _layoutBuilder = null!; + + public event Action FinishedEvent; + + public void Initialize() + { + BuildLayout(_layoutBuilder.Init(this)); + } + + private void BuildLayout(LayoutContainer root) + { + root.GameObject.TryRemoveComponent(); + root.GameObject.TryRemoveComponent(); + var rootRect = root.GameObject.GetComponent(); + rootRect.offsetMin = new Vector2(35f, -35f); + rootRect.offsetMax = new Vector2(-35f, 0f); + rootRect.sizeDelta = new Vector2(-70f, 35f); + + var verticalSplit = root.AddVerticalLayoutGroup("VerticalSplit", + expandChildHeight: true, expandChildWidth: true, childAlignment: TextAnchor.MiddleCenter); + verticalSplit.PreferredWidth = 90f; + verticalSplit.PreferredWidth = 100f; + + var scrollRoot = verticalSplit.AddVerticalLayoutScrollView(); + + var content = scrollRoot.Content!; + content.InsertMargin(-1f, 6f); + + // foreach (var param in ServerFilterParams.AllParams) + // { + // var toggle = param.CreateControl(content); + // toggle.ToggledEvent += value => + // { + // _filterParams.SetValue(param.Key, value); + // }; + // _toggleControls[param.Key] = toggle; + // } + + var bottomHorizontal = verticalSplit.AddHorizontalLayoutGroup("BottomControls"); + bottomHorizontal.PreferredWidth = 90f; + bottomHorizontal.PreferredHeight = 10f; + + var applyButton = bottomHorizontal.AddButton("Select server", true, + preferredWidth: 40f, preferredHeight: 13f); + applyButton.AddClickHandler(() => FinishedEvent?.Invoke()); + } + } +} \ No newline at end of file diff --git a/UI/Components/BssbFloatingAlert.cs b/UI/Components/BssbFloatingAlert.cs deleted file mode 100644 index d1a827c..0000000 --- a/UI/Components/BssbFloatingAlert.cs +++ /dev/null @@ -1,268 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using HMUI; -using SiraUtil.Logging; -using UnityEngine; -using UnityEngine.UI; -using Zenject; - -namespace ServerBrowser.UI.Components -{ - public class BssbFloatingAlert : MonoBehaviour, IInitializable - { - private const float BasePosX = 0f; - private const float BasePosY = 1.5f; - private const float BasePosZ = 3f; - private const float AnimationDuration = .15f; - private const float AnimationOffsetY = -.5f; - private const float DisplayTime = 5f; - - public static Vector3 BasePos => new Vector3(BasePosX, BasePosY, BasePosZ); - - [Inject] private readonly DiContainer _diContainer = null!; - [Inject] private readonly SiraLog _log = null!; - - private BssbLevelBarClone? _levelBar; - private CanvasGroup? _canvasGroup; - private Queue _pendingNotifications; - private bool _isPresenting; - private NotificationData? _lastNotification = null; - - public BssbFloatingAlert() - { - _pendingNotifications = new(); - } - - #region Lifecycle - public void Initialize() - { - } - - internal void OnMenu() - { - var canvas = gameObject.AddComponent(); - canvas.renderMode = RenderMode.WorldSpace; - canvas.scaleFactor = 3.44f; - canvas.referencePixelsPerUnit = 10; - canvas.planeDistance = 100; - - gameObject.AddComponent(); - gameObject.AddComponent(); - gameObject.AddComponent(); - _canvasGroup = gameObject.AddComponent(); - - _levelBar = BssbLevelBarClone.Create(_diContainer, transform); - - StopPresentingImmediate(); - } - - public void Update() - { - // If we are not presenting a notification, and have a pending one, do so now - // If the queue is empty, self-disable, we are done for now - - if (_isPresenting) - return; - - if (_pendingNotifications.Count == 0) - { - gameObject.SetActive(false); - return; - } - - PresentNotificationImmediate(_pendingNotifications.Dequeue()); - } - - #endregion - - public void StopPresentingImmediate() - { - StopAllCoroutines(); - - if (_canvasGroup is not null) - _canvasGroup.alpha = 0f; - - transform.position = BasePos; - transform.rotation = Quaternion.identity; - transform.localScale = new Vector3(.04f, .04f, .04f); - - _isPresenting = false; - } - - public void PresentNotification(NotificationData notification) - { - if (_isPresenting && _lastNotification is not null && _lastNotification.Equals(notification)) - // Ignore duplicate - return; - - _pendingNotifications.Enqueue(notification); - - _log.Debug($"Enqueue notification: {notification.Title} / {notification.MessageText}"); - - if (!isActiveAndEnabled) - gameObject.SetActive(true); - - // Update will present next notification in queue when appropriate - } - - public void PresentNotificationImmediate(NotificationData notification) - { - if (_levelBar is null || _canvasGroup is null) - return; - - if (_isPresenting && _lastNotification is not null && _lastNotification.Equals(notification)) - // Ignore duplicate - return; - - StopPresentingImmediate(); - - gameObject.SetActive(true); - - _levelBar.SetBackgroundStyle(notification.BackgroundStyle, padLeft: true); - _levelBar.SetImageSprite(notification.Sprite); - _levelBar.SetText(notification.Title, notification.MessageText); - - _log.Debug($"Presenting notification ({notification.Title}, {notification.MessageText})"); - - _isPresenting = true; - _lastNotification = notification; - - StartCoroutine(AnimateIn(autoDismiss: !notification.Pinned)); - } - - public void DismissAllPending() - { - if (_pendingNotifications.Count == 0) - return; - - _pendingNotifications.Clear(); - _log.Info("Cleared all pending notifications"); - } - - public bool DismissPinned() - { - if (!_isPresenting) - return false; - - if (_lastNotification is null || !_lastNotification.Pinned) - return false; - - if (gameObject.activeSelf) - StartCoroutine(nameof(AnimateOut)); - - return true; - } - - public void DismissAllImmediate() - { - StopPresentingImmediate(); - DismissAllPending(); - } - - public void DismissAnimated() - { - DismissAllPending(); - - if (gameObject.activeSelf) - StartCoroutine(nameof(AnimateOut)); - } - - #region Animation/Coroutines - private IEnumerator AnimateIn(bool autoDismiss = true) - { - if (_canvasGroup is null) - yield break; - - var runTime = 0f; - - while (runTime < AnimationDuration) - { - runTime += Time.deltaTime; - var animationProgress = (runTime / AnimationDuration); - - _canvasGroup.alpha = 1.0f * animationProgress; - var yOffset = AnimationOffsetY - (AnimationOffsetY * (runTime / AnimationDuration)); - transform.position = new Vector3(BasePosX, BasePosY + yOffset, BasePosZ); - - yield return null; - } - - _canvasGroup.alpha = 1f; - transform.position = BasePos; - - if (autoDismiss) - { - // Wait for some time then animate out - yield return new WaitForSeconds(DisplayTime); - - if (gameObject.activeSelf) - StartCoroutine(nameof(AnimateOut)); - } - } - - private IEnumerator AnimateOut() - { - if (_canvasGroup is null) - yield break; - - var runTime = 0f; - - while (runTime < AnimationDuration) - { - runTime += Time.deltaTime; - var animationProgress = (runTime / AnimationDuration); - - _canvasGroup.alpha = 1.0f - (1.0f * (animationProgress)); - var yOffset = (-AnimationOffsetY * animationProgress); - transform.position = new Vector3(BasePosX, BasePosY + yOffset, BasePosZ); - - yield return null; - } - - _canvasGroup.alpha = 0f; - - // Animated out and no longer visible, end of presentation - yield return new WaitForEndOfFrame(); - StopPresentingImmediate(); - } - - #endregion - - public class NotificationData - { - public readonly Sprite? Sprite; - public readonly string Title; - public readonly string MessageText; - public readonly BssbLevelBarClone.BackgroundStyle BackgroundStyle; - public readonly bool Pinned; - - public NotificationData(Sprite? sprite, string title, string messageText, - BssbLevelBarClone.BackgroundStyle backgroundStyle = BssbLevelBarClone.BackgroundStyle.ColorfulGradient, - bool pinned = false) - { - Sprite = sprite; - Title = title; - MessageText = messageText; - BackgroundStyle = backgroundStyle; - Pinned = pinned; - } - - public override bool Equals(object obj) - { - if (obj is NotificationData otherNotification) - { - return Title.Equals(otherNotification.Title, StringComparison.InvariantCulture) - && MessageText.Equals(otherNotification.MessageText, StringComparison.InvariantCulture); - } - - return false; - } - - public override int GetHashCode() - { - return Title.GetHashCode() * 17 + MessageText.GetHashCode(); - } - } - } -} \ No newline at end of file diff --git a/UI/Components/BssbFloatingAlertMenuInit.cs b/UI/Components/BssbFloatingAlertMenuInit.cs deleted file mode 100644 index fffef12..0000000 --- a/UI/Components/BssbFloatingAlertMenuInit.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Zenject; - -namespace ServerBrowser.UI.Components -{ - /// - /// Helps the global BssbFloatingAlert set up at menu time. - /// - // ReSharper disable once ClassNeverInstantiated.Global - public class BssbFloatingAlertMenuInit : IInitializable - { - [Inject] private readonly BssbFloatingAlert _floatingAlert = null!; - - #region Lifecycle - public void Initialize() - { - _floatingAlert.OnMenu(); - } - #endregion - } -} \ No newline at end of file diff --git a/UI/Components/BssbLevelBarClone.cs b/UI/Components/BssbLevelBarClone.cs deleted file mode 100644 index f145303..0000000 --- a/UI/Components/BssbLevelBarClone.cs +++ /dev/null @@ -1,169 +0,0 @@ -using System; -using System.Linq; -using HMUI; -using IPA.Utilities; -using UnityEngine; -using UnityEngine.UI; -using Zenject; - -namespace ServerBrowser.UI.Components -{ - public class BssbLevelBarClone : MonoBehaviour - { - #region Template/init - private static GameObject? _templateCached; - - private static GameObject Template - { - get - { - if (_templateCached == null) - { - _templateCached = Resources.FindObjectsOfTypeAll() - .First(x => x.gameObject.name == "LevelDetail") - .transform.Find("LevelBarBig") - .gameObject; - } - - return _templateCached; - } - } - - public static BssbLevelBarClone Create(DiContainer container, Transform parent, bool occupyLayoutSpace = false) - { - var clone = Instantiate(Template, parent); - clone.gameObject.name = "BssbLevelBarClone"; - - if (occupyLayoutSpace) - { - var layoutElement = clone.gameObject.AddComponent(); - layoutElement.minWidth = 120; - layoutElement.minHeight = 14; - } - - clone.gameObject.SetActive(true); - - var script = clone.gameObject.AddComponent(); - script.InitUI(); - return script; - } - #endregion - - #region UI Access - private ImageView _bg = null!; - private ImageView _image = null!; - private Transform _textContainer = null!; - private CurvedTextMeshPro _titleText = null!; - private CurvedTextMeshPro _secondaryText = null!; - - private void InitUI() - { - // Hover hints - foreach (var hoverHint in GetComponentsInChildren()) - Destroy(hoverHint); - - // Parts - _bg = transform.Find("BG").GetComponent(); - _image = transform.Find("SongArtwork").GetComponent(); - - transform.Find("MultipleLineTextContainer").gameObject.SetActive(false); - _textContainer = transform.Find("SingleLineTextContainer"); - _textContainer.gameObject.SetActive(true); - - _titleText = _textContainer.Find("SongNameText").GetComponent(); - _secondaryText = _textContainer.Find("AuthorNameText").GetComponent(); - - // Enable rich text for detail text - _secondaryText.richText = true; - } - - public enum BackgroundStyle : byte - { - GameDefault, - ColorfulGradient, - GrayTitle, - SolidBlue, - SolidCerise - } - - public void SetBackgroundStyle(BackgroundStyle style = BackgroundStyle.GameDefault, bool skew = true, - bool enableRaycast = false, bool padLeft = false) - { - if (_bg == null || _image == null || _textContainer == null) - return; - - // Primary background color - _bg.color = style switch - { - BackgroundStyle.ColorfulGradient => Color.white, - BackgroundStyle.GrayTitle => new Color(1, 1, 1, .2f), - BackgroundStyle.SolidBlue => new Color(52f / 255f, 31f / 255f, 151f / 255f), - BackgroundStyle.SolidCerise => new Color(207f / 255f, 3f / 255f, 137f / 255f), - _ => Color.black - }; - // Gradient left color - _bg.color0 = style switch - { - BackgroundStyle.ColorfulGradient => new Color(0, .55f, .99f, 0f), - BackgroundStyle.GrayTitle => Color.white, - BackgroundStyle.SolidBlue => Color.white, - BackgroundStyle.SolidCerise => Color.white, - _ => new Color(1, 1, 1, 0) - }; - // Gradient right color - _bg.color1 = style switch - { - BackgroundStyle.ColorfulGradient => new Color(1f, 0, .5f, 1f), - BackgroundStyle.GrayTitle => new Color(1, 1, 1, 0), - BackgroundStyle.SolidBlue => Color.white, - BackgroundStyle.SolidCerise => Color.white, - _ => new Color(1, 1, 1, .3f) - }; - // Skew - _bg.SetField("_skew", (skew ? .18f : 0)); - // Pad left - const float imageBaseX = -59.33f; - (_image.transform as RectTransform)!.localPosition = new Vector3(padLeft ? (imageBaseX + 13.5f) : imageBaseX, -14, 0); - const float textBaseX = 3.5f; - (_textContainer as RectTransform)!.localPosition = new Vector3(padLeft ? (textBaseX + 2f) : 3.5f, -7, 0); - // Raycast - _image.raycastTarget = enableRaycast; - _bg.raycastTarget = enableRaycast; - } - - public void SetImageVisible(bool visible) - { - if (_image != null) - _image.gameObject.SetActive(visible); - - if (_textContainer != null) - (_textContainer.transform as RectTransform)!.sizeDelta = new Vector2((visible ? -27f : 0), -2); - - if (_bg != null) - (_bg.transform as RectTransform)!.sizeDelta = (visible ? new Vector2(-4, 0) : Vector2.zero); - } - - public void SetImageSprite(Sprite? sprite) - { - try - { - if (_image != null) - _image.sprite = sprite; - } - catch (Exception) - { - // Intentionally suppressing Unity errors that can happen here due to async loads - } - } - - public void SetText(string? titleText, string? secondaryText) - { - if (_titleText != null) - _titleText.SetText(titleText ?? ""); - - if (_secondaryText != null) - _secondaryText.SetText(secondaryText ?? ""); - } - #endregion - } -} \ No newline at end of file diff --git a/UI/Components/BssbLoadingControl.cs b/UI/Components/BssbLoadingControl.cs deleted file mode 100644 index c5488fc..0000000 --- a/UI/Components/BssbLoadingControl.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Linq; -using UnityEngine; - -namespace ServerBrowser.UI.Components -{ - public class BssbLoadingControl - { - #region Template/init - private static GameObject? _templateCached; - - private static GameObject? Template - { - get - { - // Intentionally nullable as native GameServersListTableView may be removed from the game at some point - - if (_templateCached == null) - { - var nativeGameServerList = Resources.FindObjectsOfTypeAll() - .FirstOrDefault(); - - if (nativeGameServerList != null) - _templateCached = nativeGameServerList.transform.Find("TableView/Viewport/MainLoadingControl") - .gameObject; - } - - return _templateCached; - } - } - - public static LoadingControl? Create(Transform parent) - { - if (Template == null) - return null; - - var clone = Object.Instantiate(Template, parent); - clone.gameObject.name = "BssbLoadingControl"; - clone.gameObject.SetActive(true); - - var loadingControl = clone.GetComponent(); - loadingControl.gameObject.SetActive(true); - loadingControl.Hide(); - return loadingControl; - } - #endregion - } -} \ No newline at end of file diff --git a/UI/Components/BssbPlayersTable.cs b/UI/Components/BssbPlayersTable.cs deleted file mode 100644 index 98bd852..0000000 --- a/UI/Components/BssbPlayersTable.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Collections.Generic; -using ServerBrowser.Models; -using UnityEngine; - -namespace ServerBrowser.UI.Components -{ - public class BssbPlayersTable - { - private GameObject _parent; - private GameObject _rowPrefab; - private Dictionary _rows; - private int _rowIterator; - - public BssbPlayersTable(GameObject parent, GameObject rowPrefab) - { - rowPrefab.gameObject.SetActive(false); - - _parent = parent; - _parent.name = "BssbPlayersTable"; - _rowPrefab = rowPrefab; - _rows = new(); - _rowIterator = 0; - } - - public void Clear() - { - foreach (var row in _rows.Values) - row.gameObject.SetActive(false); - - _rowIterator = 0; - } - - public void SetData(IReadOnlyCollection playerList) - { - Clear(); - - foreach (var player in playerList) - { - var row = GetNewRow(); - row.SetData(player); - } - } - - private BssbPlayersTableRow GetNewRow() - { - BssbPlayersTableRow row; - - if (_rows.ContainsKey(_rowIterator)) - { - row = _rows[_rowIterator]; - } - else - { - var go = Object.Instantiate(_rowPrefab, _parent.transform); - go.name = $"BssbPlayersTableRow{_rowIterator}"; - go.transform.SetSiblingIndex(_rowIterator); - - row = go.AddComponent(); - - _rows[_rowIterator] = row; - } - - row.gameObject.SetActive(true); - _rowIterator++; - return row; - } - - } -} \ No newline at end of file diff --git a/UI/Components/BssbPlayersTableRow.cs b/UI/Components/BssbPlayersTableRow.cs deleted file mode 100644 index 33af5bb..0000000 --- a/UI/Components/BssbPlayersTableRow.cs +++ /dev/null @@ -1,81 +0,0 @@ -using BeatSaberMarkupLanguage.Components; -using HMUI; -using ServerBrowser.Assets; -using ServerBrowser.Models; -using ServerBrowser.UI.Utils; -using UnityEngine; - -namespace ServerBrowser.UI.Components -{ - public class BssbPlayersTableRow : MonoBehaviour - { - private bool _initialized; - private ImageView _bg = null!; - private ImageView _icon = null!; - private FormattableText _nameText = null!; - private FormattableText _secondaryText = null!; - - public void Initialize() - { - if (_initialized) - return; - - _initialized = true; - - _bg = GetComponent(); - _icon = transform.GetChild(0).Find("BSMLImage").GetComponent(); - _nameText = transform.GetChild(1).Find("BSMLText").GetComponent(); - _secondaryText = transform.GetChild(2).Find("BSMLText").GetComponent(); - - // Disable raycast target for our images so it doesn't cover other UI when scrolling - _bg.raycastTarget = false; - _icon.raycastTarget = false; - } - - public void SetData(BssbServerPlayer player) - { - Initialize(); - - // Icon and color - Sprite? sprite = Sprites.Person; - var spriteColor = Color.white; - var nameColor = Color.white; - - if (player.IsHost) - { - sprite = Sprites.Robot; - spriteColor = BssbColorScheme.Pinkish; - nameColor = BssbColorScheme.Pinkish; - } - else if (player.IsPartyLeader) - { - sprite = Sprites.Crown; - spriteColor = BssbColorScheme.Gold; - nameColor = BssbColorScheme.Gold; - } - else if (player.IsAnnouncing) - { - sprite = Sprites.Announce; - nameColor = BssbColorScheme.Blue; - } - - if (sprite != null) - { - _icon.sprite = sprite; - _icon.color = spriteColor; - _icon.preserveAspect = true; - _icon.gameObject.SetActive(true); - } - else - { - _icon.gameObject.SetActive(false); - } - - // Text - _nameText.SetText(player.UserName); - _nameText.color = nameColor; - - _secondaryText.SetText(player.ListText); - } - } -} \ No newline at end of file diff --git a/UI/Components/BssbServerCellExtensions.cs b/UI/Components/BssbServerCellExtensions.cs deleted file mode 100644 index 3eabe4e..0000000 --- a/UI/Components/BssbServerCellExtensions.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using HMUI; -using ServerBrowser.Assets; -using ServerBrowser.Models; -using ServerBrowser.UI.Utils; -using UnityEngine; -using Zenject; - -namespace ServerBrowser.UI.Components -{ - public class BssbServerCellExtensions : MonoBehaviour - { - [Inject] private readonly CoverArtLoader _coverArtLoader = null!; - - private TableCell? _cell = null; - private BssbServerCellInfo? _cellInfo = null; - private BssbServer? _server = null; - - private ImageView? _coverImage; - private CurvedTextMeshPro? _songName; - private CurvedTextMeshPro? _songAuthor; - private ImageView? _favoritesIcon; - private CurvedTextMeshPro? _songTime; - private CurvedTextMeshPro? _songBpm; - private ImageView? _bpmIcon; - - public void SetData(TableCell cell, BssbServerCellInfo cellInfo, BssbServer server) - { - if (_cell is null || _cell != cell) - { - _cell = cell; - BindComponents(); - } - - _cellInfo = cellInfo; - _server = server; - - RefreshContent(); - } - - private void BindComponents() - { - _coverImage = transform.Find("CoverImage").GetComponent(); - _songName = _cell!.transform.Find("SongName").GetComponent(); - _songAuthor = _cell.transform.Find("SongAuthor").GetComponent(); - _favoritesIcon = _cell.transform.Find("FavoritesIcon").GetComponent(); - _songTime = _cell.transform.Find("SongTime").GetComponent(); - _songBpm = _cell.transform.Find("SongBpm").GetComponent(); - _bpmIcon = _cell.transform.Find("BpmIcon").GetComponent(); - - // Events - _cell.selectionDidChangeEvent += HandleCellSelectionChange; - } - - private void HandleCellSelectionChange(SelectableCell x, SelectableCell.TransitionType y, object z) - { - RefreshContent(); - } - - private void RefreshContent() - { - if (_server is null) - return; - - // Re-align BPM text and allow more horizontal space - we use this for extended lobby type - if (_songBpm != null) - { - var songBpmTransform = (_songBpm.transform as RectTransform)!; - songBpmTransform.anchorMax = new Vector2(1.03f, 0.5f); - songBpmTransform.offsetMin = new Vector2(-32.00f, -4.60f); - } - - // Limit text size for server name and song name - if (_songName != null) - (_songName.transform as RectTransform)!.anchorMax = new Vector2(0.8f, 0.5f); - - if (_songAuthor != null) - (_songAuthor.transform as RectTransform)!.anchorMax = new Vector2(0.8f, 0.5f); - - // Allow bigger player count size (just in case we get those fat 127/127 lobbies) - if (_songTime != null) - (_songTime.transform as RectTransform)!.offsetMin = new Vector2(-13.0f, -2.3f); - - // Enable rich text for subtext - if (_songAuthor != null) - _songAuthor.richText = true; - - // Unused parts - if (_favoritesIcon != null) - _favoritesIcon.gameObject.SetActive(false); - - if (_bpmIcon != null) - _bpmIcon.gameObject.SetActive(false); - - // Player count - if (_songTime != null) - { - _songTime.gameObject.SetActive(true); - _songTime.text = $"{_server.ReadOnlyPlayerCount}/{_server.PlayerLimit}"; - _songTime.fontSize = 4; - _songTime.color = _server.ReadOnlyPlayerCount >= _server.PlayerLimit - ? BssbColorScheme.MutedGray - : BssbColorScheme.White; - } - - // Lobby type - if (_songBpm != null) - { - _songBpm.gameObject.SetActive(true); - _songBpm.text = _server.ServerTypeText; - - if (_cell != null && _cell.selected) - _songBpm.color = BssbColorScheme.White; - else if (_server.IsOfficial) - _songBpm.color = BssbColorScheme.Gold; - else if (_server.IsBeatTogetherHost) - _songBpm.color = BssbColorScheme.Green; - else if (_server.IsBeatUpServerHost) - _songBpm.color = BssbColorScheme.Pinkish; - else if (_server.IsBeatDediHost) - _songBpm.color = BssbColorScheme.Red; - else - _songBpm.color = BssbColorScheme.Blue; - } - } - - public async Task SetCoverArt(CancellationToken token) - { - try - { - if (_cell == null || _cellInfo == null || _server == null || _coverImage == null) - return; - - if (_server.IsInLobby || _server.Level is null) - { - // Not in level, show lobby icon - _coverImage.sprite = Sprites.PortalUser; - return; - } - - // Playing level, show cover art - _coverImage.sprite = Sprites.BeatSaverLogo; - - var sprite = await _coverArtLoader.FetchCoverArtAsync( - new CoverArtLoader.CoverArtRequest(_server, token) - ); - - if (sprite != null && _coverImage != null) - _coverImage.sprite = sprite; - } - catch (Exception) - { - // Intentionally suppressing Unity errors that can happen here due to async loads - } - } - } -} \ No newline at end of file diff --git a/UI/Components/BssbServerCellInfo.cs b/UI/Components/BssbServerCellInfo.cs deleted file mode 100644 index 7f97608..0000000 --- a/UI/Components/BssbServerCellInfo.cs +++ /dev/null @@ -1,16 +0,0 @@ -using BeatSaberMarkupLanguage.Components; -using ServerBrowser.Models; - -namespace ServerBrowser.UI.Components -{ - public class BssbServerCellInfo : CustomListTableData.CustomCellInfo - { - public readonly BssbServer Server; - - public BssbServerCellInfo(BssbServer server) - : base(server.Name, server.BrowserDetailTextWithDifficulty, null) - { - Server = server; - } - } -} \ No newline at end of file diff --git a/UI/CreateServerExtender.cs b/UI/CreateServerExtender.cs deleted file mode 100644 index e30d6c1..0000000 --- a/UI/CreateServerExtender.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using ServerBrowser.Core; -using ServerBrowser.UI.Forms; -using SiraUtil.Affinity; -using UnityEngine; -using Zenject; - -namespace ServerBrowser.UI -{ - // ReSharper disable once ClassNeverInstantiated.Global - public class CreateServerExtender : IInitializable, IAffinity - { - [Inject] private readonly PluginConfig _config = null!; - [Inject] private readonly ServerBrowserClient _bssbClient = null!; - [Inject] private readonly CreateServerViewController _viewController = null!; - - private Transform _wrapper = null!; - private Transform _formView = null!; - - private bool _isActivated; - - private FormExtender? _formExtender; - private ExtendedToggleField? _announcePartyField; - private ExtendedStringField? _serverNameField; - private ExtendedLabelField? _masterServerText; - - public void Initialize() - { - _wrapper = _viewController.transform.Find("Wrapper"); - _formView = _wrapper.transform.Find("CreateServerFormView"); - - // Remove extra spacing - var spaceIdx = 0; - - foreach (RectTransform rect in _wrapper) - { - if (rect.gameObject.name != "Space") - continue; - - switch (spaceIdx++) - { - case 1: // Bottom space - rect.gameObject.SetActive(false); - break; - } - } - - // Extend form - _formExtender = _formView.gameObject.AddComponent(); - - _announcePartyField = _formExtender.CreateToggleInput("Add to Server Browser", _config.AnnounceParty); - _announcePartyField.OnChange += HandleAnnouncePartyChange; - - _serverNameField = _formExtender.CreateTextInput("Server Name", _config.ServerName); - _serverNameField.OnChange += HandleServerNameChange; - - _masterServerText = _formExtender.CreateLabel(""); - - // Set initial values - UpdateForm(); - - // Bind events - _viewController.didActivateEvent += HandleViewActivated; - _viewController.didDeactivateEvent += HandleViewDeactivated; - } - - private void HandleViewActivated(bool firstActivation, bool addedToHierarchy, bool screenSystemEnabling) - { - UpdateForm(); - - _isActivated = true; - } - - private void HandleViewDeactivated(bool removedFromHierarchy, bool screenSystemDisabling) - { - _isActivated = false; - } - - private void HandleAnnouncePartyChange(object sender, bool newValue) - { - if (!_isActivated) - return; - - _config.AnnounceParty = newValue; - - UpdateForm(); - } - - private void HandleServerNameChange(object sender, string? newValue) - { - if (!_isActivated) - return; - - _config.ServerName = newValue; - - UpdateForm(); - } - - [AffinityPostfix] - [AffinityPatch(typeof(CreateServerViewController), "ApplyAndGetData")] - [SuppressMessage("ReSharper", "InconsistentNaming")] - private void HandleFormCompletion(ref CreateServerFormData __result) - { - if (!_config.AnnounceParty) - // Announce disabled; do not modify - return; - - __result.allowInviteOthers = true; - __result.usePassword = false; - - // If we are announcing the game, change form data to make the game "Public" on the master server - // BT may use this in the future to announce from the server side - // We can not set this for Official Servers as it forces the player limit to 5 - __result.netDiscoverable = !_bssbClient.UsingOfficialMaster; - } - - private void UpdateForm() - { - if (_announcePartyField is not null) - { - _announcePartyField.Value = _config.AnnounceParty; - } - - if (_serverNameField is not null) - { - _serverNameField.Visible = _config.AnnounceParty; - _serverNameField.Value = _bssbClient.PreferredServerName; - } - - if (_masterServerText is not null) - { - string text; - - if (_bssbClient.UsingOfficialMaster) - text = $"Creating lobby on Official Servers (custom songs NOT supported)"; - else if (_bssbClient.UsingBeatTogetherMaster) - text = $"Creating lobby on BeatTogether (supports custom songs)"; - else - text = $"Creating lobby on custom master server: {_bssbClient.MasterGraphHostname}"; - - _masterServerText.Label = text; - } - - if (_formExtender != null) - _formExtender.MarkDirty(); - } - } -} \ No newline at end of file diff --git a/UI/Forms/CreateServerFormExtender.cs b/UI/Forms/CreateServerFormExtender.cs new file mode 100644 index 0000000..de70fe9 --- /dev/null +++ b/UI/Forms/CreateServerFormExtender.cs @@ -0,0 +1,64 @@ +using System; +using JetBrains.Annotations; +using ServerBrowser.Data; +using ServerBrowser.Models; +using ServerBrowser.UI.Toolkit.Components; +using UnityEngine; +using Zenject; + +namespace ServerBrowser.UI.Forms +{ + [UsedImplicitly] + public class CreateServerFormExtender : FormExtender, IInitializable, IDisposable + { + [Inject] private readonly BssbConfig _config = null!; + [Inject] private readonly MasterServerRepository _masterServerRepository = null!; + [Inject] private readonly CreateServerViewController _createServerViewController = null!; + + private TkToggleControl _togglePublic = null!; + private TkToggleControl _togglePpModifiers = null!; + private TkToggleControl _togglePpDifficulties = null!; + private TkToggleControl _togglePpMaps = null!; + + public void Initialize() + { + base.Initialize(_createServerViewController); + + _togglePublic = AddToggle("Add to Server Browser", _config.ToggleAnnounce); + _togglePpModifiers = AddToggle("Per-player modifiers", _config.TogglePpModifiers); + _togglePpDifficulties = AddToggle("Per-player difficulties", _config.TogglePpDifficulties); + _togglePpMaps = AddToggle("Per-player maps", _config.TogglePpMaps); + + _createServerViewController.didActivateEvent += HandleViewActivated; + } + + public new void Dispose() + { + base.Dispose(); + + _createServerViewController.didActivateEvent -= HandleViewActivated; + } + + private void HandleViewActivated(bool firstActivation, bool addedToHierarchy, bool screenSystemEnabling) + { + var masterServer = _masterServerRepository.SelectedMasterServer; + + _togglePpModifiers.GameObject.SetActive(masterServer.SupportsPpModifiers); + _togglePpDifficulties.GameObject.SetActive(masterServer.SupportsPpDifficulties); + _togglePpMaps.GameObject.SetActive(masterServer.SupportsPpMaps); + + MarkLayoutDirty(); + } + + private TkToggleControl AddToggle(string label, bool initialValue = false) + { + var toggle = Container.AddToggleControl(label, initialValue); + + var transform = (toggle.GameObject.transform as RectTransform)!; + transform.SetSiblingIndex(NextSiblingIndex); + transform.sizeDelta = new Vector2(90.0f, 7.0f); + + return toggle; + } + } +} \ No newline at end of file diff --git a/UI/Forms/ExtendedField.cs b/UI/Forms/ExtendedField.cs deleted file mode 100644 index f3d4331..0000000 --- a/UI/Forms/ExtendedField.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; - -namespace ServerBrowser.UI.Forms -{ - public abstract class ExtendedField - { - /// - /// Toggles whether this field should be visible or not. - /// - public abstract bool Visible { get; set; } - } - - public abstract class ExtendedField : ExtendedField - { - /// - /// Gets or sets the value on the form field. - /// - public abstract TValue Value { get; set; } - - /// - /// This event is triggered when the user changes the field value. - /// - public abstract event EventHandler? OnChange; - } -} \ No newline at end of file diff --git a/UI/Forms/ExtendedLabelField.cs b/UI/Forms/ExtendedLabelField.cs deleted file mode 100644 index 3fb927c..0000000 --- a/UI/Forms/ExtendedLabelField.cs +++ /dev/null @@ -1,43 +0,0 @@ -using BeatSaberMarkupLanguage.Components; -using BeatSaberMarkupLanguage.Tags; -using TMPro; -using UnityEngine; - -namespace ServerBrowser.UI.Forms -{ - public class ExtendedLabelField : ExtendedField - { - private readonly FormattableText _labelText; - - public override bool Visible - { - get => _labelText.gameObject.activeSelf; - set => _labelText.gameObject.SetActive(value); - } - - public string Label - { - get => _labelText.text; - set - { - _labelText.text = value; - _labelText.RefreshText(); - } - } - - public ExtendedLabelField(Transform parent, string label) - { - var textTagObject = (new TextTag()).CreateObject(parent); - - _labelText = textTagObject.GetComponent(); - _labelText.text = label; - _labelText.rectTransform.offsetMin = new Vector2(0.0f, -30.0f); - _labelText.rectTransform.offsetMax = new Vector2(90.0f, -30.0f); - _labelText.rectTransform.sizeDelta = new Vector2(90.0f, 15.0f); - _labelText.alignment = TextAlignmentOptions.Center; - _labelText.fontSize = 4f; - _labelText.extraPadding = true; - _labelText.RefreshText(); - } - } -} \ No newline at end of file diff --git a/UI/Forms/ExtendedStringField.cs b/UI/Forms/ExtendedStringField.cs deleted file mode 100644 index 0eb8855..0000000 --- a/UI/Forms/ExtendedStringField.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System; -using System.Threading.Tasks; -using BeatSaberMarkupLanguage.Components.Settings; -using BeatSaberMarkupLanguage.Tags.Settings; -using HMUI; -using ServerBrowser.Assets; -using TMPro; -using UnityEngine; - -namespace ServerBrowser.UI.Forms -{ - public class ExtendedStringField : ExtendedField - { - private string? _value; - private readonly StringSetting _stringSetting; - private readonly TextMeshProUGUI _labelText; - - public override bool Visible - { - get => _stringSetting.gameObject.activeSelf; - set => _stringSetting.gameObject.SetActive(value); - } - - public string Label - { - get => _labelText.text; - set => _labelText.text = value; - } - - public override string? Value - { - get => _value; - set - { - _value = value; - _stringSetting.EnterPressed(_value); // this will update both keyboard text & button face text - } - } - - public override event EventHandler? OnChange; - - public ExtendedStringField(Transform parent, string label, string? initialValue) - { - // Base - var stringTagObj = (new StringSettingTag()).CreateObject(parent); - ((stringTagObj.transform as RectTransform)!).sizeDelta = new Vector2(90.0f, 7.0f); - _stringSetting = stringTagObj.GetComponent(); - - // Label - _labelText = _stringSetting.GetComponentInChildren(); - _labelText.text = label; - - // Value - _value = initialValue; - - _stringSetting.modalKeyboard.clearOnOpen = false; - _stringSetting.modalKeyboard.keyboard.KeyboardText.text = _value; - _stringSetting.text.text = _value; - _stringSetting.text.richText = false; - _stringSetting.text.alignment = TextAlignmentOptions.Center; - - // Event - _stringSetting.modalKeyboard.keyboard.EnterPressed += (async delegate (string newValue) - { - _value = newValue; - await Task.Delay(1); // we need to run OnChange after BSML's own EnterPressed, and this, well, it works - OnChange?.Invoke(this, newValue); - }); - - // Make the icon look not-wonky - var valuePicker = _stringSetting.transform.Find("ValuePicker"); - var editButton = valuePicker.transform.Find("EditButton"); - - var editButtonIcon = editButton.Find("EditIcon").GetComponent(); - editButtonIcon.sprite = Sprites.Pencil; - editButtonIcon.transform.localScale = new Vector3(-1.0f, -1.0f, 1.0f); - } - } -} \ No newline at end of file diff --git a/UI/Forms/ExtendedToggleField.cs b/UI/Forms/ExtendedToggleField.cs deleted file mode 100644 index 80878b4..0000000 --- a/UI/Forms/ExtendedToggleField.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; -using BeatSaberMarkupLanguage.Components.Settings; -using BeatSaberMarkupLanguage.Tags; -using UnityEngine; - -namespace ServerBrowser.UI.Forms -{ - public class ExtendedToggleField : ExtendedField - { - private bool _value; - private readonly ToggleSetting _toggleSetting; - - public override bool Visible - { - get => _toggleSetting.gameObject.activeSelf; - set => _toggleSetting.gameObject.SetActive(value); - } - - public string Label - { - get => _toggleSetting.text.text; - set => _toggleSetting.text.SetText(value); - } - - public override bool Value - { - get => _value; - set - { - _value = value; - _toggleSetting.toggle.isOn = value; - } - } - - public override event EventHandler? OnChange; - - public ExtendedToggleField(Transform parent, string label, bool initialValue) - { - // Base - var toggleTagObj = (new ToggleSettingTag()).CreateObject(parent); - (toggleTagObj.transform as RectTransform)!.sizeDelta = new Vector2(90.0f, 7.0f); - _toggleSetting = toggleTagObj.GetComponent(); - - // Label - _toggleSetting.text.SetText(label); - - // Value - _toggleSetting.toggle.isOn = initialValue; - - // Event - _toggleSetting.toggle.onValueChanged.RemoveAllListeners(); - _toggleSetting.toggle.onValueChanged.AddListener(delegate (bool newValue) - { - _toggleSetting.toggle.isOn = newValue; - OnChange?.Invoke(this, newValue); - }); - } - } -} \ No newline at end of file diff --git a/UI/Forms/FormExtender.cs b/UI/Forms/FormExtender.cs index 1223629..00ce281 100644 --- a/UI/Forms/FormExtender.cs +++ b/UI/Forms/FormExtender.cs @@ -1,92 +1,136 @@ -using System.Collections.Generic; +using System; +using HMUI; +using ServerBrowser.UI.Toolkit; +using ServerBrowser.UI.Toolkit.Components; +using ServerBrowser.Util; using UnityEngine; using UnityEngine.UI; +using Zenject; namespace ServerBrowser.UI.Forms { - public class FormExtender : MonoBehaviour + public abstract class FormExtender : ITickable, IDisposable { - private readonly List _fields; - private RectTransform _rectTransform; - private RectTransform _rectTransformParent; - private bool _innerLayoutDirty; - private bool _outerLayoutDirty; - - public FormExtender() - { - _fields = new(); - _rectTransform = GetComponent(); - _rectTransformParent = transform.parent.GetComponent(); - _innerLayoutDirty = true; - _outerLayoutDirty = true; - } + [Inject] private readonly LayoutBuilder _layoutBuilder = null!; + + private ViewController _viewController = null!; + private VerticalLayoutGroup _outerWrapper = null!; + private VerticalLayoutGroup _innerWrapper = null!; + private ContentSizeFitter _contentSizeFitter = null!; + private Transform _spaceBeforeContent = null!; + private Transform _spaceBeforeButtons = null!; + private TkDropdownControl _serverSelectorControl = null!; - #region Layout update + protected LayoutContainer Container { get; private set; } = null!; + + public int PrependSiblingIndex => _outerWrapper == _innerWrapper ? + _spaceBeforeContent.GetSiblingIndex() + 1 : 0; + public int NextSiblingIndex => _spaceBeforeButtons.GetSiblingIndex(); + + private bool _layoutDirtyOuter; + private bool _layoutDirtyInner; - public void Update() + public event Action? MasterServerSwitchRequestedEvent; + + protected void Initialize(ViewController viewController) { - if (_innerLayoutDirty) + _viewController = viewController; + _viewController.didActivateEvent += HandleViewActivatedEvent; + + // Each of the form view controllers has a direct "Wrapper" child that is a VLayout + _outerWrapper = _viewController.transform.Find("Wrapper").GetComponent(); + _innerWrapper = _outerWrapper; + + // The inner wrapper is equal to the outer wrapper, except when it's the create server form view + foreach (Transform transform in _outerWrapper.transform) { - _innerLayoutDirty = false; - LayoutRebuilder.ForceRebuildLayoutImmediate(_rectTransform); - return; + if (!transform.name.Contains("FormView")) + continue; + _innerWrapper = transform.GetComponent(); + break; } + + // Inner wrapper should have a content size fitter for dynamic resize + _contentSizeFitter = _innerWrapper.gameObject.GetOrAddComponent(); - if (_outerLayoutDirty) + // Init container wrapper + Container = new LayoutContainer(_layoutBuilder, _innerWrapper.transform, false); + + // Find first/last space in transform + foreach (Transform transform in _outerWrapper.transform) { - _outerLayoutDirty = false; - LayoutRebuilder.ForceRebuildLayoutImmediate(_rectTransformParent); - return; + if (transform.name != "Space") + continue; + + if (_spaceBeforeContent == null) + { + _spaceBeforeContent = transform; // first space + (_spaceBeforeContent.transform as RectTransform)!.sizeDelta = new Vector2(1f, 5f); + } + + _spaceBeforeButtons = transform; // last space + } + foreach (Transform transform in _innerWrapper.transform) + { + if (transform.name == "Space") + _spaceBeforeButtons = transform; // last space } - } - - public void MarkDirty() - { - var formViewVerticalLayout = transform.GetComponent(); - var parentVerticalLayout = transform.parent.GetComponent(); - var contentSizeFitter = transform.GetComponent(); - - if (formViewVerticalLayout != null && !formViewVerticalLayout.enabled) - formViewVerticalLayout.enabled = true; - if (contentSizeFitter != null && !contentSizeFitter.enabled) - contentSizeFitter.enabled = true; + // Insert server selector control + var margin = Container.InsertMargin(1f, 5f); + margin.SetSiblingIndex(PrependSiblingIndex); - if (parentVerticalLayout != null && !parentVerticalLayout.enabled) - parentVerticalLayout.enabled = true; + _serverSelectorControl = Container.AddDropdownControl("Master Server"); + _serverSelectorControl.GameObject.transform.SetSiblingIndex(PrependSiblingIndex); - _innerLayoutDirty = true; - _outerLayoutDirty = true; + // Enqueue full layout update for activation + MarkLayoutDirty(); } - #endregion - - #region Fields API - - public ExtendedStringField CreateTextInput(string label, string? initialValue) + public void Dispose() { - var field = new ExtendedStringField(transform, label, initialValue); - _fields.Add(field); - MarkDirty(); - return field; + _viewController.didActivateEvent -= HandleViewActivatedEvent; } - - public ExtendedToggleField CreateToggleInput(string label, bool initialValue) + + private void HandleViewActivatedEvent(bool firstActivation, bool addedToHierarchy, bool screenSystemEnabling) { - var field = new ExtendedToggleField(transform, label, initialValue); - _fields.Add(field); - MarkDirty(); - return field; + _serverSelectorControl.RemoveAllOnClickActions(); + _serverSelectorControl.AddOnClick(() => MasterServerSwitchRequestedEvent?.Invoke()); } - - public ExtendedLabelField CreateLabel(string label) + + protected void MarkLayoutDirty() { - var field = new ExtendedLabelField(transform, label); - _fields.Add(field); - MarkDirty(); - return field; + if (_outerWrapper != null && !_outerWrapper.enabled) + _outerWrapper.enabled = true; + + if (_innerWrapper != null && !_innerWrapper.enabled) + _innerWrapper.enabled = true; + + if (_contentSizeFitter != null && !_contentSizeFitter.enabled) + _contentSizeFitter.enabled = true; + + _layoutDirtyOuter = true; + _layoutDirtyInner = true; } - #endregion + public void Tick() + { + if (!_viewController.isActivated) + return; + + if (_layoutDirtyOuter && _outerWrapper != null) + { + LayoutRebuilder.ForceRebuildLayoutImmediate(_outerWrapper.transform as RectTransform); + _layoutDirtyOuter = false; + return; + } + + if (_layoutDirtyInner && _innerWrapper != null) + { + LayoutRebuilder.ForceRebuildLayoutImmediate(_innerWrapper.transform as RectTransform); + _layoutDirtyInner = false; + return; + } + } } } \ No newline at end of file diff --git a/UI/Forms/QuickPlayFormExtender.cs b/UI/Forms/QuickPlayFormExtender.cs new file mode 100644 index 0000000..c92bf15 --- /dev/null +++ b/UI/Forms/QuickPlayFormExtender.cs @@ -0,0 +1,16 @@ +using JetBrains.Annotations; +using Zenject; + +namespace ServerBrowser.UI.Forms +{ + [UsedImplicitly] + public class QuickPlayFormExtender : FormExtender, IInitializable + { + [Inject] private readonly JoinQuickPlayViewController _joinQuickPlayViewController = null!; + + public void Initialize() + { + base.Initialize(_joinQuickPlayViewController); + } + } +} \ No newline at end of file diff --git a/UI/Forms/ServerCodeFormExtender.cs b/UI/Forms/ServerCodeFormExtender.cs new file mode 100644 index 0000000..95f0652 --- /dev/null +++ b/UI/Forms/ServerCodeFormExtender.cs @@ -0,0 +1,16 @@ +using JetBrains.Annotations; +using Zenject; + +namespace ServerBrowser.UI.Forms +{ + [UsedImplicitly] + public class ServerCodeFormExtender : FormExtender, IInitializable + { + [Inject] private readonly ServerCodeEntryViewController _serverCodeEntryViewController = null!; + + public void Initialize() + { + base.Initialize(_serverCodeEntryViewController); + } + } +} \ No newline at end of file diff --git a/UI/JoiningLobbyExtender.cs b/UI/JoiningLobbyExtender.cs deleted file mode 100644 index b6ef4b4..0000000 --- a/UI/JoiningLobbyExtender.cs +++ /dev/null @@ -1,94 +0,0 @@ -using IPA.Utilities; -using Polyglot; -using SiraUtil.Affinity; -using SiraUtil.Logging; -using Zenject; - -namespace ServerBrowser.UI -{ - /// - /// Extends the "Joining lobby" view with actually useful information. - /// - public class JoiningLobbyExtender : IAffinity - { - private const string LocalizationKeyJoiningLobby = "LABEL_JOINING_LOBBY"; - private const string LocalizationKeyJoiningGame = "LABEL_JOINING_GAME"; - private const string LocalizationKeyCreatingServer = "LABEL_CREATING_SERVER"; - private const string LocalizationKeyJoiningQuickPlay = "LABEL_JOINING_QUICK_PLAY"; - - [Inject] private readonly SiraLog _log = null!; - [Inject] private readonly JoiningLobbyViewController _viewController = null!; - - private bool _weAreHandling; - - #region View events - - [AffinityPostfix] - [AffinityPatch(typeof(JoiningLobbyViewController), "Init")] - private void HandleViewInit(string text) - { - if (text == Localization.Get(LocalizationKeyJoiningLobby) - || text == Localization.Get(LocalizationKeyJoiningGame) - || text == Localization.Get(LocalizationKeyCreatingServer) - || text == Localization.Get(LocalizationKeyJoiningQuickPlay)) - { - _weAreHandling = true; - } - else - { - _weAreHandling = false; - } - } - - #endregion - - #region Connection events - - [AffinityPrefix] - [AffinityPatch(typeof(GameLiftConnectionManager), "HandleConnectToServerSuccess")] - private void HandleConnectToServerSuccess() - { - if (!_weAreHandling) - return; - - SetText("Connecting to game..."); - } - - [AffinityPrefix] - [AffinityPatch(typeof(MultiplayerSessionManager), "UpdateConnectionState")] - private void HandleUpdateSessionConnectionState(UpdateConnectionStateReason updateReason) - { - if (!_weAreHandling) - return; - - switch (updateReason) - { - case UpdateConnectionStateReason.SyncTimeInitialized: - // We are connected to the game server, and are about to enter the lobby - SetText("Entering lobby..."); - break; - } - } - - #endregion - - #region View utils - - private string? GetCurrentText() => _viewController.GetField("_text"); - - private void SetText(string text) - { - if (GetCurrentText() == text) - return; - - var loadingControl = _viewController.GetField("_loadingControl"); - if (loadingControl == null) - return; - - _log.Debug($"Extended join status: {text}"); - loadingControl.ShowLoading(text); - } - - #endregion - } -} \ No newline at end of file diff --git a/UI/Lobby/LobbyConfigPanel.bsml b/UI/Lobby/LobbyConfigPanel.bsml deleted file mode 100644 index 6ffdc8f..0000000 --- a/UI/Lobby/LobbyConfigPanel.bsml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - -