## Summary
- **Add a self-referencing `InterpolationHandler` to `ValueStringBuilder`** that writes directly into the builder's buffer — zero intermediate allocation, works with `stackalloc`-backed builders
- **Replace all `System.Text.StringBuilder` usage** across the codebase with `ValueStringBuilder`
- **Convert `ValueStringBuilder.Create()` to `stackalloc`** at 10 sites where output length is provably bounded
- **Convert manual `Dispose()` to `using var`** where possible, and hoist loop-scoped builders outside loops with `Reset()`
- **Convert verbose `Append()` chains to `Append($"...")`** interpolation for readability
- **Add comprehensive documentation** for string handling patterns
## InterpolationHandler Design
`ValueStringBuilder` is a `ref struct`, which creates challenges for C#'s interpolated string handler pattern:
- **`ref` fields to ref structs are not allowed** (CS9050)
- **`[InterpolatedStringHandlerArgument("")]` passes struct receivers by value**, not by ref
- **`ISelfInterpolatedStringHandler` requires boxing** ref structs into interface fields
**Solution: Copy-and-reconcile pattern.** The handler receives a value copy of the builder. The copy shares the same underlying `char` buffer (`Span` points to the same `stackalloc`/pooled memory), so writes go to the original buffer. `Append()` reconciles by `this = handler._builder`, updating `_length` and any buffer references changed by `Grow()`.
This is safe because:
- The game loop is single-threaded — no concurrent access between handler construction and reconciliation
- If `Grow()` occurs in the copy, the original's stale buffer isn't accessed until `Append()` replaces it
- `Dispose()` correctly returns the reconciled buffer to the pool
## Changes by Category
### ValueStringBuilder (`Projects/Server/Buffers/ValueStringBuilder.cs`)
- Added nested `InterpolationHandler` ref struct with copy-and-reconcile pattern
- Added `Append([InterpolatedStringHandlerArgument("")] scoped ref InterpolationHandler)` method
- Removed `RawInterpolatedStringHandler` overloads (new handler replaces them)
- All `AppendFormatted` overloads delegate to existing `Append` methods (no code duplication)
- Alignment support via direct private field access (nested type privilege)
### StringBuilder → ValueStringBuilder (15 files)
Replaced all `new StringBuilder()` with `ValueStringBuilder.Create()` or `stackalloc`:
- ConPVP games: KingOfTheHill, DoubleDom, CTF, BombingRun, TourneyMatch
- ConPVP infrastructure: Tournament, Participant, TourneyParticipant
- ConPVP gumps: ArenaGump, TournamentBracketGump, AcceptTeamGump, ConfirmSignupGump
- Commands: Handlers, Logging, Add
- Other: TownCrier, SpeechLogGump, TestCenter
Key patterns:
- `sb = new StringBuilder()` reassignment → `sb.Reset()`
- `sb.AppendFormat("{0:N0}", value)` → `sb.Append($"{value:N0}")`
- `sb.Append(x).Append(y)` chains → separate statements (VSB returns void)
### Create() → stackalloc (10 files)
Converted heap-allocated builders to stackalloc where output is bounded:
- ClientVersion (32), MapSelection (160), HouseRaffleStone (48)
- HolySense (96), UnholySense (96), ClientVerification (192)
- AcceptTeamGump (64), ConfirmSignupGump (64)
- BaseWeapon (160), BaseArmor (128)
### Loop optimizations (2 files)
Hoisted `ValueStringBuilder` creation outside loops with `Reset()` per iteration:
- TourneyMatch.cs: `using var` inside for loop → stackalloc before loop
- ArenaGump.cs: `Create()` + `Dispose()` per iteration → stackalloc before loop
### Append chain → interpolation (5 files)
Converted multi-line `Append()` chains to `Append($"...")`:
- BountyMessage.cs: title switch (6 cases), paragraph (15→1 Append), description lines, closing
- AcceptTeamGump, ConfirmSignupGump, TournamentBracketGump: tournament type strings
- AdminGump: comment/tag formatting in loops
### Documentation
- `dev-docs/string-handling.md`: Full reference — construction, interpolation, disposal, decision guide
- `dev-docs/claude-skills/modernuo-string-handling.md`: Claude skill with quick reference
- `CLAUDE.md`: Added rule 17 (no StringBuilder), dev-docs table entry, skills table entry
- `dev-docs/code-standards.md`: Updated memory management section
## Test Plan
- [x] `dotnet build` — 0 errors, 0 warnings
- [x] `dotnet test` — 940/940 tests pass
- [x] 28 ValueStringBuilder tests covering all reconciliation scenarios:
- Stackalloc no-grow, stackalloc with grow (→pool transition)
- Heap no-grow, heap with grow, heap double grow
- Pre-existing content with and without grow
- Sequential multiple `Append($"...")` calls
- Mixed plain + interpolated Append
- Empty interpolation, literal-only, format specifiers
- Null string holes, ISpanFormattable types
- Dispose after stackalloc→pool grow
513 lines
13 KiB
C#
513 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using ModernUO.Serialization;
|
|
using Server.Text;
|
|
using Server.Gumps;
|
|
using Server.Items;
|
|
using Server.Network;
|
|
using Server.Prompts;
|
|
|
|
namespace Server.Mobiles;
|
|
|
|
public interface ITownCrierEntryList
|
|
{
|
|
List<TownCrierEntry> Entries { get; }
|
|
TownCrierEntry GetRandomEntry();
|
|
TownCrierEntry AddEntry(string[] lines, TimeSpan duration);
|
|
void RemoveEntry(TownCrierEntry entry);
|
|
}
|
|
|
|
public class GlobalTownCrierEntryList : ITownCrierEntryList
|
|
{
|
|
private static GlobalTownCrierEntryList m_Instance;
|
|
|
|
public static GlobalTownCrierEntryList Instance => m_Instance ??= new GlobalTownCrierEntryList();
|
|
|
|
public bool IsEmpty => Entries == null || Entries.Count == 0;
|
|
|
|
public List<TownCrierEntry> Entries { get; private set; }
|
|
|
|
public TownCrierEntry GetRandomEntry()
|
|
{
|
|
for (var i = (Entries?.Count ?? 0) - 1; i >= 0; --i)
|
|
{
|
|
if (i >= Entries!.Count)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var tce = Entries[i];
|
|
|
|
if (tce.Expired)
|
|
{
|
|
RemoveEntry(tce);
|
|
}
|
|
}
|
|
|
|
return Entries?.RandomElement();
|
|
}
|
|
|
|
public TownCrierEntry AddEntry(string[] lines, TimeSpan duration)
|
|
{
|
|
Entries ??= new List<TownCrierEntry>();
|
|
|
|
var tce = new TownCrierEntry(lines, duration);
|
|
|
|
Entries.Add(tce);
|
|
|
|
var instances = TownCrier.Instances;
|
|
|
|
for (var i = 0; i < instances.Count; ++i)
|
|
{
|
|
instances[i].ForceBeginAutoShout();
|
|
}
|
|
|
|
return tce;
|
|
}
|
|
|
|
public void RemoveEntry(TownCrierEntry tce)
|
|
{
|
|
if (Entries == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Entries.Remove(tce);
|
|
|
|
if (Entries.Count == 0)
|
|
{
|
|
Entries = null;
|
|
}
|
|
}
|
|
|
|
public static void Configure()
|
|
{
|
|
CommandSystem.Register("TownCriers", AccessLevel.GameMaster, TownCriers_OnCommand);
|
|
}
|
|
|
|
[Usage("TownCriers"), Description("Manages the global town crier list.")]
|
|
public static void TownCriers_OnCommand(CommandEventArgs e)
|
|
{
|
|
e.Mobile.SendGump(new TownCrierGump(e.Mobile, Instance));
|
|
}
|
|
}
|
|
|
|
public class TownCrierEntry
|
|
{
|
|
public TownCrierEntry(string[] lines, TimeSpan duration)
|
|
{
|
|
Lines = lines;
|
|
|
|
ExpireTime = Core.Now + duration.Clamp(TimeSpan.Zero, TimeSpan.FromDays(365));
|
|
}
|
|
|
|
public string[] Lines { get; }
|
|
|
|
public DateTime ExpireTime { get; }
|
|
|
|
public bool Expired => Core.Now >= ExpireTime;
|
|
}
|
|
|
|
public class TownCrierDurationPrompt : Prompt
|
|
{
|
|
private readonly ITownCrierEntryList m_Owner;
|
|
|
|
public TownCrierDurationPrompt(ITownCrierEntryList owner) => m_Owner = owner;
|
|
|
|
public override void OnResponse(Mobile from, string text)
|
|
{
|
|
if (!TimeSpan.TryParse(text, out var ts))
|
|
{
|
|
from.SendMessage("Value was not properly formatted. Use: <hours:minutes:seconds, 00:00:00>");
|
|
from.SendGump(new TownCrierGump(from, m_Owner));
|
|
return;
|
|
}
|
|
|
|
if (ts < TimeSpan.Zero)
|
|
{
|
|
ts = TimeSpan.Zero;
|
|
}
|
|
|
|
from.SendMessage($"Duration set to: {ts}");
|
|
from.SendMessage("Enter the first line to shout:");
|
|
|
|
from.Prompt = new TownCrierLinesPrompt(m_Owner, null, new List<string>(), ts);
|
|
}
|
|
|
|
public override void OnCancel(Mobile from)
|
|
{
|
|
from.SendLocalizedMessage(502980); // Message entry cancelled.
|
|
from.SendGump(new TownCrierGump(from, m_Owner));
|
|
}
|
|
}
|
|
|
|
public class TownCrierLinesPrompt : Prompt
|
|
{
|
|
private readonly TimeSpan m_Duration;
|
|
private readonly TownCrierEntry m_Entry;
|
|
private readonly List<string> m_Lines;
|
|
private readonly ITownCrierEntryList m_Owner;
|
|
|
|
public TownCrierLinesPrompt(ITownCrierEntryList owner, TownCrierEntry entry, List<string> lines, TimeSpan duration)
|
|
{
|
|
m_Owner = owner;
|
|
m_Entry = entry;
|
|
m_Lines = lines;
|
|
m_Duration = duration;
|
|
}
|
|
|
|
public override void OnResponse(Mobile from, string text)
|
|
{
|
|
m_Lines.Add(text);
|
|
|
|
from.SendMessage("Enter the next line to shout, or press <ESC> if the message is finished.");
|
|
from.Prompt = new TownCrierLinesPrompt(m_Owner, m_Entry, m_Lines, m_Duration);
|
|
}
|
|
|
|
public override void OnCancel(Mobile from)
|
|
{
|
|
if (m_Entry != null)
|
|
{
|
|
m_Owner.RemoveEntry(m_Entry);
|
|
}
|
|
|
|
if (m_Lines.Count > 0)
|
|
{
|
|
m_Owner.AddEntry(m_Lines.ToArray(), m_Duration);
|
|
from.SendMessage("Message has been set.");
|
|
}
|
|
else
|
|
{
|
|
if (m_Entry != null)
|
|
{
|
|
from.SendMessage("Message deleted.");
|
|
}
|
|
else
|
|
{
|
|
from.SendLocalizedMessage(502980); // Message entry cancelled.
|
|
}
|
|
}
|
|
|
|
from.SendGump(new TownCrierGump(from, m_Owner));
|
|
}
|
|
}
|
|
|
|
public class TownCrierGump : Gump
|
|
{
|
|
private readonly Mobile m_From;
|
|
private readonly ITownCrierEntryList m_Owner;
|
|
|
|
public override bool Singleton => true;
|
|
|
|
public TownCrierGump(Mobile from, ITownCrierEntryList owner) : base(50, 50)
|
|
{
|
|
m_From = from;
|
|
m_Owner = owner;
|
|
|
|
AddPage(0);
|
|
|
|
var entries = owner.Entries;
|
|
|
|
owner.GetRandomEntry(); // force expiration checks
|
|
|
|
var count = entries?.Count ?? 0;
|
|
|
|
AddImageTiled(0, 0, 300, 38 + (count == 0 ? 20 : count * 85), 0xA40);
|
|
AddAlphaRegion(1, 1, 298, 36 + (count == 0 ? 20 : count * 85));
|
|
|
|
AddHtml(8, 8, 300 - 8 - 30, 20, "<basefont color=#FFFFFF><center>TOWN CRIER MESSAGES</center></basefont>");
|
|
|
|
AddButton(300 - 8 - 30, 8, 0xFAB, 0xFAD, 1);
|
|
|
|
if (count == 0)
|
|
{
|
|
AddHtml(8, 30, 284, 20, "<basefont color=#FFFFFF>The crier has no news.</basefont>");
|
|
}
|
|
else
|
|
{
|
|
for (var i = 0; i < entries!.Count; ++i)
|
|
{
|
|
var tce = entries[i];
|
|
|
|
var toExpire = Utility.Max(tce.ExpireTime - Core.Now, TimeSpan.Zero);
|
|
|
|
using var sb = ValueStringBuilder.Create(512);
|
|
|
|
sb.Append("[Expires: ");
|
|
|
|
if (toExpire.TotalHours >= 1)
|
|
{
|
|
sb.Append((int)toExpire.TotalHours);
|
|
sb.Append(':');
|
|
sb.Append(toExpire.Minutes.ToString("D2"));
|
|
}
|
|
else
|
|
{
|
|
sb.Append(toExpire.Minutes);
|
|
}
|
|
|
|
sb.Append(':');
|
|
sb.Append(toExpire.Seconds.ToString("D2"));
|
|
|
|
sb.Append("] ");
|
|
|
|
for (var j = 0; j < tce.Lines.Length; ++j)
|
|
{
|
|
if (j > 0)
|
|
{
|
|
sb.Append("<br>");
|
|
}
|
|
|
|
sb.Append(tce.Lines[j]);
|
|
}
|
|
|
|
AddHtml(8, 35 + i * 85, 254, 80, sb.ToString(), true, true);
|
|
|
|
AddButton(300 - 8 - 26, 35 + i * 85, 0x15E1, 0x15E5, 2 + i);
|
|
}
|
|
}
|
|
}
|
|
|
|
public override void OnResponse(NetState sender, in RelayInfo info)
|
|
{
|
|
if (info.ButtonID == 1)
|
|
{
|
|
m_From.SendMessage("Enter the duration for the new message. Format: <hours:minutes:seconds, 00:00:00>");
|
|
m_From.Prompt = new TownCrierDurationPrompt(m_Owner);
|
|
}
|
|
else if (info.ButtonID > 1)
|
|
{
|
|
var entries = m_Owner.Entries;
|
|
var index = info.ButtonID - 2;
|
|
|
|
if (index < entries?.Count)
|
|
{
|
|
var tce = entries[index];
|
|
var ts = Utility.Max(tce.ExpireTime - Core.Now, TimeSpan.Zero);
|
|
|
|
m_From.SendMessage($"Editing entry #{index + 1}.");
|
|
m_From.SendMessage("Enter the first line to shout:");
|
|
m_From.Prompt = new TownCrierLinesPrompt(m_Owner, tce, new List<string>(), ts);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
[SerializationGenerator(0, false)]
|
|
public partial class TownCrier : Mobile, ITownCrierEntryList
|
|
{
|
|
private Timer _autoShoutTimer;
|
|
private Timer _newsTimer;
|
|
|
|
[Constructible]
|
|
public TownCrier()
|
|
{
|
|
Instances.Add(this);
|
|
|
|
InitStats(100, 100, 25);
|
|
|
|
Title = "the town crier";
|
|
Hue = Race.Human.RandomSkinHue();
|
|
|
|
if (!Core.AOS)
|
|
{
|
|
NameHue = 0x35;
|
|
}
|
|
|
|
if (Female = Utility.RandomBool())
|
|
{
|
|
Body = 0x191;
|
|
Name = NameList.RandomName("female");
|
|
}
|
|
else
|
|
{
|
|
Body = 0x190;
|
|
Name = NameList.RandomName("male");
|
|
}
|
|
|
|
AddItem(new FancyShirt(Utility.RandomBlueHue()));
|
|
|
|
var skirt = Utility.Random(2) switch
|
|
{
|
|
0 => (Item)new Skirt(),
|
|
1 => new Kilt(),
|
|
_ => new Kilt()
|
|
};
|
|
|
|
skirt.Hue = Utility.RandomGreenHue();
|
|
|
|
AddItem(skirt);
|
|
|
|
AddItem(new FeatheredHat(Utility.RandomGreenHue()));
|
|
|
|
var boots = Utility.Random(2) switch
|
|
{
|
|
0 => (Item)new Boots(),
|
|
1 => new ThighBoots(),
|
|
_ => new ThighBoots()
|
|
};
|
|
|
|
AddItem(boots);
|
|
|
|
Utility.AssignRandomHair(this);
|
|
}
|
|
|
|
public TownCrier(Serial serial) : base(serial)
|
|
{
|
|
Instances.Add(this);
|
|
}
|
|
|
|
public static List<TownCrier> Instances { get; } = new();
|
|
|
|
public List<TownCrierEntry> Entries { get; private set; }
|
|
|
|
public TownCrierEntry GetRandomEntry()
|
|
{
|
|
var count = Entries?.Count ?? 0;
|
|
for (var i = count - 1; i >= 0; --i)
|
|
{
|
|
if (i >= Entries!.Count)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var tce = Entries[i];
|
|
|
|
if (tce.Expired)
|
|
{
|
|
RemoveEntry(tce);
|
|
}
|
|
}
|
|
|
|
var entry = GlobalTownCrierEntryList.Instance.GetRandomEntry();
|
|
if (count > 0 && entry != null && Utility.RandomBool())
|
|
{
|
|
return entry;
|
|
}
|
|
|
|
return Entries?.RandomElement();
|
|
}
|
|
|
|
public TownCrierEntry AddEntry(string[] lines, TimeSpan duration)
|
|
{
|
|
Entries ??= new List<TownCrierEntry>();
|
|
|
|
var tce = new TownCrierEntry(lines, duration);
|
|
|
|
Entries.Add(tce);
|
|
|
|
ForceBeginAutoShout();
|
|
|
|
return tce;
|
|
}
|
|
|
|
public void RemoveEntry(TownCrierEntry tce)
|
|
{
|
|
if (Entries == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Entries.Remove(tce);
|
|
|
|
if (Entries.Count == 0)
|
|
{
|
|
Entries = null;
|
|
}
|
|
|
|
if (Entries == null && GlobalTownCrierEntryList.Instance.IsEmpty)
|
|
{
|
|
_autoShoutTimer?.Stop();
|
|
_autoShoutTimer = null;
|
|
}
|
|
}
|
|
|
|
public void ForceBeginAutoShout()
|
|
{
|
|
_autoShoutTimer ??= Timer.DelayCall(TimeSpan.FromSeconds(5.0), TimeSpan.FromMinutes(1.0), AutoShout_Callback);
|
|
}
|
|
|
|
private void AutoShout_Callback()
|
|
{
|
|
var tce = GetRandomEntry();
|
|
|
|
if (tce == null)
|
|
{
|
|
_autoShoutTimer?.Stop();
|
|
_autoShoutTimer = null;
|
|
}
|
|
else if (_newsTimer == null)
|
|
{
|
|
_newsTimer = Timer.DelayCall(
|
|
TimeSpan.FromSeconds(1.0),
|
|
TimeSpan.FromSeconds(3.0),
|
|
tce.Lines.Length,
|
|
() => ShoutNews_Callback(tce)
|
|
);
|
|
|
|
PublicOverheadMessage(MessageType.Regular, 0x3B2, 502976); // Hear ye! Hear ye!
|
|
}
|
|
}
|
|
|
|
private void ShoutNews_Callback(TownCrierEntry tce)
|
|
{
|
|
var index = _newsTimer.Index;
|
|
if (index >= tce.Lines.Length)
|
|
{
|
|
_newsTimer?.Stop();
|
|
_newsTimer = null;
|
|
}
|
|
else
|
|
{
|
|
PublicOverheadMessage(MessageType.Regular, 0x3B2, false, tce.Lines[index]);
|
|
}
|
|
}
|
|
|
|
public override void OnDoubleClick(Mobile from)
|
|
{
|
|
if (from.AccessLevel >= AccessLevel.GameMaster)
|
|
{
|
|
from.SendGump(new TownCrierGump(from, this));
|
|
}
|
|
else
|
|
{
|
|
base.OnDoubleClick(from);
|
|
}
|
|
}
|
|
|
|
public override bool HandlesOnSpeech(Mobile from) => _newsTimer == null && from.Alive && InRange(from, 12);
|
|
|
|
public override void OnSpeech(SpeechEventArgs e)
|
|
{
|
|
if (_newsTimer == null && e.HasKeyword(0x30) && e.Mobile.Alive && InRange(e.Mobile, 12)) // *news*
|
|
{
|
|
Direction = GetDirectionTo(e.Mobile);
|
|
|
|
var tce = GetRandomEntry();
|
|
|
|
if (tce == null)
|
|
{
|
|
PublicOverheadMessage(MessageType.Regular, 0x3B2, 1005643); // I have no news at this time.
|
|
}
|
|
else
|
|
{
|
|
_newsTimer = Timer.DelayCall(
|
|
TimeSpan.FromSeconds(1.0),
|
|
TimeSpan.FromSeconds(3.0),
|
|
tce.Lines.Length,
|
|
() => ShoutNews_Callback(tce)
|
|
);
|
|
|
|
PublicOverheadMessage(MessageType.Regular, 0x3B2, 502978); // Some of the latest news!
|
|
}
|
|
}
|
|
}
|
|
|
|
public override bool CanBeDamaged() => false;
|
|
|
|
public override void OnDelete()
|
|
{
|
|
Instances.Remove(this);
|
|
base.OnDelete();
|
|
}
|
|
}
|