ModernUO/Projects/Server/World/World.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

543 lines
18 KiB
C#

/*************************************************************************
* ModernUO *
* Copyright 2019-2024 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: World.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.CompilerServices;
using System.Threading;
using Server.Guilds;
using Server.Logging;
using Server.Network;
namespace Server;
public enum WorldState
{
Initial,
Loading,
Running,
PendingSave,
Saving,
WritingSave
}
public static class World
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(World));
private static readonly ItemPersistence _itemPersistence = new();
private static readonly MobilePersistence _mobilePersistence = new();
private static readonly GenericEntityPersistence<BaseGuild> _guildPersistence = new("Guilds", 3, 1, 0x7FFFFFFF);
private static int _threadId;
internal static SerializationThreadWorker[] _threadWorkers;
private static readonly ManualResetEvent _diskWriteHandle = new(true);
private static string _tempSavePath; // Path to the temporary folder for the save
public const bool DirtyTrackingEnabled = false;
public const uint ItemOffset = 0x40000000;
public const uint MaxItemSerial = 0x7EEEEEEE;
public const uint MaxMobileSerial = ItemOffset - 1;
public const uint ResetVirtualSerial = MaxItemSerial;
public const uint MaxVirtualSerial = 0x7FFFFFFF;
private static uint _nextVirtualSerial = ResetVirtualSerial;
public static Serial NewMobile => _mobilePersistence.NewEntity;
public static Serial NewItem => _itemPersistence.NewEntity;
public static Serial NewGuild => _guildPersistence.NewEntity;
// Virtual things don't persist across saves
public static Serial NewVirtual
{
get
{
#if THREADGUARD
if (Thread.CurrentThread != Core.Thread)
{
logger.Error(
"Attempted to get a new virtual serial from the wrong thread!\n{StackTrace}",
new StackTrace()
);
}
#endif
var value = _nextVirtualSerial > MaxVirtualSerial ? ResetVirtualSerial : _nextVirtualSerial;
_nextVirtualSerial = value + 1;
return (Serial)value;
}
}
public static Dictionary<Serial, Item> Items => _itemPersistence.EntitiesBySerial;
public static Dictionary<Serial, Mobile> Mobiles => _mobilePersistence.EntitiesBySerial;
public static Dictionary<Serial, BaseGuild> Guilds => _guildPersistence.EntitiesBySerial;
public static bool UseMultiThreadedSaves { get; private set; }
public static string SavePath { get; private set; }
public static WorldState WorldState { get; private set; }
public static bool Saving => WorldState == WorldState.Saving;
public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial;
public static bool Loading => WorldState == WorldState.Loading;
public static void Configure()
{
var tempSavePath = ServerConfiguration.GetSetting("world.tempSavePath", "temp");
_tempSavePath = PathUtility.GetFullPath(tempSavePath);
var savePath = ServerConfiguration.GetOrUpdateSetting("world.savePath", "Saves");
SavePath = PathUtility.GetFullPath(savePath);
UseMultiThreadedSaves = ServerConfiguration.GetOrUpdateSetting("world.useMultithreadedSaves", true);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void WaitForWriteCompletion() => _diskWriteHandle.WaitOne();
public static void Broadcast(int hue, bool ascii, string text)
{
var length = OutgoingMessagePackets.GetMaxMessageLength(text);
var buffer = stackalloc byte[length].InitializePacket();
foreach (var ns in NetState.Instances)
{
if (ns.Mobile == null)
{
continue;
}
length = OutgoingMessagePackets.CreateMessage(
buffer, Serial.MinusOne, -1, MessageType.Regular, hue, 3, ascii, "ENU", "System", text
);
if (length != buffer.Length)
{
buffer = buffer[..length]; // Adjust to the actual size
}
ns.Send(buffer);
}
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);
var buffer = stackalloc byte[length].InitializePacket();
foreach (var ns in NetState.Instances)
{
if (ns.Mobile == null || ns.Mobile.AccessLevel < AccessLevel.GameMaster)
{
continue;
}
length = OutgoingMessagePackets.CreateMessage(
buffer, Serial.MinusOne, -1, MessageType.Regular, hue, 3, ascii, "ENU", "System", text
);
if (length != buffer.Length)
{
buffer = buffer[..length]; // Adjust to the actual size
}
ns.Send(buffer);
}
NetState.FlushAll();
}
public static void Load()
{
if (WorldState != WorldState.Initial)
{
return;
}
WorldState = WorldState.Loading;
logger.Information("Loading world");
var watch = Stopwatch.StartNew();
Persistence.Load(SavePath);
EventSink.InvokeWorldLoad();
// Set the world to running before we process our queues
WorldState = WorldState.Running;
Persistence.PostDeserializeAll(); // Process safety queues
watch.Stop();
logger.Information("Loading world {Status} ({ItemCount} items, {MobileCount} mobiles) ({Duration:F2} seconds)",
"done",
Items.Count,
Mobiles.Count,
watch.Elapsed.TotalSeconds
);
// Create the serialization threads.
var threadCount = UseMultiThreadedSaves ? Math.Max(Environment.ProcessorCount - 1, 1) : 1;
_threadWorkers = new SerializationThreadWorker[threadCount];
for (var i = 0; i < _threadWorkers.Length; i++)
{
_threadWorkers[i] = new SerializationThreadWorker(i);
}
}
/**
* Duplicates can be weeded out asynchronously while flushing
* If performance becomes a problem, we need to build a dual mode concurrent array.
*
****************************************************** Proposal ******************************************************
* The structure is initialized with a large capacity to avoid unnecessary resizing.
* Write Mode:
* - Multiple threads can add a single, or a range of elements concurrently.
* - Elements can be Peeked, but there are no guarantees.
* - To resize the internal array, replaced it with the next size up from an array pool.
* - The structure cannot be cleared in this mode.
*
* Read Mode:
* - The array can be read from multiple threads using a ref struct enumerator.
* - Elements cannot be added or reassigned.
* - Cleared by replacing the internal array with another one from the pool.
* - Note: Upon clearing, the existing array is not sent back to the pool until there are zero enumerators.
*
* Enumeration:
* - Multiple threads can enumerate while in read mode. The enumerator will Interlocked.Increment a read counter.
* - Upon dispose of the enumerator, the read counter will be lowered with an Interlocked.Decrement
* - When the read counter reaches 0, if there is a cleared array, the array is sent back to the pool zeroed.
*
* Notes:
* - Elements can never be removed.
*
* How is this different from ConcurrentQueue?
* The functionality is very similar, except the constraints allow the implementation to be done without locks.
* Since this implementation uses pooled arrays, allocations will approach zero over time.
**********************************************************************************************************************
*/
public static ConcurrentQueue<Type> SerializedTypes { get; } = new();
public static void Save()
{
if (WorldState != WorldState.Running)
{
return;
}
WaitForWriteCompletion(); // Blocks Save until current disk flush is done.
_diskWriteHandle.Reset();
WorldState = WorldState.PendingSave;
ThreadPool.QueueUserWorkItem(Preserialize);
}
private static void Preserialize(object state)
{
try
{
// Allocate the heaps for the GC
foreach (var worker in _threadWorkers)
{
worker.AllocateHeap();
}
WakeSerializationThreads();
// Execute this synchronously so we don't have a race condition
Core.LoopContext.Post(() => Core.RequestSnapshot(PathUtility.EnsureRandomPath(_tempSavePath)));
}
catch (Exception ex)
{
logger.Error(ex, "Preparing to save world {Status}", "failed");
Persistence.TraceException(ex);
BroadcastStaff(0x35, true, "Preparing for a world save failed! Check the logs!");
}
}
internal static void Snapshot(string snapshotPath)
{
if (WorldState != WorldState.PendingSave)
{
return;
}
NetState.FlushAll();
WorldState = WorldState.Saving;
Broadcast(0x35, true, "The world is saving, please wait.");
logger.Information("Saving world");
var watch = Stopwatch.StartNew();
Exception exception = null;
try
{
if (string.IsNullOrEmpty(snapshotPath))
{
throw new ArgumentException("Snapshot path cannot be null or empty", nameof(snapshotPath));
}
Persistence.SerializeAll();
PauseSerializationThreads();
EventSink.InvokeWorldSave();
}
catch (Exception ex)
{
exception = ex;
}
WorldState = WorldState.WritingSave;
ThreadPool.QueueUserWorkItem(WriteFiles, snapshotPath);
watch.Stop();
if (exception == null)
{
var duration = watch.Elapsed.TotalSeconds;
logger.Information("Saving world {Status} ({Duration:F2} seconds)", "done", duration);
Broadcast(0x35, true, $"World save completed in {duration:F2} seconds.");
}
else
{
logger.Error(exception, "Saving world {Status}", "failed");
Persistence.TraceException(exception);
BroadcastStaff(0x35, true, "World save failed! Check the logs!");
}
}
private static readonly HashSet<Type> _typesSet = [];
private static void WriteFiles(object state)
{
var snapshotPath = (string)state;
try
{
var watch = Stopwatch.StartNew();
logger.Information("Writing world save snapshot");
// Dedupe the types
while (SerializedTypes.TryDequeue(out var type))
{
_typesSet.Add(type);
}
Persistence.WriteSnapshotAll(snapshotPath, _typesSet);
_typesSet.Clear();
try
{
EventSink.InvokeWorldSavePostSnapshot(SavePath, snapshotPath);
PathUtility.MoveDirectoryContents(snapshotPath, SavePath);
Directory.SetLastWriteTimeUtc(SavePath, Core.Now);
}
catch (Exception ex)
{
Persistence.TraceException(ex);
}
watch.Stop();
logger.Information("Writing world save snapshot {Status} ({Duration:F2} seconds)", "done", watch.Elapsed.TotalSeconds);
}
catch (Exception ex)
{
logger.Error(ex, "Writing world save snapshot failed");
Persistence.TraceException(ex);
BroadcastStaff(0x35, true, "Writing world save snapshot failed! Check the logs!");
}
// Clear types
SerializedTypes.Clear();
_diskWriteHandle.Set();
Core.LoopContext.Post(FinishWorldSave);
}
private static void FinishWorldSave()
{
WorldState = WorldState.Running;
Persistence.PostWorldSaveAll(); // Process decay and safety queues
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void WakeSerializationThreads()
{
for (var i = 0; i < _threadWorkers.Length; i++)
{
_threadWorkers[i].Wake();
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void PauseSerializationThreads()
{
for (var i = 0; i < _threadWorkers.Length; i++)
{
_threadWorkers[i].Sleep();
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static int GetThreadWorkerCount() => Math.Max(Environment.ProcessorCount - 1, 1);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void ResetRoundRobin() => _threadId = 0;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void PushToCache(IGenericSerializable e)
{
_threadWorkers[_threadId++].Push(e);
if (_threadId == _threadWorkers.Length)
{
_threadId = 0;
}
}
public static void ExitSerializationThreads()
{
for (var i = 0; i < _threadWorkers.Length; i++)
{
_threadWorkers[i]?.Exit();
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Item FindItem(Serial serial, bool returnDeleted = false) => _itemPersistence.Find(serial, returnDeleted);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Mobile FindMobile(Serial serial, bool returnDeleted = false) =>
_mobilePersistence.Find(serial, returnDeleted);
// Legacy: Only used for retrieving Items and Mobiles.
public static void AddEntity(IEntity entity)
{
if (entity is Item item)
{
_itemPersistence.AddEntity(item);
}
else if (entity is Mobile mobile)
{
_mobilePersistence.AddEntity(mobile);
}
else
{
logger.Warning("Attempted to call World.AddEntity with '{Entity}'. Must be a mobile or item.", entity.GetType());
}
}
public static void RemoveEntity(IEntity entity)
{
if (entity is Item item)
{
_itemPersistence.RemoveEntity(item);
}
else if (entity is Mobile mobile)
{
_mobilePersistence.RemoveEntity(mobile);
}
else
{
logger.Warning($"Attempted to call World.RemoveEntity with '{entity.GetType()}'. Must be a mobile or item.");
return;
}
#if TRACK_LEAKS
EntityFinalizationTracker.TrackEntity(entity);
#endif
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static BaseGuild FindGuild(Serial serial) => _guildPersistence.Find(serial);
public static void AddGuild(BaseGuild guild) => _guildPersistence.AddEntity(guild);
public static void RemoveGuild(BaseGuild guild) => _guildPersistence.RemoveEntity(guild);
// Legacy: Only used for retrieving Items and Mobiles.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static IEntity FindEntity(Serial serial, bool returnDeleted = false) =>
FindEntity<IEntity>(serial, returnDeleted);
// Legacy: Only used for retrieving Items and Mobiles.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static T FindEntity<T>(Serial serial, bool returnDeleted = false)
where T : class, IEntity
{
if (serial.IsItem)
{
return _itemPersistence.Find(serial, returnDeleted) as T;
}
return _mobilePersistence.Find(serial, returnDeleted) as T;
}
private class ItemPersistence : GenericEntityPersistence<Item>
{
public ItemPersistence() : base("Items", 2, ItemOffset, MaxItemSerial)
{
}
public override void PostDeserialize()
{
base.PostDeserialize();
foreach (var item in EntitiesBySerial.Values)
{
if (item.Parent == null)
{
item.UpdateTotals();
}
item.ClearProperties();
item.UpdateDecayRegistration();
}
}
}
private class MobilePersistence : GenericEntityPersistence<Mobile>
{
public MobilePersistence() : base("Mobiles", 1, 1, MaxMobileSerial)
{
}
public override void PostDeserialize()
{
base.PostDeserialize();
foreach (var m in EntitiesBySerial.Values)
{
m.UpdateRegion(); // Is this really needed?
m.UpdateTotals();
m.ClearProperties();
}
}
}
}