From 861c7e5f4d61db7e9ac7d30813416bfdf402f5eb Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:29:22 -0700 Subject: [PATCH] refactor(factions): migrate Faction/Town from RunUO Parse(string) to ISpanParsable Faction and Town were never converted from RunUO's bare static Parse(string) to IParsable/ISpanParsable, so they had only a 1-arg Parse(string) that returned null on no-match. That made them unreachable through Server.Types' IParsable path (they fell to Convert.ChangeType and failed) and, worse, would have let [set assign null silently on a bad name. Both now implement ISpanParsable (string + span Parse/TryParse, throw-on- failure contract), matching Race/Poison. No external callers depended on the old null-returning Parse. Also documents the convention in dev-docs/runuo-migration-docs/01-foundation-changes.md (new section 15 + checklist item): ModernUO expects any RunUO static Parse(string) to be converted to IParsable/ISpanParsable. Server.Types still binds a legacy Parse(string) by reflection as a safety net. Full suite 535/535. --- .../Engines/Factions/Core/Faction.cs | 32 ++++++++-- .../UOContent/Engines/Factions/Core/Town.cs | 33 ++++++++-- .../01-foundation-changes.md | 60 +++++++++++++++++++ 3 files changed, 115 insertions(+), 10 deletions(-) diff --git a/Projects/UOContent/Engines/Factions/Core/Faction.cs b/Projects/UOContent/Engines/Factions/Core/Faction.cs index 8aa8085ca..33166f5a0 100644 --- a/Projects/UOContent/Engines/Factions/Core/Faction.cs +++ b/Projects/UOContent/Engines/Factions/Core/Faction.cs @@ -16,7 +16,7 @@ using Server.Targeting; namespace Server.Factions; [CustomEnum(["Minax", "Council of Mages", "True Britannians", "Shadowlords"])] -public abstract class Faction : IComparable +public abstract class Faction : IComparable, ISpanParsable { public const int StabilityFactor = 300; // 300% greater (3 times) than smallest faction public const int StabilityActivation = 200; // Stability code goes into effect when largest faction has > 200 people @@ -1313,7 +1313,27 @@ public abstract class Faction : IComparable return null; } - public static Faction Parse(string name) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Faction Parse(string s) => Parse(s, null); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Faction Parse(string s, IFormatProvider provider) => Parse(s.AsSpan(), provider); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryParse(string s, IFormatProvider provider, out Faction result) => + TryParse(s.AsSpan(), provider, out result); + + public static Faction Parse(ReadOnlySpan 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 s, IFormatProvider provider, out Faction result) { var factions = Factions; @@ -1321,13 +1341,15 @@ public abstract class Faction : IComparable { var faction = factions[i]; - if (faction.Definition.FriendlyName.InsensitiveEquals(name)) + if (s.InsensitiveEquals(faction.Definition.FriendlyName)) { - return faction; + result = faction; + return true; } } - return null; + result = null; + return false; } public static bool InSkillLoss(Mobile mob) => m_SkillLoss.ContainsKey(mob); diff --git a/Projects/UOContent/Engines/Factions/Core/Town.cs b/Projects/UOContent/Engines/Factions/Core/Town.cs index 8aa136ff6..a30dc23a6 100644 --- a/Projects/UOContent/Engines/Factions/Core/Town.cs +++ b/Projects/UOContent/Engines/Factions/Core/Town.cs @@ -1,11 +1,12 @@ 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 +public abstract class Town : IComparable, ISpanParsable { public const int SilverCaptureBonus = 10000; @@ -482,7 +483,27 @@ public abstract class Town : IComparable return null; } - public static Town Parse(string name) + [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 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 s, IFormatProvider provider, out Town result) { var towns = Towns; @@ -490,13 +511,15 @@ public abstract class Town : IComparable { var town = towns[i]; - if (town.Definition.FriendlyName.InsensitiveEquals(name)) + if (s.InsensitiveEquals(town.Definition.FriendlyName)) { - return town; + result = town; + return true; } } - return null; + result = null; + return false; } [Usage("GrantTownSilver ")] diff --git a/dev-docs/runuo-migration-docs/01-foundation-changes.md b/dev-docs/runuo-migration-docs/01-foundation-changes.md index 0af53c6d7..668701bea 100644 --- a/dev-docs/runuo-migration-docs/01-foundation-changes.md +++ b/dev-docs/runuo-migration-docs/01-foundation-changes.md @@ -282,6 +282,65 @@ public MyItem(Serial serial) : base(serial) { } // ModernUO — DELETE THIS CONSTRUCTOR. The source generator creates it. ``` +## 15. Static `Parse(string)` → `IParsable` / `ISpanParsable` + +RunUO predates `IParsable`/`ISpanParsable` (C# 11 / .NET 7 static-abstract interface +members), so RunUO types that convert from a string expose a bare `public static T Parse(string value)`. +**ModernUO expects any such type to implement `IParsable` (string) and, where practical, +`ISpanParsable` (span; it extends `IParsable`, so implement span and you get both).** + +This matters because the engine's string→value converter, `Server.Types.TryParse` — used by `[set`, +`[props`, spawner property assignment, the conditional-command compiler (`[where`), and Advanced +Search — binds to the `Parse(string, IFormatProvider)` signature. A type with **only** a legacy +`Parse(string)` is discovered by `Types` through a reflection fallback, but that fallback is a safety +net, not the intended path: a bare `Parse(string)` is easy to miss, doesn't participate in the +span-based fast paths, and (if it returns `null` instead of throwing) makes `[set` silently assign +`null` on bad input. Convert it. + +The `Parse` overloads throw `FormatException` on failure; `TryParse` returns `false`. Delegate the +string overloads to a span core (see `Race`, `Poison`, `Point3D` for the established pattern): + +```csharp +// RunUO +public abstract class Faction : IComparable +{ + public static Faction Parse(string name) // returns null on no-match — wrong contract, not IParsable + { + // ... linear search by name ... + return null; + } +} + +// ModernUO +public abstract class Faction : IComparable, ISpanParsable +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Faction Parse(string s) => Parse(s, null); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Faction Parse(string s, IFormatProvider provider) => Parse(s.AsSpan(), provider); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryParse(string s, IFormatProvider provider, out Faction result) => + TryParse(s.AsSpan(), provider, out result); + + public static Faction Parse(ReadOnlySpan s, IFormatProvider provider) => + TryParse(s, provider, out var result) + ? result + : throw new FormatException($"The input string '{s}' was not in a correct format."); + + public static bool TryParse(ReadOnlySpan s, IFormatProvider provider, out Faction result) + { + // ... linear search by name using s.InsensitiveEquals(...) ... + result = null; + return false; + } +} +``` + +To find un-migrated types: search for `public static [A-Za-z0-9_<>]+ Parse\(string ` and check whether +the declaring type lists `IParsable`/`ISpanParsable`. + ## Quick Checklist When migrating any RunUO script, apply these changes in order: @@ -300,6 +359,7 @@ When migrating any RunUO script, apply these changes in order: 12. [ ] Modernize property syntax 13. [ ] Remove `Serial` constructor (handled by serialization generator) 14. [ ] Update usings +15. [ ] Convert bare static `Parse(string)` to `IParsable`/`ISpanParsable` ## See Also