## Summary
Hardens the **Advanced Search** engine (`Projects/UOContent/Engines/Advanced Search/`) — the GM entity finder that fans searches across background worker threads. A code review surfaced 14 defects (A–N), including a shard-crasher reachable from a single admin typo and a path that silently disables autosave for the rest of the shard's uptime. Each behavioral fix ships with a test.
Full `UOContent.Tests` suite: **530/530 green** (21 new AdvancedSearch tests).
## Fixes
### Crash / data-loss
- **A — Shard crash on a malformed Property Test.** `AdvancedSearchThreadWorker.Execute` had no `try/catch` and the worker `Thread` is foreground, so a parse throw (`Hits>abc`, `Layer=onehanded` — `Enum.Parse` was case-sensitive, `Hits>1@` — empty sub-expression indexing) terminated the process. Now: `ParseValue`/`CompareValues` use `TryParse`/`Enum.TryParse(ignoreCase)` and return no-match instead of throwing; the per-entity filter is wrapped in `try/catch` (logs + skips); empty expressions are guarded.
- **C — Overlapping searches corrupt state + brick autosave.** `_threadWorkers`/`_threadId` were `static` but `DoSearch` is an instance method; a second search (double-click / two admins) stomped shared worker state and could leave a drain waiting forever on the shared `AutoResetEvent`, so `AutoSave.SavesEnabled` was never restored. Now: an `Interlocked` re-entrancy guard rejects concurrent searches.
- **G — Autosave restore not guaranteed.** The restore lived only in the success callback. Now it's in a `finally` (plus an outer `catch` covering the synchronous setup and a `catch` on the drain body), so autosave + the guard are always released.
### Wrong results
- **D — `@`/`|` operator precedence.** `a@b|c` evaluated as `a && (b || c)` instead of `(a && b) || c`. OR now binds looser than AND (`AdvancedSearchUtilities.EvaluateBoolean`, unit-tested).
- **E — Descending sort, partial last page rendered blank** (the index decreased in descending mode and the `break` early-out killed the loop). Now a bounded `VisibleCount`-driven loop renders the last page in both directions.
- **F — Deleted entities** were not skipped (ghost rows). Now `DoEntitySearch` skips `entity.Deleted`.
- **N — Reference-type comparisons** threw (`Comparer<T>.Default.Compare` on non-`IComparable`) and compared references to a string. Now equality is by value and ordering is guarded to `IComparable` (no throw).
### Worker perf / hardening
- **H** busy-spin → `Thread.Yield()` in the drain; **I** `GetProperties()` cached per `Type`; **J** `HandleValidInternal` moved behind the cheap map/range/region filters; **K** worker threads are `IsBackground` + `Exit()` tolerates an already-terminated worker; **L** `_filter == null` guard; **M** consistent `Volatile` access on `_pause`/`_exit`.
### Documented
- **B** — the residual worker/event-loop read race is documented on `AdvancedSearchThreadWorker`: workers read live entity state concurrently with the loop, so value-type reads may be stale-but-safe and getter exceptions are swallowed; fully eliminating it would require snapshotting entity fields on the main thread (deferred).
## Notes
- New test-only seams (`TryBeginSearch`/`EndSearch`/`IsSearchInProgress`/`VisibleCount`/`TryParseValue`/`EvaluateBoolean`) are `internal` via the existing `InternalsVisibleTo("UOContent.Tests")`.
- Dead `public ParseValue<T>` removed.
- `ConcurrentDictionary` for the reflection cache is intentional — these workers are genuinely parallel.
547 lines
13 KiB
C#
547 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Runtime.CompilerServices;
|
|
using Server.Targeting;
|
|
|
|
namespace Server.Factions;
|
|
|
|
[CustomEnum(["Britain", "Magincia", "Minoc", "Moonglow", "Skara Brae", "Trinsic", "Vesper", "Yew"])]
|
|
public abstract class Town : IComparable<Town>, ISpanParsable<Town>
|
|
{
|
|
public const int SilverCaptureBonus = 10000;
|
|
|
|
public static readonly TimeSpan TaxChangePeriod = TimeSpan.FromHours(12.0);
|
|
public static readonly TimeSpan IncomePeriod = TimeSpan.FromDays(1.0);
|
|
|
|
private Timer _incomeTimer;
|
|
private TownState m_State;
|
|
|
|
public Town()
|
|
{
|
|
m_State = new TownState(this);
|
|
ConstructVendorLists();
|
|
ConstructGuardLists();
|
|
StartIncomeTimer();
|
|
}
|
|
|
|
public TownDefinition Definition { get; set; }
|
|
|
|
public TownState State
|
|
{
|
|
get => m_State;
|
|
set
|
|
{
|
|
m_State = value;
|
|
ConstructGuardLists();
|
|
}
|
|
}
|
|
|
|
public int Silver
|
|
{
|
|
get => m_State.Silver;
|
|
set => m_State.Silver = value;
|
|
}
|
|
|
|
public Faction Owner
|
|
{
|
|
get => m_State.Owner;
|
|
set => Capture(value);
|
|
}
|
|
|
|
public Mobile Sheriff
|
|
{
|
|
get => m_State.Sheriff;
|
|
set => m_State.Sheriff = value;
|
|
}
|
|
|
|
public Mobile Finance
|
|
{
|
|
get => m_State.Finance;
|
|
set => m_State.Finance = value;
|
|
}
|
|
|
|
public int Tax
|
|
{
|
|
get => m_State.Tax;
|
|
set => m_State.Tax = value;
|
|
}
|
|
|
|
public DateTime LastTaxChange
|
|
{
|
|
get => m_State.LastTaxChange;
|
|
set => m_State.LastTaxChange = value;
|
|
}
|
|
|
|
public bool TaxChangeReady => m_State.LastTaxChange + TaxChangePeriod < Core.Now;
|
|
|
|
public int FinanceUpkeep
|
|
{
|
|
get
|
|
{
|
|
var vendorLists = VendorLists;
|
|
var upkeep = 0;
|
|
|
|
for (var i = 0; i < vendorLists.Count; ++i)
|
|
{
|
|
upkeep += vendorLists[i].Vendors.Count * vendorLists[i].Definition.Upkeep;
|
|
}
|
|
|
|
return upkeep;
|
|
}
|
|
}
|
|
|
|
public int SheriffUpkeep
|
|
{
|
|
get
|
|
{
|
|
var guardLists = GuardLists;
|
|
var upkeep = 0;
|
|
|
|
for (var i = 0; i < guardLists.Count; ++i)
|
|
{
|
|
upkeep += guardLists[i].Guards.Count * guardLists[i].Definition.Upkeep;
|
|
}
|
|
|
|
return upkeep;
|
|
}
|
|
}
|
|
|
|
public int DailyIncome => 10000 * (100 + m_State.Tax) / 100;
|
|
|
|
public int NetCashFlow => DailyIncome - FinanceUpkeep - SheriffUpkeep;
|
|
|
|
public TownMonolith Monolith
|
|
{
|
|
get
|
|
{
|
|
var monoliths = BaseMonolith.Monoliths;
|
|
|
|
foreach (var monolith in monoliths)
|
|
{
|
|
if (monolith is TownMonolith townMonolith && townMonolith.Town == this)
|
|
{
|
|
return townMonolith;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public DateTime LastIncome
|
|
{
|
|
get => m_State.LastIncome;
|
|
set => m_State.LastIncome = value;
|
|
}
|
|
|
|
public List<VendorList> VendorLists { get; set; }
|
|
|
|
public List<GuardList> GuardLists { get; set; }
|
|
|
|
public static List<Town> Towns => Reflector.Towns;
|
|
|
|
public int CompareTo(Town other) => Definition.Sort - (other?.Definition.Sort ?? 0);
|
|
|
|
public static Town FromRegion(Region reg)
|
|
{
|
|
if (reg.Map != Faction.Facet)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var towns = Towns;
|
|
|
|
for (var i = 0; i < towns.Count; ++i)
|
|
{
|
|
var town = towns[i];
|
|
|
|
if (reg.IsPartOf(town.Definition.Region))
|
|
{
|
|
return town;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public void BeginOrderFiring(Mobile from)
|
|
{
|
|
var isFinance = IsFinance(from);
|
|
var isSheriff = IsSheriff(from);
|
|
string type = null;
|
|
|
|
// NOTE: Messages not OSI-accurate, intentional
|
|
if (isFinance && isSheriff) // GM only
|
|
{
|
|
type = "vendor or guard";
|
|
}
|
|
else if (isFinance)
|
|
{
|
|
type = "vendor";
|
|
}
|
|
else if (isSheriff)
|
|
{
|
|
type = "guard";
|
|
}
|
|
|
|
from.SendMessage($"Target the {type} you wish to dismiss.");
|
|
from.BeginTarget(12, false, TargetFlags.None, EndOrderFiring);
|
|
}
|
|
|
|
public void EndOrderFiring(Mobile from, object obj)
|
|
{
|
|
var isFinance = IsFinance(from);
|
|
var isSheriff = IsSheriff(from);
|
|
|
|
if (obj is BaseFactionVendor vendor && vendor.Town == this && isFinance)
|
|
{
|
|
vendor.Delete();
|
|
}
|
|
else if (obj is BaseFactionGuard guard && guard.Town == this && isSheriff)
|
|
{
|
|
guard.Delete();
|
|
}
|
|
else if (isFinance && isSheriff) // GM only
|
|
{
|
|
from.SendMessage("That is not a vendor or guard!");
|
|
}
|
|
else if (isFinance)
|
|
{
|
|
from.SendMessage("That is not a vendor!");
|
|
}
|
|
else if (isSheriff)
|
|
{
|
|
from.SendMessage("That is not a guard!");
|
|
}
|
|
}
|
|
|
|
public void StartIncomeTimer()
|
|
{
|
|
_incomeTimer ??= Timer.DelayCall(TimeSpan.FromMinutes(1.0), TimeSpan.FromMinutes(1.0), CheckIncome);
|
|
}
|
|
|
|
public void Delete()
|
|
{
|
|
_incomeTimer?.Stop();
|
|
}
|
|
|
|
public void CheckIncome()
|
|
{
|
|
if (LastIncome + IncomePeriod > Core.Now || Owner == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ProcessIncome();
|
|
}
|
|
|
|
public void ProcessIncome()
|
|
{
|
|
LastIncome = Core.Now;
|
|
|
|
var flow = NetCashFlow;
|
|
|
|
if (Silver + flow < 0)
|
|
{
|
|
var toDelete = BuildFinanceList();
|
|
|
|
while (Silver + flow < 0 && toDelete.Count > 0)
|
|
{
|
|
var mob = toDelete.RandomElement();
|
|
mob.Delete();
|
|
|
|
toDelete.Remove(mob);
|
|
flow = NetCashFlow;
|
|
}
|
|
}
|
|
|
|
Silver += flow;
|
|
}
|
|
|
|
public List<Mobile> BuildFinanceList()
|
|
{
|
|
var list = new List<Mobile>();
|
|
|
|
for (var i = 0; i < VendorLists.Count; ++i)
|
|
{
|
|
list.AddRange(VendorLists[i].Vendors);
|
|
}
|
|
|
|
for (var i = 0; i < GuardLists.Count; ++i)
|
|
{
|
|
list.AddRange(GuardLists[i].Guards);
|
|
}
|
|
|
|
return list;
|
|
}
|
|
|
|
public void ConstructGuardLists()
|
|
{
|
|
var defs = Owner?.Definition.Guards ?? [];
|
|
|
|
GuardLists = [];
|
|
|
|
for (var i = 0; i < defs.Length; ++i)
|
|
{
|
|
GuardLists.Add(new GuardList(defs[i]));
|
|
}
|
|
}
|
|
|
|
public GuardList FindGuardList(Type type)
|
|
{
|
|
var guardLists = GuardLists;
|
|
|
|
for (var i = 0; i < guardLists.Count; ++i)
|
|
{
|
|
var guardList = guardLists[i];
|
|
|
|
if (guardList.Definition.Type == type)
|
|
{
|
|
return guardList;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public void ConstructVendorLists()
|
|
{
|
|
var defs = VendorDefinition.Definitions;
|
|
|
|
VendorLists = [];
|
|
|
|
for (var i = 0; i < defs.Length; ++i)
|
|
{
|
|
VendorLists.Add(new VendorList(defs[i]));
|
|
}
|
|
}
|
|
|
|
public VendorList FindVendorList(Type type)
|
|
{
|
|
var vendorLists = VendorLists;
|
|
|
|
for (var i = 0; i < vendorLists.Count; ++i)
|
|
{
|
|
var vendorList = vendorLists[i];
|
|
|
|
if (vendorList.Definition.Type == type)
|
|
{
|
|
return vendorList;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public bool RegisterGuard(BaseFactionGuard guard)
|
|
{
|
|
if (guard == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var guardList = FindGuardList(guard.GetType());
|
|
|
|
if (guardList == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
guardList.Guards.Add(guard);
|
|
return true;
|
|
}
|
|
|
|
public bool UnregisterGuard(BaseFactionGuard guard) =>
|
|
guard != null && FindGuardList(guard.GetType())?.Guards.Remove(guard) == true;
|
|
|
|
public bool RegisterVendor(BaseFactionVendor vendor)
|
|
{
|
|
if (vendor == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var vendorList = FindVendorList(vendor.GetType());
|
|
|
|
if (vendorList == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
vendorList.Vendors.Add(vendor);
|
|
return true;
|
|
}
|
|
|
|
public bool UnregisterVendor(BaseFactionVendor vendor) =>
|
|
vendor != null && (FindVendorList(vendor.GetType())?.Vendors.Remove(vendor) ?? false);
|
|
|
|
public static void Configure()
|
|
{
|
|
CommandSystem.Register("GrantTownSilver", AccessLevel.Administrator, GrantTownSilver_OnCommand);
|
|
}
|
|
|
|
public static void Initialize()
|
|
{
|
|
// Looks useless, but is used to set the PlayerState for these roles
|
|
// TODO: When this moves to a deserialization pattern, put it in AfterDeserialization
|
|
for (var i = 0; i < Towns.Count; ++i)
|
|
{
|
|
var town = Towns[i];
|
|
town.Sheriff = town.Sheriff;
|
|
town.Finance = town.Finance;
|
|
}
|
|
}
|
|
|
|
public bool IsSheriff(Mobile mob) =>
|
|
mob?.Deleted == false &&
|
|
(mob.AccessLevel >= AccessLevel.GameMaster || mob == Sheriff);
|
|
|
|
public bool IsFinance(Mobile mob) =>
|
|
mob?.Deleted == false &&
|
|
(mob.AccessLevel >= AccessLevel.GameMaster || mob == Finance);
|
|
|
|
public void Capture(Faction f)
|
|
{
|
|
if (m_State.Owner == f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (m_State.Owner == null) // going from unowned to owned
|
|
{
|
|
LastIncome = Core.Now;
|
|
f.Silver += SilverCaptureBonus;
|
|
}
|
|
else if (f == null) // going from owned to unowned
|
|
{
|
|
LastIncome = DateTime.MinValue;
|
|
}
|
|
else // otherwise changing hands, income timer doesn't change
|
|
{
|
|
f.Silver += SilverCaptureBonus;
|
|
}
|
|
|
|
m_State.Owner = f;
|
|
|
|
Sheriff = null;
|
|
Finance = null;
|
|
|
|
var monolith = Monolith;
|
|
|
|
if (monolith != null)
|
|
{
|
|
monolith.Faction = f;
|
|
}
|
|
|
|
var vendorLists = VendorLists;
|
|
|
|
for (var i = 0; i < vendorLists.Count; ++i)
|
|
{
|
|
var vendorList = vendorLists[i];
|
|
var vendors = vendorList.Vendors;
|
|
|
|
for (var j = vendors.Count - 1; j >= 0; --j)
|
|
{
|
|
vendors[j].Delete();
|
|
}
|
|
}
|
|
|
|
var guardLists = GuardLists;
|
|
|
|
for (var i = 0; i < guardLists.Count; ++i)
|
|
{
|
|
var guardList = guardLists[i];
|
|
var guards = guardList.Guards;
|
|
|
|
for (var j = guards.Count - 1; j >= 0; --j)
|
|
{
|
|
guards[j].Delete();
|
|
}
|
|
}
|
|
|
|
ConstructGuardLists();
|
|
}
|
|
|
|
public override string ToString() => Definition.FriendlyName;
|
|
|
|
public static void WriteReference(IGenericWriter writer, Town town)
|
|
{
|
|
var idx = Towns.IndexOf(town);
|
|
|
|
writer.WriteEncodedInt(idx + 1);
|
|
}
|
|
|
|
public static Town ReadReference(IGenericReader reader)
|
|
{
|
|
var idx = reader.ReadEncodedInt() - 1;
|
|
|
|
if (idx >= 0 && idx < Towns.Count)
|
|
{
|
|
return Towns[idx];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public static Town Parse(string s) => Parse(s, null);
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public static Town Parse(string s, IFormatProvider provider) => Parse(s.AsSpan(), provider);
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public static bool TryParse(string s, IFormatProvider provider, out Town result) =>
|
|
TryParse(s.AsSpan(), provider, out result);
|
|
|
|
public static Town Parse(ReadOnlySpan<char> s, IFormatProvider provider)
|
|
{
|
|
if (TryParse(s, provider, out var result))
|
|
{
|
|
return result;
|
|
}
|
|
|
|
throw new FormatException($"The input string '{s}' was not in a correct format.");
|
|
}
|
|
|
|
public static bool TryParse(ReadOnlySpan<char> s, IFormatProvider provider, out Town result)
|
|
{
|
|
var towns = Towns;
|
|
|
|
for (var i = 0; i < towns.Count; ++i)
|
|
{
|
|
var town = towns[i];
|
|
|
|
if (s.InsensitiveEquals(town.Definition.FriendlyName))
|
|
{
|
|
result = town;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
result = null;
|
|
return false;
|
|
}
|
|
|
|
[Usage("GrantTownSilver <amount>")]
|
|
[Description("Grants silver to the town of the current region.")]
|
|
public static void GrantTownSilver_OnCommand(CommandEventArgs e)
|
|
{
|
|
var town = FromRegion(e.Mobile.Region);
|
|
|
|
if (town == null)
|
|
{
|
|
e.Mobile.SendMessage("You are not in a faction town.");
|
|
}
|
|
else if (e.Length == 0)
|
|
{
|
|
e.Mobile.SendMessage("Format: GrantTownSilver <amount>");
|
|
}
|
|
else
|
|
{
|
|
town.Silver += e.GetInt32(0);
|
|
e.Mobile.SendMessage(
|
|
$"You have granted {e.GetInt32(0):N0} silver to the town. It now has {town.Silver:N0} silver."
|
|
);
|
|
}
|
|
}
|
|
}
|