From 9c9fa69854e4196ca6c1a6e74c8ceb48a6d6ec99 Mon Sep 17 00:00:00 2001 From: Ferox <83085562+fenixJK@users.noreply.github.com> Date: Mon, 25 May 2026 11:37:52 -0400 Subject: [PATCH] Harden XP display and config handling --- .../API/DisplayProviders/XPDisplayProvider.cs | 16 ++- XPSystem/API/XPAPI.cs | 115 ++++++++++++++---- .../Display/Patch/RankSetXPDisplayProvider.cs | 26 +++- .../Display/SyncVar/RankXPDisplayProvider.cs | 8 +- .../Commands/Admin/DatabasePlayerCommand.cs | 8 +- .../Commands/Admin/Subcommands/GiveCommand.cs | 3 +- .../Commands/Admin/Subcommands/SetCommand.cs | 3 +- .../Admin/Subcommands/SetLevelCommand.cs | 3 +- XPSystem/Config/Config.cs | 21 +++- .../YamlConverters/StringYamlConverter.cs | 32 +++++ .../EventHandlers/UnifiedEventHandlers.cs | 8 +- XPSystem/MessagingProviders.cs | 54 +++++++- XPSystem/XPSystem.csproj | 5 +- 13 files changed, 256 insertions(+), 46 deletions(-) create mode 100644 XPSystem/Config/YamlConverters/StringYamlConverter.cs diff --git a/XPSystem/API/DisplayProviders/XPDisplayProvider.cs b/XPSystem/API/DisplayProviders/XPDisplayProvider.cs index 0e56f5e..9c4fb12 100644 --- a/XPSystem/API/DisplayProviders/XPDisplayProvider.cs +++ b/XPSystem/API/DisplayProviders/XPDisplayProvider.cs @@ -139,10 +139,24 @@ internal override void LoadConfig(string folder) try { Config = Deserializer.Deserialize(File.ReadAllText(file)); + if (Config == null) + throw new InvalidDataException("Config file deserialized to null."); } catch (Exception e) { LogError($"Error loading display provider config for {name}: {e}"); + Config = new T(); + + try + { + string defaultFile = Path.Combine(folder, name + ".default.yml"); + File.WriteAllText(defaultFile, Serializer.Serialize(Config)); + LogWarn($"Using default config for {name}. A fresh default was written to {defaultFile}"); + } + catch (Exception writeException) + { + LogError($"Could not write default display provider config for {name}: {writeException}"); + } } } else @@ -164,4 +178,4 @@ public abstract class ConfigXPDisplayProvider : IXPDisplayProvider internal abstract IXPDisplayProviderConfig ConfigPropertyInternal { get; } internal abstract void LoadConfig(string folder); } -} \ No newline at end of file +} diff --git a/XPSystem/API/XPAPI.cs b/XPSystem/API/XPAPI.cs index 4041bd1..86413e6 100644 --- a/XPSystem/API/XPAPI.cs +++ b/XPSystem/API/XPAPI.cs @@ -5,6 +5,7 @@ namespace XPSystem.API using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text; + using MEC; using NorthwoodLib.Pools; using PlayerRoles; using XPSystem.API.DisplayProviders; @@ -58,6 +59,7 @@ public static class XPAPI /// private static SerializerBuilder SerializerBuilder => new SerializerBuilder() .WithLoaderTypeConverters() + .WithTypeConverter(new StringYamlConverter()) .WithTypeConverter(new XPECFileYamlConverter()) .WithTypeInspector(inner => new CommentGatheringTypeInspector(inner)) .WithEmissionPhaseObjectGraphVisitor(args => new CommentsObjectGraphVisitor(args.InnerVisitor)) @@ -265,19 +267,11 @@ internal static bool AddXP(PlayerInfoWrapper playerInfo, int amount, XPPlayer? p XPPlayer.TryGetXP(playerInfo.Player, out player); int prevLevel = playerInfo.Level; - float floatAmount = amount; bool connected = player != null; - if (amount > 0 || Config.XPMultiplierForXPLoss) - { - if (player?.XPMultiplier != null) - floatAmount *= player.XPMultiplier; - - if (connected || Config.GlobalXPMultiplierForNonOnline) - floatAmount *= Config.GlobalXPMultiplier; - } - - amount = (int)floatAmount; + amount = CalculateModifiedXPAmount(amount, player, connected); + if (amount == 0) + return false; playerInfo.PlayerInfo.XP += amount; StorageProvider!.SetPlayerInfo(playerInfo); @@ -290,6 +284,22 @@ internal static bool AddXP(PlayerInfoWrapper playerInfo, int amount, XPPlayer? p return true; } + + private static int CalculateModifiedXPAmount(int amount, XPPlayer? player, bool connected) + { + float floatAmount = amount; + + if (amount > 0 || Config.XPMultiplierForXPLoss) + { + if (player?.XPMultiplier != null) + floatAmount *= player.XPMultiplier; + + if (connected || Config.GlobalXPMultiplierForNonOnline) + floatAmount *= Config.GlobalXPMultiplier; + } + + return (int)floatAmount; + } #endregion #region Translations /// @@ -360,22 +370,47 @@ public static bool AddXPAndDisplayMessage(XPPlayer player, XPECItem? xpecItem) /// Whether or not XP was added. public static bool AddXPAndDisplayMessage(XPPlayer player, int amount, string? message, bool force = false) { - if (amount == 0 && !force || XPGainPaused) + if (amount == 0 && !force || XPGainPaused && !force) return false; PlayerInfoWrapper playerInfo = GetPlayerInfo(player.PlayerId); - bool levelup = AddXP(player, amount, force: true, playerInfo: playerInfo); + int modifiedAmount = CalculateModifiedXPAmount(amount, player, true); + bool levelup = AddXPWithoutLevelMessage(player, amount, playerInfo); - if (levelup && !Config.ShowXPOnLevelUp) - return true; + List messages = new(); - if (!string.IsNullOrWhiteSpace(message)) + if ((!levelup || Config.ShowXPOnLevelUp) && !string.IsNullOrWhiteSpace(message)) { if (Config.UseAddedXPTemplate) - message = FormatMessage(message!, playerInfo); - DisplayMessage(player, message); + message = FormatMessage(message!, playerInfo, modifiedAmount); + + if (!string.IsNullOrWhiteSpace(message)) + messages.Add(message!); } + if (levelup && Config.ShowAddedLVL) + messages.Add(Config.AddedLVLMessage.Replace("%level%", playerInfo.Level.ToString())); + + if (messages.Count > 0) + DisplayMessage(player, string.Join(Config.AddedXPLevelSeparator, messages)); + + return levelup || modifiedAmount != 0 || force; + } + + private static bool AddXPWithoutLevelMessage(XPPlayer player, int amount, PlayerInfoWrapper playerInfo) + { + int prevLevel = playerInfo.Level; + amount = CalculateModifiedXPAmount(amount, player, true); + if (amount == 0) + return false; + + playerInfo.PlayerInfo.XP += amount; + StorageProvider!.SetPlayerInfo(playerInfo); + + if (playerInfo.Level == prevLevel) + return false; + + HandleLevelUp(player, playerInfo, prevLevel, false); return true; } @@ -385,10 +420,17 @@ public static bool AddXPAndDisplayMessage(XPPlayer player, int amount, string? m /// The message to format. /// The of the player to format the message for. /// The formatted message. - public static string FormatMessage(string message, PlayerInfoWrapper playerInfo) + public static string FormatMessage(string message, PlayerInfoWrapper playerInfo, int amount = 0) { - message = Config.AddedXPTemplate + string template = Config.AddedXPTemplate; + string xpChange = FormatXPChange(amount); + if (!template.Contains("%xpchange%") && !template.Contains("%xpamount%")) + message = $"{message} {xpChange} XP"; + + message = template .Replace("%message%", message) + .Replace("%xpamount%", Math.Abs(amount).ToString()) + .Replace("%xpchange%", xpChange) .Replace("%currentlevel%", playerInfo.Level.ToString()) .Replace("%nextlevel%", (playerInfo.Level + 1).ToString()); @@ -418,8 +460,9 @@ public static string FormatMessage(string message, PlayerInfoWrapper playerInfo) { char filledChar = Config.AddedXPProgressBarChars[0]; char remainingChar = Config.AddedXPProgressBarChars[1]; - double fillPercentage = (double)(playerInfo.XP - playerInfo.NeededXPCurrent) / - (playerInfo.NeededXPNext - playerInfo.NeededXPCurrent); + int neededDelta = Math.Max(1, playerInfo.NeededXPNext - playerInfo.NeededXPCurrent); + double fillPercentage = Math.Max(0d, Math.Min(1d, + (double)(playerInfo.XP - playerInfo.NeededXPCurrent) / neededDelta)); int fill = (int)(Config.AddedXPProgressBarLength * fillPercentage); message = message @@ -432,6 +475,8 @@ public static string FormatMessage(string message, PlayerInfoWrapper playerInfo) return message; } + + private static string FormatXPChange(int amount) => amount > 0 ? "+" + amount : amount.ToString(); #endregion #region Misc /// @@ -440,11 +485,11 @@ public static string FormatMessage(string message, PlayerInfoWrapper playerInfo) /// The player that leveled up. /// The belonging to the player. /// The previous level the player had. - public static void HandleLevelUp(XPPlayer player, PlayerInfoWrapper wrapper, int prevLevel) + public static void HandleLevelUp(XPPlayer player, PlayerInfoWrapper wrapper, int prevLevel, bool showMessage = true) { - DisplayProviders.RefreshOf(player); + RefreshDisplaysAfterXPChange(player, wrapper); - if (Config.ShowAddedLVL) + if (showMessage && Config.ShowAddedLVL) { player.DisplayMessage(Config.AddedLVLMessage.Replace("%level%", wrapper.Level.ToString())); @@ -453,6 +498,24 @@ public static void HandleLevelUp(XPPlayer player, PlayerInfoWrapper wrapper, int PlayerLevelUp.Invoke(player, wrapper.Level, prevLevel); } + public static void RefreshDisplaysAfterXPChange(XPPlayer player, PlayerInfoWrapper? playerInfo = null) + { + playerInfo ??= GetPlayerInfo(player); + DisplayProviders.RefreshOf(player, playerInfo); + + int retries = Math.Max(0, Config.DisplayRefreshRetryCount); + float delay = Math.Max(0f, Config.DisplayRefreshRetryDelay); + for (int i = 1; i <= retries; i++) + { + float refreshDelay = delay * i + Config.ExtraDelay; + Timing.CallDelayed(refreshDelay, () => + { + if (player.IsConnected) + DisplayProviders.RefreshOf(player, playerInfo); + }); + } + } + /// /// Attempts to create a from an id and an . /// @@ -579,4 +642,4 @@ public static string FormatType(Type type) } #endregion } -} \ No newline at end of file +} diff --git a/XPSystem/BuiltInProviders/Display/Patch/RankSetXPDisplayProvider.cs b/XPSystem/BuiltInProviders/Display/Patch/RankSetXPDisplayProvider.cs index f1bfad3..911192c 100644 --- a/XPSystem/BuiltInProviders/Display/Patch/RankSetXPDisplayProvider.cs +++ b/XPSystem/BuiltInProviders/Display/Patch/RankSetXPDisplayProvider.cs @@ -13,7 +13,15 @@ public class RankSetXPDisplayProvider : XPDisplayProvider { - public override void RefreshAll() {} + public override void RefreshAll() + { + if (!Config.Enabled) + return; + + foreach (BaseXPPlayer player in XPPlayer.PlayersRealConnected) + Refresh(player, player is XPPlayer xpPlayer ? xpPlayer.GetPlayerInfo() : null); + } + public override void RefreshTo(BaseXPPlayer player) {} private Badge? GetBadge(BaseXPPlayer player, PlayerInfoWrapper? playerInfo) @@ -37,7 +45,9 @@ public override void RefreshTo(BaseXPPlayer player) {} Badge? badge = null; string format = !player.HasBadge || player.HasHiddenBadge - ? Config.BadgeStructureNoBadge + ? string.IsNullOrWhiteSpace(Config.BadgeStructureNoBadge) + ? Config.BadgeStructure + : Config.BadgeStructureNoBadge : Config.BadgeStructure; string? color = null; @@ -61,7 +71,7 @@ public override void RefreshTo(BaseXPPlayer player) {} Text = format .Replace("%lvl%", playerInfo.Level.ToString()) .Replace("%badge%", badge.Text) - .Replace("%oldbadge%", player.BadgeText), + .Replace("%oldbadge%", player.BadgeText ?? string.Empty), Color = color ?? "default" }; } @@ -76,6 +86,14 @@ private void Refresh(BaseXPPlayer player, PlayerInfoWrapper? playerInfo = null) player.Hub.serverRoles.Network_myColor = badge.Color; } + public override void RefreshOf(BaseXPPlayer player, PlayerInfoWrapper? playerInfo = null) + { + if (!Config.Enabled) + return; + + Refresh(player, playerInfo); + } + public override void Enable() { base.Enable(); @@ -202,4 +220,4 @@ public class RankConfig : IXPDisplayProviderConfig }; } } -} \ No newline at end of file +} diff --git a/XPSystem/BuiltInProviders/Display/SyncVar/RankXPDisplayProvider.cs b/XPSystem/BuiltInProviders/Display/SyncVar/RankXPDisplayProvider.cs index 41ec43c..17f4d88 100644 --- a/XPSystem/BuiltInProviders/Display/SyncVar/RankXPDisplayProvider.cs +++ b/XPSystem/BuiltInProviders/Display/SyncVar/RankXPDisplayProvider.cs @@ -38,7 +38,9 @@ public class RankXPDisplayProvider : SyncVarXPDisplayProvider arguments, byte targetPl return false; } + + protected void RefreshTargetDisplayIfOnline(IPlayerId playerId, PlayerInfoWrapper playerInfo) + { + if (XPPlayer.TryGetXP(playerId, out XPPlayer? player)) + XPAPI.RefreshDisplaysAfterXPChange(player, playerInfo); + } } -} \ No newline at end of file +} diff --git a/XPSystem/Commands/Admin/Subcommands/GiveCommand.cs b/XPSystem/Commands/Admin/Subcommands/GiveCommand.cs index 5eb3255..0a313df 100644 --- a/XPSystem/Commands/Admin/Subcommands/GiveCommand.cs +++ b/XPSystem/Commands/Admin/Subcommands/GiveCommand.cs @@ -37,6 +37,7 @@ public override bool Execute(ArraySegment arguments, ICommandSender send return false; playerInfo.XP += amount; + RefreshTargetDisplayIfOnline(playerId, playerInfo); response = $"Gave {amount} XP to {playerId.ToString()} ({playerInfo.Nickname})."; return true; @@ -45,4 +46,4 @@ public override bool Execute(ArraySegment arguments, ICommandSender send public override string Command { get; } = "give"; public override string Description { get; } = "Give a player XP."; } -} \ No newline at end of file +} diff --git a/XPSystem/Commands/Admin/Subcommands/SetCommand.cs b/XPSystem/Commands/Admin/Subcommands/SetCommand.cs index 751fbf9..6b2238e 100644 --- a/XPSystem/Commands/Admin/Subcommands/SetCommand.cs +++ b/XPSystem/Commands/Admin/Subcommands/SetCommand.cs @@ -39,6 +39,7 @@ public override bool Execute(ArraySegment arguments, ICommandSender send return false; playerInfo.XP = amount; + RefreshTargetDisplayIfOnline(playerId, playerInfo); response = $"Set {playerId.ToString()} ({playerInfo.Nickname})'s XP to {amount}."; return true; @@ -47,4 +48,4 @@ public override bool Execute(ArraySegment arguments, ICommandSender send public override string Command { get; } = "set"; public override string Description { get; } = "Set a player's XP."; } -} \ No newline at end of file +} diff --git a/XPSystem/Commands/Admin/Subcommands/SetLevelCommand.cs b/XPSystem/Commands/Admin/Subcommands/SetLevelCommand.cs index 5d99973..c1b0545 100644 --- a/XPSystem/Commands/Admin/Subcommands/SetLevelCommand.cs +++ b/XPSystem/Commands/Admin/Subcommands/SetLevelCommand.cs @@ -39,6 +39,7 @@ public override bool Execute(ArraySegment arguments, ICommandSender send return false; playerInfo.Level = level; + RefreshTargetDisplayIfOnline(playerId, playerInfo); response = $"Set {playerId.ToString()} ({playerInfo.Nickname})'s level to {level}."; return true; @@ -47,4 +48,4 @@ public override bool Execute(ArraySegment arguments, ICommandSender send public override string Command { get; } = "setlevel"; public override string Description { get; } = "Set a player's level."; } -} \ No newline at end of file +} diff --git a/XPSystem/Config/Config.cs b/XPSystem/Config/Config.cs index ea5a3e4..a2034b1 100644 --- a/XPSystem/Config/Config.cs +++ b/XPSystem/Config/Config.cs @@ -62,9 +62,9 @@ public abstract class Config [Description("Whether or not to format a message according to a template when adding XP.")] public bool UseAddedXPTemplate { get; set; } = true; - [Description("When enabled, template used for messages that modify XP. Parameters: %message%, %currentxp%, %currentlevel%, %neededxp%, %nextlevel%." + [Description("When enabled, template used for messages that modify XP. Parameters: %message%, %xpamount%, %xpchange%, %currentxp%, %currentlevel%, %neededxp%, %nextlevel%." + "Also: %progressbarfilled%, %progressbarremaining%. Using them requires the same extra calculations as UseTotalXP = false.")] - public string AddedXPTemplate { get; set; } = "%message%, (%currentxp% / %neededxp%)"; + public string AddedXPTemplate { get; set; } = "%message% %xpchange% XP (%currentxp% / %neededxp%)"; [Description("The characters to use for the progress bar, if present in the AddedXPTemplate.")] public string AddedXPProgressBarChars { get; set; } = "██"; @@ -84,12 +84,27 @@ public abstract class Config [Description("When enabled, what message to show if player advances a level.")] public string AddedLVLMessage { get; set; } = "NEW LEVEL: %level%"; + [Description("Separator used when an XP gain and a level-up message are shown together.")] + public string AddedXPLevelSeparator { get; set; } = "\n"; + [Description("Decide how messages (ex. XP gain, level up) are displayed.")] public DisplayMode DisplayMode { get; set; } = DisplayMode.Hint; [Description("The duration of the message, if applicable.")] public float DisplayDuration { get; set; } = 5f; + [Description("Vertical TextMeshPro offset applied to hint messages. Positive values move hints toward the top of the screen. Empty disables repositioning.")] + public string HintVerticalOffset { get; set; } = "24em"; + + [Description("How long hint-mode messages for the same player are collected before being shown. Prevents rapid XP events from overwriting each other. Set to 0 to disable.")] + public float HintCoalesceWindow { get; set; } = 0.20f; + + [Description("How many delayed display refreshes to run after XP/rank changes. Helps when the server or another plugin rewrites rank data shortly after the XPSystem update.")] + public int DisplayRefreshRetryCount { get; set; } = 2; + + [Description("Delay between delayed display refreshes after XP/rank changes.")] + public float DisplayRefreshRetryDelay { get; set; } = 0.75f; + [Description("Appended to all messages.")] public string TextPrefix { get; set; } = ""; @@ -117,4 +132,4 @@ public abstract class Config public abstract string ExtendedConfigPath { get; set; } public abstract string LegacyDefaultDatabasePath { get; set; } } -} \ No newline at end of file +} diff --git a/XPSystem/Config/YamlConverters/StringYamlConverter.cs b/XPSystem/Config/YamlConverters/StringYamlConverter.cs new file mode 100644 index 0000000..f94afcf --- /dev/null +++ b/XPSystem/Config/YamlConverters/StringYamlConverter.cs @@ -0,0 +1,32 @@ +#pragma warning disable CS8600 // Converting null literal or possible null value to non-nullable type. +namespace XPSystem.Config.YamlConverters +{ + using System; + using YamlDotNet.Core; + using YamlDotNet.Core.Events; + using YamlDotNet.Serialization; + + public class StringYamlConverter : IYamlTypeConverter + { + public bool Accepts(Type type) => type == typeof(string); + + public object ReadYaml(IParser parser, Type type) + { + if (!parser.TryConsume(out Scalar scalar)) + return string.Empty; + + return scalar.Value ?? string.Empty; + } + + public void WriteYaml(IEmitter emitter, object? value, Type type) + { + emitter.Emit(new Scalar( + AnchorName.Empty, + TagName.Empty, + value?.ToString() ?? string.Empty, + ScalarStyle.DoubleQuoted, + true, + false)); + } + } +} diff --git a/XPSystem/EventHandlers/UnifiedEventHandlers.cs b/XPSystem/EventHandlers/UnifiedEventHandlers.cs index 1bce46b..639f926 100644 --- a/XPSystem/EventHandlers/UnifiedEventHandlers.cs +++ b/XPSystem/EventHandlers/UnifiedEventHandlers.cs @@ -93,7 +93,13 @@ protected void OnPlayerChangedRole(XPPlayer? player, RoleTypeId oldRole, RoleTyp { if (player == null) return; + PlayerChangedRole.Invoke(player, oldRole, newRole); + Timing.CallDelayed(.25f + Config.ExtraDelay, () => + { + if (player.IsConnected) + RefreshDisplaysAfterXPChange(player); + }); } protected void OnRoundEnded(RoundSummary.LeadingTeam leadingTeam) @@ -148,4 +154,4 @@ protected void OnPlayedDied(XPPlayer? attacker, XPPlayer? target, RoleTypeId? ta protected void OnPlayerEscaped(XPPlayer? player) => player?.TryAddXPAndDisplayMessage("escape"); protected void OnPlayerResurrected(XPPlayer? scp049) => scp049?.TryAddXPAndDisplayMessage("resurrect"); } -} \ No newline at end of file +} diff --git a/XPSystem/MessagingProviders.cs b/XPSystem/MessagingProviders.cs index 8934112..b708fce 100644 --- a/XPSystem/MessagingProviders.cs +++ b/XPSystem/MessagingProviders.cs @@ -1,5 +1,8 @@ namespace XPSystem { + using System; + using System.Collections.Generic; + using MEC; using XPSystem.API; using XPSystem.API.Enums; using XPSystem.API.Player; @@ -17,10 +20,59 @@ public class MessagingProviders public class HintMessagingProvider : IMessagingProvider { + private static readonly Dictionary PendingHints = new(); + public void DisplayMessage(BaseXPPlayer player, string message, float duration) { + float window = Math.Max(0f, XPAPI.Config.HintCoalesceWindow); + if (window <= 0f) + { + ShowHint(player, message, duration); + return; + } + + if (PendingHints.TryGetValue(player, out PendingHint pendingHint)) + { + pendingHint.Messages.Add(message); + pendingHint.Duration = Math.Max(pendingHint.Duration, duration); + return; + } + + pendingHint = new PendingHint(message, duration); + PendingHints[player] = pendingHint; + + Timing.CallDelayed(window, () => + { + if (!PendingHints.TryGetValue(player, out PendingHint hint)) + return; + + PendingHints.Remove(player); + if (!player.IsConnected) + return; + + ShowHint(player, string.Join(XPAPI.Config.AddedXPLevelSeparator, hint.Messages), hint.Duration); + }); + } + + private static void ShowHint(BaseXPPlayer player, string message, float duration) + { + if (!string.IsNullOrWhiteSpace(XPAPI.Config.HintVerticalOffset)) + message = $"{message}"; + player.ShowHint(message, duration); } + + private class PendingHint + { + public List Messages { get; } = new(); + public float Duration { get; set; } + + public PendingHint(string message, float duration) + { + Messages.Add(message); + Duration = duration; + } + } } public class BroadcastMessagingProvider : IMessagingProvider @@ -39,4 +91,4 @@ public void DisplayMessage(BaseXPPlayer player, string message, float duration) } } } -} \ No newline at end of file +} diff --git a/XPSystem/XPSystem.csproj b/XPSystem/XPSystem.csproj index dbd03b8..6cd0f20 100644 --- a/XPSystem/XPSystem.csproj +++ b/XPSystem/XPSystem.csproj @@ -137,6 +137,7 @@ ..\packages\ExMod.Exiled.9.12.1\lib\net48\NorthwoodLib.dll + ..\packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll @@ -158,9 +159,6 @@ ..\packages\System.Numerics.Vectors.4.5.0\lib\net46\System.Numerics.Vectors.dll - - ..\packages\System.Reflection.4.3.0\lib\net462\System.Reflection.dll - ..\packages\System.Runtime.CompilerServices.Unsafe.6.0.0\lib\net461\System.Runtime.CompilerServices.Unsafe.dll @@ -255,6 +253,7 @@ +