feat: Adds Feature Flag System (#2328)

## Feature Flag System

A runtime feature flag system for dynamically enabling/disabling game systems and blocking specific game elements
without server restarts.

### Overview

Two types of controls:

1. Feature Flags - Named boolean flags that gate entire systems (trading, PvP, vendors, housing, etc.)
2. Dynamic Blocks - Block specific gumps, items, skills, or spells by type

All changes are persisted to JSON, broadcast to online staff, and bypass-able by administrators.

### Predefined Flags
```
┌─────────────────┬──────────┬─────────────────────────────────────┐
│      Flag       │ Category │             Description             │
├─────────────────┼──────────┼─────────────────────────────────────┤
│ player_trading  │ Economy  │ Allow secure trades between players │
├─────────────────┼──────────┼─────────────────────────────────────┤
│ vendor_purchase │ Economy  │ Allow purchasing from NPC vendors   │
├─────────────────┼──────────┼─────────────────────────────────────┤
│ vendor_sell     │ Economy  │ Allow selling to NPC vendors        │
├─────────────────┼──────────┼─────────────────────────────────────┤
│ player_vendors  │ Economy  │ Allow player vendor interactions    │
├─────────────────┼──────────┼─────────────────────────────────────┤
│ bank_access     │ Economy  │ Allow bank box access               │
├─────────────────┼──────────┼─────────────────────────────────────┤
│ house_placement │ Housing  │ Allow new house placements          │
├─────────────────┼──────────┼─────────────────────────────────────┤
│ boat_placement  │ Housing  │ Allow new boat placements           │
├─────────────────┼──────────┼─────────────────────────────────────┤
│ bulk_orders     │ Crafting │ Allow bulk order deeds              │
├─────────────────┼──────────┼─────────────────────────────────────┤
│ pvp_combat      │ Combat   │ Allow player vs player combat       │
└─────────────────┴──────────┴─────────────────────────────────────┘
```

### Commands

#### Feature Flags
```
┌────────────────────────────────────────────────────┬───────────────────────┐
│                      Command                       │      Description      │
├────────────────────────────────────────────────────┼───────────────────────┤
│ [FeatureFlag <key> on|off|toggle|info              │ Manage a feature flag │
├────────────────────────────────────────────────────┼───────────────────────┤
│ [FF <key> on|off                                   │ Shorthand alias       │
├────────────────────────────────────────────────────┼───────────────────────┤
│ [FeatureFlag <key> create <category> <desc>        │ Create a custom flag  │
├────────────────────────────────────────────────────┼───────────────────────┤
│ [FeatureFlag <key> delete                          │ Delete a custom flag  │
├────────────────────────────────────────────────────┼───────────────────────┤
│ [FeatureList [flags|gumps|items|skills|spells|all] │ List all flags/blocks │
├────────────────────────────────────────────────────┼───────────────────────┤
│ [FeatureAdmin                                      │ Open the admin gump   │
└────────────────────────────────────────────────────┴───────────────────────┘
```

#### Gump Blocks
```
┌────────────────────────────────┬──────────────────────────────────────────────────────┐
│            Command             │                     Description                      │
├────────────────────────────────┼──────────────────────────────────────────────────────┤
│ [BlockGump <typeName> [reason] │ Block a gump type from displaying                    │
├────────────────────────────────┼──────────────────────────────────────────────────────┤
│ [UnblockGump <typeName>        │ Remove a gump block                                  │
├────────────────────────────────┼──────────────────────────────────────────────────────┤
│ [ListGumps [self]              │ List open gumps on a player (for finding type names) │
└────────────────────────────────┴──────────────────────────────────────────────────────┘
```

#### Item Blocks
```
┌────────────────────────────────────────────────┬───────────────────────────────┐
│                    Command                     │          Description          │
├────────────────────────────────────────────────┼───────────────────────────────┤
│ [BlockItemUse <typeName|target> [reason]       │ Block item use                │
├────────────────────────────────────────────────┼───────────────────────────────┤
│ [BlockItemEquip <typeName|target> [reason]     │ Block item equipping          │
├────────────────────────────────────────────────┼───────────────────────────────┤
│ [BlockItemContainer <typeName|target> [reason] │ Block container access        │
├────────────────────────────────────────────────┼───────────────────────────────┤
│ [UnblockItemUse <typeName>                     │ Remove use block              │
├────────────────────────────────────────────────┼───────────────────────────────┤
│ [UnblockItemEquip <typeName>                   │ Remove equip block            │
├────────────────────────────────────────────────┼───────────────────────────────┤
│ [UnblockItemContainer <typeName>               │ Remove container access block │
└────────────────────────────────────────────────┴───────────────────────────────┘
```
Item block commands create an entry if one doesn't exist, or flip the relevant flag on an existing entry. Unblock
commands only clear their specific flag -- the entry is removed when all flags (use/equip/container) are off.

#### Skill & Spell Blocks
```
┌──────────────────────────────────────┬──────────────────────┐
│               Command                │     Description      │
├──────────────────────────────────────┼──────────────────────┤
│ [BlockSkill <SkillName> [reason]     │ Block a skill        │
├──────────────────────────────────────┼──────────────────────┤
│ [UnblockSkill <SkillName>            │ Remove a skill block │
├──────────────────────────────────────┼──────────────────────┤
│ [BlockSpell <SpellTypeName> [reason] │ Block a spell        │
├──────────────────────────────────────┼──────────────────────┤
│ [UnblockSpell <SpellTypeName>        │ Remove a spell block │
└──────────────────────────────────────┴──────────────────────┘
```

### Architecture

- Static bool classes for hot-path checks (no dictionary lookups): ServerFeatureFlags (Server project) and
ContentFeatureFlags (UOContent)
- Synced automatically by FeatureFlagManager.SyncStaticFlag() when flags change
- Predefined flags loaded from Distribution/Configuration/FeatureFlags/default-flags.json
- Runtime state persisted to Configuration/FeatureFlags/*.json (flags, gump-blocks, item-blocks, skill-blocks,
spell-blocks)
- Admin gump with tabbed UI for managing all block types, per-entry PropertiesGump editing, and pagination
- All staff at AccessLevel.GameMaster+ bypass dynamic blocks; AccessLevel.Administrator+ bypass feature flags

### Hook Points

Hook: Player trading
Location: Mobile.OpenTrade
Mechanism: ServerFeatureFlags.PlayerTrading
────────────────────────────────────────
Hook: PvP combat
Location: Mobile.CanBeHarmful
Mechanism: ServerFeatureFlags.PvPCombat
────────────────────────────────────────
Hook: Bank access
Location: BankBox.Open()
Mechanism: ServerFeatureFlags.BankAccess
────────────────────────────────────────
Hook: Vendor buy/sell
Location: BaseVendor
Mechanism: ContentFeatureFlags.VendorPurchase/Sell
────────────────────────────────────────
Hook: Player vendors
Location: PlayerVendor
Mechanism: ContentFeatureFlags.PlayerVendors
────────────────────────────────────────
Hook: House placement
Location: HousePlacement.Check()
Mechanism: ContentFeatureFlags.HousePlacement (returns BadRegionTemp)
────────────────────────────────────────
Hook: Boat placement
Location: BaseBoatDeed.OnDoubleClick/OnPlacement
Mechanism: ContentFeatureFlags.BoatPlacement
────────────────────────────────────────
Hook: Bulk orders
Location: SmallBOD/LargeBOD
Mechanism: ContentFeatureFlags.BulkOrders
────────────────────────────────────────
Hook: Gump display
Location: GumpSystem.SendGump
Mechanism: FeatureFlagManager.IsGumpBlocked
────────────────────────────────────────
Hook: Item use
Location: PlayerMobile.AllowItemUse
Mechanism: FeatureFlagManager.IsItemUseBlocked
────────────────────────────────────────
Hook: Item equip
Location: PlayerMobile.CheckEquip
Mechanism: FeatureFlagManager.IsItemEquipBlocked
────────────────────────────────────────
Hook: Container access
Location: BaseContainer.DisplayTo
Mechanism: FeatureFlagManager.IsContainerAccessBlocked
────────────────────────────────────────
Hook: Skill use
Location: PlayerMobile.AllowSkillUse
Mechanism: FeatureFlagManager.IsSkillBlocked
────────────────────────────────────────
Hook: Spell casting
Location: Spell.Cast / Spellbook.CastSpellRequest
Mechanism: FeatureFlagManager.IsSpellBlocked
This commit is contained in:
Kamron Batman 2026-02-07 12:02:57 -08:00 committed by GitHub
parent e4178bb495
commit b191498569
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2461 additions and 117 deletions

View file

@ -1019,6 +1019,8 @@ public ref struct PooledRefList<T>
return array;
}
public ReadOnlySpan<T> 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

View file

@ -0,0 +1,12 @@
namespace Server;
/// <summary>
/// Static boolean flags for Server project hot paths.
/// Values are synced by FeatureFlagManager in UOContent when flags change.
/// </summary>
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;
}

View file

@ -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)

View file

@ -7487,6 +7487,12 @@ public partial class Mobile : IHued, IComparable<Mobile>, 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<Mobile>, 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))
{

View file

@ -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);

View file

@ -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)

View file

@ -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)

View file

@ -0,0 +1,15 @@
namespace Server.Systems.FeatureFlags;
/// <summary>
/// Static boolean flags for UOContent hot paths.
/// Values are synced by FeatureFlagManager when flags change.
/// </summary>
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;
}

View file

@ -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<FeatureFlag>(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<FeatureFlagBlockEntry> blocks,
string helpText
)
{
using var list = PooledRefList<FeatureFlagBlockEntry>.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<FeatureFlagBlockEntry> 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<ItemBlockEntry> 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);
}
}

View file

@ -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 <flagKey> [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 <key> create <category> <description>");
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 <typeName> [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 <typeName> [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 <typeName>")]
[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 <typeName>");
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 <typeName|target> [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 <typeName|target> [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 <typeName|target> [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<Type, string, string> blockByType,
Func<string, string, string, bool> blockByName)
{
var from = e.Mobile;
if (e.Arguments.Length == 0)
{
from.SendMessage($"Usage: [BlockItem{action} <typeName> [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<Type, string, string> _blockByType;
public BlockItemTarget(string action, string reason, Action<Type, string, string> 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 <typeName>")]
[Description("Remove the use block for an item type.")]
private static void UnblockItemUse_OnCommand(CommandEventArgs e) =>
HandleUnblockItem(e, "Use", FeatureFlagManager.UnblockItemUseByName);
[Usage("UnblockItemEquip <typeName>")]
[Description("Remove the equip block for an item type.")]
private static void UnblockItemEquip_OnCommand(CommandEventArgs e) =>
HandleUnblockItem(e, "Equip", FeatureFlagManager.UnblockItemEquipByName);
[Usage("UnblockItemContainer <typeName>")]
[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<string, string, bool> unblockByName)
{
var from = e.Mobile;
if (e.Arguments.Length == 0)
{
from.SendMessage($"Usage: [UnblockItem{action} <typeName>");
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 <SkillName> [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 <SkillName> [reason]");
from.SendMessage("Example: [BlockSkill Magery Investigating exploit");
return;
}
if (!Enum.TryParse<SkillName>(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 <SkillName>")]
[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 <SkillName>");
from.SendMessage("Use [FeatureList skills to see blocked skills.");
return;
}
if (!Enum.TryParse<SkillName>(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 <SpellTypeName> [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 <SpellTypeName> [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 <SpellTypeName>")]
[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 <SpellTypeName>");
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<FeatureFlag>(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<string>(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.");
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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");
}

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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;

View file

@ -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!

View file

@ -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.

View file

@ -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<HouseRegion>() || 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<HouseRegion>() || 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.
}
}

View file

@ -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.
}
}

View file

@ -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

View file

@ -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());
}
}

View file

@ -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());

View file

@ -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)
{