ModernUO/Projects/UOContent/Engines/FeatureFlags/FeatureFlagAdminGump.cs
Kamron Batman b191498569
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
2026-02-07 12:02:57 -08:00

464 lines
17 KiB
C#

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