diff --git a/Projects/Server/Collections/PooledRefList.cs b/Projects/Server/Collections/PooledRefList.cs index 5919e65fe..dc7a80d1f 100644 --- a/Projects/Server/Collections/PooledRefList.cs +++ b/Projects/Server/Collections/PooledRefList.cs @@ -1019,6 +1019,8 @@ public ref struct PooledRefList return array; } + public ReadOnlySpan AsSpan() => _items.AsSpan(0, _size); + // Sets the capacity of this list to the size of the list. This method can // be used to minimize a list's memory overhead once it is known that no // new elements will be added to the list. To completely clear a list and diff --git a/Projects/Server/FeatureFlags.cs b/Projects/Server/FeatureFlags.cs new file mode 100644 index 000000000..32916efa7 --- /dev/null +++ b/Projects/Server/FeatureFlags.cs @@ -0,0 +1,12 @@ +namespace Server; + +/// +/// Static boolean flags for Server project hot paths. +/// Values are synced by FeatureFlagManager in UOContent when flags change. +/// +public static class ServerFeatureFlags +{ + public static bool PlayerTrading { get; set; } = true; + public static bool PvPCombat { get; set; } = true; + public static bool BankAccess { get; set; } = true; +} diff --git a/Projects/Server/Items/Containers.cs b/Projects/Server/Items/Containers.cs index aeb721e3b..e3c78d723 100644 --- a/Projects/Server/Items/Containers.cs +++ b/Projects/Server/Items/Containers.cs @@ -28,6 +28,12 @@ public partial class BankBox : Container public void Open() { + if (!ServerFeatureFlags.BankAccess && Owner?.AccessLevel < AccessLevel.Administrator) + { + Owner.SendMessage(0x22, "Bank access is temporarily disabled."); + return; + } + Opened = true; if (Owner != null) diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index 94234ebff..f0572cdb7 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -7487,6 +7487,12 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public virtual bool OpenTrade(Mobile from, Item offer = null) { + if (!ServerFeatureFlags.PlayerTrading) + { + from.SendMessage(0x22, "Player trading is temporarily disabled."); + return false; + } + if (!from.Player || !Player || !from.Alive || !Alive) { return false; @@ -8222,6 +8228,16 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro return true; } + if (!ServerFeatureFlags.PvPCombat && Player && target.Player) + { + if (message) + { + SendLocalizedMessage(1001018); // You can not perform negative acts on your target. + } + + return false; + } + // TODO: Pets if (!Region.AllowHarmful(this, target)) { diff --git a/Projects/Server/World/World.cs b/Projects/Server/World/World.cs index 0d9e2aeb6..56fac8837 100644 --- a/Projects/Server/World/World.cs +++ b/Projects/Server/World/World.cs @@ -136,6 +136,8 @@ public static class World NetState.FlushAll(); } + public static void BroadcastStaff(string text) => BroadcastStaff(0x35, false, text); + public static void BroadcastStaff(int hue, bool ascii, string text) { var length = OutgoingMessagePackets.GetMaxMessageLength(text); diff --git a/Projects/UOContent/Engines/Bulk Orders/LargeBOD.cs b/Projects/UOContent/Engines/Bulk Orders/LargeBOD.cs index 9b9764a0e..c648e7390 100644 --- a/Projects/UOContent/Engines/Bulk Orders/LargeBOD.cs +++ b/Projects/UOContent/Engines/Bulk Orders/LargeBOD.cs @@ -1,6 +1,7 @@ using ModernUO.Serialization; using Server.Gumps; using Server.Mobiles; +using Server.Systems.FeatureFlags; namespace Server.Engines.BulkOrders { @@ -75,14 +76,19 @@ namespace Server.Engines.BulkOrders public override void OnDoubleClick(Mobile from) { - if (IsChildOf(from.Backpack) || InSecureTrade || RootParent is PlayerVendor) - { - from.SendGump(new LargeBODGump(this)); - } - else + if (!(IsChildOf(from.Backpack) || InSecureTrade || RootParent is PlayerVendor)) { from.SendLocalizedMessage(1045156); // You must have the deed in your backpack to use it. + return; } + + if (!ContentFeatureFlags.BulkOrders && from.AccessLevel < AccessLevel.Administrator) + { + from.SendMessage(0x22, "Bulk orders are temporarily disabled."); + return; + } + + from.SendGump(new LargeBODGump(this)); } public override void EndCombine(Mobile from, Item item) diff --git a/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs b/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs index f73d2c01f..6356780de 100644 --- a/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs +++ b/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs @@ -3,6 +3,7 @@ using ModernUO.Serialization; using Server.Gumps; using Server.Items; using Server.Mobiles; +using Server.Systems.FeatureFlags; namespace Server.Engines.BulkOrders; @@ -69,14 +70,19 @@ public abstract partial class SmallBOD : BaseBOD public override void OnDoubleClick(Mobile from) { - if (IsChildOf(from.Backpack) || InSecureTrade || RootParent is PlayerVendor) - { - from.SendGump(new SmallBODGump(this)); - } - else + if (!(IsChildOf(from.Backpack) || InSecureTrade || RootParent is PlayerVendor)) { from.SendLocalizedMessage(1045156); // You must have the deed in your backpack to use it. + return; } + + if (!ContentFeatureFlags.BulkOrders && from.AccessLevel < AccessLevel.Administrator) + { + from.SendMessage(0x22, "Bulk orders are temporarily disabled."); + return; + } + + from.SendGump(new SmallBODGump(this)); } public override void OnDoubleClickNotAccessible(Mobile from) diff --git a/Projects/UOContent/Engines/FeatureFlags/ContentFeatureFlags.cs b/Projects/UOContent/Engines/FeatureFlags/ContentFeatureFlags.cs new file mode 100644 index 000000000..25daeeef8 --- /dev/null +++ b/Projects/UOContent/Engines/FeatureFlags/ContentFeatureFlags.cs @@ -0,0 +1,15 @@ +namespace Server.Systems.FeatureFlags; + +/// +/// Static boolean flags for UOContent hot paths. +/// Values are synced by FeatureFlagManager when flags change. +/// +public static class ContentFeatureFlags +{ + public static bool VendorPurchase { get; set; } = true; + public static bool VendorSell { get; set; } = true; + public static bool PlayerVendors { get; set; } = true; + public static bool HousePlacement { get; set; } = true; + public static bool BoatPlacement { get; set; } = true; + public static bool BulkOrders { get; set; } = true; +} diff --git a/Projects/UOContent/Engines/FeatureFlags/FeatureFlagAdminGump.cs b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagAdminGump.cs new file mode 100644 index 000000000..b90c235ed --- /dev/null +++ b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagAdminGump.cs @@ -0,0 +1,464 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Server.Collections; +using Server.Gumps; +using Server.Network; + +namespace Server.Systems.FeatureFlags; + +public sealed class FeatureFlagAdminGump : DynamicGump +{ + public enum FeatureFlagPage + { + Flags, + GumpBlocks, + ItemBlocks, + SkillBlocks, + SpellBlocks + } + + private FeatureFlagPage _currentPage; + private int _pageIndex; + private int _displayedCount; + private readonly FeatureFlag[] _displayedFlags = new FeatureFlag[FlagsPerPage]; + private readonly FeatureFlagBlockEntry[] _displayedBlocks = new FeatureFlagBlockEntry[BlocksPerPage]; + private const int FlagsPerPage = 10; + private const int BlocksPerPage = 7; + private const int FlagRowHeight = 25; + private const int BlockRowHeight = 45; + + public FeatureFlagAdminGump(FeatureFlagPage page = FeatureFlagPage.Flags, int pageIndex = 0) : base(50, 50) + { + _currentPage = page; + _pageIndex = pageIndex; + } + + private void Resend(Mobile from, FeatureFlagPage page, int pageIndex = 0) + { + _currentPage = page; + _pageIndex = pageIndex; + from.SendGump(this); + } + + protected override void BuildLayout(ref DynamicGumpBuilder builder) + { + builder.AddPage(); + + // Background + builder.AddBackground(0, 0, 820, 500, 9270); + + // Title + builder.AddHtml(0, 15, 820, 25, "Feature Flag Administration".Center("#00FF00")); + + // Tab buttons + var flagsColor = _currentPage == FeatureFlagPage.Flags ? GumpTextColors.Yellow : GumpTextColors.White; + var gumpsColor = _currentPage == FeatureFlagPage.GumpBlocks ? GumpTextColors.Yellow : GumpTextColors.White; + var itemsColor = _currentPage == FeatureFlagPage.ItemBlocks ? GumpTextColors.Yellow : GumpTextColors.White; + var skillsColor = _currentPage == FeatureFlagPage.SkillBlocks ? GumpTextColors.Yellow : GumpTextColors.White; + var spellsColor = _currentPage == FeatureFlagPage.SpellBlocks ? GumpTextColors.Yellow : GumpTextColors.White; + + builder.AddButton(20, 45, 4005, 4007, 1); + builder.AddHtml(55, 47, 80, 20, "Flags".Color(flagsColor)); + + builder.AddButton(140, 45, 4005, 4007, 2); + builder.AddHtml(175, 47, 100, 20, "Gumps".Color(gumpsColor)); + + builder.AddButton(270, 45, 4005, 4007, 3); + builder.AddHtml(305, 47, 80, 20, "Items".Color(itemsColor)); + + builder.AddButton(390, 45, 4005, 4007, 4); + builder.AddHtml(425, 47, 80, 20, "Skills".Color(skillsColor)); + + builder.AddButton(510, 45, 4005, 4007, 5); + builder.AddHtml(545, 47, 80, 20, "Spells".Color(spellsColor)); + + // Content area + builder.AddAlphaRegion(15, 75, 790, 380); + + if (_currentPage == FeatureFlagPage.Flags) + { + BuildFlagsPage(ref builder); + } + else if (_currentPage == FeatureFlagPage.GumpBlocks) + { + BuildBlockPage( + ref builder, + "Gump Type", + FeatureFlagManager.GetAllGumpBlocks(), + "Use [BlockGump to add new blocks" + ); + } + else if (_currentPage == FeatureFlagPage.ItemBlocks) + { + BuildItemBlockPage( + ref builder, + FeatureFlagManager.GetAllItemBlocks(), + "Use [BlockItem to add new blocks" + ); + } + else if (_currentPage == FeatureFlagPage.SkillBlocks) + { + BuildBlockPage( + ref builder, + "Skill", + FeatureFlagManager.GetAllSkillBlocks(), + "Use [BlockSkill to add new blocks" + ); + } + else if (_currentPage == FeatureFlagPage.SpellBlocks) + { + BuildBlockPage( + ref builder, + "Spell Type", + FeatureFlagManager.GetAllSpellBlocks(), + "Use [BlockSpell to add new blocks" + ); + } + + // Close button + builder.AddButton(770, 460, 4017, 4019, 0); + builder.AddHtml(720, 462, 50, 20, "Close".Color(GumpTextColors.White)); + + // Save button + builder.AddButton(20, 460, 4023, 4025, 100); + builder.AddHtml(55, 462, 50, 20, "Save".Color(GumpTextColors.White)); + + // Refresh button + builder.AddButton(120, 460, 4014, 4016, 101); + builder.AddHtml(155, 462, 60, 20, "Refresh".Color(GumpTextColors.White)); + } + + private void BuildFlagsPage(ref DynamicGumpBuilder builder) + { + builder.AddHtml(20, 80, 150, 20, "Flag".Color(GumpTextColors.White)); + builder.AddHtml(180, 80, 150, 20, "Category".Color(GumpTextColors.White)); + builder.AddHtml(275, 80, 350, 20, "Description".Color(GumpTextColors.White)); + builder.AddHtml(690, 80, 60, 20, "Status".Color(GumpTextColors.White)); + + var flags = new List(FeatureFlagManager.GetAllFlags()); + flags.Sort((a, b) => + { + var cmp = a.Category.InsensitiveCompare(b.Category); + return cmp != 0 ? cmp : a.Key.InsensitiveCompare(b.Key); + }); + + var startIndex = _pageIndex * FlagsPerPage; + var endIndex = Math.Min(startIndex + FlagsPerPage, flags.Count); + var totalPages = Math.Max(1, (int)Math.Ceiling(flags.Count / (double)FlagsPerPage)); + + _displayedCount = 0; + var y = 105; + for (var i = startIndex; i < endIndex; i++) + { + var flag = flags[i]; + _displayedFlags[_displayedCount] = flag; + var statusColor = flag.Enabled ? GumpTextColors.Green : GumpTextColors.Red; + + builder.AddButton(20, y, flag.Enabled ? 2154 : 2151, flag.Enabled ? 2151 : 2154, 1000 + _displayedCount); + builder.AddHtml(60, y + 3, 130, 20, flag.Key.Color(GumpTextColors.White)); + builder.AddHtml(180, y + 3, 150, 20, (flag.Category ?? "").Color(GumpTextColors.LightGray)); + builder.AddHtml(275, y + 3, 350, 20, (flag.Description ?? "").Color(GumpTextColors.LightGray)); + builder.AddHtml(690, y + 3, 60, 20, (flag.Enabled ? "ON" : "OFF").Color(statusColor)); + + _displayedCount++; + y += FlagRowHeight; + } + + AddPagination(ref builder, totalPages); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void BuildBlockPage( + ref DynamicGumpBuilder builder, + string headerLabel, + IReadOnlyCollection blocks, + string helpText + ) + { + using var list = PooledRefList.Create(); + foreach (var b in blocks) + { + if (b != null) + { + list.Add(b); + } + } + BuildBlockPage(ref builder, headerLabel, list.AsSpan(), helpText); + } + + private void BuildBlockPage( + ref DynamicGumpBuilder builder, + string headerLabel, + ReadOnlySpan blocks, + string helpText + ) + { + builder.AddHtml(20, 80, 180, 20, headerLabel.Color(GumpTextColors.White)); + builder.AddHtml(200, 80, 400, 20, "Reason".Color(GumpTextColors.White)); + builder.AddHtml(690, 80, 60, 20, "Status".Color(GumpTextColors.White)); + builder.AddHtml(750, 80, 60, 20, "Remove".Color(GumpTextColors.White)); + + var startIndex = _pageIndex * BlocksPerPage; + var count = 0; + + _displayedCount = 0; + var skipped = 0; + var y = 105; + + for (var i = 0; i < blocks.Length; i++) + { + var block = blocks[i]; + if (block == null) + { + continue; + } + + count++; + + if (skipped < startIndex) + { + skipped++; + continue; + } + + if (_displayedCount < BlocksPerPage) + { + _displayedBlocks[_displayedCount] = block; + var statusColor = block.Active ? GumpTextColors.Red : GumpTextColors.Green; + + builder.AddButton(20, y, block.Active ? 2151 : 2154, block.Active ? 2154 : 2151, 2000 + _displayedCount); + builder.AddHtml(60, y + 3, 150, 20, block.DisplayName.Color(GumpTextColors.White)); + builder.AddHtml(200, y + 3, 400, 40, (block.Reason ?? "(default)").Color(GumpTextColors.LightGray)); + builder.AddHtml(690, y + 3, 60, 20, (block.Active ? "OFF" : "ON").Color(statusColor)); + builder.AddButton(750, y + 3, 4017, 4019, 3000 + _displayedCount); + + _displayedCount++; + y += BlockRowHeight; + } + } + + var totalPages = Math.Max(1, (int)Math.Ceiling(count / (double)BlocksPerPage)); + AddPagination(ref builder, totalPages); + builder.AddHtml(20, 430, 400, 20, helpText.Color(GumpTextColors.LightGray)); + } + + private void BuildItemBlockPage( + ref DynamicGumpBuilder builder, + IReadOnlyCollection blocks, + string helpText + ) + { + builder.AddHtml(20, 80, 150, 20, "Item Type".Color(GumpTextColors.White)); + builder.AddHtml(180, 80, 280, 20, "Reason".Color(GumpTextColors.White)); + builder.AddHtml(530, 80, 50, 20, "Use".Color(GumpTextColors.White)); + builder.AddHtml(580, 80, 50, 20, "Equip".Color(GumpTextColors.White)); + builder.AddHtml(640, 80, 50, 20, "Open".Color(GumpTextColors.White)); + builder.AddHtml(700, 80, 40, 20, "Edit".Color(GumpTextColors.White)); + builder.AddHtml(750, 80, 60, 20, "Remove".Color(GumpTextColors.White)); + + var startIndex = _pageIndex * BlocksPerPage; + + _displayedCount = 0; + var skipped = 0; + var y = 105; + + foreach (var block in blocks) + { + if (skipped < startIndex) + { + skipped++; + continue; + } + + if (_displayedCount >= BlocksPerPage) + { + break; + } + + _displayedBlocks[_displayedCount] = block; + + builder.AddButton(20, y, block.Active ? 2151 : 2154, block.Active ? 2154 : 2151, 2000 + _displayedCount); + builder.AddHtml(60, y + 3, 120, 20, block.DisplayName.Color(GumpTextColors.White)); + builder.AddHtml(180, y + 3, 280, 40, (block.Reason ?? "(default)").Color(GumpTextColors.LightGray)); + + var useColor = block.BlockUse ? GumpTextColors.Red : GumpTextColors.Green; + var equipColor = block.BlockEquip ? GumpTextColors.Red : GumpTextColors.Green; + var containerColor = block.BlockContainerAccess ? GumpTextColors.Red : GumpTextColors.Green; + + builder.AddHtml(530, y + 3, 50, 20, (block.BlockUse ? "X" : "-").Color(useColor)); + builder.AddHtml(580, y + 3, 50, 20, (block.BlockEquip ? "X" : "-").Color(equipColor)); + builder.AddHtml(640, y + 3, 50, 20, (block.BlockContainerAccess ? "X" : "-").Color(containerColor)); + + builder.AddButton(700, y + 3, 4011, 4013, 4000 + _displayedCount); + builder.AddButton(750, y + 3, 4017, 4019, 3000 + _displayedCount); + + _displayedCount++; + y += BlockRowHeight; + } + + var totalCount = blocks.Count; + var totalPages = Math.Max(1, (int)Math.Ceiling(totalCount / (double)BlocksPerPage)); + AddPagination(ref builder, totalPages); + builder.AddHtml(20, 430, 400, 20, helpText.Color(GumpTextColors.LightGray)); + } + + private void AddPagination(ref DynamicGumpBuilder builder, int totalPages) + { + if (totalPages <= 1) + { + return; + } + + if (_pageIndex > 0) + { + builder.AddButton(300, 425, 4014, 4016, 102); + builder.AddHtml(335, 425, 50, 20, "Prev".Color(GumpTextColors.White)); + } + + builder.AddHtml(370, 425, 60, 20, $"{_pageIndex + 1}/{totalPages}".Color(GumpTextColors.White)); + + if (_pageIndex < totalPages - 1) + { + builder.AddButton(420, 425, 4005, 4007, 103); + builder.AddHtml(455, 425, 50, 20, "Next".Color(GumpTextColors.White)); + } + } + + public override void OnResponse(NetState sender, in RelayInfo info) + { + var from = sender.Mobile; + var buttonId = info.ButtonID; + + if (buttonId == 0) + { + return; + } + + if (buttonId is >= (int)(FeatureFlagPage.Flags + 1) and <= (int)(FeatureFlagPage.SpellBlocks + 1)) + { + Resend(from, (FeatureFlagPage)(buttonId - 1)); + return; + } + + if (buttonId == 100) + { + FeatureFlagManager.Save(); + from.SendMessage(0x35, "Feature flags saved to disk."); + from.SendGump(this); + return; + } + + if (buttonId == 101) + { + from.SendGump(this); + return; + } + + if (buttonId == 102) + { + Resend(from, _currentPage, _pageIndex - 1); + return; + } + + if (buttonId == 103) + { + Resend(from, _currentPage, _pageIndex + 1); + return; + } + + switch (buttonId) + { + // Toggle feature flags + case >= 1000 and < 1000 + FlagsPerPage: + { + var index = buttonId - 1000; + if (index < _displayedCount) + { + var flag = _displayedFlags[index]; + FeatureFlagManager.SetFlag(flag.Key, !flag.Enabled, from.Name); + } + break; + } + // Toggle block + case >= 2000 and < 2000 + BlocksPerPage: + { + var index = buttonId - 2000; + if (index < _displayedCount) + { + var block = _displayedBlocks[index]; + switch (_currentPage) + { + case FeatureFlagPage.GumpBlocks: + { + FeatureFlagManager.SetGumpBlockActive(block.ResolvedType, !block.Active, from.Name); + break; + } + case FeatureFlagPage.ItemBlocks: + { + FeatureFlagManager.SetItemBlockActive(block.ResolvedType, !block.Active, from.Name); + break; + } + case FeatureFlagPage.SkillBlocks: + { + FeatureFlagManager.SetSkillBlockActive(((SkillBlockEntry)block).Skill, !block.Active, from.Name); + break; + } + case FeatureFlagPage.SpellBlocks: + { + FeatureFlagManager.SetSpellBlockActive(((SpellBlockEntry)block).SpellId, !block.Active, from.Name); + break; + } + } + } + + break; + } + // Remove block + case >= 3000 and < 3000 + BlocksPerPage: + { + var index = buttonId - 3000; + if (index < _displayedCount) + { + var block = _displayedBlocks[index]; + switch (_currentPage) + { + case FeatureFlagPage.GumpBlocks: + { + FeatureFlagManager.UnblockGump(block.ResolvedType, from.Name); + break; + } + case FeatureFlagPage.ItemBlocks: + { + FeatureFlagManager.RemoveItemBlock(block.ResolvedType, from.Name); + break; + } + case FeatureFlagPage.SkillBlocks: + { + FeatureFlagManager.UnblockSkill(((SkillBlockEntry)block).Skill, from.Name); + break; + } + case FeatureFlagPage.SpellBlocks: + { + FeatureFlagManager.UnblockSpell(block.ResolvedType, from.Name); + break; + } + } + } + break; + } + // Edit item block (PropertiesGump) + case >= 4000 and < 4000 + BlocksPerPage: + { + var index = buttonId - 4000; + if (index < _displayedCount && _currentPage == FeatureFlagPage.ItemBlocks) + { + from.SendGump(new PropertiesGump(from, _displayedBlocks[index])); + } + break; + } + } + + from.SendGump(this); + } +} diff --git a/Projects/UOContent/Engines/FeatureFlags/FeatureFlagCommands.cs b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagCommands.cs new file mode 100644 index 000000000..af63881ec --- /dev/null +++ b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagCommands.cs @@ -0,0 +1,602 @@ +using System; +using System.Collections.Generic; +using Server.Gumps; +using Server.Targeting; + +namespace Server.Systems.FeatureFlags; + +public static class FeatureFlagCommands +{ + public static void Configure() + { + CommandSystem.Register("FeatureFlag", AccessLevel.Administrator, FeatureFlag_OnCommand); + CommandSystem.Register("FF", AccessLevel.Administrator, FeatureFlag_OnCommand); + CommandSystem.Register("BlockGump", AccessLevel.Administrator, BlockGump_OnCommand); + CommandSystem.Register("UnblockGump", AccessLevel.Administrator, UnblockGump_OnCommand); + CommandSystem.Register("BlockItemUse", AccessLevel.Administrator, BlockItemUse_OnCommand); + CommandSystem.Register("BlockItemEquip", AccessLevel.Administrator, BlockItemEquip_OnCommand); + CommandSystem.Register("BlockItemContainer", AccessLevel.Administrator, BlockItemContainer_OnCommand); + CommandSystem.Register("UnblockItemUse", AccessLevel.Administrator, UnblockItemUse_OnCommand); + CommandSystem.Register("UnblockItemEquip", AccessLevel.Administrator, UnblockItemEquip_OnCommand); + CommandSystem.Register("UnblockItemContainer", AccessLevel.Administrator, UnblockItemContainer_OnCommand); + CommandSystem.Register("BlockSkill", AccessLevel.Administrator, BlockSkill_OnCommand); + CommandSystem.Register("UnblockSkill", AccessLevel.Administrator, UnblockSkill_OnCommand); + CommandSystem.Register("BlockSpell", AccessLevel.Administrator, BlockSpell_OnCommand); + CommandSystem.Register("UnblockSpell", AccessLevel.Administrator, UnblockSpell_OnCommand); + CommandSystem.Register("FeatureList", AccessLevel.GameMaster, FeatureList_OnCommand); + CommandSystem.Register("FeatureAdmin", AccessLevel.Administrator, FeatureAdmin_OnCommand); + CommandSystem.Register("ListGumps", AccessLevel.GameMaster, ListGumps_OnCommand); + } + + [Usage("FeatureFlag [on|off|toggle|info|create|delete]")] + [Aliases("FF")] + [Description("Manage feature flags. Use without arguments to open admin gump.")] + private static void FeatureFlag_OnCommand(CommandEventArgs e) + { + var from = e.Mobile; + + if (e.Arguments.Length == 0) + { + from.SendGump(new FeatureFlagAdminGump()); + return; + } + + var flagKey = e.Arguments[0].ToLowerInvariant(); + var action = e.Arguments.Length > 1 ? e.Arguments[1].ToLowerInvariant() : "info"; + + if (action is "on" or "enable" or "true" or "1") + { + if (FeatureFlagManager.SetFlag(flagKey, true, from.Name)) + { + from.SendMessage(0x35, $"Feature flag '{flagKey}' has been ENABLED."); + } + else + { + from.SendMessage(0x22, $"Feature flag '{flagKey}' not found."); + } + } + else if (action is "off" or "disable" or "false" or "0") + { + if (FeatureFlagManager.SetFlag(flagKey, false, from.Name)) + { + from.SendMessage(0x35, $"Feature flag '{flagKey}' has been DISABLED."); + } + else + { + from.SendMessage(0x22, $"Feature flag '{flagKey}' not found."); + } + } + else if (action == "toggle") + { + var flag = FeatureFlagManager.GetFlag(flagKey); + if (flag != null) + { + FeatureFlagManager.SetFlag(flagKey, !flag.Enabled, from.Name); + from.SendMessage(0x35, $"Feature flag '{flagKey}' toggled to {(flag.Enabled ? "DISABLED" : "ENABLED")}."); + } + else + { + from.SendMessage(0x22, $"Feature flag '{flagKey}' not found."); + } + } + else if (action == "create") + { + if (e.Arguments.Length < 4) + { + from.SendMessage("Usage: [FeatureFlag create "); + return; + } + + var category = e.Arguments[2]; + var description = string.Join(" ", e.Arguments, 3, e.Arguments.Length - 3); + FeatureFlagManager.CreateOrUpdateFlag(flagKey, description, category, true, from.Name); + from.SendMessage(0x35, $"Feature flag '{flagKey}' created."); + } + else if (action is "delete" or "remove") + { + if (FeatureFlagManager.RemoveFlag(flagKey, from.Name)) + { + from.SendMessage(0x35, $"Feature flag '{flagKey}' removed."); + } + else + { + from.SendMessage(0x22, $"Feature flag '{flagKey}' not found."); + } + } + else + { + var infoFlag = FeatureFlagManager.GetFlag(flagKey); + if (infoFlag != null) + { + from.SendMessage(0x35, $"=== Feature Flag: {infoFlag.Key} ==="); + from.SendMessage($"Enabled: {(infoFlag.Enabled ? "Yes" : "No")}"); + from.SendMessage($"Default: {(infoFlag.DefaultEnabled ? "Yes" : "No")}"); + from.SendMessage($"Category: {infoFlag.Category}"); + from.SendMessage($"Description: {infoFlag.Description}"); + from.SendMessage($"Last Modified: {infoFlag.LastModified:G} by {infoFlag.LastModifiedBy}"); + } + else + { + from.SendMessage(0x22, $"Feature flag '{flagKey}' not found."); + from.SendMessage("Use [FeatureList to see all available flags."); + } + } + } + + [Usage("BlockGump [reason]")] + [Description("Block a gump type from being displayed to players.")] + private static void BlockGump_OnCommand(CommandEventArgs e) + { + var from = e.Mobile; + + if (e.Arguments.Length == 0) + { + from.SendMessage("Usage: [BlockGump [reason]"); + from.SendMessage("Example: [BlockGump CraftGump Crafting temporarily disabled"); + return; + } + + var typeName = e.Arguments[0]; + var reason = e.Arguments.Length > 1 + ? string.Join(" ", e.Arguments, 1, e.Arguments.Length - 1) + : null; + + if (FeatureFlagManager.BlockGumpByName(typeName, reason, from.Name)) + { + from.SendMessage(0x35, $"Gump '{typeName}' has been BLOCKED."); + if (reason != null) + { + from.SendMessage($"Reason: {reason}"); + } + } + else + { + from.SendMessage(0x22, $"Could not find gump type '{typeName}'."); + from.SendMessage("Make sure you're using the correct type name (e.g., CraftGump, HelpGump, etc.)"); + } + } + + [Usage("UnblockGump ")] + [Description("Remove a gump type block, allowing it to be displayed again.")] + private static void UnblockGump_OnCommand(CommandEventArgs e) + { + var from = e.Mobile; + + if (e.Arguments.Length == 0) + { + from.SendMessage("Usage: [UnblockGump "); + from.SendMessage("Use [FeatureList gumps to see blocked gumps."); + return; + } + + var typeName = e.Arguments[0]; + + if (FeatureFlagManager.UnblockGumpByName(typeName, from.Name)) + { + from.SendMessage(0x35, $"Gump block for '{typeName}' has been REMOVED."); + } + else + { + from.SendMessage(0x22, $"No block found for gump '{typeName}'."); + } + } + + [Usage("BlockItemUse [reason]")] + [Description("Block an item type from being used by players.")] + private static void BlockItemUse_OnCommand(CommandEventArgs e) => + HandleBlockItem(e, "Use", FeatureFlagManager.BlockItemUse, FeatureFlagManager.BlockItemUseByName); + + [Usage("BlockItemEquip [reason]")] + [Description("Block an item type from being equipped by players.")] + private static void BlockItemEquip_OnCommand(CommandEventArgs e) => + HandleBlockItem(e, "Equip", FeatureFlagManager.BlockItemEquip, FeatureFlagManager.BlockItemEquipByName); + + [Usage("BlockItemContainer [reason]")] + [Description("Block a container type from being opened by players.")] + private static void BlockItemContainer_OnCommand(CommandEventArgs e) => + HandleBlockItem(e, "Container", FeatureFlagManager.BlockItemContainer, FeatureFlagManager.BlockItemContainerByName); + + private static void HandleBlockItem( + CommandEventArgs e, string action, + Action blockByType, + Func blockByName) + { + var from = e.Mobile; + + if (e.Arguments.Length == 0) + { + from.SendMessage($"Usage: [BlockItem{action} [reason]"); + from.SendMessage($"Or target an item: [BlockItem{action} target [reason]"); + return; + } + + if (e.Arguments[0].Equals("target", StringComparison.OrdinalIgnoreCase)) + { + var reason = e.Arguments.Length > 1 + ? string.Join(" ", e.Arguments, 1, e.Arguments.Length - 1) + : null; + + from.SendMessage("Target the item to block:"); + from.Target = new BlockItemTarget(action, reason, blockByType); + return; + } + + var typeName = e.Arguments[0]; + var blockReason = e.Arguments.Length > 1 + ? string.Join(" ", e.Arguments, 1, e.Arguments.Length - 1) + : null; + + if (blockByName(typeName, blockReason, from.Name)) + { + from.SendMessage(0x35, $"Item '{typeName}' {action} has been BLOCKED."); + if (blockReason != null) + { + from.SendMessage($"Reason: {blockReason}"); + } + } + else + { + from.SendMessage(0x22, $"Could not find item type '{typeName}'."); + } + } + + private sealed class BlockItemTarget : Target + { + private readonly string _action; + private readonly string _reason; + private readonly Action _blockByType; + + public BlockItemTarget(string action, string reason, Action blockByType) + : base(-1, false, TargetFlags.None) + { + _action = action; + _reason = reason; + _blockByType = blockByType; + } + + protected override void OnTarget(Mobile from, object targeted) + { + if (targeted is Item item) + { + var type = item.GetType(); + _blockByType(type, _reason, from.Name); + from.SendMessage(0x35, $"Item '{type.Name}' {_action} has been BLOCKED."); + if (_reason != null) + { + from.SendMessage($"Reason: {_reason}"); + } + } + else + { + from.SendMessage(0x22, "That is not an item."); + } + } + } + + [Usage("UnblockItemUse ")] + [Description("Remove the use block for an item type.")] + private static void UnblockItemUse_OnCommand(CommandEventArgs e) => + HandleUnblockItem(e, "Use", FeatureFlagManager.UnblockItemUseByName); + + [Usage("UnblockItemEquip ")] + [Description("Remove the equip block for an item type.")] + private static void UnblockItemEquip_OnCommand(CommandEventArgs e) => + HandleUnblockItem(e, "Equip", FeatureFlagManager.UnblockItemEquipByName); + + [Usage("UnblockItemContainer ")] + [Description("Remove the container access block for an item type.")] + private static void UnblockItemContainer_OnCommand(CommandEventArgs e) => + HandleUnblockItem(e, "Container", FeatureFlagManager.UnblockItemContainerByName); + + private static void HandleUnblockItem( + CommandEventArgs e, string action, Func unblockByName) + { + var from = e.Mobile; + + if (e.Arguments.Length == 0) + { + from.SendMessage($"Usage: [UnblockItem{action} "); + from.SendMessage("Use [FeatureList items to see blocked items."); + return; + } + + var typeName = e.Arguments[0]; + + if (unblockByName(typeName, from.Name)) + { + from.SendMessage(0x35, $"Item '{typeName}' {action} block has been REMOVED."); + } + else + { + from.SendMessage(0x22, $"No {action} block found for item '{typeName}'."); + } + } + + [Usage("BlockSkill [reason]")] + [Description("Block a skill from being used by players.")] + private static void BlockSkill_OnCommand(CommandEventArgs e) + { + var from = e.Mobile; + + if (e.Arguments.Length == 0) + { + from.SendMessage("Usage: [BlockSkill [reason]"); + from.SendMessage("Example: [BlockSkill Magery Investigating exploit"); + return; + } + + if (!Enum.TryParse(e.Arguments[0], true, out var skill)) + { + from.SendMessage(0x22, $"Unknown skill name '{e.Arguments[0]}'."); + from.SendMessage("Valid skills: Alchemy, Anatomy, Magery, Mining, etc."); + return; + } + + var reason = e.Arguments.Length > 1 + ? string.Join(" ", e.Arguments, 1, e.Arguments.Length - 1) + : null; + + FeatureFlagManager.BlockSkill(skill, reason, from.Name); + from.SendMessage(0x35, $"Skill '{skill}' has been BLOCKED."); + if (reason != null) + { + from.SendMessage($"Reason: {reason}"); + } + } + + [Usage("UnblockSkill ")] + [Description("Remove a skill block.")] + private static void UnblockSkill_OnCommand(CommandEventArgs e) + { + var from = e.Mobile; + + if (e.Arguments.Length == 0) + { + from.SendMessage("Usage: [UnblockSkill "); + from.SendMessage("Use [FeatureList skills to see blocked skills."); + return; + } + + if (!Enum.TryParse(e.Arguments[0], true, out var skill)) + { + from.SendMessage(0x22, $"Unknown skill name '{e.Arguments[0]}'."); + return; + } + + if (FeatureFlagManager.UnblockSkill(skill, from.Name)) + { + from.SendMessage(0x35, $"Skill block for '{skill}' has been REMOVED."); + } + else + { + from.SendMessage(0x22, $"No block found for skill '{skill}'."); + } + } + + [Usage("BlockSpell [reason]")] + [Description("Block a spell from being cast by players.")] + private static void BlockSpell_OnCommand(CommandEventArgs e) + { + var from = e.Mobile; + + if (e.Arguments.Length == 0) + { + from.SendMessage("Usage: [BlockSpell [reason]"); + from.SendMessage("Example: [BlockSpell RecallSpell Investigating exploit"); + return; + } + + var typeName = e.Arguments[0]; + var reason = e.Arguments.Length > 1 + ? string.Join(" ", e.Arguments, 1, e.Arguments.Length - 1) + : null; + + if (FeatureFlagManager.BlockSpellByName(typeName, reason, from.Name)) + { + from.SendMessage(0x35, $"Spell '{typeName}' has been BLOCKED."); + if (reason != null) + { + from.SendMessage($"Reason: {reason}"); + } + } + else + { + from.SendMessage(0x22, $"Could not find spell type '{typeName}'."); + from.SendMessage("Make sure you're using the correct type name (e.g., RecallSpell, GateTravelSpell, etc.)"); + } + } + + [Usage("UnblockSpell ")] + [Description("Remove a spell block.")] + private static void UnblockSpell_OnCommand(CommandEventArgs e) + { + var from = e.Mobile; + + if (e.Arguments.Length == 0) + { + from.SendMessage("Usage: [UnblockSpell "); + from.SendMessage("Use [FeatureList spells to see blocked spells."); + return; + } + + var typeName = e.Arguments[0]; + + if (FeatureFlagManager.UnblockSpellByName(typeName, from.Name)) + { + from.SendMessage(0x35, $"Spell block for '{typeName}' has been REMOVED."); + } + else + { + from.SendMessage(0x22, $"No block found for spell '{typeName}'."); + } + } + + [Usage("FeatureList [flags|gumps|items|skills|spells|all]")] + [Description("List all feature flags and blocks.")] + private static void FeatureList_OnCommand(CommandEventArgs e) + { + var from = e.Mobile; + var filter = e.Arguments.Length > 0 ? e.Arguments[0].ToLowerInvariant() : "all"; + + if (filter is "flags" or "all") + { + var flags = new List(FeatureFlagManager.GetAllFlags()); + flags.Sort((a, b) => + { + var cmp = string.Compare(a.Category, b.Category, StringComparison.OrdinalIgnoreCase); + return cmp != 0 ? cmp : string.Compare(a.Key, b.Key, StringComparison.OrdinalIgnoreCase); + }); + from.SendMessage(0x35, $"=== Feature Flags ({flags.Count}) ==="); + foreach (var flag in flags) + { + var status = flag.Enabled ? "[ON]" : "[OFF]"; + from.SendMessage($" {status} {flag.Key} ({flag.Category}): {flag.Description}"); + } + } + + if (filter is "gumps" or "all") + { + var gumpBlocks = FeatureFlagManager.GetAllGumpBlocks(); + from.SendMessage(0x35, $"=== Blocked Gumps ({gumpBlocks.Count}) ==="); + foreach (var block in gumpBlocks) + { + var status = block.Active ? "[OFF]" : "[ON]"; + from.SendMessage($" {status} {block.DisplayName}: {block.Reason ?? FeatureFlagSettings.DefaultGumpBlockedMessage}"); + } + } + + if (filter is "items" or "all") + { + var itemBlocks = FeatureFlagManager.GetAllItemBlocks(); + from.SendMessage(0x35, $"=== Blocked Items ({itemBlocks.Count}) ==="); + foreach (var block in itemBlocks) + { + var actions = new List(3); + if (block.BlockUse) actions.Add("Use"); + if (block.BlockEquip) actions.Add("Equip"); + if (block.BlockContainerAccess) actions.Add("Container"); + + var actionsStr = actions.Count > 0 ? $"[{string.Join("][", actions)}]" : "[none]"; + var status = block.Active ? "[OFF]" : "[ON]"; + from.SendMessage($" {status} {block.DisplayName} {actionsStr}: {block.Reason ?? FeatureFlagSettings.DefaultItemUseBlockedMessage}"); + } + } + + if (filter is "skills" or "all") + { + var skillBlocks = FeatureFlagManager.GetAllSkillBlocks(); + from.SendMessage(0x35, "=== Blocked Skills ==="); + for (var i = 0; i < skillBlocks.Length; i++) + { + var block = skillBlocks[i]; + if (block == null) + { + continue; + } + + var status = block.Active ? "[OFF]" : "[ON]"; + from.SendMessage($" {status} {block.DisplayName}: {block.Reason ?? FeatureFlagSettings.DefaultSkillDisabledMessage}"); + } + } + + if (filter is "spells" or "all") + { + var spellBlocks = FeatureFlagManager.GetAllSpellBlocks(); + from.SendMessage(0x35, "=== Blocked Spells ==="); + foreach (var block in spellBlocks) + { + if (block == null) + { + continue; + } + + var status = block.Active ? "[OFF]" : "[ON]"; + from.SendMessage($" {status} {block.DisplayName}: {block.Reason ?? FeatureFlagSettings.DefaultSpellDisabledMessage}"); + } + } + + if (filter != "flags" && filter != "gumps" && filter != "items" && filter != "skills" && filter != "spells" && filter != "all") + { + from.SendMessage("Usage: [FeatureList [flags|gumps|items|skills|spells|all]"); + } + } + + [Usage("FeatureAdmin")] + [Description("Open the feature flag administration gump.")] + private static void FeatureAdmin_OnCommand(CommandEventArgs e) + { + e.Mobile.SendGump(new FeatureFlagAdminGump()); + } + + [Usage("ListGumps")] + [Description("List all open gumps for yourself or a targeted player. Useful for finding gump names to block.")] + private static void ListGumps_OnCommand(CommandEventArgs e) + { + var from = e.Mobile; + + if (e.Arguments.Length > 0 && e.Arguments[0].Equals("self", StringComparison.OrdinalIgnoreCase)) + { + ListGumpsFor(from, from); + } + else + { + from.SendMessage("Target a player to list their open gumps (or use [ListGumps self):"); + from.Target = new ListGumpsTarget(); + } + } + + private static void ListGumpsFor(Mobile from, Mobile target) + { + if (target?.NetState == null) + { + from.SendMessage(0x22, "That player is not online."); + return; + } + + var gumps = target.GetGumps(); + var count = 0; + var gumpList = new List<(string shortName, string fullName)>(); + + foreach (var gump in gumps) + { + var type = gump.GetType(); + var fullName = type.FullName ?? type.Name; + var shortName = type.Name; + gumpList.Add((shortName, fullName)); + count++; + } + + if (count == 0) + { + from.SendMessage(0x35, $"{target.Name} has no gumps open."); + return; + } + + from.SendMessage(0x35, $"=== Open Gumps for {target.Name} ({count}) ==="); + foreach (var (shortName, fullName) in gumpList) + { + from.SendMessage($" • {shortName}"); + from.SendMessage($" Full: {fullName}"); + from.SendMessage(0x3B2, $" Block with: [BlockGump {shortName}"); + } + } + + private sealed class ListGumpsTarget : Target + { + public ListGumpsTarget() : base(-1, false, TargetFlags.None) + { + } + + protected override void OnTarget(Mobile from, object targeted) + { + if (targeted is Mobile m) + { + ListGumpsFor(from, m); + } + else + { + from.SendMessage(0x22, "That is not a player."); + } + } + } +} diff --git a/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs new file mode 100644 index 000000000..ef9aba91e --- /dev/null +++ b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs @@ -0,0 +1,1038 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Server.Gumps; +using Server.Json; +using Server.Logging; +using Server.Spells; + +namespace Server.Systems.FeatureFlags; + +public static class FeatureFlagManager +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(FeatureFlagManager)); + + // Primary storage + private static readonly Dictionary _flags = new(StringComparer.OrdinalIgnoreCase); + private static readonly Dictionary _gumpBlocks = new(); + private static readonly Dictionary _itemBlocks = new(); + private static readonly SpellBlockEntry[] _spellBlocks = new SpellBlockEntry[SpellRegistry.Types.Length]; + private static readonly SkillBlockEntry[] _skillBlocks = new SkillBlockEntry[58]; + + // Fast bailout flags + private static bool _hasActiveGumpBlocks; + private static bool _hasActiveItemBlocks; + private static bool _hasActiveSkillBlocks; + private static bool _hasActiveSpellBlocks; + + private static bool _initialized; + + public static void Initialize() + { + if (_initialized) + { + return; + } + + var savePath = FeatureFlagSettings.SavePath; + if (!Directory.Exists(savePath)) + { + Directory.CreateDirectory(savePath); + } + + // Load predefined flags from JSON, then overlay runtime state + LoadDefaultFlags(); + Load(); + + _initialized = true; + logger.Information( + "Feature Flag system initialized with {FlagCount} flags, {GumpBlockCount} gump blocks, {ItemBlockCount} item blocks, {SkillBlockCount} skill blocks, {SpellBlockCount} spell blocks", + _flags.Count, _gumpBlocks.Count, _itemBlocks.Count, CountActiveSkillBlocks(), CountActiveSpellBlocks()); + } + + private static int CountActiveSkillBlocks() + { + var count = 0; + for (var i = 0; i < _skillBlocks.Length; i++) + { + if (_skillBlocks[i] != null) + { + count++; + } + } + return count; + } + + private static int CountActiveSpellBlocks() + { + var count = 0; + for (var i = 0; i < _spellBlocks.Length; i++) + { + if (_spellBlocks[i] != null) + { + count++; + } + } + return count; + } + + public static bool IsEnabled(string flagKey) => _flags.TryGetValue(flagKey, out var flag) && flag.Enabled; + + public static FeatureFlag GetFlag(string flagKey) => _flags.GetValueOrDefault(flagKey); + + public static IReadOnlyCollection GetAllFlags() => _flags.Values; + + public static bool SetFlag(string flagKey, bool enabled, string modifiedBy = "System") + { + if (!_flags.TryGetValue(flagKey, out var flag)) + { + return false; + } + + var previousState = flag.Enabled; + flag.Enabled = enabled; + flag.LastModified = Core.Now; + flag.LastModifiedBy = modifiedBy; + + SyncStaticFlag(flagKey, enabled); + + if (FeatureFlagSettings.LogChanges) + { + logger.Information("Feature flag '{FlagKey}' changed from {Previous} to {Current} by {ModifiedBy}", + flagKey, previousState, enabled, modifiedBy); + } + + if (FeatureFlagSettings.BroadcastChangesToStaff) + { + World.BroadcastStaff($"[Feature Flag] '{flagKey}' {(enabled ? "ENABLED" : "DISABLED")} by {modifiedBy}"); + } + + SaveFlags(); + return true; + } + + public static FeatureFlag CreateOrUpdateFlag(string key, string description, string category, bool defaultEnabled, string createdBy = "System") + { + var flag = new FeatureFlag + { + Key = key, + Description = description, + Category = category, + DefaultEnabled = defaultEnabled, + Enabled = defaultEnabled, + LastModified = Core.Now, + LastModifiedBy = createdBy + }; + + _flags[key] = flag; + + SyncStaticFlag(key, flag.Enabled); + + if (FeatureFlagSettings.LogChanges) + { + logger.Information("Feature flag '{FlagKey}' created/updated by {CreatedBy}", key, createdBy); + } + + SaveFlags(); + return flag; + } + + public static bool RemoveFlag(string flagKey, string removedBy = "System") + { + if (_flags.Remove(flagKey)) + { + SyncStaticFlag(flagKey, true); // Reset to default enabled + + if (FeatureFlagSettings.LogChanges) + { + logger.Information("Feature flag '{FlagKey}' removed by {RemovedBy}", flagKey, removedBy); + } + + SaveFlags(); + return true; + } + return false; + } + + public static bool IsGumpBlocked() where T : BaseGump => + _hasActiveGumpBlocks && _gumpBlocks.TryGetValue(typeof(T), out var entry) && entry.Active; + + public static bool IsGumpBlocked(Type gumpType) => + _hasActiveGumpBlocks && _gumpBlocks.TryGetValue(gumpType, out var entry) && entry.Active; + + public static GumpBlockEntry GetGumpBlockEntry(Type gumpType) => + _hasActiveGumpBlocks ? _gumpBlocks.GetValueOrDefault(gumpType) : null; + + public static IReadOnlyCollection GetAllGumpBlocks() => _gumpBlocks.Values; + + public static void BlockGump(string reason, string blockedBy = "System") where T : BaseGump => + BlockGump(typeof(T), reason, blockedBy); + + public static void BlockGump(Type gumpType, string reason, string blockedBy = "System") + { + var entry = new GumpBlockEntry + { + ResolvedType = gumpType, + Reason = reason, + Active = true, + CreatedAt = Core.Now, + CreatedBy = blockedBy + }; + + _gumpBlocks[gumpType] = entry; + UpdateGumpBlocksFlag(); + + if (FeatureFlagSettings.LogChanges) + { + logger.Warning("Gump '{GumpType}' BLOCKED by {BlockedBy}. Reason: {Reason}", gumpType.FullName, blockedBy, reason); + } + + if (FeatureFlagSettings.BroadcastChangesToStaff) + { + World.BroadcastStaff($"[Gump Block] '{gumpType.Name}' BLOCKED by {blockedBy}. Reason: {reason}"); + } + + SaveGumpBlocks(); + } + + public static bool BlockGumpByName(string typeName, string reason, string blockedBy = "System") + { + var type = ResolveType(typeName); + if (type == null || !typeof(BaseGump).IsAssignableFrom(type)) + { + return false; + } + + BlockGump(type, reason, blockedBy); + return true; + } + + public static bool UnblockGump(string unblockedBy = "System") where T : BaseGump => + UnblockGump(typeof(T), unblockedBy); + + public static bool UnblockGump(Type gumpType, string unblockedBy = "System") + { + if (!_gumpBlocks.Remove(gumpType)) + { + return false; + } + + UpdateGumpBlocksFlag(); + + if (FeatureFlagSettings.LogChanges) + { + logger.Information("Gump '{GumpType}' UNBLOCKED by {UnblockedBy}", gumpType.FullName, unblockedBy); + } + + if (FeatureFlagSettings.BroadcastChangesToStaff) + { + World.BroadcastStaff($"[Gump Block] '{gumpType.Name}' UNBLOCKED by {unblockedBy}"); + } + + SaveGumpBlocks(); + return true; + } + + public static bool UnblockGumpByName(string typeName, string unblockedBy = "System") + { + var type = ResolveType(typeName); + return type != null && UnblockGump(type, unblockedBy); + } + + public static bool SetGumpBlockActive(string typeName, bool active, string modifiedBy = "System") + { + var type = ResolveType(typeName); + return type != null && SetGumpBlockActive(type, active, modifiedBy); + } + + public static bool SetGumpBlockActive(Type gumpType, bool active, string modifiedBy = "System") + { + if (!_gumpBlocks.TryGetValue(gumpType, out var entry)) + { + return false; + } + + entry.Active = active; + UpdateGumpBlocksFlag(); + + if (FeatureFlagSettings.LogChanges) + { + logger.Information("Gump block '{GumpType}' set to {Active} by {ModifiedBy}", gumpType.FullName, active ? "ACTIVE" : "INACTIVE", modifiedBy); + } + + if (FeatureFlagSettings.BroadcastChangesToStaff) + { + World.BroadcastStaff($"[Gump Block] '{gumpType.Name}' set to {(active ? "ACTIVE" : "INACTIVE")} by {modifiedBy}"); + } + + SaveGumpBlocks(); + return true; + } + + public static bool IsItemUseBlocked(Type itemType, out string reason) + { + if (_hasActiveItemBlocks && _itemBlocks.TryGetValue(itemType, out var entry) && entry.Active && + entry.BlockUse) + { + reason = entry.Reason ?? FeatureFlagSettings.DefaultItemUseBlockedMessage; + return true; + } + + reason = null; + return false; + } + + public static bool IsItemEquipBlocked(Type itemType, out string reason) + { + if (_hasActiveItemBlocks && _itemBlocks.TryGetValue(itemType, out var entry) && entry.Active && + entry.BlockEquip) + { + reason = entry.Reason ?? FeatureFlagSettings.DefaultItemEquipBlockedMessage; + return true; + } + + reason = null; + return false; + } + + public static bool IsContainerAccessBlocked(Type containerType, out string reason) + { + if (_hasActiveItemBlocks && _itemBlocks.TryGetValue(containerType, out var entry) && entry.Active && + entry.BlockContainerAccess) + { + reason = entry.Reason ?? FeatureFlagSettings.DefaultContainerBlockedMessage; + return true; + } + + reason = null; + return false; + } + + public static ItemBlockEntry GetItemBlockEntry(Type itemType) => + _hasActiveItemBlocks ? _itemBlocks.GetValueOrDefault(itemType) : null; + + public static IReadOnlyCollection GetAllItemBlocks() => _itemBlocks.Values; + + public static void BlockItemUse(Type itemType, string reason, string blockedBy = "System") => + SetItemBlockFlag(itemType, "Use", reason, blockedBy, (e, v) => e.BlockUse = v); + + public static void BlockItemEquip(Type itemType, string reason, string blockedBy = "System") => + SetItemBlockFlag(itemType, "Equip", reason, blockedBy, (e, v) => e.BlockEquip = v); + + public static void BlockItemContainer(Type itemType, string reason, string blockedBy = "System") => + SetItemBlockFlag(itemType, "Container", reason, blockedBy, (e, v) => e.BlockContainerAccess = v); + + public static bool BlockItemUseByName(string typeName, string reason, string blockedBy = "System") => + ResolveItemType(typeName, out var type) && Apply(() => BlockItemUse(type, reason, blockedBy)); + + public static bool BlockItemEquipByName(string typeName, string reason, string blockedBy = "System") => + ResolveItemType(typeName, out var type) && Apply(() => BlockItemEquip(type, reason, blockedBy)); + + public static bool BlockItemContainerByName(string typeName, string reason, string blockedBy = "System") => + ResolveItemType(typeName, out var type) && Apply(() => BlockItemContainer(type, reason, blockedBy)); + + public static bool UnblockItemUse(Type itemType, string unblockedBy = "System") => + ClearItemBlockFlag(itemType, "Use", unblockedBy, (e, v) => e.BlockUse = v, e => e.BlockUse); + + public static bool UnblockItemEquip(Type itemType, string unblockedBy = "System") => + ClearItemBlockFlag(itemType, "Equip", unblockedBy, (e, v) => e.BlockEquip = v, e => e.BlockEquip); + + public static bool UnblockItemContainer(Type itemType, string unblockedBy = "System") => + ClearItemBlockFlag(itemType, "Container", unblockedBy, (e, v) => e.BlockContainerAccess = v, e => e.BlockContainerAccess); + + public static bool UnblockItemUseByName(string typeName, string unblockedBy = "System") => + ResolveItemType(typeName, out var type) && UnblockItemUse(type, unblockedBy); + + public static bool UnblockItemEquipByName(string typeName, string unblockedBy = "System") => + ResolveItemType(typeName, out var type) && UnblockItemEquip(type, unblockedBy); + + public static bool UnblockItemContainerByName(string typeName, string unblockedBy = "System") => + ResolveItemType(typeName, out var type) && UnblockItemContainer(type, unblockedBy); + + private static bool ResolveItemType(string typeName, out Type type) + { + type = ResolveType(typeName); + return type != null && typeof(Item).IsAssignableFrom(type); + } + + private static bool Apply(Action action) + { + action(); + return true; + } + + private static void SetItemBlockFlag( + Type itemType, string action, string reason, string blockedBy, + Action setter) + { + if (!_itemBlocks.TryGetValue(itemType, out var entry)) + { + entry = new ItemBlockEntry + { + ResolvedType = itemType, + Active = true, + CreatedAt = Core.Now, + CreatedBy = blockedBy + }; + _itemBlocks[itemType] = entry; + } + + setter(entry, true); + + if (reason != null) + { + entry.Reason = reason; + } + + UpdateItemBlocksFlag(); + + if (FeatureFlagSettings.LogChanges) + { + logger.Warning("Item '{ItemType}' {Action} BLOCKED by {BlockedBy}. Reason: {Reason}", itemType.FullName, action, blockedBy, reason); + } + + if (FeatureFlagSettings.BroadcastChangesToStaff) + { + World.BroadcastStaff($"[Item Block] '{itemType.Name}' {action} BLOCKED by {blockedBy}. Reason: {reason}"); + } + + SaveItemBlocks(); + } + + private static bool ClearItemBlockFlag( + Type itemType, string action, string unblockedBy, + Action setter, Func getter) + { + if (!_itemBlocks.TryGetValue(itemType, out var entry) || !getter(entry)) + { + return false; + } + + setter(entry, false); + + // Remove entry entirely if no flags remain + if (!entry.BlockUse && !entry.BlockEquip && !entry.BlockContainerAccess) + { + _itemBlocks.Remove(itemType); + } + + UpdateItemBlocksFlag(); + + if (FeatureFlagSettings.LogChanges) + { + logger.Information("Item '{ItemType}' {Action} UNBLOCKED by {UnblockedBy}", itemType.FullName, action, unblockedBy); + } + + if (FeatureFlagSettings.BroadcastChangesToStaff) + { + World.BroadcastStaff($"[Item Block] '{itemType.Name}' {action} UNBLOCKED by {unblockedBy}"); + } + + SaveItemBlocks(); + return true; + } + + public static bool RemoveItemBlock(Type itemType, string removedBy = "System") + { + if (!_itemBlocks.Remove(itemType)) + { + return false; + } + + UpdateItemBlocksFlag(); + + if (FeatureFlagSettings.LogChanges) + { + logger.Information("Item '{ItemType}' block REMOVED by {RemovedBy}", itemType.FullName, removedBy); + } + + if (FeatureFlagSettings.BroadcastChangesToStaff) + { + World.BroadcastStaff($"[Item Block] '{itemType.Name}' REMOVED by {removedBy}"); + } + + SaveItemBlocks(); + return true; + } + + public static bool SetItemBlockActive(Type itemType, bool active, string modifiedBy = "System") + { + if (!_itemBlocks.TryGetValue(itemType, out var entry)) + { + return false; + } + + entry.Active = active; + UpdateItemBlocksFlag(); + + if (FeatureFlagSettings.LogChanges) + { + logger.Information("Item block '{ItemType}' set to {Active} by {ModifiedBy}", itemType.FullName, active ? "ACTIVE" : "INACTIVE", modifiedBy); + } + + if (FeatureFlagSettings.BroadcastChangesToStaff) + { + World.BroadcastStaff($"[Item Block] '{itemType.Name}' set to {(active ? "ACTIVE" : "INACTIVE")} by {modifiedBy}"); + } + + SaveItemBlocks(); + return true; + } + + public static bool IsSkillBlocked(SkillName skill, out string reason) + { + var index = (int)skill; + if (!_hasActiveSkillBlocks || index < 0 || index >= _skillBlocks.Length + || _skillBlocks[index] is not { Active: true } entry) + { + reason = null; + return false; + } + + reason = entry.Reason ?? FeatureFlagSettings.DefaultSkillDisabledMessage; + return entry is { Active: true }; + } + + public static SkillBlockEntry GetSkillBlockEntry(SkillName skill) + { + var index = (int)skill; + return index >= 0 && index < _skillBlocks.Length ? _skillBlocks[index] : null; + } + + // NOTE: Will contain nulls! + public static ReadOnlySpan GetAllSkillBlocks() => _skillBlocks; + + public static void BlockSkill(SkillName skill, string reason, string blockedBy = "System") + { + var index = (int)skill; + if (index < 0 || index >= _skillBlocks.Length) + { + return; + } + + var entry = new SkillBlockEntry + { + Skill = skill, + Reason = reason, + Active = true, + CreatedAt = Core.Now, + CreatedBy = blockedBy + }; + + _skillBlocks[index] = entry; + UpdateSkillBlocksFlag(); + + if (FeatureFlagSettings.LogChanges) + { + logger.Warning("Skill '{Skill}' BLOCKED by {BlockedBy}. Reason: {Reason}", skill, blockedBy, reason); + } + + if (FeatureFlagSettings.BroadcastChangesToStaff) + { + World.BroadcastStaff($"[Skill Block] '{skill}' BLOCKED by {blockedBy}. Reason: {reason}"); + } + + SaveSkillBlocks(); + } + + public static bool UnblockSkill(SkillName skill, string unblockedBy = "System") + { + var index = (int)skill; + if (index < 0 || index >= _skillBlocks.Length) + { + return false; + } + + if (_skillBlocks[index] != null) + { + _skillBlocks[index] = null; + UpdateSkillBlocksFlag(); + + if (FeatureFlagSettings.LogChanges) + { + logger.Information("Skill '{Skill}' UNBLOCKED by {UnblockedBy}", skill, unblockedBy); + } + + if (FeatureFlagSettings.BroadcastChangesToStaff) + { + World.BroadcastStaff($"[Skill Block] '{skill}' UNBLOCKED by {unblockedBy}"); + } + + SaveSkillBlocks(); + return true; + } + + return false; + } + + public static bool SetSkillBlockActive(SkillName skill, bool active, string modifiedBy = "System") + { + var index = (int)skill; + if (index < 0 || index >= _skillBlocks.Length) + { + return false; + } + + var entry = _skillBlocks[index]; + if (entry != null) + { + entry.Active = active; + UpdateSkillBlocksFlag(); + + if (FeatureFlagSettings.LogChanges) + { + logger.Information("Skill block '{Skill}' set to {Active} by {ModifiedBy}", skill, active ? "ACTIVE" : "INACTIVE", modifiedBy); + } + + if (FeatureFlagSettings.BroadcastChangesToStaff) + { + World.BroadcastStaff($"[Skill Block] '{skill}' set to {(active ? "ACTIVE" : "INACTIVE")} by {modifiedBy}"); + } + + SaveSkillBlocks(); + return true; + } + return false; + } + + public static bool IsSpellBlocked(int spellId, out string reason) + { + if (!_hasActiveSpellBlocks || spellId < 0 || spellId >= _spellBlocks.Length || + _spellBlocks[spellId] is not { Active: true } entry) + { + reason = null; + return false; + } + + reason = entry.Reason ?? FeatureFlagSettings.DefaultSpellDisabledMessage; + return true; + } + + public static bool IsSpellBlocked(Type spellType) + { + if (!_hasActiveSpellBlocks) + { + return false; + } + + var id = SpellRegistry.GetRegistryNumber(spellType); + return id >= 0 && id < _spellBlocks.Length && _spellBlocks[id] is { Active: true }; + } + + public static SpellBlockEntry GetSpellBlockEntry(int spellId) => + !_hasActiveSpellBlocks || spellId >= 0 && spellId >= _spellBlocks.Length ? null : _spellBlocks[spellId]; + + public static SpellBlockEntry GetSpellBlockEntry(Type spellType) + { + if (!_hasActiveSpellBlocks) + { + return null; + } + + var id = SpellRegistry.GetRegistryNumber(spellType); + return id >= 0 && id < _spellBlocks.Length ? _spellBlocks[id] : null; + } + + // NOTE: Will contain nulls! + public static ReadOnlySpan GetAllSpellBlocks() => _spellBlocks; + + public static void BlockSpell(Type spellType, string reason, string blockedBy = "System") + { + var spellId = SpellRegistry.GetRegistryNumber(spellType); + if (spellId < 0 || spellId >= _spellBlocks.Length) + { + return; + } + + var entry = new SpellBlockEntry + { + ResolvedType = spellType, + SpellId = spellId, + Reason = reason, + Active = true, + CreatedAt = Core.Now, + CreatedBy = blockedBy + }; + + _spellBlocks[spellId] = entry; + UpdateSpellBlocksFlag(); + + if (FeatureFlagSettings.LogChanges) + { + logger.Warning("Spell '{SpellType}' BLOCKED by {BlockedBy}. Reason: {Reason}", spellType.FullName, blockedBy, reason); + } + + if (FeatureFlagSettings.BroadcastChangesToStaff) + { + World.BroadcastStaff($"[Spell Block] '{spellType.Name}' BLOCKED by {blockedBy}. Reason: {reason}"); + } + + SaveSpellBlocks(); + } + + public static bool BlockSpellByName(string typeName, string reason, string blockedBy = "System") + { + var type = ResolveType(typeName); + if (type != null) + { + BlockSpell(type, reason, blockedBy); + return true; + } + + return false; + } + + public static bool UnblockSpell(Type spellType, string unblockedBy = "System") + { + var spellId = SpellRegistry.GetRegistryNumber(spellType); + if (spellId < 0 || spellId >= _spellBlocks.Length) + { + return false; + } + + if (_spellBlocks[spellId] != null) + { + _spellBlocks[spellId] = null; + UpdateSpellBlocksFlag(); + + if (FeatureFlagSettings.LogChanges) + { + logger.Information("Spell '{SpellType}' UNBLOCKED by {UnblockedBy}", spellType.Name, unblockedBy); + } + + if (FeatureFlagSettings.BroadcastChangesToStaff) + { + World.BroadcastStaff($"[Spell Block] '{spellType.Name}' UNBLOCKED by {unblockedBy}"); + } + + SaveSpellBlocks(); + return true; + } + return false; + } + + public static bool UnblockSpellByName(string typeName, string unblockedBy = "System") + { + var type = ResolveType(typeName); + return type != null && UnblockSpell(type, unblockedBy); + } + + public static bool SetSpellBlockActive(int spellId, bool active, string modifiedBy = "System") + { + if (spellId < 0 || spellId >= _spellBlocks.Length) + { + return false; + } + + var entry = _spellBlocks[spellId]; + if (entry != null) + { + entry.Active = active; + UpdateSpellBlocksFlag(); + + var typeName = entry.DisplayName; + + if (FeatureFlagSettings.LogChanges) + { + logger.Information("Spell block '{SpellType}' set to {Active} by {ModifiedBy}", typeName, active ? "ACTIVE" : "INACTIVE", modifiedBy); + } + + if (FeatureFlagSettings.BroadcastChangesToStaff) + { + World.BroadcastStaff($"[Spell Block] '{typeName}' set to {(active ? "ACTIVE" : "INACTIVE")} by {modifiedBy}"); + } + + SaveSpellBlocks(); + return true; + } + + return false; + } + + private static void LoadDefaultFlags() + { + var defaultFlagsPath = Path.Combine(Core.BaseDirectory, "Configuration", "FeatureFlags", "default-flags.json"); + var defaultFlags = JsonConfig.Deserialize>(defaultFlagsPath); + if (defaultFlags != null) + { + foreach (var flag in defaultFlags) + { + _flags.TryAdd(flag.Key, flag); + } + } + } + + public static void Save() + { + SaveFlags(); + SaveGumpBlocks(); + SaveItemBlocks(); + SaveSkillBlocks(); + SaveSpellBlocks(); + } + + private static void SaveFlags() + { + try + { + var flagsList = new List(_flags.Values); + JsonConfig.Serialize(Path.Combine(FeatureFlagSettings.SavePath, "flags.json"), flagsList); + } + catch (Exception ex) + { + logger.Error(ex, "Failed to save flags"); + } + } + + private static void SaveGumpBlocks() + { + try + { + var list = new List(_gumpBlocks.Values); + JsonConfig.Serialize(Path.Combine(FeatureFlagSettings.SavePath, "gump-blocks.json"), list); + } + catch (Exception ex) + { + logger.Error(ex, "Failed to save gump blocks"); + } + } + + private static void SaveItemBlocks() + { + try + { + var list = new List(_itemBlocks.Values); + JsonConfig.Serialize(Path.Combine(FeatureFlagSettings.SavePath, "item-blocks.json"), list); + } + catch (Exception ex) + { + logger.Error(ex, "Failed to save item blocks"); + } + } + + private static void SaveSkillBlocks() + { + try + { + var list = new List(); + for (var i = 0; i < _skillBlocks.Length; i++) + { + if (_skillBlocks[i] != null) + { + list.Add(_skillBlocks[i]); + } + } + JsonConfig.Serialize(Path.Combine(FeatureFlagSettings.SavePath, "skill-blocks.json"), list); + } + catch (Exception ex) + { + logger.Error(ex, "Failed to save skill blocks"); + } + } + + private static void SaveSpellBlocks() + { + try + { + var list = new List(); + for (var i = 0; i < _spellBlocks.Length; i++) + { + if (_spellBlocks[i] != null) + { + list.Add(_spellBlocks[i]); + } + } + JsonConfig.Serialize(Path.Combine(FeatureFlagSettings.SavePath, "spell-blocks.json"), list); + } + catch (Exception ex) + { + logger.Error(ex, "Failed to save spell blocks"); + } + } + + public static void Load() + { + try + { + var savePath = FeatureFlagSettings.SavePath; + + // Load flags + var flags = JsonConfig.Deserialize>(Path.Combine(savePath, "flags.json")); + if (flags != null) + { + foreach (var flag in flags) + { + _flags[flag.Key] = flag; + } + } + + // Load gump blocks (TypeConverter resolves Type from JSON) + var gumpBlocks = JsonConfig.Deserialize>(Path.Combine(savePath, "gump-blocks.json")); + if (gumpBlocks != null) + { + for (var i = 0; i < gumpBlocks.Count; i++) + { + var entry = gumpBlocks[i]; + if (entry.ResolvedType != null) + { + _gumpBlocks[entry.ResolvedType] = entry; + } + } + } + + // Load item blocks (or migrate from old format) + var itemBlocksPath = Path.Combine(savePath, "item-blocks.json"); + var itemBlocks = JsonConfig.Deserialize>(itemBlocksPath); + if (itemBlocks != null) + { + for (var i = 0; i < itemBlocks.Count; i++) + { + var entry = itemBlocks[i]; + if (entry.ResolvedType != null) + { + _itemBlocks[entry.ResolvedType] = entry; + } + } + } + + // Load skill blocks (JsonStringEnumConverter deserializes SkillName) + var skillBlocks = JsonConfig.Deserialize>(Path.Combine(savePath, "skill-blocks.json")); + if (skillBlocks != null) + { + for (var i = 0; i < skillBlocks.Count; i++) + { + var entry = skillBlocks[i]; + var index = (int)entry.Skill; + if (index >= 0 && index < _skillBlocks.Length) + { + _skillBlocks[index] = entry; + } + } + } + + // Load spell blocks (TypeConverter resolves Type, then look up SpellId) + var spellBlocks = JsonConfig.Deserialize>(Path.Combine(savePath, "spell-blocks.json")); + if (spellBlocks != null) + { + for (var i = 0; i < spellBlocks.Count; i++) + { + var entry = spellBlocks[i]; + if (entry.ResolvedType == null) + { + continue; + } + + var spellId = SpellRegistry.GetRegistryNumber(entry.ResolvedType); + if (spellId < 0 || spellId >= _spellBlocks.Length) + { + logger.Warning( + "Spell type '{SpellType}' has no registered spell ID, skipping", + entry.ResolvedType.FullName + ); + continue; + } + + entry.SpellId = spellId; + _spellBlocks[spellId] = entry; + } + } + + // Update fast-bailout flags + UpdateGumpBlocksFlag(); + UpdateItemBlocksFlag(); + UpdateSkillBlocksFlag(); + UpdateSpellBlocksFlag(); + + // Sync static bool flags + SyncAllStaticFlags(); + + logger.Debug("Feature flags loaded successfully"); + } + catch (Exception ex) + { + logger.Error(ex, "Failed to load feature flags"); + } + } + + private static Type ResolveType(string typeName) => + AssemblyHandler.FindTypeByFullName(typeName) ?? AssemblyHandler.FindTypeByName(typeName); + + private static void SyncStaticFlag(string key, bool enabled) + { + _ = key.ToLowerInvariant() switch + { + // Server project flags + "player_trading" => ServerFeatureFlags.PlayerTrading = enabled, + "pvp_combat" => ServerFeatureFlags.PvPCombat = enabled, + "bank_access" => ServerFeatureFlags.BankAccess = enabled, + + // UOContent flags + "vendor_purchase" => ContentFeatureFlags.VendorPurchase = enabled, + "vendor_sell" => ContentFeatureFlags.VendorSell = enabled, + "player_vendors" => ContentFeatureFlags.PlayerVendors = enabled, + "house_placement" => ContentFeatureFlags.HousePlacement = enabled, + "boat_placement" => ContentFeatureFlags.BoatPlacement = enabled, + "bulk_orders" => ContentFeatureFlags.BulkOrders = enabled, + }; + } + + private static void SyncAllStaticFlags() + { + foreach (var flag in _flags.Values) + { + SyncStaticFlag(flag.Key, flag.Enabled); + } + } + + private static void UpdateGumpBlocksFlag() + { + foreach (var entry in _gumpBlocks.Values) + { + if (entry.Active) + { + _hasActiveGumpBlocks = true; + return; + } + } + _hasActiveGumpBlocks = false; + } + + private static void UpdateItemBlocksFlag() + { + foreach (var entry in _itemBlocks.Values) + { + if (entry.Active) + { + _hasActiveItemBlocks = true; + return; + } + } + _hasActiveItemBlocks = false; + } + + private static void UpdateSkillBlocksFlag() + { + for (var i = 0; i < _skillBlocks.Length; i++) + { + if (_skillBlocks[i] is { Active: true }) + { + _hasActiveSkillBlocks = true; + return; + } + } + _hasActiveSkillBlocks = false; + } + + private static void UpdateSpellBlocksFlag() + { + for (var i = 0; i < _spellBlocks.Length; i++) + { + if (_spellBlocks[i] is { Active: true }) + { + _hasActiveSpellBlocks = true; + return; + } + } + _hasActiveSpellBlocks = false; + } +} diff --git a/Projects/UOContent/Engines/FeatureFlags/FeatureFlagSettings.cs b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagSettings.cs new file mode 100644 index 000000000..6003eda42 --- /dev/null +++ b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagSettings.cs @@ -0,0 +1,82 @@ +using System; +using System.IO; +using System.Text.Json.Serialization; + +namespace Server.Systems.FeatureFlags; + +public sealed class FeatureFlag +{ + public string Key { get; init; } + public string Description { get; init; } + public bool Enabled { get; set; } + public bool DefaultEnabled { get; init; } + public string Category { get; init; } + public DateTime LastModified { get; set; } + public string LastModifiedBy { get; set; } +} + +[PropertyObject] +public class FeatureFlagBlockEntry +{ + [CommandProperty(AccessLevel.Administrator)] + public Type ResolvedType { get; set; } + + [CommandProperty(AccessLevel.Administrator)] + public string Reason { get; set; } + + [CommandProperty(AccessLevel.Administrator)] + public bool Active { get; set; } + + [CommandProperty(AccessLevel.Administrator, readOnly: true)] + public DateTime CreatedAt { get; init; } + + [CommandProperty(AccessLevel.Administrator, readOnly: true)] + public string CreatedBy { get; init; } + + [JsonIgnore] + public virtual string DisplayName => ResolvedType?.Name; +} + +public sealed class GumpBlockEntry : FeatureFlagBlockEntry; + +public sealed class ItemBlockEntry : FeatureFlagBlockEntry +{ + [CommandProperty(AccessLevel.Administrator)] + public bool BlockUse { get; set; } + + [CommandProperty(AccessLevel.Administrator)] + public bool BlockEquip { get; set; } + + [CommandProperty(AccessLevel.Administrator)] + public bool BlockContainerAccess { get; set; } +} + +public sealed class SkillBlockEntry : FeatureFlagBlockEntry +{ + public SkillName Skill { get; set; } + + [JsonIgnore] + public override string DisplayName => Skill.ToString(); +} + +public sealed class SpellBlockEntry : FeatureFlagBlockEntry +{ + [JsonIgnore] + public int SpellId { get; set; } +} + +public static class FeatureFlagSettings +{ + public const string DefaultGumpBlockedMessage = "This feature is temporarily disabled."; + public const string DefaultItemUseBlockedMessage = "This item cannot be used at this time."; + public const string DefaultItemEquipBlockedMessage = "This item cannot be equipped at this time."; + public const string DefaultContainerBlockedMessage = "This container cannot be opened at this time."; + public const string DefaultSkillDisabledMessage = "This skill is temporarily disabled."; + public const string DefaultSpellDisabledMessage = "This spell is temporarily disabled."; + + public static AccessLevel RequiredAccessLevel { get; set; } = AccessLevel.Administrator; + public static bool LogChanges { get; set; } = true; + public static bool BroadcastChangesToStaff { get; set; } = true; + + public static string SavePath => Path.Combine(Core.BaseDirectory, "Configuration", "FeatureFlags"); +} diff --git a/Projects/UOContent/Gumps/Base/GumpColors.cs b/Projects/UOContent/Gumps/Base/GumpColors.cs index 9933c919c..35b5b9041 100644 --- a/Projects/UOContent/Gumps/Base/GumpColors.cs +++ b/Projects/UOContent/Gumps/Base/GumpColors.cs @@ -13,6 +13,7 @@ public class GumpTextColors public const string BrightRed = "#FF0000"; public const string LightGreen = "#E6FFC0"; public const string Yellow = "#FFFFBB"; + public const string LightGray = "#CCCCCC"; } public class GumpHues diff --git a/Projects/UOContent/Gumps/Base/GumpSystem.cs b/Projects/UOContent/Gumps/Base/GumpSystem.cs index c8c9d7728..1fc742406 100644 --- a/Projects/UOContent/Gumps/Base/GumpSystem.cs +++ b/Projects/UOContent/Gumps/Base/GumpSystem.cs @@ -14,6 +14,7 @@ *************************************************************************/ using Server.Network; +using Server.Systems.FeatureFlags; using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -105,6 +106,14 @@ public static partial class GumpSystem { ArgumentNullException.ThrowIfNull(m); + if (m.AccessLevel < FeatureFlagSettings.RequiredAccessLevel + && FeatureFlagManager.IsGumpBlocked(g.GetType())) + { + var entry = FeatureFlagManager.GetGumpBlockEntry(g.GetType()); + m.SendMessage(0x22, entry?.Reason ?? FeatureFlagSettings.DefaultGumpBlockedMessage); + return; + } + var state = m.NetState; if (state != null) diff --git a/Projects/UOContent/Items/Containers/Container.cs b/Projects/UOContent/Items/Containers/Container.cs index e4dd27669..164c285c4 100644 --- a/Projects/UOContent/Items/Containers/Container.cs +++ b/Projects/UOContent/Items/Containers/Container.cs @@ -3,6 +3,7 @@ using Server.Collections; using Server.ContextMenus; using Server.Mobiles; using Server.Multis; +using Server.Systems.FeatureFlags; namespace Server.Items; @@ -19,6 +20,18 @@ public abstract class BaseContainer : Container public override int DefaultMaxWeight => IsSecure ? 0 : base.DefaultMaxWeight; + public override void DisplayTo(Mobile to) + { + if (to.AccessLevel < FeatureFlagSettings.RequiredAccessLevel + && FeatureFlagManager.IsItemUseBlocked(GetType(), out var reason)) + { + to.SendMessage(0x22, reason); + return; + } + + base.DisplayTo(to); + } + public override bool IsAccessibleTo(Mobile m) => BaseHouse.CheckAccessible(m, this) && base.IsAccessibleTo(m); public override bool CheckHold(Mobile m, Item item, bool message, bool checkItems, int plusItems, int plusWeight) diff --git a/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs b/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs index cdd60bd51..c94a362ab 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs @@ -4,9 +4,11 @@ using ModernUO.Serialization; using Server.Commands; using Server.Engines.Craft; using Server.Ethics; +using Server.Mobiles; using Server.Multis; using Server.Network; using Server.Spells; +using Server.Systems.FeatureFlags; using Server.Targeting; namespace Server.Items; @@ -306,6 +308,14 @@ public partial class Spellbook : Item, ICraftable, ISlayer, IAosItem return; // They are customizing } + // Early rejection by spell ID before instantiation + if (from is PlayerMobile { AccessLevel: < AccessLevel.Administrator } + && FeatureFlagManager.IsSpellBlocked(spellID, out var reason)) + { + from.SendMessage(0x22, reason); + return; + } + var book = item as Spellbook; if (book?.HasSpell(spellID) != true) diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs index ffc5f3812..3ac802b20 100644 --- a/Projects/UOContent/Mobiles/PlayerMobile.cs +++ b/Projects/UOContent/Mobiles/PlayerMobile.cs @@ -34,6 +34,7 @@ using Server.Spells.Necromancy; using Server.Spells.Ninjitsu; using Server.Spells.Sixth; using Server.Spells.Spellweaving; +using Server.Systems.FeatureFlags; using Server.Targeting; using BaseQuestGump = Server.Engines.MLQuests.Gumps.BaseQuestGump; using CalcMoves = Server.Movement.Movement; @@ -1764,16 +1765,25 @@ namespace Server.Mobiles public override bool AllowItemUse(Item item) { - if (DuelContext?.AllowItemUse(this, item) == false) + if (AccessLevel < FeatureFlagSettings.RequiredAccessLevel && + FeatureFlagManager.IsItemUseBlocked(item.GetType(), out var reason)) { + SendMessage(0x22, reason); return false; } - return DesignContext.Check(this); + return DuelContext?.AllowItemUse(this, item) != false && DesignContext.Check(this); } public override bool AllowSkillUse(SkillName skill) { + if (AccessLevel < FeatureFlagSettings.RequiredAccessLevel + && FeatureFlagManager.IsSkillBlocked(skill, out var reason)) + { + SendMessage(0x22, reason); + return false; + } + if (AnimalForm.UnderTransformation(this)) { for (var i = 0; i < AnimalFormRestrictedSkills.Length; i++) @@ -2033,6 +2043,13 @@ namespace Server.Mobiles public override bool CheckEquip(Item item) { + if (AccessLevel < FeatureFlagSettings.RequiredAccessLevel + && FeatureFlagManager.IsItemEquipBlocked(item.GetType(), out var reason)) + { + SendMessage(0x22, reason); + return false; + } + if (!base.CheckEquip(item)) { return false; diff --git a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs index 23c42c7e2..a7e775874 100644 --- a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs @@ -11,6 +11,7 @@ using Server.Mobiles; using Server.Network; using Server.Regions; using Server.Logging; +using Server.Systems.FeatureFlags; namespace Server.Mobiles { @@ -851,6 +852,12 @@ namespace Server.Mobiles return; } + if (!ContentFeatureFlags.VendorPurchase && from.AccessLevel < AccessLevel.Administrator) + { + from.SendMessage(0x22, "Vendor purchases are temporarily disabled."); + return; + } + if (!CheckVendorAccess(from)) { Say(501522); // I shall not treat with scum like thee! @@ -1022,6 +1029,12 @@ namespace Server.Mobiles return; } + if (!ContentFeatureFlags.VendorSell && from.AccessLevel < AccessLevel.Administrator) + { + from.SendMessage(0x22, "Vendor sales are temporarily disabled."); + return; + } + if (!CheckVendorAccess(from)) { Say(501522); // I shall not treat with scum like thee! diff --git a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs index cc63701d3..943271935 100644 --- a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs @@ -9,6 +9,7 @@ using Server.Items; using Server.Misc; using Server.Multis; using Server.Prompts; +using Server.Systems.FeatureFlags; using Server.Targeting; namespace Server.Mobiles; @@ -730,6 +731,12 @@ public partial class PlayerVendor : Mobile return; } + if (!ContentFeatureFlags.PlayerVendors && from.AccessLevel < AccessLevel.Administrator) + { + from.SendMessage(0x22, "Player vendor transactions are temporarily disabled."); + return; + } + if (vendor.IsOwner(from)) { vendor.SayTo(from, 503212); // You own this shop, just take what you want. diff --git a/Projects/UOContent/Multis/Boats/BaseBoatDeed.cs b/Projects/UOContent/Multis/Boats/BaseBoatDeed.cs index 9bd31c0e7..961b4898c 100644 --- a/Projects/UOContent/Multis/Boats/BaseBoatDeed.cs +++ b/Projects/UOContent/Multis/Boats/BaseBoatDeed.cs @@ -1,6 +1,7 @@ using ModernUO.Serialization; using Server.Engines.CannedEvil; using Server.Regions; +using Server.Systems.FeatureFlags; using Server.Targeting; namespace Server.Multis; @@ -66,64 +67,68 @@ public abstract partial class BaseBoatDeed : Item if (!IsChildOf(from.Backpack)) { from.SendLocalizedMessage(1042001); // That must be in your pack for you to use it. + return; + } + + var map = from.Map; + + if (map == null) + { + return; + } + + if (!ContentFeatureFlags.BoatPlacement && from.AccessLevel < FeatureFlagSettings.RequiredAccessLevel) + { + from.SendMessage(0x22, "Boat placement is temporarily disabled."); + } + + if (from.AccessLevel < AccessLevel.GameMaster && (map == Map.Ilshenar || map == Map.Malas)) + { + from.SendLocalizedMessage(1043284); // A ship can not be created here. + return; + } + + if (from.Region.IsPartOf() || BaseBoat.FindBoatAt(from.Location, from.Map) != null) + { + // You may not place a ship while on another ship or inside a house. + from.SendLocalizedMessage(1010568, null, 0x25); + return; + } + + var boat = Boat; + + if (boat == null) + { + return; + } + + p = new Point3D(p.X - Offset.X, p.Y - Offset.Y, p.Z - Offset.Z); + + if (BaseBoat.IsValidLocation(p, map) && boat.CanFit(p, map, boat.ItemID)) + { + Delete(); + + boat.Owner = from; + boat.Anchored = true; + + var keyValue = boat.CreateKeys(from); + + if (boat.PPlank != null) + { + boat.PPlank.KeyValue = keyValue; + } + + if (boat.SPlank != null) + { + boat.SPlank.KeyValue = keyValue; + } + + boat.MoveToWorld(p, map); } else { - var map = from.Map; - - if (map == null) - { - return; - } - - if (from.AccessLevel < AccessLevel.GameMaster && (map == Map.Ilshenar || map == Map.Malas)) - { - from.SendLocalizedMessage(1043284); // A ship can not be created here. - return; - } - - if (from.Region.IsPartOf() || BaseBoat.FindBoatAt(from.Location, from.Map) != null) - { - // You may not place a ship while on another ship or inside a house. - from.SendLocalizedMessage(1010568, null, 0x25); - return; - } - - var boat = Boat; - - if (boat == null) - { - return; - } - - p = new Point3D(p.X - Offset.X, p.Y - Offset.Y, p.Z - Offset.Z); - - if (BaseBoat.IsValidLocation(p, map) && boat.CanFit(p, map, boat.ItemID)) - { - Delete(); - - boat.Owner = from; - boat.Anchored = true; - - var keyValue = boat.CreateKeys(from); - - if (boat.PPlank != null) - { - boat.PPlank.KeyValue = keyValue; - } - - if (boat.SPlank != null) - { - boat.SPlank.KeyValue = keyValue; - } - - boat.MoveToWorld(p, map); - } - else - { - boat.Delete(); - from.SendLocalizedMessage(1043284); // A ship can not be created here. - } + boat.Delete(); + from.SendLocalizedMessage(1043284); // A ship can not be created here. } } diff --git a/Projects/UOContent/Multis/Boats/BaseDockedBoat.cs b/Projects/UOContent/Multis/Boats/BaseDockedBoat.cs index 7c859a046..07d1c7ac5 100644 --- a/Projects/UOContent/Multis/Boats/BaseDockedBoat.cs +++ b/Projects/UOContent/Multis/Boats/BaseDockedBoat.cs @@ -1,6 +1,7 @@ using ModernUO.Serialization; using Server.Engines.CannedEvil; using Server.Regions; +using Server.Systems.FeatureFlags; using Server.Targeting; namespace Server.Multis; @@ -83,53 +84,58 @@ public abstract partial class BaseDockedBoat : Item if (!IsChildOf(from.Backpack)) { from.SendLocalizedMessage(1042001); // That must be in your pack for you to use it. + return; + } + + var map = from.Map; + + if (map == null) + { + return; + } + + var boat = Boat; + + if (boat == null) + { + return; + } + + if (!ContentFeatureFlags.BoatPlacement && from.AccessLevel < FeatureFlagSettings.RequiredAccessLevel) + { + from.SendMessage(0x22, "Boat placement is temporarily disabled."); + return; + } + + p = new Point3D(p.X - Offset.X, p.Y - Offset.Y, p.Z - Offset.Z); + + if (BaseBoat.IsValidLocation(p, map) && boat.CanFit(p, map, boat.ItemID) && map != Map.Ilshenar && + map != Map.Malas) + { + Delete(); + + boat.Owner = from; + boat.Anchored = true; + boat.ShipName = _shipName; + + var keyValue = boat.CreateKeys(from); + + if (boat.PPlank != null) + { + boat.PPlank.KeyValue = keyValue; + } + + if (boat.SPlank != null) + { + boat.SPlank.KeyValue = keyValue; + } + + boat.MoveToWorld(p, map); } else { - var map = from.Map; - - if (map == null) - { - return; - } - - var boat = Boat; - - if (boat == null) - { - return; - } - - p = new Point3D(p.X - Offset.X, p.Y - Offset.Y, p.Z - Offset.Z); - - if (BaseBoat.IsValidLocation(p, map) && boat.CanFit(p, map, boat.ItemID) && map != Map.Ilshenar && - map != Map.Malas) - { - Delete(); - - boat.Owner = from; - boat.Anchored = true; - boat.ShipName = _shipName; - - var keyValue = boat.CreateKeys(from); - - if (boat.PPlank != null) - { - boat.PPlank.KeyValue = keyValue; - } - - if (boat.SPlank != null) - { - boat.SPlank.KeyValue = keyValue; - } - - boat.MoveToWorld(p, map); - } - else - { - boat.Delete(); - from.SendLocalizedMessage(1043284); // A ship can not be created here. - } + boat.Delete(); + from.SendLocalizedMessage(1043284); // A ship can not be created here. } } diff --git a/Projects/UOContent/Multis/Houses/HousePlacement.cs b/Projects/UOContent/Multis/Houses/HousePlacement.cs index 13760a318..86a364048 100644 --- a/Projects/UOContent/Multis/Houses/HousePlacement.cs +++ b/Projects/UOContent/Multis/Houses/HousePlacement.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Server.Collections; using Server.Regions; using Server.Spells; +using Server.Systems.FeatureFlags; namespace Server.Multis { @@ -52,6 +53,11 @@ namespace Server.Multis return HousePlacementResult.BadLand; // A house cannot go here } + if (!ContentFeatureFlags.HousePlacement && from.AccessLevel < FeatureFlagSettings.RequiredAccessLevel) + { + return HousePlacementResult.BadRegionTemp; + } + if (from.AccessLevel >= AccessLevel.GameMaster) { return HousePlacementResult.Valid; // Staff can place anywhere diff --git a/Projects/UOContent/Multis/Houses/HousePlacementTool.cs b/Projects/UOContent/Multis/Houses/HousePlacementTool.cs index 7b8f241a9..4ccf68593 100644 --- a/Projects/UOContent/Multis/Houses/HousePlacementTool.cs +++ b/Projects/UOContent/Multis/Houses/HousePlacementTool.cs @@ -21,14 +21,13 @@ public partial class HousePlacementTool : Item public override void OnDoubleClick(Mobile from) { - if (IsChildOf(from.Backpack)) - { - from.SendGump(new HousePlacementCategoryGump()); - } - else + if (!IsChildOf(from.Backpack)) { from.SendLocalizedMessage(1042001); // That must be in your pack for you to use it. + return; } + + from.SendGump(new HousePlacementCategoryGump()); } } diff --git a/Projects/UOContent/Spells/Base/Spell.cs b/Projects/UOContent/Spells/Base/Spell.cs index de8d8563c..50927ae36 100644 --- a/Projects/UOContent/Spells/Base/Spell.cs +++ b/Projects/UOContent/Spells/Base/Spell.cs @@ -9,6 +9,7 @@ using Server.Spells.Necromancy; using Server.Spells.Ninjitsu; using Server.Spells.Second; using Server.Spells.Spellweaving; +using Server.Systems.FeatureFlags; using Server.Targeting; namespace Server.Spells @@ -508,6 +509,12 @@ namespace Server.Spells else if ((Caster as PlayerMobile)?.DuelContext?.AllowSpellCast(Caster, this) == false) { } + else if (Caster is PlayerMobile { AccessLevel: < AccessLevel.Administrator } && + FeatureFlagManager.IsSpellBlocked(GetType())) + { + var entry = FeatureFlagManager.GetSpellBlockEntry(GetType()); + Caster.SendMessage(0x22, entry?.Reason ?? "This spell is temporarily disabled."); + } else { var requiredMana = ScaleMana(GetMana()); diff --git a/Projects/UOContent/Spells/Base/SpellRegistry.cs b/Projects/UOContent/Spells/Base/SpellRegistry.cs index 3d7847d27..1f3060d76 100644 --- a/Projects/UOContent/Spells/Base/SpellRegistry.cs +++ b/Projects/UOContent/Spells/Base/SpellRegistry.cs @@ -66,7 +66,7 @@ namespace Server.Spells public static int GetRegistryNumber(SpecialMove s) => GetRegistryNumber(s.GetType()); - public static int GetRegistryNumber(Type type) => m_IDsFromTypes.TryGetValue(type, out var value) ? value : -1; + public static int GetRegistryNumber(Type type) => m_IDsFromTypes.GetValueOrDefault(type, -1); public static void Register(int spellID, Type type) {