diff --git a/Projects/Server.Tests/Tests/PropertyList/OplTextBlockTests.cs b/Projects/Server.Tests/Tests/PropertyList/OplTextBlockTests.cs new file mode 100644 index 000000000..c159c0771 --- /dev/null +++ b/Projects/Server.Tests/Tests/PropertyList/OplTextBlockTests.cs @@ -0,0 +1,88 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Text; +using Server; +using Xunit; + +namespace Server.Tests; + +public class OplTextBlockTests +{ + private static (int cliloc, string arg)[] Decode(ObjectPropertyList opl) + { + opl.Terminate(); + var buffer = opl.Buffer; + var entries = new List<(int, string)>(); + var pos = 15; + while (true) + { + var cliloc = BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(pos)); + pos += 4; + if (cliloc == 0) + { + break; + } + + var byteLen = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(pos)); + pos += 2; + entries.Add((cliloc, Encoding.Unicode.GetString(buffer, pos, byteLen))); + pos += byteLen; + } + + return entries.ToArray(); + } + + [Fact] + public void MultipleLines_JoinIntoSingleCyclingEntry() + { + var opl = new ObjectPropertyList(null); + using (var block = opl.TextBlock()) + { + block.Add("Line One".AsSpan()); + block.Add("Line Two".AsSpan()); + } + + var entries = Decode(opl); + Assert.Single(entries); + Assert.Equal((1042971, "Line One\nLine Two"), entries[0]); // first cycling cliloc + } + + [Fact] + public void EmptyLines_AreSkipped() + { + var opl = new ObjectPropertyList(null); + using (var block = opl.TextBlock()) + { + block.Add("Only".AsSpan()); + block.Add(ReadOnlySpan.Empty); + } + + Assert.Equal((1042971, "Only"), Decode(opl)[0]); + } + + [Fact] + public void NoLines_EmitsNothing() + { + var opl = new ObjectPropertyList(null); + using (var block = opl.TextBlock()) + { + // add nothing + } + + Assert.Empty(Decode(opl)); + } + + [Fact] + public void Interpolated_AppendsZeroAlloc() + { + var opl = new ObjectPropertyList(null); + var v = 5; + using (var block = opl.TextBlock()) + { + block.Add($"Luck Bonus: +{v}%"); + } + + Assert.Equal((1042971, "Luck Bonus: +5%"), Decode(opl)[0]); + } +} diff --git a/Projects/Server/PropertyList/ObjectPropertyList.cs b/Projects/Server/PropertyList/ObjectPropertyList.cs index 99d538333..2ee1f35cc 100644 --- a/Projects/Server/PropertyList/ObjectPropertyList.cs +++ b/Projects/Server/PropertyList/ObjectPropertyList.cs @@ -155,6 +155,7 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable public void Add(ReadOnlySpan argument) => InternalAdd(GetStringNumber(), argument); public void Add(int number, ReadOnlySpan argument) => InternalAdd(number, argument); + public OplTextBlock TextBlock() => new(this); private void InternalAdd(int number, ReadOnlySpan chars) { diff --git a/Projects/Server/PropertyList/OplTextBlock.cs b/Projects/Server/PropertyList/OplTextBlock.cs new file mode 100644 index 000000000..fdd157cad --- /dev/null +++ b/Projects/Server/PropertyList/OplTextBlock.cs @@ -0,0 +1,109 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: OplTextBlock.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 . * + *************************************************************************/ + +using System; +using System.Runtime.CompilerServices; +using Server.Text; + +namespace Server; + +// Accumulates '\n'-joined free-text lines and emits ONE cycling passthrough entry on dispose. +// Use with `using var block = list.TextBlock();`. ref struct: single-threaded OPL build only. +public ref struct OplTextBlock +{ + private readonly ObjectPropertyList _list; + internal ValueStringBuilder _builder; + private bool _any; + + internal OplTextBlock(ObjectPropertyList list) + { + _list = list; + _builder = ValueStringBuilder.Create(); + _any = false; + } + + public void Add(scoped ReadOnlySpan line) + { + if (line.IsEmpty) + { + return; + } + + _builder.Append(line); + _builder.Append('\n', 1); // ValueStringBuilder has no single-char Append + _any = true; + } + + // Zero-alloc interpolated overload: block.Add($"Luck Bonus: +{v}%"). + public void Add([InterpolatedStringHandlerArgument("")] scoped ref OplInterpolationHandler handler) + { + var wrote = handler._wrote; + this = handler._block; // reconcile possibly-grown builder + if (wrote) + { + _builder.Append('\n', 1); + _any = true; + } + } + + public void Dispose() + { + if (_any) + { + // Strip the trailing '\n' (Length >= 2 whenever _any: content + separator). + _list.Add(_builder.AsSpan(0, _builder.Length - 1)); + } + + _builder.Dispose(); + } + + [InterpolatedStringHandler] + public ref struct OplInterpolationHandler + { + internal OplTextBlock _block; + internal bool _wrote; + + public OplInterpolationHandler(int literalLength, int formattedCount, OplTextBlock block) + { + _block = block; + _wrote = false; + _block._builder.EnsureCapacity(_block._builder.Length + literalLength + formattedCount * 11); + } + + public void AppendLiteral(string value) + { + _block._builder.Append(value); + _wrote = true; + } + + public void AppendFormatted(T value) + { + _block._builder.Append(value); + _wrote = true; + } + + public void AppendFormatted(T value, string format) + { + _block._builder.Append(value, format); + _wrote = true; + } + + public void AppendFormatted(scoped ReadOnlySpan value) + { + _block._builder.Append(value); + _wrote = true; + } + } +}