refactor(factions): migrate Faction/Town from RunUO Parse(string) to ISpanParsable<T>

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<T> (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<T>/ISpanParsable<T>. Server.Types still binds a legacy Parse(string)
by reflection as a safety net. Full suite 535/535.
This commit is contained in:
Kamron Batman 2026-07-20 09:29:22 -07:00
parent 8997aaf498
commit 861c7e5f4d
3 changed files with 115 additions and 10 deletions

View file

@ -16,7 +16,7 @@ using Server.Targeting;
namespace Server.Factions;
[CustomEnum(["Minax", "Council of Mages", "True Britannians", "Shadowlords"])]
public abstract class Faction : IComparable<Faction>
public abstract class Faction : IComparable<Faction>, ISpanParsable<Faction>
{
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<Faction>
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<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 Faction result)
{
var factions = Factions;
@ -1321,13 +1341,15 @@ public abstract class Faction : IComparable<Faction>
{
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);

View file

@ -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<Town>
public abstract class Town : IComparable<Town>, ISpanParsable<Town>
{
public const int SilverCaptureBonus = 10000;
@ -482,7 +483,27 @@ public abstract class Town : IComparable<Town>
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<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;
@ -490,13 +511,15 @@ public abstract class Town : IComparable<Town>
{
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 <amount>")]

View file

@ -282,6 +282,65 @@ public MyItem(Serial serial) : base(serial) { }
// ModernUO — DELETE THIS CONSTRUCTOR. The source generator creates it.
```
## 15. Static `Parse(string)``IParsable<T>` / `ISpanParsable<T>`
RunUO predates `IParsable<T>`/`ISpanParsable<T>` (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<T>` (string) and, where practical,
`ISpanParsable<T>` (span; it extends `IParsable<T>`, 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<Faction>
{
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<Faction>, ISpanParsable<Faction>
{
[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<char> 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<char> 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<T>`/`ISpanParsable<T>`.
## 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<T>`/`ISpanParsable<T>`
## See Also