From c0b6076bbcd71221654c71c9abfc85fec45f75a1 Mon Sep 17 00:00:00 2001 From: Liparakis Date: Sat, 28 Mar 2026 21:58:40 +0200 Subject: [PATCH 01/16] feat: Added multiplayer button hooks to UiManager --- SSMP/Api/Client/IUiManager.cs | 39 +++++++++++++++++++++++++++++++++++ SSMP/Ui/UiManager.cs | 26 ++++++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/SSMP/Api/Client/IUiManager.cs b/SSMP/Api/Client/IUiManager.cs index e4a35c0..c9f4fff 100644 --- a/SSMP/Api/Client/IUiManager.cs +++ b/SSMP/Api/Client/IUiManager.cs @@ -1,3 +1,5 @@ +using System; + namespace SSMP.Api.Client; /// @@ -8,4 +10,41 @@ public interface IUiManager { /// The message box that shows information related to SSMP. /// IChatBox ChatBox { get; } + + /// + /// Fired when the multiplayer button is pressed, before any blocking hooks run. + /// Use this for fire-and-forget reactions such as logging or showing a notification? + /// + event Action? MultiplayerButtonPressed; + + /// + /// Registers a hook that is invoked when the multiplayer button is pressed. + /// + /// + /// Hooks are executed in reverse order (last registered runs first). + /// + /// Example (informational hook): + /// + /// RegisterMultiplayerMenuHook(next => { + /// MyNonMandatoryDependencyPopup.Show( + /// "Addon X unavailable", + /// onAccept: _ => {continue; }, + /// onDecline: _ => { continue; }); + /// }); + /// + /// + /// Example (blocking hook): + /// + /// RegisterMultiplayerMenuHook(next => { + /// MyMandatoryDependencyPopup.Show( + /// "Addon X unavailable", + /// onConfirm: agreed => { if (agreed) continue; }); + /// onDecline: agreed => { if (!agreed) return; }); + /// }); + /// + /// + /// + /// The hook to register. Receives a callback to invoke when ready to proceed. + /// + void RegisterMultiplayerMenuHook(Action hook); } diff --git a/SSMP/Ui/UiManager.cs b/SSMP/Ui/UiManager.cs index a0d1219..f49b85f 100644 --- a/SSMP/Ui/UiManager.cs +++ b/SSMP/Ui/UiManager.cs @@ -142,6 +142,9 @@ internal class UiManager : IUiManager { /// public event Action? RequestClientDisconnectEvent; + /// + public event Action? MultiplayerButtonPressed; + #endregion #region Fields @@ -207,6 +210,12 @@ internal class UiManager : IUiManager { /// private bool _isSlotSelectionActive; + /// + /// The head of the multiplayer menu hook chain. + /// Starts as the bare transition; each registered hook wraps it. + /// + private Action _multiplayerMenuChain; + #endregion #region Properties @@ -243,6 +252,13 @@ public IChatBox ChatBox { public UiManager(ModSettings modSettings, NetClient netClient) { _modSettings = modSettings; _netClient = netClient; + _multiplayerMenuChain = () => UM.StartCoroutine(GoToMultiplayerMenu()); + } + + /// + public void RegisterMultiplayerMenuHook(Action hook) { + var previous = _multiplayerMenuChain; + _multiplayerMenuChain = () => hook(previous); } #endregion @@ -738,7 +754,7 @@ private void ConfigureButtonTriggers(GameObject button) { if (eventTrigger == null) return; eventTrigger.triggers.Clear(); - AddButtonTriggers(eventTrigger, () => UM.StartCoroutine(GoToMultiplayerMenu())); + AddButtonTriggers(eventTrigger, OnMultiplayerMenuRequested); } /// @@ -777,6 +793,14 @@ private void SetNavigation(MenuButton button, MenuButton? selectOnUp = null, Men button.navigation = nav; } + /// + /// Handles the multiplayer menu request by firing the notification event, then invoking the hook chain. + /// + private void OnMultiplayerMenuRequested() { + MultiplayerButtonPressed?.Invoke(); + _multiplayerMenuChain(); + } + #endregion #region Menu Navigation From 2c63b389fd29ef192b5aa77df2e4117e72dcd51f Mon Sep 17 00:00:00 2001 From: Liparakis Date: Sat, 28 Mar 2026 22:24:19 +0200 Subject: [PATCH 02/16] chore: Suggested fixes --- SSMP/Api/Client/IUiManager.cs | 2 +- SSMP/Ui/UiManager.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/SSMP/Api/Client/IUiManager.cs b/SSMP/Api/Client/IUiManager.cs index c9f4fff..049c0dd 100644 --- a/SSMP/Api/Client/IUiManager.cs +++ b/SSMP/Api/Client/IUiManager.cs @@ -13,7 +13,7 @@ public interface IUiManager { /// /// Fired when the multiplayer button is pressed, before any blocking hooks run. - /// Use this for fire-and-forget reactions such as logging or showing a notification? + /// Use this for fire-and-forget reactions such as logging or showing a notification. /// event Action? MultiplayerButtonPressed; diff --git a/SSMP/Ui/UiManager.cs b/SSMP/Ui/UiManager.cs index f49b85f..29292a8 100644 --- a/SSMP/Ui/UiManager.cs +++ b/SSMP/Ui/UiManager.cs @@ -798,7 +798,7 @@ private void SetNavigation(MenuButton button, MenuButton? selectOnUp = null, Men /// private void OnMultiplayerMenuRequested() { MultiplayerButtonPressed?.Invoke(); - _multiplayerMenuChain(); + _multiplayerMenuChain.Invoke(); } #endregion From 909dbeb8fc1648d7e4f77de2cf9ce2d515724eb9 Mon Sep 17 00:00:00 2001 From: Liparakis Date: Tue, 31 Mar 2026 11:34:55 +0300 Subject: [PATCH 03/16] feat(api): expose local player ID and settings and change events - Add `Id` property to `IClientManager` for local player identification. - Add `OnChanged` event to `IServerSettings` to track server-side setting updates on the client. - Add `OnTeamChanged` event to `IClientPlayer` for per-player team tracking. - Update `ServerManager` to include the connecting player's ID and username in the initial `ServerInfo` handshake. - Implement automatic local ID resolution in `ClientManager` upon successful connection. --- SSMP/Api/Addon/AddonLoader.cs | 2 +- SSMP/Api/Client/IClientManager.cs | 16 ++ SSMP/Api/Client/IClientPlayer.cs | 6 + SSMP/Api/Server/IServerPlayer.cs | 6 + SSMP/Api/Server/IServerSettings.cs | 19 +- SSMP/Game/Client/ClientManager.cs | 43 +++-- SSMP/Game/Client/ClientPlayerData.cs | 13 +- SSMP/Game/Server/ServerManager.cs | 9 +- SSMP/Game/Server/ServerPlayerData.cs | 13 +- SSMP/Game/Settings/ModSettings.cs | 21 ++- SSMP/Game/Settings/ServerSettings.cs | 38 ++-- SSMP/Serialization/ObservableConverter.cs | 43 +++++ SSMP/Ui/ConnectInterface.cs | 6 +- SSMP/Ui/Menu/ModMenu.cs | 15 +- SSMP/Util/Observable.cs | 80 +++++++++ SSMP/Util/ObservableBase.cs | 210 ++++++++++++++++++++++ 16 files changed, 474 insertions(+), 66 deletions(-) create mode 100644 SSMP/Serialization/ObservableConverter.cs create mode 100644 SSMP/Util/Observable.cs create mode 100644 SSMP/Util/ObservableBase.cs diff --git a/SSMP/Api/Addon/AddonLoader.cs b/SSMP/Api/Addon/AddonLoader.cs index 6529ee9..b6ddcbb 100644 --- a/SSMP/Api/Addon/AddonLoader.cs +++ b/SSMP/Api/Addon/AddonLoader.cs @@ -27,7 +27,7 @@ internal abstract class AddonLoader { ]; /// - /// Get the paths for all assembly files in the HKMP directory. + /// Get the paths for all assembly files in the SSMP directory. /// /// A string array containing file paths. private static string[] GetAssemblyPaths() { diff --git a/SSMP/Api/Client/IClientManager.cs b/SSMP/Api/Client/IClientManager.cs index 3f0c73e..5966ed0 100644 --- a/SSMP/Api/Client/IClientManager.cs +++ b/SSMP/Api/Client/IClientManager.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using SSMP.Game; +using SSMP.Game.Settings; namespace SSMP.Api.Client; @@ -18,6 +19,11 @@ public interface IClientManager { /// string Username { get; } + /// + /// The unique ID assigned to the local player. + /// + ushort Id { get; } + /// /// The current team of the local player. /// @@ -28,6 +34,16 @@ public interface IClientManager { /// IReadOnlyCollection Players { get; } + /// + /// A read-only that contains the settings related to gameplay. + /// + ServerSettings ServerSettings { get; } + + /// + /// Event that is called when the server settings change. + /// + event Action ServerSettingsChangedEvent; + /// /// Disconnect the local client from the server. /// diff --git a/SSMP/Api/Client/IClientPlayer.cs b/SSMP/Api/Client/IClientPlayer.cs index 604a8d1..1d70ee0 100644 --- a/SSMP/Api/Client/IClientPlayer.cs +++ b/SSMP/Api/Client/IClientPlayer.cs @@ -1,3 +1,4 @@ +using System; using SSMP.Game; using SSMP.Internals; using UnityEngine; @@ -8,6 +9,11 @@ namespace SSMP.Api.Client; /// A class containing all the relevant data managed by the client about a player. /// public interface IClientPlayer { + /// + /// Event triggered when the player changes their team. + /// + public event Action? OnTeamChanged; + /// /// The ID of the player. /// diff --git a/SSMP/Api/Server/IServerPlayer.cs b/SSMP/Api/Server/IServerPlayer.cs index 404a90c..58ab923 100644 --- a/SSMP/Api/Server/IServerPlayer.cs +++ b/SSMP/Api/Server/IServerPlayer.cs @@ -1,3 +1,4 @@ +using System; using SSMP.Game; using SSMP.Internals; using SSMP.Math; @@ -8,6 +9,11 @@ namespace SSMP.Api.Server; /// A class containing all the relevant data managed by the server about a player. /// public interface IServerPlayer { + /// + /// Event triggered when the player changes their team. + /// + public event Action? OnTeamChanged; + /// /// The ID of the player. /// diff --git a/SSMP/Api/Server/IServerSettings.cs b/SSMP/Api/Server/IServerSettings.cs index c3af1cc..f9001e1 100644 --- a/SSMP/Api/Server/IServerSettings.cs +++ b/SSMP/Api/Server/IServerSettings.cs @@ -1,3 +1,5 @@ +using System; +using SSMP.Util; namespace SSMP.Api.Server; @@ -5,35 +7,40 @@ namespace SSMP.Api.Server; /// Settings related to gameplay that is shared between server and clients. /// public interface IServerSettings { + /// + /// Event triggered whenever any of the server settings are changed. + /// + public event Action? OnChanged; + /// /// Whether player vs. player damage is enabled. /// - public bool IsPvpEnabled { get; } + public Observable IsPvpEnabled { get; } /// /// Whether to always show map icons. /// - public bool AlwaysShowMapIcons { get; } + public Observable AlwaysShowMapIcons { get; } /// /// Whether to only broadcast the map icon of a player if they have wayward compass equipped. /// - public bool OnlyBroadcastMapIconWithCompass { get; } + public Observable OnlyBroadcastMapIconWithCompass { get; } /// /// Whether to display player names above the player objects. /// - public bool DisplayNames { get; } + public Observable DisplayNames { get; } /// /// Whether teams are enabled. /// - public bool TeamsEnabled { get; } + public Observable TeamsEnabled { get; } /// /// Whether skins are allowed. /// - public bool AllowSkins { get; } + public Observable AllowSkins { get; } // /// // /// Whether other player's attacks can be parried. diff --git a/SSMP/Game/Client/ClientManager.cs b/SSMP/Game/Client/ClientManager.cs index efa2070..699abc2 100644 --- a/SSMP/Game/Client/ClientManager.cs +++ b/SSMP/Game/Client/ClientManager.cs @@ -55,11 +55,6 @@ internal class ClientManager : IClientManager { /// private readonly UiManager _uiManager; - /// - /// The current server settings. - /// - private readonly ServerSettings _serverSettings; - /// /// The loaded mod settings. /// @@ -189,6 +184,12 @@ internal class ClientManager : IClientManager { /// public IMapManager MapManager => _mapManager; + + /// + public ServerSettings ServerSettings { get; } + + /// + public ushort Id { get; private set; } /// public string Username => !_netClient.IsConnected ? throw new Exception("Client is not connected, username is undefined") : _username!; @@ -229,7 +230,7 @@ ModSettings modSettings _netClient = netClient; _packetManager = packetManager; _uiManager = uiManager; - _serverSettings = serverSettings; + ServerSettings = serverSettings; _modSettings = modSettings; _playerData = new Dictionary(); @@ -263,7 +264,7 @@ ModSettings modSettings /// public void Initialize(ServerManager serverManager) { _playerManager.Initialize(); - _animationManager.Initialize(_serverSettings); + _animationManager.Initialize(ServerSettings); _mapManager.Initialize(); // _entityManager.Initialize(); @@ -552,6 +553,7 @@ private void InternalDisconnect() { Logger.Info("Disconnecting from server"); _autoConnect = false; + Id = 0; _netClient.Disconnect(); @@ -663,11 +665,11 @@ private void OnClientConnect(ServerInfo serverInfo) { Logger.Info("Received server info from server"); // Update the locally stored server settings - _serverSettings.SetAllProperties(serverInfo.ServerSettingsUpdate.ServerSettings); + ServerSettings.SetAllProperties(serverInfo.ServerSettingsUpdate.ServerSettings); // Call the event that the settings were updated ServerSettingsChangedEvent?.Invoke(serverInfo.ServerSettingsUpdate.ServerSettings); - // Note whether full synchronisation is enabled + // Note whether full synchronization is enabled _fullSynchronisation = serverInfo.FullSynchronisation; // Register hooks and packet handlers before we load into the game @@ -711,6 +713,9 @@ private void OnClientConnect(ServerInfo serverInfo) { // Fill the player data dictionary with the info from the packet foreach (var (id, username) in serverInfo.PlayerInfo) { _playerData[id] = new ClientPlayerData(id, username); + + // If the username matches our own, we found our ID + if (username == _username) Id = id; } // Add the username to the player if we are in-game already @@ -1064,7 +1069,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { var newServerSettings = update.ServerSettings; // Check whether the PvP state changed - if (_serverSettings.IsPvpEnabled != newServerSettings.IsPvpEnabled) { + if (ServerSettings.IsPvpEnabled != newServerSettings.IsPvpEnabled) { var message = $"PvP is now {(newServerSettings.IsPvpEnabled ? "enabled" : "disabled")}"; UiManager.InternalChatBox.AddMessage(message); @@ -1072,7 +1077,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { } // Check whether the always show map icons state changed - if (_serverSettings.AlwaysShowMapIcons != newServerSettings.AlwaysShowMapIcons) { + if (ServerSettings.AlwaysShowMapIcons != newServerSettings.AlwaysShowMapIcons) { alwaysShowMapChanged = true; var message = @@ -1083,7 +1088,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { } // Check whether the wayward compass broadcast state changed - if (_serverSettings.OnlyBroadcastMapIconWithCompass != + if (ServerSettings.OnlyBroadcastMapIconWithCompass != newServerSettings.OnlyBroadcastMapIconWithCompass) { onlyCompassChanged = true; @@ -1095,7 +1100,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { } // Check whether the display names setting changed - if (_serverSettings.DisplayNames != newServerSettings.DisplayNames) { + if (ServerSettings.DisplayNames != newServerSettings.DisplayNames) { displayNamesChanged = true; var message = $"Names are {(newServerSettings.DisplayNames ? "now" : "no longer")} displayed"; @@ -1105,7 +1110,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { } // Check whether the teams enabled setting changed - if (_serverSettings.TeamsEnabled != newServerSettings.TeamsEnabled) { + if (ServerSettings.TeamsEnabled != newServerSettings.TeamsEnabled) { teamsChanged = true; var message = $"Teams are {(newServerSettings.TeamsEnabled ? "now" : "no longer")} enabled"; @@ -1115,7 +1120,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { } // Check whether allow skins setting changed - if (_serverSettings.AllowSkins != newServerSettings.AllowSkins) { + if (ServerSettings.AllowSkins != newServerSettings.AllowSkins) { allowSkinsChanged = true; var message = $"Skins are {(newServerSettings.AllowSkins ? "now" : "no longer")} enabled"; @@ -1125,7 +1130,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { } // Update the settings so callbacks can read updated values - _serverSettings.SetAllProperties(newServerSettings); + ServerSettings.SetAllProperties(newServerSettings); // Call the event that the settings were updated ServerSettingsChangedEvent?.Invoke(newServerSettings); @@ -1135,7 +1140,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { } if (alwaysShowMapChanged || onlyCompassChanged) { - if (_serverSettings is { AlwaysShowMapIcons: false, OnlyBroadcastMapIconWithCompass: false }) { + if (ServerSettings is { AlwaysShowMapIcons.Value: false, OnlyBroadcastMapIconWithCompass.Value: false }) { _mapManager.RemoveAllIcons(); } } @@ -1143,7 +1148,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { // If the teams setting changed, we invoke the registered event handler if they exist if (teamsChanged) { // If the team setting was disabled, we reset all teams and call the event - if (!_serverSettings.TeamsEnabled) { + if (!ServerSettings.TeamsEnabled) { _playerManager.ResetAllTeams(); TeamChangedEvent?.Invoke(Team.None); @@ -1154,7 +1159,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { // If the allow skins setting changed, and it is no longer allowed, we reset all existing skins and call the // event - if (allowSkinsChanged && !_serverSettings.AllowSkins) { + if (allowSkinsChanged && !ServerSettings.AllowSkins) { _playerManager.ResetAllPlayerSkins(); SkinChangedEvent?.Invoke(0); diff --git a/SSMP/Game/Client/ClientPlayerData.cs b/SSMP/Game/Client/ClientPlayerData.cs index d9acda0..eec147e 100644 --- a/SSMP/Game/Client/ClientPlayerData.cs +++ b/SSMP/Game/Client/ClientPlayerData.cs @@ -1,3 +1,4 @@ +using System; using SSMP.Api.Client; using SSMP.Internals; using UnityEngine; @@ -6,6 +7,9 @@ namespace SSMP.Game.Client; /// internal class ClientPlayerData : IClientPlayer { + /// + public event Action? OnTeamChanged; + /// public ushort Id { get; } @@ -22,7 +26,14 @@ internal class ClientPlayerData : IClientPlayer { public GameObject? PlayerObject { get; set; } /// - public Team Team { get; set; } + public Team Team { + get; + set { + if (field == value) return; + field = value; + OnTeamChanged?.Invoke(value); + } + } /// public byte SkinId { get; set; } diff --git a/SSMP/Game/Server/ServerManager.cs b/SSMP/Game/Server/ServerManager.cs index e4c7d5f..b3baecf 100644 --- a/SSMP/Game/Server/ServerManager.cs +++ b/SSMP/Game/Server/ServerManager.cs @@ -1429,14 +1429,13 @@ out var correspondingServerAddon serverInfo.FullSynchronisation = FullSynchronisation; // Construct the player info to send to the new client in the server info - var playerInfo = new List<(ushort, string)>(); + var playerInfo = new List<(ushort, string)> { + (netServerClient.Id, clientInfo.Username) + }; foreach (var idPlayerDataPair in _playerData) { var otherId = idPlayerDataPair.Key; - if (otherId == netServerClient.Id) { - continue; - } - + var otherPd = idPlayerDataPair.Value; playerInfo.Add((otherId, otherPd.Username)); diff --git a/SSMP/Game/Server/ServerPlayerData.cs b/SSMP/Game/Server/ServerPlayerData.cs index c76b54c..2c4e994 100644 --- a/SSMP/Game/Server/ServerPlayerData.cs +++ b/SSMP/Game/Server/ServerPlayerData.cs @@ -1,3 +1,4 @@ +using System; using SSMP.Api.Server; using SSMP.Game.Server.Auth; using SSMP.Internals; @@ -7,6 +8,9 @@ namespace SSMP.Game.Server; /// internal class ServerPlayerData : IServerPlayer { + /// + public event Action? OnTeamChanged; + /// public ushort Id { get; } @@ -41,7 +45,14 @@ internal class ServerPlayerData : IServerPlayer { public ushort AnimationId { get; set; } /// - public Team Team { get; set; } = Team.None; + public Team Team { + get; + set { + if (field == value) return; + field = value; + OnTeamChanged?.Invoke(value); + } + } = Team.None; /// public byte SkinId { get; set; } diff --git a/SSMP/Game/Settings/ModSettings.cs b/SSMP/Game/Settings/ModSettings.cs index 17318eb..0de7101 100644 --- a/SSMP/Game/Settings/ModSettings.cs +++ b/SSMP/Game/Settings/ModSettings.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using Newtonsoft.Json; using SSMP.Serialization; @@ -10,7 +10,7 @@ namespace SSMP.Game.Settings; /// /// Settings class that stores user preferences. /// -internal class ModSettings { +internal class ModSettings : ObservableBase { /// /// The name of the file containing the mod settings. /// @@ -25,27 +25,27 @@ internal class ModSettings { /// The keybinds for SSMP. /// [JsonConverter(typeof(PlayerActionSetConverter))] - public Keybinds Keybinds { get; set; } = new(); + public Keybinds Keybinds { get; } = new(); /// /// The last used address to join a server. /// - public string ConnectAddress { get; set; } = ""; + public Observable ConnectAddress { get; } = new(""); /// /// The last used port to join a server. /// - public int ConnectPort { get; set; } = -1; + public Observable ConnectPort { get; } = new(-1); /// /// The last used username to join a server. /// - public string Username { get; set; } = ""; + public Observable Username { get; } = new(""); /// /// Whether to display a UI element for the ping. /// - public bool DisplayPing { get; set; } = true; + public Observable DisplayPing { get; } = new(true); /// /// Set of addon names for addons that are disabled by the user. @@ -54,10 +54,10 @@ internal class ModSettings { public HashSet DisabledAddons { get; set; } = []; /// - /// Whether full synchronisation of bosses, enemies, worlds, and saves is enabled. + /// Whether full synchronization of bosses, enemies, worlds, and saves is enabled. /// // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global - public bool FullSynchronisation { get; set; } = false; + public Observable FullSynchronisation { get; } = new(false); /// /// The last used server settings in a hosted server. @@ -83,6 +83,8 @@ public static ModSettings Load() { // Try to load the mod settings from the file or construct a new instance if the util returns null var modSettings = FileUtil.LoadObjectFromJsonFile(filePath); + modSettings?.AcceptChanges(); + return modSettings ?? New(); ModSettings New() { @@ -104,5 +106,6 @@ public void Save() { } FileUtil.WriteObjectToJsonFile(this, filePath); + AcceptChanges(); } } diff --git a/SSMP/Game/Settings/ServerSettings.cs b/SSMP/Game/Settings/ServerSettings.cs index 0cb8f4d..9f2afee 100644 --- a/SSMP/Game/Settings/ServerSettings.cs +++ b/SSMP/Game/Settings/ServerSettings.cs @@ -1,6 +1,7 @@ using System; using SSMP.Api.Server; using SSMP.Ui.Menu; +using SSMP.Util; // ReSharper disable UnusedAutoPropertyAccessor.Global // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global @@ -9,36 +10,36 @@ namespace SSMP.Game.Settings; /// -public class ServerSettings : IServerSettings, IEquatable { +public class ServerSettings : ObservableBase, IServerSettings, IEquatable { /// [SettingAlias("pvp")] [ModMenuSetting("PvP", "Player versus Player damage")] - public bool IsPvpEnabled { get; set; } + public Observable IsPvpEnabled { get; } = new(false); /// [SettingAlias("globalmapicons")] [ModMenuSetting("Global Map Icons", "Always show map icons for all players")] - public bool AlwaysShowMapIcons { get; set; } + public Observable AlwaysShowMapIcons { get; } = new(false); /// [SettingAlias("compassicon", "compassicons")] [ModMenuSetting("Compass Map Icons", "Only show map icons when Compass is equipped")] - public bool OnlyBroadcastMapIconWithCompass { get; set; } = true; + public Observable OnlyBroadcastMapIconWithCompass { get; } = new(true); /// [SettingAlias("names")] [ModMenuSetting("Show Names", "Show names of player above their characters")] - public bool DisplayNames { get; set; } = true; + public Observable DisplayNames { get; } = new(true); /// [SettingAlias("teams")] [ModMenuSetting("Teams", "Whether players can join teams")] - public bool TeamsEnabled { get; set; } + public Observable TeamsEnabled { get; } = new(false); /// [SettingAlias("skins")] [ModMenuSetting("Skins", "Whether players can have skins")] - public bool AllowSkins { get; set; } = true; + public Observable AllowSkins { get; } = new(true); // /// // [SettingAlias("parries")] @@ -126,13 +127,15 @@ public class ServerSettings : IServerSettings, IEquatable { /// /// The instance to copy from. public void SetAllProperties(ServerSettings serverSettings) { - // Use reflection to copy over all properties into this object + // Use reflection to copy over all observable values into this object foreach (var prop in GetType().GetProperties()) { if (!prop.CanRead || !prop.CanWrite) { continue; } - prop.SetValue(this, prop.GetValue(serverSettings, null), null); + if (prop.GetValue(this) is IObservable myObs + && prop.GetValue(serverSettings) is IObservable otherObs) + myObs.Value = otherObs.Value; } } @@ -161,10 +164,12 @@ public bool Equals(ServerSettings other) { if (!prop.CanRead) { continue; } - - if (prop.GetValue(this) != prop.GetValue(other)) { - return false; - } + + var myValue = prop.GetValue(this); + var otherValue = prop.GetValue(other); + var myUnwrapped = myValue is IObservable myObs ? myObs.Value : myValue; + var otherUnwrapped = otherValue is IObservable otherObs ? otherObs.Value : otherValue; + if (!Equals(myUnwrapped, otherUnwrapped)) return false; } return true; @@ -196,9 +201,10 @@ public override int GetHashCode() { if (!prop.CanRead) { continue; } - - var propHashCode = prop.GetValue(this).GetHashCode(); - + + var raw = prop.GetValue(this); + var propHashCode = (raw is IObservable obs ? obs.Value : raw)?.GetHashCode() ?? 0; + if (first) { hashCode = propHashCode; first = false; diff --git a/SSMP/Serialization/ObservableConverter.cs b/SSMP/Serialization/ObservableConverter.cs new file mode 100644 index 0000000..aafe024 --- /dev/null +++ b/SSMP/Serialization/ObservableConverter.cs @@ -0,0 +1,43 @@ +using System; +using Newtonsoft.Json; +using SSMP.Util; + +namespace SSMP.Serialization; + +/// +/// A for that serializes and deserializes the underlying +/// value directly. +/// +public class ObservableConverter : JsonConverter +{ + /// + public override bool CanConvert(Type objectType) { + return objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(Observable<>); + } + + /// + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { + if (value == null) { + writer.WriteNull(); + return; + } + + var valueProp = value.GetType().GetProperty("Value"); + var innerValue = valueProp?.GetValue(value); + serializer.Serialize(writer, innerValue); + } + + /// + public override object? ReadJson( + JsonReader reader, + Type objectType, + object? existingValue, + JsonSerializer serializer + ) { + var innerType = objectType.GetGenericArguments()[0]; + var innerValue = serializer.Deserialize(reader, innerType); + + // Create a new Observable with the deserialized value + return Activator.CreateInstance(objectType, innerValue); + } +} diff --git a/SSMP/Ui/ConnectInterface.cs b/SSMP/Ui/ConnectInterface.cs index e9e450b..8524cbc 100644 --- a/SSMP/Ui/ConnectInterface.cs +++ b/SSMP/Ui/ConnectInterface.cs @@ -1678,9 +1678,9 @@ private static bool TryParsePort(string portString, out int port) { /// Saves connection settings (address, port, username) to persistent storage. /// private void SaveConnectionSettings(string address, int port, string username) { - _modSettings.ConnectAddress = address; - _modSettings.ConnectPort = port; - _modSettings.Username = username; + _modSettings.ConnectAddress.Value = address; + _modSettings.ConnectPort.Value = port; + _modSettings.Username.Value = username; _modSettings.Save(); } diff --git a/SSMP/Ui/Menu/ModMenu.cs b/SSMP/Ui/Menu/ModMenu.cs index 45cd784..44e4ae3 100644 --- a/SSMP/Ui/Menu/ModMenu.cs +++ b/SSMP/Ui/Menu/ModMenu.cs @@ -20,7 +20,7 @@ namespace SSMP.Ui.Menu; /// -/// Class for building the HKMP mod menu. +/// Class for building the SSMP mod menu. /// internal class ModMenu { /// @@ -30,7 +30,7 @@ internal class ModMenu { private const float SettingApplyDelay = 1.5f; /// - /// The HKMP mod settings instance. + /// The SSMP mod settings instance. /// private readonly ModSettings _modSettings; @@ -55,9 +55,9 @@ internal class ModMenu { private readonly List> _serverSettingsChangedCallbacks; /// - /// The top-level HKMP mod menu. + /// The top-level SSMP mod menu. /// - private MenuScreen _hkmpMenu; + private MenuScreen _ssmpMenu; /// /// The menu containing the client settings. Needs to be a static variable here to allow it to be accessed by /// lambdas and modified. @@ -74,7 +74,7 @@ internal class ModMenu { /// A local copy of the server settings for modification through the menu that will be used to either network to /// the server or modify our own hosted servers. /// - private ServerSettings _localServerSettings; + private readonly ServerSettings _localServerSettings; /// /// Coroutine that delays applying new server settings until no more changes are made within a certain time period. @@ -133,6 +133,11 @@ NetClient netClient _netClient = netClient; _serverSettingsChangedCallbacks = []; + + // Subscribe to settings changes. + // Commented out since we don't have a way to update the + // setting from in-game currently. + // _modSettings.OnChanged += _=> { _modSettings.Save(); }; } // /// diff --git a/SSMP/Util/Observable.cs b/SSMP/Util/Observable.cs new file mode 100644 index 0000000..b6207c0 --- /dev/null +++ b/SSMP/Util/Observable.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using SSMP.Serialization; + +namespace SSMP.Util; + +/// +/// A wrapper for a value that tracks changes from an original baseline and provides events for when the value is modified. +/// This class implements to allow for non-generic change tracking at the collection level. +/// +/// The type of the underlying value to track. +[JsonConverter(typeof(ObservableConverter))] +public sealed class Observable : IObservable { + private T _value; + private T _original; + + /// + /// Event triggered whenever the value is changed. + /// Passes the new value to the subscribers. + /// + public event Action? OnChanged; + + /// + /// Initializes a new instance of the class with the specified initial value. + /// Both the current value and the original baseline are set to the initial value. + /// + /// The initial value to track. + public Observable(T initialValue) { + _value = initialValue; + _original = initialValue; + } + + /// + /// Gets or sets the current value of the observable. + /// Setting a value that is different from the current value triggers the event. + /// + public T Value { + get => _value; + set { + if (EqualityComparer.Default.Equals(_value, value)) { + return; + } + + _value = value; + OnChanged?.Invoke(value); + } + } + + /// + /// Gets a value indicating whether the current value has been modified from its original baseline. + /// + public bool IsModified => !EqualityComparer.Default.Equals(_value, _original); + + /// + /// Resets the original baseline to the current value, clearing the status. + /// + public void AcceptChanges() { + _original = _value; + } + + /// + object? IObservable.Value { + get => Value; + set => Value = (T) value!; + } + + /// + /// Implicitly converts an instance to its underlying value of type . + /// This allows for transparent reading of the value in most contexts. + /// + /// The observable instance to convert. + public static implicit operator T(Observable observable) => observable._value; + + /// + /// Returns the string representation of the underlying value. + /// + /// The string representation of the value, or null if the value is null. + public override string? ToString() => _value?.ToString(); +} diff --git a/SSMP/Util/ObservableBase.cs b/SSMP/Util/ObservableBase.cs new file mode 100644 index 0000000..8452580 --- /dev/null +++ b/SSMP/Util/ObservableBase.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace SSMP.Util; + +/// +/// Base class for settings objects that automatically discovers and tracks all +/// fields and properties declared on concrete subclasses. +/// Reflection runs only once per concrete type; all per-instance and per-event-fire +/// paths are allocation-free. +/// +public abstract class ObservableBase { + /// + /// All instances discovered on this concrete instance, + /// used for bulk checks and sweeps. + /// + private readonly List _managedObservables = []; + + /// + /// Maps each concrete subclass type to the set of members + /// discovered via reflection. Written once per type under ; + /// safe for concurrent reads thereafter. + /// + private static readonly Dictionary MemberCache = []; + + /// + /// Guards the one-time write to for each new concrete type. + /// + private static readonly object MemberCacheLock = new(); + + /// + /// Raised whenever any tracked member changes. + /// The argument is the member's resolved name (from or + /// the member name itself). + /// + public event Action? OnChanged; + + /// + /// Initializes the instance by discovering and subscribing to all + /// members on the concrete type. + /// + protected ObservableBase() { + InitializeObservables(); + } + + /// + /// Scans the concrete type for fields and properties, + /// caches the member list per type, then subscribes to each member's change event. + /// + private void InitializeObservables() { + var type = GetType(); + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + + MemberInfo[] members; + lock (MemberCacheLock) { + if (!MemberCache.TryGetValue(type, out members!)) { + var fields = type.GetFields(flags); + var properties = type.GetProperties(flags); + var result = new List(fields.Length + properties.Length); + + result.AddRange(fields.Where(f => IsObservableType(f.FieldType))); + result.AddRange(properties.Where(IsObservableProperty)); + + members = result.ToArray(); + MemberCache[type] = members; + } + } + + foreach (var member in members) { + InitializeMember(member); + } + } + + /// + /// Resolves the instance and its reported name for a given member, + /// then wires it to . + /// + /// The reflected field or property to initialize. + private void InitializeMember(MemberInfo member) { + Type memberType; + IObservable? observable; + + if (member is FieldInfo fi) { + memberType = fi.FieldType; + observable = fi.GetValue(this) as IObservable; + } else { + var pi = (PropertyInfo) member; + memberType = pi.PropertyType; + observable = pi.GetValue(this) as IObservable; + } + + if (observable == null) { + return; + } + + var alias = member.GetCustomAttribute(); + var name = alias?.PropertyName ?? member.Name; + + Subscribe(observable, name, memberType); + _managedObservables.Add(observable); + } + + /// + /// Returns true if is a closed or open construction of + /// . + /// + /// The type to test. + private static bool IsObservableType(Type t) => + t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Observable<>); + + /// + /// Returns true if is a non-indexed property of type + /// . + /// + /// The property to test. + private static bool IsObservableProperty(PropertyInfo p) => + p.GetIndexParameters().Length == 0 && IsObservableType(p.PropertyType); + + /// + /// Wires a to the member's OnChanged event. + /// The wrapper closes over (parent, name) and exposes a concrete Handle(T) + /// method; binds to it directly, producing a plain + /// virtual call on each event fire. + /// + /// The instance to subscribe to. + /// The resolved member name surfaced through . + /// The closed generic type of the member, used to reflect its event and construct the wrapper. + private void Subscribe(IObservable observable, string name, Type memberType) { + var innerType = memberType.GetGenericArguments()[0]; + var eventInfo = memberType.GetEvent("OnChanged")!; + var actionType = typeof(Action<>).MakeGenericType(innerType); + + var wrapperType = typeof(ChangeHandlerWrapper<>).MakeGenericType(innerType); + var wrapper = Activator.CreateInstance(wrapperType, this, name)!; + var handleMethod = wrapperType.GetMethod(nameof(ChangeHandlerWrapper<>.Handle))!; + var handler = Delegate.CreateDelegate(actionType, wrapper, handleMethod); + + eventInfo.AddEventHandler(observable, handler); + } + + /// + /// Closes over (parent, name) so the delegate produced from Handle is a + /// plain bound-method call. The new value is intentionally discarded - + /// surfaces only the member name. + /// + /// The value type of the being watched. + /// The instance that owns the subscription. + /// The resolved member name to forward to . + private sealed class ChangeHandlerWrapper(ObservableBase parent, string name) { + /// + /// Invoked by the event; forwards the member + /// name to . The new value is intentionally ignored. + /// + /// The new value; unused. + public void Handle(T _) => parent.OnChanged?.Invoke(name); + } + + /// + /// Returns true if any tracked observable has been modified since the last + /// call. + /// + public bool IsModified { + get { return _managedObservables.Any(o => o.IsModified); } + } + + /// + /// Resets the original baseline for all tracked observables. + /// + public void AcceptChanges() { + foreach (var o in _managedObservables) + o.AcceptChanges(); + } +} + +/// +/// Attribute used to mark a field or property as an observable member and explicitly map it to a +/// change-event name. When absent, the name is derived automatically from the member name. +/// +/// The name to surface in . +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] +public abstract class SettingAliasAttribute(string propertyName) : Attribute { + /// + /// The name surfaced through in place of the + /// member's declared name. + /// + public string PropertyName { get; } = propertyName; +} + +/// +/// Non-generic contract implemented by to allow uniform state +/// management in without per-call reflection or boxing. +/// +internal interface IObservable { + /// + /// Gets or sets the underlying value as an object. + /// + object? Value { get; set; } + + /// + /// true if the value has changed since the last call. + /// + bool IsModified { get; } + + /// + /// Snapshots the current value as the new baseline, clearing . + /// + void AcceptChanges(); +} From 68d9248992e3d30e639337c258cc4be4b3cdf5e0 Mon Sep 17 00:00:00 2001 From: Liparakis Date: Tue, 31 Mar 2026 12:34:07 +0300 Subject: [PATCH 04/16] chore: LINQ -> forloops and handled SettingsAlias edge case. --- SSMP/Util/ObservableBase.cs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/SSMP/Util/ObservableBase.cs b/SSMP/Util/ObservableBase.cs index 8452580..37cd762 100644 --- a/SSMP/Util/ObservableBase.cs +++ b/SSMP/Util/ObservableBase.cs @@ -63,6 +63,18 @@ private void InitializeObservables() { result.AddRange(fields.Where(f => IsObservableType(f.FieldType))); result.AddRange(properties.Where(IsObservableProperty)); + var seen = new HashSet(result.Count); + // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator + foreach (var m in result) { + var alias = m.GetCustomAttribute(); + var resolvedName = alias?.PropertyName ?? m.Name; + if (!seen.Add(resolvedName)) { + throw new InvalidOperationException( + $"{type.Name} declares two observable members that both resolve to the name \"{resolvedName}\". " + + "Use [SettingAlias] to assign distinct names."); + } + } + members = result.ToArray(); MemberCache[type] = members; } @@ -162,7 +174,14 @@ private sealed class ChangeHandlerWrapper(ObservableBase parent, string name) /// call. /// public bool IsModified { - get { return _managedObservables.Any(o => o.IsModified); } + get { + // ReSharper disable once ForCanBeConvertedToForeach + // ReSharper disable once LoopCanBeConvertedToQuery + for (var i = 0; i < _managedObservables.Count; i++) { + if (_managedObservables[i].IsModified) return true; + } + return false; + } } /// From 3983a781e41e036d58e8898135a9ea71d45c7cd0 Mon Sep 17 00:00:00 2001 From: Liparakis Date: Tue, 31 Mar 2026 14:30:24 +0300 Subject: [PATCH 05/16] fix: Observable-backed server settings reflection --- SSMP/Game/Command/Server/SettingsCommand.cs | 21 ++++--- SSMP/Game/Settings/ServerSettings.cs | 9 ++- .../Packet/Data/ServerSettingsUpdate.cs | 50 +++++++++------ SSMP/Util/ObservableBase.cs | 19 +----- SSMP/Util/ObservableReflection.cs | 63 +++++++++++++++++++ 5 files changed, 115 insertions(+), 47 deletions(-) create mode 100644 SSMP/Util/ObservableReflection.cs diff --git a/SSMP/Game/Command/Server/SettingsCommand.cs b/SSMP/Game/Command/Server/SettingsCommand.cs index 193bb86..3b6a41f 100644 --- a/SSMP/Game/Command/Server/SettingsCommand.cs +++ b/SSMP/Game/Command/Server/SettingsCommand.cs @@ -4,6 +4,7 @@ using SSMP.Game.Server; using SSMP.Game.Settings; using SSMP.Api.Command; +using SSMP.Util; namespace SSMP.Game.Command.Server; @@ -76,29 +77,31 @@ public virtual void Execute(ICommandSender commandSender, string[] args) { if (args.Length < 3) { // The user only supplied the name of the setting, so we print its value - var currentValue = settingProperty.GetValue(ServerSettings, null); + var displayedValue = ObservableReflection.GetUnwrappedPropertyValue(settingProperty, ServerSettings); - commandSender.SendMessage($"Setting '{propName}' currently has value: {currentValue}"); + commandSender.SendMessage($"Setting '{propName}' currently has value: {displayedValue}"); return; } var newValueString = args[2]; - if (!settingProperty.CanWrite) { + var settingType = ObservableReflection.UnwrapType(settingProperty.PropertyType); + + if (!settingProperty.CanWrite && !ObservableReflection.IsObservableType(settingProperty.PropertyType)) { commandSender.SendMessage($"Could not change value of setting with name: {propName} (non-writable)"); return; } object newValueObject; - if (settingProperty.PropertyType == typeof(bool)) { + if (settingType == typeof(bool)) { if (!bool.TryParse(newValueString, out var newValueBool)) { commandSender.SendMessage("Please provide a boolean value (true/false) for this setting"); return; } newValueObject = newValueBool; - } else if (settingProperty.PropertyType == typeof(byte)) { + } else if (settingType == typeof(byte)) { if (!byte.TryParse(newValueString, out var newValueByte)) { commandSender.SendMessage("Please provide a byte value (>= 0 and <= 255) for this setting"); return; @@ -111,12 +114,16 @@ public virtual void Execute(ICommandSender commandSender, string[] args) { return; } - if (settingProperty.GetValue(ServerSettings).Equals(newValueObject)) { + var existingValue = ObservableReflection.GetUnwrappedPropertyValue(settingProperty, ServerSettings); + if (Equals(existingValue, newValueObject)) { commandSender.SendMessage($"Setting '{propName}' already has value: {newValueObject}"); return; } - settingProperty.SetValue(ServerSettings, newValueObject, null); + if (!ObservableReflection.TrySetPropertyValue(settingProperty, ServerSettings, newValueObject)) { + commandSender.SendMessage($"Could not change value of setting with name: {propName} (non-writable)"); + return; + } commandSender.SendMessage($"Changed setting '{propName}' to: {newValueObject}"); diff --git a/SSMP/Game/Settings/ServerSettings.cs b/SSMP/Game/Settings/ServerSettings.cs index 9f2afee..25f96b1 100644 --- a/SSMP/Game/Settings/ServerSettings.cs +++ b/SSMP/Game/Settings/ServerSettings.cs @@ -129,13 +129,12 @@ public class ServerSettings : ObservableBase, IServerSettings, IEquatable public ServerSettings ServerSettings { get; set; } = null!; + + /// Maps syncable property types to their packet read handlers. + private static readonly Dictionary> Readers = new() { + [typeof(bool)] = p => p.ReadBool(), + [typeof(byte)] = p => p.ReadByte(), + }; + + /// Maps syncable property types to their packet write handlers. + private static readonly Dictionary> Writers = new() { + [typeof(bool)] = (p, v) => p.Write((bool) v), + [typeof(byte)] = (p, v) => p.Write((byte) v), + }; /// public void WriteData(IPacket packet) { - // Use reflection to loop over all properties and write their values to the packet foreach (var prop in ServerSettings.GetType().GetProperties()) { - if (!prop.CanRead) { + if (!ObservableReflection.IsSyncableProperty(prop)) continue; + + var type = ObservableReflection.UnwrapType(prop.PropertyType); + + if (!Writers.TryGetValue(type, out var write)) { + Logger.Error($"No write handler for property type: {prop.PropertyType}"); continue; } - if (prop.PropertyType == typeof(bool)) { - packet.Write((bool) prop.GetValue(ServerSettings, null)); - } else if (prop.PropertyType == typeof(byte)) { - packet.Write((byte) prop.GetValue(ServerSettings, null)); - } else { - Logger.Error($"No write handler for property type: {prop.GetType()}"); - } + write(packet, ObservableReflection.GetUnwrappedPropertyValue(prop, ServerSettings)!); } } @@ -42,19 +55,18 @@ public void WriteData(IPacket packet) { public void ReadData(IPacket packet) { ServerSettings = new ServerSettings(); - // Use reflection to loop over all properties and set their value by reading from the packet foreach (var prop in ServerSettings.GetType().GetProperties()) { - if (!prop.CanWrite) { + if (!ObservableReflection.IsSyncableProperty(prop)) continue; + + var type = ObservableReflection.UnwrapType(prop.PropertyType); + + if (!Readers.TryGetValue(type, out var read)) { + Logger.Error($"No read handler for property type: {prop.PropertyType}"); continue; } - // ReSharper disable once OperatorIsCanBeUsed - if (prop.PropertyType == typeof(bool)) { - prop.SetValue(ServerSettings, packet.ReadBool(), null); - } else if (prop.PropertyType == typeof(byte)) { - prop.SetValue(ServerSettings, packet.ReadByte(), null); - } else { - Logger.Error($"No read handler for property type: {prop.GetType()}"); + if (!ObservableReflection.TrySetPropertyValue(prop, ServerSettings, read(packet))) { + Logger.Error($"Could not set reflected property value for: {prop.Name}"); } } } diff --git a/SSMP/Util/ObservableBase.cs b/SSMP/Util/ObservableBase.cs index 37cd762..a1f4515 100644 --- a/SSMP/Util/ObservableBase.cs +++ b/SSMP/Util/ObservableBase.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using SSMP.Game.Settings; namespace SSMP.Util; @@ -67,7 +68,7 @@ private void InitializeObservables() { // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator foreach (var m in result) { var alias = m.GetCustomAttribute(); - var resolvedName = alias?.PropertyName ?? m.Name; + var resolvedName = alias?.Aliases.FirstOrDefault() ?? m.Name; if (!seen.Add(resolvedName)) { throw new InvalidOperationException( $"{type.Name} declares two observable members that both resolve to the name \"{resolvedName}\". " + @@ -108,7 +109,7 @@ private void InitializeMember(MemberInfo member) { } var alias = member.GetCustomAttribute(); - var name = alias?.PropertyName ?? member.Name; + var name = alias?.Aliases.FirstOrDefault() ?? member.Name; Subscribe(observable, name, memberType); _managedObservables.Add(observable); @@ -193,20 +194,6 @@ public void AcceptChanges() { } } -/// -/// Attribute used to mark a field or property as an observable member and explicitly map it to a -/// change-event name. When absent, the name is derived automatically from the member name. -/// -/// The name to surface in . -[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] -public abstract class SettingAliasAttribute(string propertyName) : Attribute { - /// - /// The name surfaced through in place of the - /// member's declared name. - /// - public string PropertyName { get; } = propertyName; -} - /// /// Non-generic contract implemented by to allow uniform state /// management in without per-call reflection or boxing. diff --git a/SSMP/Util/ObservableReflection.cs b/SSMP/Util/ObservableReflection.cs new file mode 100644 index 0000000..65d1c61 --- /dev/null +++ b/SSMP/Util/ObservableReflection.cs @@ -0,0 +1,63 @@ +using System; +using System.Reflection; + +namespace SSMP.Util; + +/// +/// Helpers for reflection-based settings code that needs to treat +/// as its wrapped value. +/// +internal static class ObservableReflection { + /// + /// Returns true for properties that should be included in settings sync. + /// + public static bool IsSyncableProperty(PropertyInfo propertyInfo) { + if (propertyInfo.GetIndexParameters().Length != 0 || !propertyInfo.CanRead) { + return false; + } + + return IsObservableType(propertyInfo.PropertyType) || propertyInfo.CanWrite; + } + + /// + /// Converts Observable<T> to T; leaves other types unchanged. + /// + public static Type UnwrapType(Type type) => + IsObservableType(type) ? type.GetGenericArguments()[0] : type; + + /// + /// Extracts the inner value from an observable wrapper. + /// + private static object? UnwrapValue(object? value) => + value is IObservable observable ? observable.Value : value; + + /// + /// Reads a property value and unwraps it when the property is observable. + /// + public static object? GetUnwrappedPropertyValue(PropertyInfo propertyInfo, object target) => + UnwrapValue(propertyInfo.GetValue(target, null)); + + /// + /// Sets an observable's inner value when present, otherwise uses the normal setter. + /// + public static bool TrySetPropertyValue(PropertyInfo propertyInfo, object target, object? value) { + var currentValue = propertyInfo.GetValue(target, null); + if (currentValue is IObservable observable) { + observable.Value = value; + return true; + } + + if (!propertyInfo.CanWrite) { + return false; + } + + propertyInfo.SetValue(target, value, null); + return true; + } + + /// + /// Detects closed generic Observable<T> types. + /// + public static bool IsObservableType(Type type) => + type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Observable<>); +} From 0ca3b3942a5b44ea1f4b31cd94a847c02e4f8e6d Mon Sep 17 00:00:00 2001 From: Liparakis Date: Tue, 31 Mar 2026 15:06:48 +0300 Subject: [PATCH 06/16] chore: rollback --- SSMP/Api/Server/IServerSettings.cs | 13 +- SSMP/Game/Client/ClientManager.cs | 2 +- SSMP/Game/Command/Server/SettingsCommand.cs | 20 +- SSMP/Game/Settings/ModSettings.cs | 52 ++++- SSMP/Game/Settings/ServerSettings.cs | 191 ++++++---------- .../Packet/Data/ServerSettingsUpdate.cs | 45 ++-- SSMP/Serialization/ObservableConverter.cs | 43 ---- SSMP/Ui/ConnectInterface.cs | 6 +- SSMP/Util/Observable.cs | 80 ------- SSMP/Util/ObservableBase.cs | 216 ------------------ SSMP/Util/ObservableReflection.cs | 63 ----- 11 files changed, 146 insertions(+), 585 deletions(-) delete mode 100644 SSMP/Serialization/ObservableConverter.cs delete mode 100644 SSMP/Util/Observable.cs delete mode 100644 SSMP/Util/ObservableBase.cs delete mode 100644 SSMP/Util/ObservableReflection.cs diff --git a/SSMP/Api/Server/IServerSettings.cs b/SSMP/Api/Server/IServerSettings.cs index f9001e1..76b2a1f 100644 --- a/SSMP/Api/Server/IServerSettings.cs +++ b/SSMP/Api/Server/IServerSettings.cs @@ -1,5 +1,4 @@ using System; -using SSMP.Util; namespace SSMP.Api.Server; @@ -15,32 +14,32 @@ public interface IServerSettings { /// /// Whether player vs. player damage is enabled. /// - public Observable IsPvpEnabled { get; } + public bool IsPvpEnabled { get; } /// /// Whether to always show map icons. /// - public Observable AlwaysShowMapIcons { get; } + public bool AlwaysShowMapIcons { get; } /// /// Whether to only broadcast the map icon of a player if they have wayward compass equipped. /// - public Observable OnlyBroadcastMapIconWithCompass { get; } + public bool OnlyBroadcastMapIconWithCompass { get; } /// /// Whether to display player names above the player objects. /// - public Observable DisplayNames { get; } + public bool DisplayNames { get; } /// /// Whether teams are enabled. /// - public Observable TeamsEnabled { get; } + public bool TeamsEnabled { get; } /// /// Whether skins are allowed. /// - public Observable AllowSkins { get; } + public bool AllowSkins { get; } // /// // /// Whether other player's attacks can be parried. diff --git a/SSMP/Game/Client/ClientManager.cs b/SSMP/Game/Client/ClientManager.cs index 699abc2..962b19a 100644 --- a/SSMP/Game/Client/ClientManager.cs +++ b/SSMP/Game/Client/ClientManager.cs @@ -1140,7 +1140,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { } if (alwaysShowMapChanged || onlyCompassChanged) { - if (ServerSettings is { AlwaysShowMapIcons.Value: false, OnlyBroadcastMapIconWithCompass.Value: false }) { + if (ServerSettings is { AlwaysShowMapIcons: false, OnlyBroadcastMapIconWithCompass: false }) { _mapManager.RemoveAllIcons(); } } diff --git a/SSMP/Game/Command/Server/SettingsCommand.cs b/SSMP/Game/Command/Server/SettingsCommand.cs index 3b6a41f..ba3eaa4 100644 --- a/SSMP/Game/Command/Server/SettingsCommand.cs +++ b/SSMP/Game/Command/Server/SettingsCommand.cs @@ -4,7 +4,6 @@ using SSMP.Game.Server; using SSMP.Game.Settings; using SSMP.Api.Command; -using SSMP.Util; namespace SSMP.Game.Command.Server; @@ -77,7 +76,7 @@ public virtual void Execute(ICommandSender commandSender, string[] args) { if (args.Length < 3) { // The user only supplied the name of the setting, so we print its value - var displayedValue = ObservableReflection.GetUnwrappedPropertyValue(settingProperty, ServerSettings); + var displayedValue = settingProperty.GetValue(ServerSettings, null); commandSender.SendMessage($"Setting '{propName}' currently has value: {displayedValue}"); return; @@ -85,23 +84,21 @@ public virtual void Execute(ICommandSender commandSender, string[] args) { var newValueString = args[2]; - var settingType = ObservableReflection.UnwrapType(settingProperty.PropertyType); - - if (!settingProperty.CanWrite && !ObservableReflection.IsObservableType(settingProperty.PropertyType)) { + if (!settingProperty.CanWrite) { commandSender.SendMessage($"Could not change value of setting with name: {propName} (non-writable)"); return; } object newValueObject; - if (settingType == typeof(bool)) { + if (settingProperty.PropertyType == typeof(bool)) { if (!bool.TryParse(newValueString, out var newValueBool)) { commandSender.SendMessage("Please provide a boolean value (true/false) for this setting"); return; } newValueObject = newValueBool; - } else if (settingType == typeof(byte)) { + } else if (settingProperty.PropertyType == typeof(byte)) { if (!byte.TryParse(newValueString, out var newValueByte)) { commandSender.SendMessage("Please provide a byte value (>= 0 and <= 255) for this setting"); return; @@ -114,17 +111,12 @@ public virtual void Execute(ICommandSender commandSender, string[] args) { return; } - var existingValue = ObservableReflection.GetUnwrappedPropertyValue(settingProperty, ServerSettings); - if (Equals(existingValue, newValueObject)) { + if (Equals(settingProperty.GetValue(ServerSettings), newValueObject)) { commandSender.SendMessage($"Setting '{propName}' already has value: {newValueObject}"); return; } - if (!ObservableReflection.TrySetPropertyValue(settingProperty, ServerSettings, newValueObject)) { - commandSender.SendMessage($"Could not change value of setting with name: {propName} (non-writable)"); - return; - } - + settingProperty.SetValue(ServerSettings, newValueObject, null); commandSender.SendMessage($"Changed setting '{propName}' to: {newValueObject}"); _serverManager.OnUpdateServerSettings(); diff --git a/SSMP/Game/Settings/ModSettings.cs b/SSMP/Game/Settings/ModSettings.cs index 0de7101..ab3e913 100644 --- a/SSMP/Game/Settings/ModSettings.cs +++ b/SSMP/Game/Settings/ModSettings.cs @@ -10,11 +10,13 @@ namespace SSMP.Game.Settings; /// /// Settings class that stores user preferences. /// -internal class ModSettings : ObservableBase { +internal class ModSettings { /// /// The name of the file containing the mod settings. /// private const string ModSettingsFileName = "modsettings.json"; + + public event System.Action? OnChanged; /// /// The authentication key for the user. @@ -30,22 +32,50 @@ internal class ModSettings : ObservableBase { /// /// The last used address to join a server. /// - public Observable ConnectAddress { get; } = new(""); + public string ConnectAddress { + get; + set { + if (field == value) return; + field = value; + OnChanged?.Invoke(nameof(ConnectAddress)); + } + } = ""; /// /// The last used port to join a server. /// - public Observable ConnectPort { get; } = new(-1); + public int ConnectPort { + get; + set { + if (field == value) return; + field = value; + OnChanged?.Invoke(nameof(ConnectPort)); + } + } = -1; /// /// The last used username to join a server. /// - public Observable Username { get; } = new(""); + public string Username { + get; + set { + if (field == value) return; + field = value; + OnChanged?.Invoke(nameof(Username)); + } + } = ""; /// /// Whether to display a UI element for the ping. /// - public Observable DisplayPing { get; } = new(true); + public bool DisplayPing { + get; + init { + if (field == value) return; + field = value; + OnChanged?.Invoke(nameof(DisplayPing)); + } + } = true; /// /// Set of addon names for addons that are disabled by the user. @@ -57,7 +87,14 @@ internal class ModSettings : ObservableBase { /// Whether full synchronization of bosses, enemies, worlds, and saves is enabled. /// // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global - public Observable FullSynchronisation { get; } = new(false); + public bool FullSynchronisation { + get; + set { + if (field == value) return; + field = value; + OnChanged?.Invoke(nameof(FullSynchronisation)); + } + } /// /// The last used server settings in a hosted server. @@ -83,8 +120,6 @@ public static ModSettings Load() { // Try to load the mod settings from the file or construct a new instance if the util returns null var modSettings = FileUtil.LoadObjectFromJsonFile(filePath); - modSettings?.AcceptChanges(); - return modSettings ?? New(); ModSettings New() { @@ -106,6 +141,5 @@ public void Save() { } FileUtil.WriteObjectToJsonFile(this, filePath); - AcceptChanges(); } } diff --git a/SSMP/Game/Settings/ServerSettings.cs b/SSMP/Game/Settings/ServerSettings.cs index 25f96b1..e42c4d5 100644 --- a/SSMP/Game/Settings/ServerSettings.cs +++ b/SSMP/Game/Settings/ServerSettings.cs @@ -1,7 +1,6 @@ using System; using SSMP.Api.Server; using SSMP.Ui.Menu; -using SSMP.Util; // ReSharper disable UnusedAutoPropertyAccessor.Global // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global @@ -10,116 +9,81 @@ namespace SSMP.Game.Settings; /// -public class ServerSettings : ObservableBase, IServerSettings, IEquatable { +public class ServerSettings : IServerSettings, IEquatable { + /// + public event Action? OnChanged; + /// [SettingAlias("pvp")] [ModMenuSetting("PvP", "Player versus Player damage")] - public Observable IsPvpEnabled { get; } = new(false); + public bool IsPvpEnabled { + get; + set { + if (field == value) return; + field = value; + OnChanged?.Invoke(nameof(IsPvpEnabled)); + } + } /// [SettingAlias("globalmapicons")] [ModMenuSetting("Global Map Icons", "Always show map icons for all players")] - public Observable AlwaysShowMapIcons { get; } = new(false); + public bool AlwaysShowMapIcons { + get; + set { + if (field == value) return; + field = value; + OnChanged?.Invoke(nameof(AlwaysShowMapIcons)); + } + } /// [SettingAlias("compassicon", "compassicons")] [ModMenuSetting("Compass Map Icons", "Only show map icons when Compass is equipped")] - public Observable OnlyBroadcastMapIconWithCompass { get; } = new(true); + public bool OnlyBroadcastMapIconWithCompass { + get; + init { + if (field == value) return; + field = value; + OnChanged?.Invoke(nameof(OnlyBroadcastMapIconWithCompass)); + } + } = true; /// [SettingAlias("names")] [ModMenuSetting("Show Names", "Show names of player above their characters")] - public Observable DisplayNames { get; } = new(true); + public bool DisplayNames { + get; + init { + if (field == value) return; + field = value; + OnChanged?.Invoke(nameof(DisplayNames)); + } + } = true; /// [SettingAlias("teams")] [ModMenuSetting("Teams", "Whether players can join teams")] - public Observable TeamsEnabled { get; } = new(false); + public bool TeamsEnabled { + get; + set { + if (field == value) return; + field = value; + OnChanged?.Invoke(nameof(TeamsEnabled)); + } + } /// [SettingAlias("skins")] [ModMenuSetting("Skins", "Whether players can have skins")] - public Observable AllowSkins { get; } = new(true); - - // /// - // [SettingAlias("parries")] - // [ModMenuSetting("Parries", "Whether parrying certain player attacks is possible")] - // public bool AllowParries { get; set; } = true; - // - // /// - // [SettingAlias("naildmg")] - // [ModMenuSetting("Nail Damage", "The number of masks of damage that a player's nail swing deals")] - // public byte NailDamage { get; set; } = 1; - // - // /// - // [SettingAlias("elegydmg")] - // [ModMenuSetting("Grubberfly's Elegy Damage", "The number of masks of damage that Grubberfly's Elegy deals")] - // public byte GrubberflyElegyDamage { get; set; } = 1; - // - // /// - // [SettingAlias("vsdmg", "fireballdamage", "fireballdmg")] - // [ModMenuSetting("Vengeful Spirit Damage", "The number of masks of damage that Vengeful Spirit deals")] - // public byte VengefulSpiritDamage { get; set; } = 1; - // - // /// - // [SettingAlias("shadesouldmg")] - // [ModMenuSetting("Shade Soul Damage", "The number of masks of damage that Shade Soul deals")] - // public byte ShadeSoulDamage { get; set; } = 2; - // - // /// - // [SettingAlias("desolatedivedmg", "ddivedmg")] - // [ModMenuSetting("Desolate Dive Damage", "The number of masks of damage that Desolate Dive deals")] - // public byte DesolateDiveDamage { get; set; } = 1; - // - // /// - // [SettingAlias("descendingdarkdmg", "ddarkdmg")] - // [ModMenuSetting("Descending Dark Damage", "The number of masks of damage that Descending Dark deals")] - // public byte DescendingDarkDamage { get; set; } = 2; - // - // /// - // [SettingAlias("howlingwraithsdamage", "howlingwraithsdmg", "wraithsdmg")] - // [ModMenuSetting("Howling Wraiths Damage", "The number of masks of damage that Howling Wraiths deals")] - // public byte HowlingWraithDamage { get; set; } = 1; - // - // /// - // [SettingAlias("abyssshriekdmg", "shriekdmg")] - // [ModMenuSetting("Abyss Shriek Damage", "The number of masks of damage that Abyss Shriek deals")] - // public byte AbyssShriekDamage { get; set; } = 2; - // - // /// - // [SettingAlias("greatslashdmg")] - // [ModMenuSetting("Great Slash Damage", "The number of masks of damage that Great Slash deals")] - // public byte GreatSlashDamage { get; set; } = 2; - // - // /// - // [SettingAlias("dashslashdmg")] - // [ModMenuSetting("Dash Slash Damage", "The number of masks of damage that Dash Slash deals")] - // public byte DashSlashDamage { get; set; } = 2; - // - // /// - // [SettingAlias("cycloneslashdmg", "cyclonedmg")] - // [ModMenuSetting("Cyclone Slash Damage", "The number of masks of damage that Cyclone Slash deals")] - // public byte CycloneSlashDamage { get; set; } = 1; - // - // /// - // [SettingAlias("sporeshroomdmg")] - // [ModMenuSetting("Spore Shroom Damage", "The number of masks of damage that a Spore Shroom cloud deals")] - // public byte SporeShroomDamage { get; set; } = 1; - // - // /// - // [SettingAlias("sporedungshroomdmg", "dungshroomdmg")] - // [ModMenuSetting("Spore-Dung Shroom Damage", "The number of masks of damage that a Spore Shroom cloud with Defender's Crest deals")] - // public byte SporeDungShroomDamage { get; set; } = 1; - // - // /// - // [SettingAlias("thornsofagonydamage", "thornsofagonydmg", "thornsdamage", "thornsdmg")] - // [ModMenuSetting("Thorns of Agongy Damage", "The number of masks of damage that the Thorns of Agony lash deals")] - // public byte ThornOfAgonyDamage { get; set; } = 1; - // - // /// - // [SettingAlias("sharpshadowdmg")] - // [ModMenuSetting("Sharp Shadow Damage", "The number of masks of damage that a Sharp Shadow dash deals")] - // public byte SharpShadowDamage { get; set; } = 1; + public bool AllowSkins { + get; + init { + if (field == value) return; + field = value; + OnChanged?.Invoke(nameof(AllowSkins)); + } + } = true; /// /// Set all properties in this instance to the values from the given @@ -127,14 +91,12 @@ public class ServerSettings : ObservableBase, IServerSettings, IEquatable /// The instance to copy from. public void SetAllProperties(ServerSettings serverSettings) { - // Use reflection to copy over all observable values into this object foreach (var prop in GetType().GetProperties()) { - if (!prop.CanRead) { + if (!prop.CanRead || !prop.CanWrite || prop.DeclaringType != typeof(ServerSettings)) { continue; } - var otherValue = ObservableReflection.GetUnwrappedPropertyValue(prop, serverSettings); - ObservableReflection.TrySetPropertyValue(prop, this, otherValue); + prop.SetValue(this, prop.GetValue(serverSettings)); } } @@ -154,85 +116,76 @@ public bool Equals(ServerSettings other) { if (ReferenceEquals(null, other)) { return false; } - + if (ReferenceEquals(this, other)) { return true; } - + foreach (var prop in GetType().GetProperties()) { - if (!ObservableReflection.IsSyncableProperty(prop)) { + if (!prop.CanRead || prop.DeclaringType != typeof(ServerSettings)) { continue; } - var myValue = prop.GetValue(this); - var otherValue = prop.GetValue(other); - var myUnwrapped = myValue is IObservable myObs ? myObs.Value : myValue; - var otherUnwrapped = otherValue is IObservable otherObs ? otherObs.Value : otherValue; - if (!Equals(myUnwrapped, otherUnwrapped)) return false; + if (!Equals(prop.GetValue(this), prop.GetValue(other))) { + return false; + } } - + return true; } - + /// public override bool Equals(object? obj) { if (ReferenceEquals(null, obj)) { return false; } - + if (ReferenceEquals(this, obj)) { return true; } - + if (obj.GetType() != GetType()) { return false; } - + return Equals((ServerSettings) obj); } - + /// public override int GetHashCode() { unchecked { var hashCode = 0; var first = true; foreach (var prop in GetType().GetProperties()) { - if (!prop.CanRead) { + if (!prop.CanRead || prop.DeclaringType != typeof(ServerSettings)) { continue; } - var raw = prop.GetValue(this); - var propHashCode = (raw is IObservable obs ? obs.Value : raw)?.GetHashCode() ?? 0; + var propHashCode = prop.GetValue(this)?.GetHashCode() ?? 0; if (first) { hashCode = propHashCode; first = false; continue; } - + hashCode = (hashCode * 397) ^ propHashCode; } - + return hashCode; } } - + /// /// Indicates whether one is equal to another . /// - /// The first to compare. - /// The second to compare. - /// true if is equal to ; false otherwise. public static bool operator ==(ServerSettings? left, ServerSettings? right) { return Equals(left, right); } - + /// /// Indicates whether one is not equal to another . /// - /// The first to compare. - /// The second to compare. - /// true if is not equal to ; false otherwise. public static bool operator !=(ServerSettings? left, ServerSettings? right) { return !Equals(left, right); } diff --git a/SSMP/Networking/Packet/Data/ServerSettingsUpdate.cs b/SSMP/Networking/Packet/Data/ServerSettingsUpdate.cs index 8075278..5b57d16 100644 --- a/SSMP/Networking/Packet/Data/ServerSettingsUpdate.cs +++ b/SSMP/Networking/Packet/Data/ServerSettingsUpdate.cs @@ -1,8 +1,5 @@ -using System; -using System.Collections.Generic; using SSMP.Game.Settings; using SSMP.Logging; -using SSMP.Util; namespace SSMP.Networking.Packet.Data; @@ -22,32 +19,21 @@ internal class ServerSettingsUpdate : IPacketData { /// The server settings instance. /// public ServerSettings ServerSettings { get; set; } = null!; - - /// Maps syncable property types to their packet read handlers. - private static readonly Dictionary> Readers = new() { - [typeof(bool)] = p => p.ReadBool(), - [typeof(byte)] = p => p.ReadByte(), - }; - - /// Maps syncable property types to their packet write handlers. - private static readonly Dictionary> Writers = new() { - [typeof(bool)] = (p, v) => p.Write((bool) v), - [typeof(byte)] = (p, v) => p.Write((byte) v), - }; /// public void WriteData(IPacket packet) { foreach (var prop in ServerSettings.GetType().GetProperties()) { - if (!ObservableReflection.IsSyncableProperty(prop)) continue; - - var type = ObservableReflection.UnwrapType(prop.PropertyType); - - if (!Writers.TryGetValue(type, out var write)) { - Logger.Error($"No write handler for property type: {prop.PropertyType}"); + if (!prop.CanRead || !prop.CanWrite || prop.DeclaringType != typeof(ServerSettings)) { continue; } - write(packet, ObservableReflection.GetUnwrappedPropertyValue(prop, ServerSettings)!); + if (prop.PropertyType == typeof(bool)) { + packet.Write((bool) prop.GetValue(ServerSettings, null)); + } else if (prop.PropertyType == typeof(byte)) { + packet.Write((byte) prop.GetValue(ServerSettings, null)); + } else { + Logger.Error($"No write handler for property type: {prop.PropertyType}"); + } } } @@ -56,17 +42,16 @@ public void ReadData(IPacket packet) { ServerSettings = new ServerSettings(); foreach (var prop in ServerSettings.GetType().GetProperties()) { - if (!ObservableReflection.IsSyncableProperty(prop)) continue; - - var type = ObservableReflection.UnwrapType(prop.PropertyType); - - if (!Readers.TryGetValue(type, out var read)) { - Logger.Error($"No read handler for property type: {prop.PropertyType}"); + if (!prop.CanRead || !prop.CanWrite || prop.DeclaringType != typeof(ServerSettings)) { continue; } - if (!ObservableReflection.TrySetPropertyValue(prop, ServerSettings, read(packet))) { - Logger.Error($"Could not set reflected property value for: {prop.Name}"); + if (prop.PropertyType == typeof(bool)) { + prop.SetValue(ServerSettings, packet.ReadBool(), null); + } else if (prop.PropertyType == typeof(byte)) { + prop.SetValue(ServerSettings, packet.ReadByte(), null); + } else { + Logger.Error($"No read handler for property type: {prop.PropertyType}"); } } } diff --git a/SSMP/Serialization/ObservableConverter.cs b/SSMP/Serialization/ObservableConverter.cs deleted file mode 100644 index aafe024..0000000 --- a/SSMP/Serialization/ObservableConverter.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using Newtonsoft.Json; -using SSMP.Util; - -namespace SSMP.Serialization; - -/// -/// A for that serializes and deserializes the underlying -/// value directly. -/// -public class ObservableConverter : JsonConverter -{ - /// - public override bool CanConvert(Type objectType) { - return objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(Observable<>); - } - - /// - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { - if (value == null) { - writer.WriteNull(); - return; - } - - var valueProp = value.GetType().GetProperty("Value"); - var innerValue = valueProp?.GetValue(value); - serializer.Serialize(writer, innerValue); - } - - /// - public override object? ReadJson( - JsonReader reader, - Type objectType, - object? existingValue, - JsonSerializer serializer - ) { - var innerType = objectType.GetGenericArguments()[0]; - var innerValue = serializer.Deserialize(reader, innerType); - - // Create a new Observable with the deserialized value - return Activator.CreateInstance(objectType, innerValue); - } -} diff --git a/SSMP/Ui/ConnectInterface.cs b/SSMP/Ui/ConnectInterface.cs index 8524cbc..e9e450b 100644 --- a/SSMP/Ui/ConnectInterface.cs +++ b/SSMP/Ui/ConnectInterface.cs @@ -1678,9 +1678,9 @@ private static bool TryParsePort(string portString, out int port) { /// Saves connection settings (address, port, username) to persistent storage. /// private void SaveConnectionSettings(string address, int port, string username) { - _modSettings.ConnectAddress.Value = address; - _modSettings.ConnectPort.Value = port; - _modSettings.Username.Value = username; + _modSettings.ConnectAddress = address; + _modSettings.ConnectPort = port; + _modSettings.Username = username; _modSettings.Save(); } diff --git a/SSMP/Util/Observable.cs b/SSMP/Util/Observable.cs deleted file mode 100644 index b6207c0..0000000 --- a/SSMP/Util/Observable.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Collections.Generic; -using Newtonsoft.Json; -using SSMP.Serialization; - -namespace SSMP.Util; - -/// -/// A wrapper for a value that tracks changes from an original baseline and provides events for when the value is modified. -/// This class implements to allow for non-generic change tracking at the collection level. -/// -/// The type of the underlying value to track. -[JsonConverter(typeof(ObservableConverter))] -public sealed class Observable : IObservable { - private T _value; - private T _original; - - /// - /// Event triggered whenever the value is changed. - /// Passes the new value to the subscribers. - /// - public event Action? OnChanged; - - /// - /// Initializes a new instance of the class with the specified initial value. - /// Both the current value and the original baseline are set to the initial value. - /// - /// The initial value to track. - public Observable(T initialValue) { - _value = initialValue; - _original = initialValue; - } - - /// - /// Gets or sets the current value of the observable. - /// Setting a value that is different from the current value triggers the event. - /// - public T Value { - get => _value; - set { - if (EqualityComparer.Default.Equals(_value, value)) { - return; - } - - _value = value; - OnChanged?.Invoke(value); - } - } - - /// - /// Gets a value indicating whether the current value has been modified from its original baseline. - /// - public bool IsModified => !EqualityComparer.Default.Equals(_value, _original); - - /// - /// Resets the original baseline to the current value, clearing the status. - /// - public void AcceptChanges() { - _original = _value; - } - - /// - object? IObservable.Value { - get => Value; - set => Value = (T) value!; - } - - /// - /// Implicitly converts an instance to its underlying value of type . - /// This allows for transparent reading of the value in most contexts. - /// - /// The observable instance to convert. - public static implicit operator T(Observable observable) => observable._value; - - /// - /// Returns the string representation of the underlying value. - /// - /// The string representation of the value, or null if the value is null. - public override string? ToString() => _value?.ToString(); -} diff --git a/SSMP/Util/ObservableBase.cs b/SSMP/Util/ObservableBase.cs deleted file mode 100644 index a1f4515..0000000 --- a/SSMP/Util/ObservableBase.cs +++ /dev/null @@ -1,216 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using SSMP.Game.Settings; - -namespace SSMP.Util; - -/// -/// Base class for settings objects that automatically discovers and tracks all -/// fields and properties declared on concrete subclasses. -/// Reflection runs only once per concrete type; all per-instance and per-event-fire -/// paths are allocation-free. -/// -public abstract class ObservableBase { - /// - /// All instances discovered on this concrete instance, - /// used for bulk checks and sweeps. - /// - private readonly List _managedObservables = []; - - /// - /// Maps each concrete subclass type to the set of members - /// discovered via reflection. Written once per type under ; - /// safe for concurrent reads thereafter. - /// - private static readonly Dictionary MemberCache = []; - - /// - /// Guards the one-time write to for each new concrete type. - /// - private static readonly object MemberCacheLock = new(); - - /// - /// Raised whenever any tracked member changes. - /// The argument is the member's resolved name (from or - /// the member name itself). - /// - public event Action? OnChanged; - - /// - /// Initializes the instance by discovering and subscribing to all - /// members on the concrete type. - /// - protected ObservableBase() { - InitializeObservables(); - } - - /// - /// Scans the concrete type for fields and properties, - /// caches the member list per type, then subscribes to each member's change event. - /// - private void InitializeObservables() { - var type = GetType(); - const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; - - MemberInfo[] members; - lock (MemberCacheLock) { - if (!MemberCache.TryGetValue(type, out members!)) { - var fields = type.GetFields(flags); - var properties = type.GetProperties(flags); - var result = new List(fields.Length + properties.Length); - - result.AddRange(fields.Where(f => IsObservableType(f.FieldType))); - result.AddRange(properties.Where(IsObservableProperty)); - - var seen = new HashSet(result.Count); - // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator - foreach (var m in result) { - var alias = m.GetCustomAttribute(); - var resolvedName = alias?.Aliases.FirstOrDefault() ?? m.Name; - if (!seen.Add(resolvedName)) { - throw new InvalidOperationException( - $"{type.Name} declares two observable members that both resolve to the name \"{resolvedName}\". " + - "Use [SettingAlias] to assign distinct names."); - } - } - - members = result.ToArray(); - MemberCache[type] = members; - } - } - - foreach (var member in members) { - InitializeMember(member); - } - } - - /// - /// Resolves the instance and its reported name for a given member, - /// then wires it to . - /// - /// The reflected field or property to initialize. - private void InitializeMember(MemberInfo member) { - Type memberType; - IObservable? observable; - - if (member is FieldInfo fi) { - memberType = fi.FieldType; - observable = fi.GetValue(this) as IObservable; - } else { - var pi = (PropertyInfo) member; - memberType = pi.PropertyType; - observable = pi.GetValue(this) as IObservable; - } - - if (observable == null) { - return; - } - - var alias = member.GetCustomAttribute(); - var name = alias?.Aliases.FirstOrDefault() ?? member.Name; - - Subscribe(observable, name, memberType); - _managedObservables.Add(observable); - } - - /// - /// Returns true if is a closed or open construction of - /// . - /// - /// The type to test. - private static bool IsObservableType(Type t) => - t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Observable<>); - - /// - /// Returns true if is a non-indexed property of type - /// . - /// - /// The property to test. - private static bool IsObservableProperty(PropertyInfo p) => - p.GetIndexParameters().Length == 0 && IsObservableType(p.PropertyType); - - /// - /// Wires a to the member's OnChanged event. - /// The wrapper closes over (parent, name) and exposes a concrete Handle(T) - /// method; binds to it directly, producing a plain - /// virtual call on each event fire. - /// - /// The instance to subscribe to. - /// The resolved member name surfaced through . - /// The closed generic type of the member, used to reflect its event and construct the wrapper. - private void Subscribe(IObservable observable, string name, Type memberType) { - var innerType = memberType.GetGenericArguments()[0]; - var eventInfo = memberType.GetEvent("OnChanged")!; - var actionType = typeof(Action<>).MakeGenericType(innerType); - - var wrapperType = typeof(ChangeHandlerWrapper<>).MakeGenericType(innerType); - var wrapper = Activator.CreateInstance(wrapperType, this, name)!; - var handleMethod = wrapperType.GetMethod(nameof(ChangeHandlerWrapper<>.Handle))!; - var handler = Delegate.CreateDelegate(actionType, wrapper, handleMethod); - - eventInfo.AddEventHandler(observable, handler); - } - - /// - /// Closes over (parent, name) so the delegate produced from Handle is a - /// plain bound-method call. The new value is intentionally discarded - - /// surfaces only the member name. - /// - /// The value type of the being watched. - /// The instance that owns the subscription. - /// The resolved member name to forward to . - private sealed class ChangeHandlerWrapper(ObservableBase parent, string name) { - /// - /// Invoked by the event; forwards the member - /// name to . The new value is intentionally ignored. - /// - /// The new value; unused. - public void Handle(T _) => parent.OnChanged?.Invoke(name); - } - - /// - /// Returns true if any tracked observable has been modified since the last - /// call. - /// - public bool IsModified { - get { - // ReSharper disable once ForCanBeConvertedToForeach - // ReSharper disable once LoopCanBeConvertedToQuery - for (var i = 0; i < _managedObservables.Count; i++) { - if (_managedObservables[i].IsModified) return true; - } - return false; - } - } - - /// - /// Resets the original baseline for all tracked observables. - /// - public void AcceptChanges() { - foreach (var o in _managedObservables) - o.AcceptChanges(); - } -} - -/// -/// Non-generic contract implemented by to allow uniform state -/// management in without per-call reflection or boxing. -/// -internal interface IObservable { - /// - /// Gets or sets the underlying value as an object. - /// - object? Value { get; set; } - - /// - /// true if the value has changed since the last call. - /// - bool IsModified { get; } - - /// - /// Snapshots the current value as the new baseline, clearing . - /// - void AcceptChanges(); -} diff --git a/SSMP/Util/ObservableReflection.cs b/SSMP/Util/ObservableReflection.cs deleted file mode 100644 index 65d1c61..0000000 --- a/SSMP/Util/ObservableReflection.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using System.Reflection; - -namespace SSMP.Util; - -/// -/// Helpers for reflection-based settings code that needs to treat -/// as its wrapped value. -/// -internal static class ObservableReflection { - /// - /// Returns true for properties that should be included in settings sync. - /// - public static bool IsSyncableProperty(PropertyInfo propertyInfo) { - if (propertyInfo.GetIndexParameters().Length != 0 || !propertyInfo.CanRead) { - return false; - } - - return IsObservableType(propertyInfo.PropertyType) || propertyInfo.CanWrite; - } - - /// - /// Converts Observable<T> to T; leaves other types unchanged. - /// - public static Type UnwrapType(Type type) => - IsObservableType(type) ? type.GetGenericArguments()[0] : type; - - /// - /// Extracts the inner value from an observable wrapper. - /// - private static object? UnwrapValue(object? value) => - value is IObservable observable ? observable.Value : value; - - /// - /// Reads a property value and unwraps it when the property is observable. - /// - public static object? GetUnwrappedPropertyValue(PropertyInfo propertyInfo, object target) => - UnwrapValue(propertyInfo.GetValue(target, null)); - - /// - /// Sets an observable's inner value when present, otherwise uses the normal setter. - /// - public static bool TrySetPropertyValue(PropertyInfo propertyInfo, object target, object? value) { - var currentValue = propertyInfo.GetValue(target, null); - if (currentValue is IObservable observable) { - observable.Value = value; - return true; - } - - if (!propertyInfo.CanWrite) { - return false; - } - - propertyInfo.SetValue(target, value, null); - return true; - } - - /// - /// Detects closed generic Observable<T> types. - /// - public static bool IsObservableType(Type type) => - type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Observable<>); -} From be80006e85e06e558e6034c6a41fd10cae59f7bd Mon Sep 17 00:00:00 2001 From: Liparakis Date: Tue, 31 Mar 2026 15:09:40 +0300 Subject: [PATCH 07/16] chore: rollback --- SSMP/Game/Settings/ServerSettings.cs | 81 ++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/SSMP/Game/Settings/ServerSettings.cs b/SSMP/Game/Settings/ServerSettings.cs index e42c4d5..37ae06b 100644 --- a/SSMP/Game/Settings/ServerSettings.cs +++ b/SSMP/Game/Settings/ServerSettings.cs @@ -85,6 +85,87 @@ public bool AllowSkins { } } = true; + // /// + // [SettingAlias("parries")] + // [ModMenuSetting("Parries", "Whether parrying certain player attacks is possible")] + // public bool AllowParries { get; set; } = true; + // + // /// + // [SettingAlias("naildmg")] + // [ModMenuSetting("Nail Damage", "The number of masks of damage that a player's nail swing deals")] + // public byte NailDamage { get; set; } = 1; + // + // /// + // [SettingAlias("elegydmg")] + // [ModMenuSetting("Grubberfly's Elegy Damage", "The number of masks of damage that Grubberfly's Elegy deals")] + // public byte GrubberflyElegyDamage { get; set; } = 1; + // + // /// + // [SettingAlias("vsdmg", "fireballdamage", "fireballdmg")] + // [ModMenuSetting("Vengeful Spirit Damage", "The number of masks of damage that Vengeful Spirit deals")] + // public byte VengefulSpiritDamage { get; set; } = 1; + // + // /// + // [SettingAlias("shadesouldmg")] + // [ModMenuSetting("Shade Soul Damage", "The number of masks of damage that Shade Soul deals")] + // public byte ShadeSoulDamage { get; set; } = 2; + // + // /// + // [SettingAlias("desolatedivedmg", "ddivedmg")] + // [ModMenuSetting("Desolate Dive Damage", "The number of masks of damage that Desolate Dive deals")] + // public byte DesolateDiveDamage { get; set; } = 1; + // + // /// + // [SettingAlias("descendingdarkdmg", "ddarkdmg")] + // [ModMenuSetting("Descending Dark Damage", "The number of masks of damage that Descending Dark deals")] + // public byte DescendingDarkDamage { get; set; } = 2; + // + // /// + // [SettingAlias("howlingwraithsdamage", "howlingwraithsdmg", "wraithsdmg")] + // [ModMenuSetting("Howling Wraiths Damage", "The number of masks of damage that Howling Wraiths deals")] + // public byte HowlingWraithDamage { get; set; } = 1; + // + // /// + // [SettingAlias("abyssshriekdmg", "shriekdmg")] + // [ModMenuSetting("Abyss Shriek Damage", "The number of masks of damage that Abyss Shriek deals")] + // public byte AbyssShriekDamage { get; set; } = 2; + // + // /// + // [SettingAlias("greatslashdmg")] + // [ModMenuSetting("Great Slash Damage", "The number of masks of damage that Great Slash deals")] + // public byte GreatSlashDamage { get; set; } = 2; + // + // /// + // [SettingAlias("dashslashdmg")] + // [ModMenuSetting("Dash Slash Damage", "The number of masks of damage that Dash Slash deals")] + // public byte DashSlashDamage { get; set; } = 2; + // + // /// + // [SettingAlias("cycloneslashdmg", "cyclonedmg")] + // [ModMenuSetting("Cyclone Slash Damage", "The number of masks of damage that Cyclone Slash deals")] + // public byte CycloneSlashDamage { get; set; } = 1; + // + // /// + // [SettingAlias("sporeshroomdmg")] + // [ModMenuSetting("Spore Shroom Damage", "The number of masks of damage that a Spore Shroom cloud deals")] + // public byte SporeShroomDamage { get; set; } = 1; + // + // /// + // [SettingAlias("sporedungshroomdmg", "dungshroomdmg")] + // [ModMenuSetting("Spore-Dung Shroom Damage", "The number of masks of damage that a Spore Shroom cloud with + // Defender's Crest deals")] + // public byte SporeDungShroomDamage { get; set; } = 1; + // + // /// + // [SettingAlias("thornsofagonydamage", "thornsofagonydmg", "thornsdamage", "thornsdmg")] + // [ModMenuSetting("Thorns of Agongy Damage", "The number of masks of damage that the Thorns of Agony lash deals")] + // public byte ThornOfAgonyDamage { get; set; } = 1; + // + // /// + // [SettingAlias("sharpshadowdmg")] + // [ModMenuSetting("Sharp Shadow Damage", "The number of masks of damage that a Sharp Shadow dash deals")] + // public byte SharpShadowDamage { get; set; } = 1; + /// /// Set all properties in this instance to the values from the given /// instance. From c353eb598c0fbbf50c3571e04be8815edc7ff586 Mon Sep 17 00:00:00 2001 From: Liparakis Date: Tue, 31 Mar 2026 15:14:06 +0300 Subject: [PATCH 08/16] rollback --- SSMP/Game/Command/Server/SettingsCommand.cs | 7 +-- SSMP/Game/Settings/ServerSettings.cs | 52 ++++++++++++--------- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/SSMP/Game/Command/Server/SettingsCommand.cs b/SSMP/Game/Command/Server/SettingsCommand.cs index ba3eaa4..193bb86 100644 --- a/SSMP/Game/Command/Server/SettingsCommand.cs +++ b/SSMP/Game/Command/Server/SettingsCommand.cs @@ -76,9 +76,9 @@ public virtual void Execute(ICommandSender commandSender, string[] args) { if (args.Length < 3) { // The user only supplied the name of the setting, so we print its value - var displayedValue = settingProperty.GetValue(ServerSettings, null); + var currentValue = settingProperty.GetValue(ServerSettings, null); - commandSender.SendMessage($"Setting '{propName}' currently has value: {displayedValue}"); + commandSender.SendMessage($"Setting '{propName}' currently has value: {currentValue}"); return; } @@ -111,12 +111,13 @@ public virtual void Execute(ICommandSender commandSender, string[] args) { return; } - if (Equals(settingProperty.GetValue(ServerSettings), newValueObject)) { + if (settingProperty.GetValue(ServerSettings).Equals(newValueObject)) { commandSender.SendMessage($"Setting '{propName}' already has value: {newValueObject}"); return; } settingProperty.SetValue(ServerSettings, newValueObject, null); + commandSender.SendMessage($"Changed setting '{propName}' to: {newValueObject}"); _serverManager.OnUpdateServerSettings(); diff --git a/SSMP/Game/Settings/ServerSettings.cs b/SSMP/Game/Settings/ServerSettings.cs index 37ae06b..81a6c8d 100644 --- a/SSMP/Game/Settings/ServerSettings.cs +++ b/SSMP/Game/Settings/ServerSettings.cs @@ -152,8 +152,7 @@ public bool AllowSkins { // // /// // [SettingAlias("sporedungshroomdmg", "dungshroomdmg")] - // [ModMenuSetting("Spore-Dung Shroom Damage", "The number of masks of damage that a Spore Shroom cloud with - // Defender's Crest deals")] + // [ModMenuSetting("Spore-Dung Shroom Damage", "The number of masks of damage that a Spore Shroom cloud with Defender's Crest deals")] // public byte SporeDungShroomDamage { get; set; } = 1; // // /// @@ -172,12 +171,13 @@ public bool AllowSkins { /// /// The instance to copy from. public void SetAllProperties(ServerSettings serverSettings) { + // Use reflection to copy over all properties into this object foreach (var prop in GetType().GetProperties()) { - if (!prop.CanRead || !prop.CanWrite || prop.DeclaringType != typeof(ServerSettings)) { + if (!prop.CanRead || !prop.CanWrite) { continue; } - prop.SetValue(this, prop.GetValue(serverSettings)); + prop.SetValue(this, prop.GetValue(serverSettings, null), null); } } @@ -197,76 +197,82 @@ public bool Equals(ServerSettings other) { if (ReferenceEquals(null, other)) { return false; } - + if (ReferenceEquals(this, other)) { return true; } - + foreach (var prop in GetType().GetProperties()) { - if (!prop.CanRead || prop.DeclaringType != typeof(ServerSettings)) { + if (!prop.CanRead) { continue; } - - if (!Equals(prop.GetValue(this), prop.GetValue(other))) { + + if (prop.GetValue(this) != prop.GetValue(other)) { return false; } } - + return true; } - + /// public override bool Equals(object? obj) { if (ReferenceEquals(null, obj)) { return false; } - + if (ReferenceEquals(this, obj)) { return true; } - + if (obj.GetType() != GetType()) { return false; } - + return Equals((ServerSettings) obj); } - + /// public override int GetHashCode() { unchecked { var hashCode = 0; var first = true; foreach (var prop in GetType().GetProperties()) { - if (!prop.CanRead || prop.DeclaringType != typeof(ServerSettings)) { + if (!prop.CanRead) { continue; } - - var propHashCode = prop.GetValue(this)?.GetHashCode() ?? 0; - + + var propHashCode = prop.GetValue(this).GetHashCode(); + if (first) { hashCode = propHashCode; first = false; continue; } - + hashCode = (hashCode * 397) ^ propHashCode; } - + return hashCode; } } - + /// /// Indicates whether one is equal to another . /// + /// The first to compare. + /// The second to compare. + /// true if is equal to ; false otherwise. public static bool operator ==(ServerSettings? left, ServerSettings? right) { return Equals(left, right); } - + /// /// Indicates whether one is not equal to another . /// + /// The first to compare. + /// The second to compare. + /// true if is not equal to ; false otherwise. public static bool operator !=(ServerSettings? left, ServerSettings? right) { return !Equals(left, right); } From dace531cb6110a6dedd95050415b5bd19b860ec3 Mon Sep 17 00:00:00 2001 From: Liparakis Date: Tue, 31 Mar 2026 15:17:07 +0300 Subject: [PATCH 09/16] chore: rollback --- SSMP/Networking/Packet/Data/ServerSettingsUpdate.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/SSMP/Networking/Packet/Data/ServerSettingsUpdate.cs b/SSMP/Networking/Packet/Data/ServerSettingsUpdate.cs index 5b57d16..e0f5dab 100644 --- a/SSMP/Networking/Packet/Data/ServerSettingsUpdate.cs +++ b/SSMP/Networking/Packet/Data/ServerSettingsUpdate.cs @@ -22,8 +22,9 @@ internal class ServerSettingsUpdate : IPacketData { /// public void WriteData(IPacket packet) { + // Use reflection to loop over all properties and write their values to the packet foreach (var prop in ServerSettings.GetType().GetProperties()) { - if (!prop.CanRead || !prop.CanWrite || prop.DeclaringType != typeof(ServerSettings)) { + if (!prop.CanRead) { continue; } @@ -32,7 +33,7 @@ public void WriteData(IPacket packet) { } else if (prop.PropertyType == typeof(byte)) { packet.Write((byte) prop.GetValue(ServerSettings, null)); } else { - Logger.Error($"No write handler for property type: {prop.PropertyType}"); + Logger.Error($"No write handler for property type: {prop.GetType()}"); } } } @@ -41,17 +42,19 @@ public void WriteData(IPacket packet) { public void ReadData(IPacket packet) { ServerSettings = new ServerSettings(); + // Use reflection to loop over all properties and set their value by reading from the packet foreach (var prop in ServerSettings.GetType().GetProperties()) { - if (!prop.CanRead || !prop.CanWrite || prop.DeclaringType != typeof(ServerSettings)) { + if (!prop.CanWrite) { continue; } + // ReSharper disable once OperatorIsCanBeUsed if (prop.PropertyType == typeof(bool)) { prop.SetValue(ServerSettings, packet.ReadBool(), null); } else if (prop.PropertyType == typeof(byte)) { prop.SetValue(ServerSettings, packet.ReadByte(), null); } else { - Logger.Error($"No read handler for property type: {prop.PropertyType}"); + Logger.Error($"No read handler for property type: {prop.GetType()}"); } } } From e1e56a63c5a3565e672d0d6d48556398858584ae Mon Sep 17 00:00:00 2001 From: Liparakis Date: Tue, 31 Mar 2026 15:39:28 +0300 Subject: [PATCH 10/16] feat: simplification --- SSMP/Api/Client/IClientManager.cs | 11 ++--- SSMP/Game/Client/ClientManager.cs | 53 +++++++++++------------ SSMP/Game/Server/ServerManager.cs | 5 +-- SSMP/Networking/Packet/Data/ServerInfo.cs | 7 +++ 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/SSMP/Api/Client/IClientManager.cs b/SSMP/Api/Client/IClientManager.cs index 5966ed0..2b914f0 100644 --- a/SSMP/Api/Client/IClientManager.cs +++ b/SSMP/Api/Client/IClientManager.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; +using SSMP.Api.Server; using SSMP.Game; -using SSMP.Game.Settings; namespace SSMP.Api.Client; @@ -35,14 +35,9 @@ public interface IClientManager { IReadOnlyCollection Players { get; } /// - /// A read-only that contains the settings related to gameplay. + /// A read-only that contains the settings related to gameplay. /// - ServerSettings ServerSettings { get; } - - /// - /// Event that is called when the server settings change. - /// - event Action ServerSettingsChangedEvent; + IServerSettings ServerSettings { get; } /// /// Disconnect the local client from the server. diff --git a/SSMP/Game/Client/ClientManager.cs b/SSMP/Game/Client/ClientManager.cs index 962b19a..ecf9a8f 100644 --- a/SSMP/Game/Client/ClientManager.cs +++ b/SSMP/Game/Client/ClientManager.cs @@ -5,6 +5,7 @@ using Steamworks; using SSMP.Animation; using SSMP.Api.Client; +using SSMP.Api.Server; using SSMP.Eventing; using SSMP.Fsm; using SSMP.Game.Client.Entity; @@ -60,6 +61,11 @@ internal class ClientManager : IClientManager { /// private readonly ModSettings _modSettings; + /// + /// The mutable local copy of the current server settings. + /// + private readonly ServerSettings _serverSettings; + /// /// The player manager instance. /// @@ -162,12 +168,6 @@ internal class ClientManager : IClientManager { /// private bool _sceneHostDetermined; - /// - /// Event for when the server settings change after being received from the server. - /// The parameter for the action is a copy of the last received server settings. - /// - public event Action? ServerSettingsChangedEvent; - /// /// Event for when the player's team changes after being received from the server. /// @@ -186,7 +186,7 @@ internal class ClientManager : IClientManager { public IMapManager MapManager => _mapManager; /// - public ServerSettings ServerSettings { get; } + public IServerSettings ServerSettings => _serverSettings; /// public ushort Id { get; private set; } @@ -230,7 +230,7 @@ ModSettings modSettings _netClient = netClient; _packetManager = packetManager; _uiManager = uiManager; - ServerSettings = serverSettings; + _serverSettings = serverSettings; _modSettings = modSettings; _playerData = new Dictionary(); @@ -264,7 +264,7 @@ ModSettings modSettings /// public void Initialize(ServerManager serverManager) { _playerManager.Initialize(); - _animationManager.Initialize(ServerSettings); + _animationManager.Initialize(_serverSettings); _mapManager.Initialize(); // _entityManager.Initialize(); @@ -665,9 +665,7 @@ private void OnClientConnect(ServerInfo serverInfo) { Logger.Info("Received server info from server"); // Update the locally stored server settings - ServerSettings.SetAllProperties(serverInfo.ServerSettingsUpdate.ServerSettings); - // Call the event that the settings were updated - ServerSettingsChangedEvent?.Invoke(serverInfo.ServerSettingsUpdate.ServerSettings); + _serverSettings.SetAllProperties(serverInfo.ServerSettingsUpdate.ServerSettings); // Note whether full synchronization is enabled _fullSynchronisation = serverInfo.FullSynchronisation; @@ -710,12 +708,12 @@ private void OnClientConnect(ServerInfo serverInfo) { ); } + Id = serverInfo.SelfId; + _playerData[Id] = new ClientPlayerData(Id, _username!); + // Fill the player data dictionary with the info from the packet foreach (var (id, username) in serverInfo.PlayerInfo) { _playerData[id] = new ClientPlayerData(id, username); - - // If the username matches our own, we found our ID - if (username == _username) Id = id; } // Add the username to the player if we are in-game already @@ -1069,7 +1067,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { var newServerSettings = update.ServerSettings; // Check whether the PvP state changed - if (ServerSettings.IsPvpEnabled != newServerSettings.IsPvpEnabled) { + if (_serverSettings.IsPvpEnabled != newServerSettings.IsPvpEnabled) { var message = $"PvP is now {(newServerSettings.IsPvpEnabled ? "enabled" : "disabled")}"; UiManager.InternalChatBox.AddMessage(message); @@ -1077,7 +1075,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { } // Check whether the always show map icons state changed - if (ServerSettings.AlwaysShowMapIcons != newServerSettings.AlwaysShowMapIcons) { + if (_serverSettings.AlwaysShowMapIcons != newServerSettings.AlwaysShowMapIcons) { alwaysShowMapChanged = true; var message = @@ -1088,7 +1086,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { } // Check whether the wayward compass broadcast state changed - if (ServerSettings.OnlyBroadcastMapIconWithCompass != + if (_serverSettings.OnlyBroadcastMapIconWithCompass != newServerSettings.OnlyBroadcastMapIconWithCompass) { onlyCompassChanged = true; @@ -1100,7 +1098,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { } // Check whether the display names setting changed - if (ServerSettings.DisplayNames != newServerSettings.DisplayNames) { + if (_serverSettings.DisplayNames != newServerSettings.DisplayNames) { displayNamesChanged = true; var message = $"Names are {(newServerSettings.DisplayNames ? "now" : "no longer")} displayed"; @@ -1110,7 +1108,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { } // Check whether the teams enabled setting changed - if (ServerSettings.TeamsEnabled != newServerSettings.TeamsEnabled) { + if (_serverSettings.TeamsEnabled != newServerSettings.TeamsEnabled) { teamsChanged = true; var message = $"Teams are {(newServerSettings.TeamsEnabled ? "now" : "no longer")} enabled"; @@ -1120,7 +1118,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { } // Check whether allow skins setting changed - if (ServerSettings.AllowSkins != newServerSettings.AllowSkins) { + if (_serverSettings.AllowSkins != newServerSettings.AllowSkins) { allowSkinsChanged = true; var message = $"Skins are {(newServerSettings.AllowSkins ? "now" : "no longer")} enabled"; @@ -1130,9 +1128,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { } // Update the settings so callbacks can read updated values - ServerSettings.SetAllProperties(newServerSettings); - // Call the event that the settings were updated - ServerSettingsChangedEvent?.Invoke(newServerSettings); + _serverSettings.SetAllProperties(newServerSettings); // Only update the player manager if the either PvP or body damage have been changed if (displayNamesChanged) { @@ -1140,7 +1136,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { } if (alwaysShowMapChanged || onlyCompassChanged) { - if (ServerSettings is { AlwaysShowMapIcons: false, OnlyBroadcastMapIconWithCompass: false }) { + if (_serverSettings is { AlwaysShowMapIcons: false, OnlyBroadcastMapIconWithCompass: false }) { _mapManager.RemoveAllIcons(); } } @@ -1148,7 +1144,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { // If the teams setting changed, we invoke the registered event handler if they exist if (teamsChanged) { // If the team setting was disabled, we reset all teams and call the event - if (!ServerSettings.TeamsEnabled) { + if (!_serverSettings.TeamsEnabled) { _playerManager.ResetAllTeams(); TeamChangedEvent?.Invoke(Team.None); @@ -1159,7 +1155,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { // If the allow skins setting changed, and it is no longer allowed, we reset all existing skins and call the // event - if (allowSkinsChanged && !ServerSettings.AllowSkins) { + if (allowSkinsChanged && !_serverSettings.AllowSkins) { _playerManager.ResetAllPlayerSkins(); SkinChangedEvent?.Invoke(0); @@ -1305,6 +1301,9 @@ private void OnPlayerSettingUpdate(ClientPlayerSettingUpdate settingUpdate) { if (settingUpdate.UpdateTypes.Contains(PlayerSettingUpdateType.Team)) { if (settingUpdate.Self) { _playerManager.OnPlayerTeamUpdate(true, settingUpdate.Team); + if (_playerData.TryGetValue(Id, out var localPlayerData)) { + localPlayerData.Team = settingUpdate.Team; + } TeamChangedEvent?.Invoke(settingUpdate.Team); } else { diff --git a/SSMP/Game/Server/ServerManager.cs b/SSMP/Game/Server/ServerManager.cs index b3baecf..f0aa96b 100644 --- a/SSMP/Game/Server/ServerManager.cs +++ b/SSMP/Game/Server/ServerManager.cs @@ -1427,11 +1427,10 @@ out var correspondingServerAddon }; serverInfo.FullSynchronisation = FullSynchronisation; + serverInfo.SelfId = netServerClient.Id; // Construct the player info to send to the new client in the server info - var playerInfo = new List<(ushort, string)> { - (netServerClient.Id, clientInfo.Username) - }; + var playerInfo = new List<(ushort, string)>(); foreach (var idPlayerDataPair in _playerData) { var otherId = idPlayerDataPair.Key; diff --git a/SSMP/Networking/Packet/Data/ServerInfo.cs b/SSMP/Networking/Packet/Data/ServerInfo.cs index 1dec48a..7f7dac4 100644 --- a/SSMP/Networking/Packet/Data/ServerInfo.cs +++ b/SSMP/Networking/Packet/Data/ServerInfo.cs @@ -45,6 +45,11 @@ internal class ServerInfo : IPacketData { /// public bool FullSynchronisation { get; set; } + /// + /// The ID assigned to the connecting client. + /// + public ushort SelfId { get; set; } + /// /// The save data currently used on the server. /// @@ -69,6 +74,7 @@ public void WriteData(IPacket packet) { ServerSettingsUpdate.WriteData(packet); packet.Write(FullSynchronisation); + packet.Write(SelfId); // CurrentSave.WriteData(packet); @@ -116,6 +122,7 @@ public void ReadData(IPacket packet) { ServerSettingsUpdate.ReadData(packet); FullSynchronisation = packet.ReadBool(); + SelfId = packet.ReadUShort(); // CurrentSave = new CurrentSave(); // CurrentSave.ReadData(packet); From dd4e79a7c952594d22496e9a317ede6b541d46e5 Mon Sep 17 00:00:00 2001 From: Liparakis Date: Tue, 31 Mar 2026 15:49:04 +0300 Subject: [PATCH 11/16] chore: small rollback --- SSMP/Game/Client/ClientManager.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/SSMP/Game/Client/ClientManager.cs b/SSMP/Game/Client/ClientManager.cs index ecf9a8f..427f4ee 100644 --- a/SSMP/Game/Client/ClientManager.cs +++ b/SSMP/Game/Client/ClientManager.cs @@ -55,16 +55,16 @@ internal class ClientManager : IClientManager { /// The UI manager instance. /// private readonly UiManager _uiManager; - + /// - /// The loaded mod settings. + /// The current server settings. /// - private readonly ModSettings _modSettings; - + private readonly ServerSettings _serverSettings; + /// - /// The mutable local copy of the current server settings. + /// The loaded mod settings. /// - private readonly ServerSettings _serverSettings; + private readonly ModSettings _modSettings; /// /// The player manager instance. From abd06ada4498a3404d7484285a4561172c17db5f Mon Sep 17 00:00:00 2001 From: Liparakis Date: Tue, 31 Mar 2026 15:53:14 +0300 Subject: [PATCH 12/16] chore: Removed Ambiguous comment --- SSMP/Ui/Menu/ModMenu.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/SSMP/Ui/Menu/ModMenu.cs b/SSMP/Ui/Menu/ModMenu.cs index 44e4ae3..8f888df 100644 --- a/SSMP/Ui/Menu/ModMenu.cs +++ b/SSMP/Ui/Menu/ModMenu.cs @@ -133,11 +133,6 @@ NetClient netClient _netClient = netClient; _serverSettingsChangedCallbacks = []; - - // Subscribe to settings changes. - // Commented out since we don't have a way to update the - // setting from in-game currently. - // _modSettings.OnChanged += _=> { _modSettings.Save(); }; } // /// From 2e17a96f54ff5fe40608015a0610a9be32e468ad Mon Sep 17 00:00:00 2001 From: Liparakis Date: Mon, 6 Apr 2026 14:11:40 +0300 Subject: [PATCH 13/16] fix: Reverted ID exposure, implemented & exposed IModSettings interface and applied naming fixes. --- SSMP/Api/Client/IClientManager.cs | 10 +++--- SSMP/Api/Client/IClientPlayer.cs | 2 +- SSMP/Api/Client/IModSettings.cs | 39 +++++++++++++++++++++++ SSMP/Api/Server/IServerPlayer.cs | 2 +- SSMP/Api/Server/IServerSettings.cs | 2 +- SSMP/Game/Client/ClientManager.cs | 10 +----- SSMP/Game/Client/ClientPlayerData.cs | 4 +-- SSMP/Game/Server/ServerManager.cs | 4 ++- SSMP/Game/Server/ServerPlayerData.cs | 4 +-- SSMP/Game/Settings/ModSettings.cs | 18 +++++++---- SSMP/Game/Settings/ServerSettings.cs | 14 ++++---- SSMP/Networking/Packet/Data/ServerInfo.cs | 7 ---- 12 files changed, 73 insertions(+), 43 deletions(-) create mode 100644 SSMP/Api/Client/IModSettings.cs diff --git a/SSMP/Api/Client/IClientManager.cs b/SSMP/Api/Client/IClientManager.cs index 2b914f0..1fa38b7 100644 --- a/SSMP/Api/Client/IClientManager.cs +++ b/SSMP/Api/Client/IClientManager.cs @@ -19,11 +19,6 @@ public interface IClientManager { /// string Username { get; } - /// - /// The unique ID assigned to the local player. - /// - ushort Id { get; } - /// /// The current team of the local player. /// @@ -39,6 +34,11 @@ public interface IClientManager { /// IServerSettings ServerSettings { get; } + /// + /// A read-only that contains the settings related to the client. + /// + IModSettings ModSettings { get; } + /// /// Disconnect the local client from the server. /// diff --git a/SSMP/Api/Client/IClientPlayer.cs b/SSMP/Api/Client/IClientPlayer.cs index 1d70ee0..d1049b9 100644 --- a/SSMP/Api/Client/IClientPlayer.cs +++ b/SSMP/Api/Client/IClientPlayer.cs @@ -12,7 +12,7 @@ public interface IClientPlayer { /// /// Event triggered when the player changes their team. /// - public event Action? OnTeamChanged; + public event Action? TeamChangedEvent; /// /// The ID of the player. diff --git a/SSMP/Api/Client/IModSettings.cs b/SSMP/Api/Client/IModSettings.cs new file mode 100644 index 0000000..bef2d1f --- /dev/null +++ b/SSMP/Api/Client/IModSettings.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; + +namespace SSMP.Api.Client; + +/// +/// Settings related to the client/mod that are accessible to addons. +/// +public interface IModSettings { + /// + /// Event triggered whenever any of the mod settings are changed. + /// + event Action? ChangedEvent; + + /// + /// The last used address to join a server. + /// + string ConnectAddress { get; } + + /// + /// The last used port to join a server. + /// + int ConnectPort { get; } + + /// + /// The username of the player. + /// + string Username { get; } + + /// + /// Whether to display a UI element for the ping. + /// + bool DisplayPing { get; } + + /// + /// Whether full synchronization of bosses, enemies, worlds, and saves is enabled. + /// + bool FullSynchronisation { get; } +} diff --git a/SSMP/Api/Server/IServerPlayer.cs b/SSMP/Api/Server/IServerPlayer.cs index 58ab923..eab2bc9 100644 --- a/SSMP/Api/Server/IServerPlayer.cs +++ b/SSMP/Api/Server/IServerPlayer.cs @@ -12,7 +12,7 @@ public interface IServerPlayer { /// /// Event triggered when the player changes their team. /// - public event Action? OnTeamChanged; + public event Action? TeamChangedEvent; /// /// The ID of the player. diff --git a/SSMP/Api/Server/IServerSettings.cs b/SSMP/Api/Server/IServerSettings.cs index 76b2a1f..32d8c38 100644 --- a/SSMP/Api/Server/IServerSettings.cs +++ b/SSMP/Api/Server/IServerSettings.cs @@ -9,7 +9,7 @@ public interface IServerSettings { /// /// Event triggered whenever any of the server settings are changed. /// - public event Action? OnChanged; + public event Action? ChangedEvent; /// /// Whether player vs. player damage is enabled. diff --git a/SSMP/Game/Client/ClientManager.cs b/SSMP/Game/Client/ClientManager.cs index 427f4ee..1b985a2 100644 --- a/SSMP/Game/Client/ClientManager.cs +++ b/SSMP/Game/Client/ClientManager.cs @@ -189,7 +189,7 @@ internal class ClientManager : IClientManager { public IServerSettings ServerSettings => _serverSettings; /// - public ushort Id { get; private set; } + public IModSettings ModSettings => _modSettings; /// public string Username => !_netClient.IsConnected ? throw new Exception("Client is not connected, username is undefined") : _username!; @@ -553,7 +553,6 @@ private void InternalDisconnect() { Logger.Info("Disconnecting from server"); _autoConnect = false; - Id = 0; _netClient.Disconnect(); @@ -708,9 +707,6 @@ private void OnClientConnect(ServerInfo serverInfo) { ); } - Id = serverInfo.SelfId; - _playerData[Id] = new ClientPlayerData(Id, _username!); - // Fill the player data dictionary with the info from the packet foreach (var (id, username) in serverInfo.PlayerInfo) { _playerData[id] = new ClientPlayerData(id, username); @@ -1301,10 +1297,6 @@ private void OnPlayerSettingUpdate(ClientPlayerSettingUpdate settingUpdate) { if (settingUpdate.UpdateTypes.Contains(PlayerSettingUpdateType.Team)) { if (settingUpdate.Self) { _playerManager.OnPlayerTeamUpdate(true, settingUpdate.Team); - if (_playerData.TryGetValue(Id, out var localPlayerData)) { - localPlayerData.Team = settingUpdate.Team; - } - TeamChangedEvent?.Invoke(settingUpdate.Team); } else { _playerManager.OnPlayerTeamUpdate(false, settingUpdate.Team, settingUpdate.Id); diff --git a/SSMP/Game/Client/ClientPlayerData.cs b/SSMP/Game/Client/ClientPlayerData.cs index eec147e..f547831 100644 --- a/SSMP/Game/Client/ClientPlayerData.cs +++ b/SSMP/Game/Client/ClientPlayerData.cs @@ -8,7 +8,7 @@ namespace SSMP.Game.Client; /// internal class ClientPlayerData : IClientPlayer { /// - public event Action? OnTeamChanged; + public event Action? TeamChangedEvent; /// public ushort Id { get; } @@ -31,7 +31,7 @@ public Team Team { set { if (field == value) return; field = value; - OnTeamChanged?.Invoke(value); + TeamChangedEvent?.Invoke(value); } } diff --git a/SSMP/Game/Server/ServerManager.cs b/SSMP/Game/Server/ServerManager.cs index f0aa96b..3241ff3 100644 --- a/SSMP/Game/Server/ServerManager.cs +++ b/SSMP/Game/Server/ServerManager.cs @@ -1427,13 +1427,15 @@ out var correspondingServerAddon }; serverInfo.FullSynchronisation = FullSynchronisation; - serverInfo.SelfId = netServerClient.Id; // Construct the player info to send to the new client in the server info var playerInfo = new List<(ushort, string)>(); foreach (var idPlayerDataPair in _playerData) { var otherId = idPlayerDataPair.Key; + if (otherId == netServerClient.Id) { + continue; + } var otherPd = idPlayerDataPair.Value; diff --git a/SSMP/Game/Server/ServerPlayerData.cs b/SSMP/Game/Server/ServerPlayerData.cs index 2c4e994..555a591 100644 --- a/SSMP/Game/Server/ServerPlayerData.cs +++ b/SSMP/Game/Server/ServerPlayerData.cs @@ -9,7 +9,7 @@ namespace SSMP.Game.Server; /// internal class ServerPlayerData : IServerPlayer { /// - public event Action? OnTeamChanged; + public event Action? TeamChangedEvent; /// public ushort Id { get; } @@ -50,7 +50,7 @@ public Team Team { set { if (field == value) return; field = value; - OnTeamChanged?.Invoke(value); + TeamChangedEvent?.Invoke(value); } } = Team.None; diff --git a/SSMP/Game/Settings/ModSettings.cs b/SSMP/Game/Settings/ModSettings.cs index ab3e913..9292aa4 100644 --- a/SSMP/Game/Settings/ModSettings.cs +++ b/SSMP/Game/Settings/ModSettings.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.IO; using Newtonsoft.Json; +using SSMP.Api.Client; using SSMP.Serialization; using SSMP.Ui.Menu; using SSMP.Util; @@ -10,13 +11,16 @@ namespace SSMP.Game.Settings; /// /// Settings class that stores user preferences. /// -internal class ModSettings { +internal class ModSettings : IModSettings { /// /// The name of the file containing the mod settings. /// private const string ModSettingsFileName = "modsettings.json"; - public event System.Action? OnChanged; + /// + /// Event triggered whenever any of the mod settings are changed. + /// + public event System.Action? ChangedEvent; /// /// The authentication key for the user. @@ -37,7 +41,7 @@ public string ConnectAddress { set { if (field == value) return; field = value; - OnChanged?.Invoke(nameof(ConnectAddress)); + ChangedEvent?.Invoke(nameof(ConnectAddress)); } } = ""; @@ -49,7 +53,7 @@ public int ConnectPort { set { if (field == value) return; field = value; - OnChanged?.Invoke(nameof(ConnectPort)); + ChangedEvent?.Invoke(nameof(ConnectPort)); } } = -1; @@ -61,7 +65,7 @@ public string Username { set { if (field == value) return; field = value; - OnChanged?.Invoke(nameof(Username)); + ChangedEvent?.Invoke(nameof(Username)); } } = ""; @@ -73,7 +77,7 @@ public bool DisplayPing { init { if (field == value) return; field = value; - OnChanged?.Invoke(nameof(DisplayPing)); + ChangedEvent?.Invoke(nameof(DisplayPing)); } } = true; @@ -92,7 +96,7 @@ public bool FullSynchronisation { set { if (field == value) return; field = value; - OnChanged?.Invoke(nameof(FullSynchronisation)); + ChangedEvent?.Invoke(nameof(FullSynchronisation)); } } diff --git a/SSMP/Game/Settings/ServerSettings.cs b/SSMP/Game/Settings/ServerSettings.cs index 81a6c8d..b8f462f 100644 --- a/SSMP/Game/Settings/ServerSettings.cs +++ b/SSMP/Game/Settings/ServerSettings.cs @@ -11,7 +11,7 @@ namespace SSMP.Game.Settings; /// public class ServerSettings : IServerSettings, IEquatable { /// - public event Action? OnChanged; + public event Action? ChangedEvent; /// [SettingAlias("pvp")] @@ -21,7 +21,7 @@ public bool IsPvpEnabled { set { if (field == value) return; field = value; - OnChanged?.Invoke(nameof(IsPvpEnabled)); + ChangedEvent?.Invoke(nameof(IsPvpEnabled)); } } @@ -33,7 +33,7 @@ public bool AlwaysShowMapIcons { set { if (field == value) return; field = value; - OnChanged?.Invoke(nameof(AlwaysShowMapIcons)); + ChangedEvent?.Invoke(nameof(AlwaysShowMapIcons)); } } @@ -45,7 +45,7 @@ public bool OnlyBroadcastMapIconWithCompass { init { if (field == value) return; field = value; - OnChanged?.Invoke(nameof(OnlyBroadcastMapIconWithCompass)); + ChangedEvent?.Invoke(nameof(OnlyBroadcastMapIconWithCompass)); } } = true; @@ -57,7 +57,7 @@ public bool DisplayNames { init { if (field == value) return; field = value; - OnChanged?.Invoke(nameof(DisplayNames)); + ChangedEvent?.Invoke(nameof(DisplayNames)); } } = true; @@ -69,7 +69,7 @@ public bool TeamsEnabled { set { if (field == value) return; field = value; - OnChanged?.Invoke(nameof(TeamsEnabled)); + ChangedEvent?.Invoke(nameof(TeamsEnabled)); } } @@ -81,7 +81,7 @@ public bool AllowSkins { init { if (field == value) return; field = value; - OnChanged?.Invoke(nameof(AllowSkins)); + ChangedEvent?.Invoke(nameof(AllowSkins)); } } = true; diff --git a/SSMP/Networking/Packet/Data/ServerInfo.cs b/SSMP/Networking/Packet/Data/ServerInfo.cs index 7f7dac4..1dec48a 100644 --- a/SSMP/Networking/Packet/Data/ServerInfo.cs +++ b/SSMP/Networking/Packet/Data/ServerInfo.cs @@ -45,11 +45,6 @@ internal class ServerInfo : IPacketData { /// public bool FullSynchronisation { get; set; } - /// - /// The ID assigned to the connecting client. - /// - public ushort SelfId { get; set; } - /// /// The save data currently used on the server. /// @@ -74,7 +69,6 @@ public void WriteData(IPacket packet) { ServerSettingsUpdate.WriteData(packet); packet.Write(FullSynchronisation); - packet.Write(SelfId); // CurrentSave.WriteData(packet); @@ -122,7 +116,6 @@ public void ReadData(IPacket packet) { ServerSettingsUpdate.ReadData(packet); FullSynchronisation = packet.ReadBool(); - SelfId = packet.ReadUShort(); // CurrentSave = new CurrentSave(); // CurrentSave.ReadData(packet); From d8afcce1d8ec928b7ca8504b970d5c2208a8178f Mon Sep 17 00:00:00 2001 From: Liparakis Date: Mon, 6 Apr 2026 19:08:44 +0300 Subject: [PATCH 14/16] chore: Seperate Teams 'Eventing' for Local player and Remote. --- SSMP/Api/Client/IClientManager.cs | 10 ++++++++++ SSMP/Game/Client/ClientManager.cs | 19 +++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/SSMP/Api/Client/IClientManager.cs b/SSMP/Api/Client/IClientManager.cs index 1fa38b7..fcf6fbe 100644 --- a/SSMP/Api/Client/IClientManager.cs +++ b/SSMP/Api/Client/IClientManager.cs @@ -59,6 +59,16 @@ public interface IClientManager { /// True if the player was found, false otherwise. bool TryGetPlayer(ushort id, out IClientPlayer? player); + /// + /// Event that is called when the local player's team changes. + /// + event Action? LocalPlayerTeamChangedEvent; + + /// + /// Event that is called when any player's (including the local player's) team changes. + /// + event Action? PlayerTeamChangedEvent; + /// /// Event that is called when the local user connects to a server. /// diff --git a/SSMP/Game/Client/ClientManager.cs b/SSMP/Game/Client/ClientManager.cs index 1b985a2..d93a0ce 100644 --- a/SSMP/Game/Client/ClientManager.cs +++ b/SSMP/Game/Client/ClientManager.cs @@ -169,9 +169,14 @@ internal class ClientManager : IClientManager { private bool _sceneHostDetermined; /// - /// Event for when the player's team changes after being received from the server. + /// Event for when the local player's team changes after being received from the server. /// - public event Action? TeamChangedEvent; + public event Action? LocalPlayerTeamChangedEvent; + + /// + /// Event for when any player's team changes after being received from the server. + /// + public event Action? PlayerTeamChangedEvent; /// /// Event for when the player's skin changes after being received from the server. @@ -1143,7 +1148,10 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { if (!_serverSettings.TeamsEnabled) { _playerManager.ResetAllTeams(); - TeamChangedEvent?.Invoke(Team.None); + LocalPlayerTeamChangedEvent?.Invoke(Team.None); + foreach (var player in _playerData.Values) { + PlayerTeamChangedEvent?.Invoke(player, Team.None); + } } // _uiManager.OnTeamSettingChange(); @@ -1297,9 +1305,12 @@ private void OnPlayerSettingUpdate(ClientPlayerSettingUpdate settingUpdate) { if (settingUpdate.UpdateTypes.Contains(PlayerSettingUpdateType.Team)) { if (settingUpdate.Self) { _playerManager.OnPlayerTeamUpdate(true, settingUpdate.Team); - TeamChangedEvent?.Invoke(settingUpdate.Team); + LocalPlayerTeamChangedEvent?.Invoke(settingUpdate.Team); } else { _playerManager.OnPlayerTeamUpdate(false, settingUpdate.Team, settingUpdate.Id); + if (TryGetPlayer(settingUpdate.Id, out var player)) { + PlayerTeamChangedEvent?.Invoke(player!, settingUpdate.Team); + } } } From 08f36908dae8a63731d74805beccc4f8cbb668d5 Mon Sep 17 00:00:00 2001 From: Liparakis Date: Mon, 6 Apr 2026 20:31:22 +0300 Subject: [PATCH 15/16] chore: Changes requested --- SSMP/Api/Server/IServerManager.cs | 6 ++++++ SSMP/Api/Server/IServerPlayer.cs | 6 ------ SSMP/Game/Client/ClientManager.cs | 19 +++++++++++-------- SSMP/Game/Server/ServerManager.cs | 12 ++++++++++++ SSMP/Game/Server/ServerPlayerData.cs | 13 +------------ 5 files changed, 30 insertions(+), 26 deletions(-) diff --git a/SSMP/Api/Server/IServerManager.cs b/SSMP/Api/Server/IServerManager.cs index dc7cf25..c6f78bd 100644 --- a/SSMP/Api/Server/IServerManager.cs +++ b/SSMP/Api/Server/IServerManager.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using SSMP.Api.Eventing.ServerEvents; +using SSMP.Game; using SSMP.Game.Settings; using SSMP.Networking.Packet.Data; @@ -97,6 +98,11 @@ public interface IServerManager { /// event Action PlayerLeaveSceneEvent; + /// + /// Event that is called when a player's team changes. + /// + event Action PlayerTeamChangedEvent; + /// /// Event that is called when a player sends a chat message. /// diff --git a/SSMP/Api/Server/IServerPlayer.cs b/SSMP/Api/Server/IServerPlayer.cs index eab2bc9..404a90c 100644 --- a/SSMP/Api/Server/IServerPlayer.cs +++ b/SSMP/Api/Server/IServerPlayer.cs @@ -1,4 +1,3 @@ -using System; using SSMP.Game; using SSMP.Internals; using SSMP.Math; @@ -9,11 +8,6 @@ namespace SSMP.Api.Server; /// A class containing all the relevant data managed by the server about a player. /// public interface IServerPlayer { - /// - /// Event triggered when the player changes their team. - /// - public event Action? TeamChangedEvent; - /// /// The ID of the player. /// diff --git a/SSMP/Game/Client/ClientManager.cs b/SSMP/Game/Client/ClientManager.cs index d93a0ce..a65cd36 100644 --- a/SSMP/Game/Client/ClientManager.cs +++ b/SSMP/Game/Client/ClientManager.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using GlobalEnums; using Steamworks; using SSMP.Animation; @@ -1146,12 +1147,12 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { if (teamsChanged) { // If the team setting was disabled, we reset all teams and call the event if (!_serverSettings.TeamsEnabled) { + var oldLocalTeam = _playerManager.LocalPlayerTeam; + var playersWithTeams = _playerData.Values.Where(player => player.Team != Team.None).ToList(); _playerManager.ResetAllTeams(); - - LocalPlayerTeamChangedEvent?.Invoke(Team.None); - foreach (var player in _playerData.Values) { - PlayerTeamChangedEvent?.Invoke(player, Team.None); - } + if (oldLocalTeam != Team.None) + LocalPlayerTeamChangedEvent?.Invoke(Team.None); + playersWithTeams.ForEach(p => PlayerTeamChangedEvent?.Invoke(p, Team.None)); } // _uiManager.OnTeamSettingChange(); @@ -1304,13 +1305,15 @@ private void OnChatMessage(ChatMessage chatMessage) { private void OnPlayerSettingUpdate(ClientPlayerSettingUpdate settingUpdate) { if (settingUpdate.UpdateTypes.Contains(PlayerSettingUpdateType.Team)) { if (settingUpdate.Self) { + var oldTeam = _playerManager.LocalPlayerTeam; _playerManager.OnPlayerTeamUpdate(true, settingUpdate.Team); - LocalPlayerTeamChangedEvent?.Invoke(settingUpdate.Team); + if (oldTeam != settingUpdate.Team) + LocalPlayerTeamChangedEvent?.Invoke(settingUpdate.Team); } else { + var oldTeam = TryGetPlayer(settingUpdate.Id, out var player) ? player!.Team : (Team?) null; _playerManager.OnPlayerTeamUpdate(false, settingUpdate.Team, settingUpdate.Id); - if (TryGetPlayer(settingUpdate.Id, out var player)) { + if (oldTeam != null && oldTeam != settingUpdate.Team) PlayerTeamChangedEvent?.Invoke(player!, settingUpdate.Team); - } } } diff --git a/SSMP/Game/Server/ServerManager.cs b/SSMP/Game/Server/ServerManager.cs index 3241ff3..d0bd90b 100644 --- a/SSMP/Game/Server/ServerManager.cs +++ b/SSMP/Game/Server/ServerManager.cs @@ -175,6 +175,9 @@ internal abstract class ServerManager : IServerManager { /// public event Action? PlayerLeaveSceneEvent; + /// + public event Action? PlayerTeamChangedEvent; + /// public event Action? PlayerChatEvent; @@ -1189,6 +1192,13 @@ public bool TryUpdatePlayerTeam(ushort id, Team team, [MaybeNullWhen(true)] out return false; } + if (playerData.Team == team) { + Logger.Info(" Team is the same as current, won't update team"); + + reason = "Already in team "; + return false; + } + // Update the team in the player data playerData.Team = team; @@ -1204,6 +1214,8 @@ public bool TryUpdatePlayerTeam(ushort id, Team team, [MaybeNullWhen(true)] out team: team ); } + + PlayerTeamChangedEvent?.Invoke(playerData, team); reason = null; return true; diff --git a/SSMP/Game/Server/ServerPlayerData.cs b/SSMP/Game/Server/ServerPlayerData.cs index 555a591..c76b54c 100644 --- a/SSMP/Game/Server/ServerPlayerData.cs +++ b/SSMP/Game/Server/ServerPlayerData.cs @@ -1,4 +1,3 @@ -using System; using SSMP.Api.Server; using SSMP.Game.Server.Auth; using SSMP.Internals; @@ -8,9 +7,6 @@ namespace SSMP.Game.Server; /// internal class ServerPlayerData : IServerPlayer { - /// - public event Action? TeamChangedEvent; - /// public ushort Id { get; } @@ -45,14 +41,7 @@ internal class ServerPlayerData : IServerPlayer { public ushort AnimationId { get; set; } /// - public Team Team { - get; - set { - if (field == value) return; - field = value; - TeamChangedEvent?.Invoke(value); - } - } = Team.None; + public Team Team { get; set; } = Team.None; /// public byte SkinId { get; set; } From 934ee46d94fad554bda71fdc8f6b11d868409b46 Mon Sep 17 00:00:00 2001 From: Extremelyd1 <10898310+Extremelyd1@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:31:31 +0200 Subject: [PATCH 16/16] Various cleanup changes - Move team change event to dedicated player manager - Remove redundant access modifiers - Remove team change event from client player data - Inherit documentation from interface - Change naming of added events - Make Team property in client manager obsolete - Remove empty spaces in reason message --- SSMP/Api/Client/IClientManager.cs | 16 +++++------- SSMP/Api/Client/IClientPlayer.cs | 6 ----- SSMP/Api/Client/IModSettings.cs | 5 ++-- SSMP/Api/Client/IPlayerManager.cs | 25 ++++++++++++++++++ SSMP/Api/Server/IServerSettings.cs | 14 +++++----- SSMP/Game/Client/ClientManager.cs | 38 ++++------------------------ SSMP/Game/Client/ClientPlayerData.cs | 12 +-------- SSMP/Game/Client/PlayerManager.cs | 33 +++++++++++++++++++----- SSMP/Game/Server/ServerManager.cs | 6 ++--- SSMP/Game/Settings/ModSettings.cs | 24 +++++------------- SSMP/Game/Settings/ServerSettings.cs | 14 +++++----- 11 files changed, 89 insertions(+), 104 deletions(-) create mode 100644 SSMP/Api/Client/IPlayerManager.cs diff --git a/SSMP/Api/Client/IClientManager.cs b/SSMP/Api/Client/IClientManager.cs index fcf6fbe..93d09ff 100644 --- a/SSMP/Api/Client/IClientManager.cs +++ b/SSMP/Api/Client/IClientManager.cs @@ -9,6 +9,11 @@ namespace SSMP.Api.Client; /// Client manager that handles the local client and related data. /// public interface IClientManager { + /// + /// Class that handles information about players. + /// + IPlayerManager PlayerManager { get; } + /// /// Class that manages player locations on the in-game map. /// @@ -22,6 +27,7 @@ public interface IClientManager { /// /// The current team of the local player. /// + [Obsolete("Use PlayerManager.LocalPlayerTeam instead.")] Team Team { get; } /// @@ -59,16 +65,6 @@ public interface IClientManager { /// True if the player was found, false otherwise. bool TryGetPlayer(ushort id, out IClientPlayer? player); - /// - /// Event that is called when the local player's team changes. - /// - event Action? LocalPlayerTeamChangedEvent; - - /// - /// Event that is called when any player's (including the local player's) team changes. - /// - event Action? PlayerTeamChangedEvent; - /// /// Event that is called when the local user connects to a server. /// diff --git a/SSMP/Api/Client/IClientPlayer.cs b/SSMP/Api/Client/IClientPlayer.cs index d1049b9..604a8d1 100644 --- a/SSMP/Api/Client/IClientPlayer.cs +++ b/SSMP/Api/Client/IClientPlayer.cs @@ -1,4 +1,3 @@ -using System; using SSMP.Game; using SSMP.Internals; using UnityEngine; @@ -9,11 +8,6 @@ namespace SSMP.Api.Client; /// A class containing all the relevant data managed by the client about a player. /// public interface IClientPlayer { - /// - /// Event triggered when the player changes their team. - /// - public event Action? TeamChangedEvent; - /// /// The ID of the player. /// diff --git a/SSMP/Api/Client/IModSettings.cs b/SSMP/Api/Client/IModSettings.cs index bef2d1f..babf0b8 100644 --- a/SSMP/Api/Client/IModSettings.cs +++ b/SSMP/Api/Client/IModSettings.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; namespace SSMP.Api.Client; @@ -23,7 +22,7 @@ public interface IModSettings { int ConnectPort { get; } /// - /// The username of the player. + /// The last used username to join a server. /// string Username { get; } @@ -33,7 +32,7 @@ public interface IModSettings { bool DisplayPing { get; } /// - /// Whether full synchronization of bosses, enemies, worlds, and saves is enabled. + /// Whether full synchronisation of bosses, enemies, worlds, and saves is enabled. /// bool FullSynchronisation { get; } } diff --git a/SSMP/Api/Client/IPlayerManager.cs b/SSMP/Api/Client/IPlayerManager.cs new file mode 100644 index 0000000..23be777 --- /dev/null +++ b/SSMP/Api/Client/IPlayerManager.cs @@ -0,0 +1,25 @@ +using System; +using SSMP.Game; + +namespace SSMP.Api.Client; + +/// +/// Player manager that handles information about players, such as the local player's team or changes to other players' +/// teams. +/// +public interface IPlayerManager { + /// + /// The team that our local player is on. + /// + Team LocalPlayerTeam { get; } + + /// + /// Event that is called when the local player's team changes. + /// + event Action? LocalPlayerTeamChangeEvent; + + /// + /// Event that is called when any player's (including the local player's) team changes. + /// + event Action? PlayerTeamChangeEvent; +} diff --git a/SSMP/Api/Server/IServerSettings.cs b/SSMP/Api/Server/IServerSettings.cs index 32d8c38..0794d4d 100644 --- a/SSMP/Api/Server/IServerSettings.cs +++ b/SSMP/Api/Server/IServerSettings.cs @@ -9,37 +9,37 @@ public interface IServerSettings { /// /// Event triggered whenever any of the server settings are changed. /// - public event Action? ChangedEvent; + event Action? ChangeEvent; /// /// Whether player vs. player damage is enabled. /// - public bool IsPvpEnabled { get; } + bool IsPvpEnabled { get; } /// /// Whether to always show map icons. /// - public bool AlwaysShowMapIcons { get; } + bool AlwaysShowMapIcons { get; } /// /// Whether to only broadcast the map icon of a player if they have wayward compass equipped. /// - public bool OnlyBroadcastMapIconWithCompass { get; } + bool OnlyBroadcastMapIconWithCompass { get; } /// /// Whether to display player names above the player objects. /// - public bool DisplayNames { get; } + bool DisplayNames { get; } /// /// Whether teams are enabled. /// - public bool TeamsEnabled { get; } + bool TeamsEnabled { get; } /// /// Whether skins are allowed. /// - public bool AllowSkins { get; } + bool AllowSkins { get; } // /// // /// Whether other player's attacks can be parried. diff --git a/SSMP/Game/Client/ClientManager.cs b/SSMP/Game/Client/ClientManager.cs index a65cd36..483bfb3 100644 --- a/SSMP/Game/Client/ClientManager.cs +++ b/SSMP/Game/Client/ClientManager.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using GlobalEnums; using Steamworks; using SSMP.Animation; @@ -56,12 +55,12 @@ internal class ClientManager : IClientManager { /// The UI manager instance. /// private readonly UiManager _uiManager; - + /// /// The current server settings. /// private readonly ServerSettings _serverSettings; - + /// /// The loaded mod settings. /// @@ -169,25 +168,13 @@ internal class ClientManager : IClientManager { /// private bool _sceneHostDetermined; - /// - /// Event for when the local player's team changes after being received from the server. - /// - public event Action? LocalPlayerTeamChangedEvent; - - /// - /// Event for when any player's team changes after being received from the server. - /// - public event Action? PlayerTeamChangedEvent; - - /// - /// Event for when the player's skin changes after being received from the server. - /// - public event Action? SkinChangedEvent; - #endregion #region IClientManager properties + /// + public IPlayerManager PlayerManager => _playerManager; + /// public IMapManager MapManager => _mapManager; @@ -1147,12 +1134,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { if (teamsChanged) { // If the team setting was disabled, we reset all teams and call the event if (!_serverSettings.TeamsEnabled) { - var oldLocalTeam = _playerManager.LocalPlayerTeam; - var playersWithTeams = _playerData.Values.Where(player => player.Team != Team.None).ToList(); _playerManager.ResetAllTeams(); - if (oldLocalTeam != Team.None) - LocalPlayerTeamChangedEvent?.Invoke(Team.None); - playersWithTeams.ForEach(p => PlayerTeamChangedEvent?.Invoke(p, Team.None)); } // _uiManager.OnTeamSettingChange(); @@ -1162,8 +1144,6 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { // event if (allowSkinsChanged && !_serverSettings.AllowSkins) { _playerManager.ResetAllPlayerSkins(); - - SkinChangedEvent?.Invoke(0); } } @@ -1305,23 +1285,15 @@ private void OnChatMessage(ChatMessage chatMessage) { private void OnPlayerSettingUpdate(ClientPlayerSettingUpdate settingUpdate) { if (settingUpdate.UpdateTypes.Contains(PlayerSettingUpdateType.Team)) { if (settingUpdate.Self) { - var oldTeam = _playerManager.LocalPlayerTeam; _playerManager.OnPlayerTeamUpdate(true, settingUpdate.Team); - if (oldTeam != settingUpdate.Team) - LocalPlayerTeamChangedEvent?.Invoke(settingUpdate.Team); } else { - var oldTeam = TryGetPlayer(settingUpdate.Id, out var player) ? player!.Team : (Team?) null; _playerManager.OnPlayerTeamUpdate(false, settingUpdate.Team, settingUpdate.Id); - if (oldTeam != null && oldTeam != settingUpdate.Team) - PlayerTeamChangedEvent?.Invoke(player!, settingUpdate.Team); } } if (settingUpdate.UpdateTypes.Contains(PlayerSettingUpdateType.Skin)) { if (settingUpdate.Self) { _playerManager.OnPlayerSkinUpdate(true, settingUpdate.SkinId); - - SkinChangedEvent?.Invoke(settingUpdate.SkinId); } else { _playerManager.OnPlayerSkinUpdate(false, settingUpdate.SkinId, settingUpdate.Id); } diff --git a/SSMP/Game/Client/ClientPlayerData.cs b/SSMP/Game/Client/ClientPlayerData.cs index f547831..34be73e 100644 --- a/SSMP/Game/Client/ClientPlayerData.cs +++ b/SSMP/Game/Client/ClientPlayerData.cs @@ -7,9 +7,6 @@ namespace SSMP.Game.Client; /// internal class ClientPlayerData : IClientPlayer { - /// - public event Action? TeamChangedEvent; - /// public ushort Id { get; } @@ -26,14 +23,7 @@ internal class ClientPlayerData : IClientPlayer { public GameObject? PlayerObject { get; set; } /// - public Team Team { - get; - set { - if (field == value) return; - field = value; - TeamChangedEvent?.Invoke(value); - } - } + public Team Team { get; set; } /// public byte SkinId { get; set; } diff --git a/SSMP/Game/Client/PlayerManager.cs b/SSMP/Game/Client/PlayerManager.cs index a8cdc31..13d7771 100644 --- a/SSMP/Game/Client/PlayerManager.cs +++ b/SSMP/Game/Client/PlayerManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using SSMP.Api.Client; using SSMP.Fsm; using SSMP.Game.Client.Skin; using SSMP.Game.Settings; @@ -17,7 +18,7 @@ namespace SSMP.Game.Client; /// /// Class that manages player objects, spawning and recycling thereof. /// -internal class PlayerManager { +internal class PlayerManager : IPlayerManager { /// /// The name of the game object for the player container prefab. /// @@ -59,11 +60,6 @@ internal class PlayerManager { /// private readonly Dictionary _playerData; - /// - /// The team that our local player is on. - /// - public Team LocalPlayerTeam { get; private set; } = Team.None; - /// /// The player container prefab GameObject. /// @@ -79,6 +75,19 @@ internal class PlayerManager { /// private readonly Dictionary _activePlayers; + /// + public Team LocalPlayerTeam { get; private set; } = Team.None; + + /// + /// Event for when the local player's team changes after being received from the server. + /// + public event Action? LocalPlayerTeamChangeEvent; + + /// + /// Event for when any player's team changes after being received from the server. + /// + public event Action? PlayerTeamChangeEvent; + public PlayerManager( ServerSettings serverSettings, Dictionary playerData @@ -573,6 +582,9 @@ private void UpdatePlayerTeam(ushort id, Team team) { Logger.Debug($"Tried to update team for ID {id} while player data did not exists"); return; } + + // Store the old team for invoking the event later + var oldTeam = playerData.Team; // Update the team in the player data playerData.Team = team; @@ -590,6 +602,10 @@ private void UpdatePlayerTeam(ushort id, Team team) { var textMeshObject = nameObject.GetComponent(); ChangeNameColor(textMeshObject, team); + + if (oldTeam != team) { + PlayerTeamChangeEvent?.Invoke(playerData, team); + } } /// @@ -597,6 +613,7 @@ private void UpdatePlayerTeam(ushort id, Team team) { /// /// The new team of the local player. private void UpdateLocalPlayerTeam(Team team) { + var oldTeam = LocalPlayerTeam; LocalPlayerTeam = team; var nameObject = HeroController.instance.gameObject.FindGameObjectInChildren(UsernameObjectName); @@ -608,6 +625,10 @@ private void UpdateLocalPlayerTeam(Team team) { var textMeshObject = nameObject.GetComponent(); ChangeNameColor(textMeshObject, team); + + if (oldTeam != team) { + LocalPlayerTeamChangeEvent?.Invoke(team); + } } /// diff --git a/SSMP/Game/Server/ServerManager.cs b/SSMP/Game/Server/ServerManager.cs index d0bd90b..b5c7aba 100644 --- a/SSMP/Game/Server/ServerManager.cs +++ b/SSMP/Game/Server/ServerManager.cs @@ -1195,7 +1195,7 @@ public bool TryUpdatePlayerTeam(ushort id, Team team, [MaybeNullWhen(true)] out if (playerData.Team == team) { Logger.Info(" Team is the same as current, won't update team"); - reason = "Already in team "; + reason = "Already in team"; return false; } @@ -1214,7 +1214,7 @@ public bool TryUpdatePlayerTeam(ushort id, Team team, [MaybeNullWhen(true)] out team: team ); } - + PlayerTeamChangedEvent?.Invoke(playerData, team); reason = null; @@ -1448,7 +1448,7 @@ out var correspondingServerAddon if (otherId == netServerClient.Id) { continue; } - + var otherPd = idPlayerDataPair.Value; playerInfo.Add((otherId, otherPd.Username)); diff --git a/SSMP/Game/Settings/ModSettings.cs b/SSMP/Game/Settings/ModSettings.cs index 9292aa4..8d9aae8 100644 --- a/SSMP/Game/Settings/ModSettings.cs +++ b/SSMP/Game/Settings/ModSettings.cs @@ -17,9 +17,7 @@ internal class ModSettings : IModSettings { /// private const string ModSettingsFileName = "modsettings.json"; - /// - /// Event triggered whenever any of the mod settings are changed. - /// + /// public event System.Action? ChangedEvent; /// @@ -33,9 +31,7 @@ internal class ModSettings : IModSettings { [JsonConverter(typeof(PlayerActionSetConverter))] public Keybinds Keybinds { get; } = new(); - /// - /// The last used address to join a server. - /// + /// public string ConnectAddress { get; set { @@ -45,9 +41,7 @@ public string ConnectAddress { } } = ""; - /// - /// The last used port to join a server. - /// + /// public int ConnectPort { get; set { @@ -57,9 +51,7 @@ public int ConnectPort { } } = -1; - /// - /// The last used username to join a server. - /// + /// public string Username { get; set { @@ -69,9 +61,7 @@ public string Username { } } = ""; - /// - /// Whether to display a UI element for the ping. - /// + /// public bool DisplayPing { get; init { @@ -87,9 +77,7 @@ public bool DisplayPing { // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global public HashSet DisabledAddons { get; set; } = []; - /// - /// Whether full synchronization of bosses, enemies, worlds, and saves is enabled. - /// + /// // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global public bool FullSynchronisation { get; diff --git a/SSMP/Game/Settings/ServerSettings.cs b/SSMP/Game/Settings/ServerSettings.cs index b8f462f..fb4414f 100644 --- a/SSMP/Game/Settings/ServerSettings.cs +++ b/SSMP/Game/Settings/ServerSettings.cs @@ -11,7 +11,7 @@ namespace SSMP.Game.Settings; /// public class ServerSettings : IServerSettings, IEquatable { /// - public event Action? ChangedEvent; + public event Action? ChangeEvent; /// [SettingAlias("pvp")] @@ -21,7 +21,7 @@ public bool IsPvpEnabled { set { if (field == value) return; field = value; - ChangedEvent?.Invoke(nameof(IsPvpEnabled)); + ChangeEvent?.Invoke(nameof(IsPvpEnabled)); } } @@ -33,7 +33,7 @@ public bool AlwaysShowMapIcons { set { if (field == value) return; field = value; - ChangedEvent?.Invoke(nameof(AlwaysShowMapIcons)); + ChangeEvent?.Invoke(nameof(AlwaysShowMapIcons)); } } @@ -45,7 +45,7 @@ public bool OnlyBroadcastMapIconWithCompass { init { if (field == value) return; field = value; - ChangedEvent?.Invoke(nameof(OnlyBroadcastMapIconWithCompass)); + ChangeEvent?.Invoke(nameof(OnlyBroadcastMapIconWithCompass)); } } = true; @@ -57,7 +57,7 @@ public bool DisplayNames { init { if (field == value) return; field = value; - ChangedEvent?.Invoke(nameof(DisplayNames)); + ChangeEvent?.Invoke(nameof(DisplayNames)); } } = true; @@ -69,7 +69,7 @@ public bool TeamsEnabled { set { if (field == value) return; field = value; - ChangedEvent?.Invoke(nameof(TeamsEnabled)); + ChangeEvent?.Invoke(nameof(TeamsEnabled)); } } @@ -81,7 +81,7 @@ public bool AllowSkins { init { if (field == value) return; field = value; - ChangedEvent?.Invoke(nameof(AllowSkins)); + ChangeEvent?.Invoke(nameof(AllowSkins)); } } = true;