using System; using System.Buffers; using System.IO; using Server.Items; using Server.Mobiles; using Server.Network; using Server.Text; namespace Server.Engines.PlayerMurderSystem; public static class BountyMessage { /// /// Sends a synthetic container content packet (0x3C) using offset player serials. /// The client displays these as bulletin board message entries. When clicked, /// HandleBBRequest reverses the offset to recover the real player serial. /// public static void SendBountyContainerContent(NetState ns, BaseBulletinBoard board, uint syntheticSerialBase) { if (ns.CannotSendPackets()) { return; } var bounties = PlayerMurderSystem.GetActiveBounties(); var entrySize = ns.ContainerGridLines ? 20 : 19; var totalSize = 5 + bounties.Count * entrySize; var writer = totalSize > 1024 ? new SpanWriter(totalSize) : new SpanWriter(stackalloc byte[totalSize]); writer.Write((byte)0x3C); // Packet ID writer.Seek(4, SeekOrigin.Current); // Length & count placeholder var written = 0; foreach (var (player, _) in bounties) { writer.Write((Serial)(syntheticSerialBase + (uint)player.Serial)); writer.Write((ushort)0xEB0); // BulletinMessage ItemID writer.Write((byte)0); // signed, itemID offset writer.Write((ushort)1); // Amount writer.Write((short)0); // X writer.Write((short)0); // Y if (ns.ContainerGridLines) { writer.Write((byte)0); // Grid location } writer.Write(board.Serial); // Container = this board writer.Write((ushort)0); // Hue written++; } writer.Seek(1, SeekOrigin.Begin); writer.Write((ushort)writer.BytesWritten); writer.Write((ushort)written); writer.Seek(0, SeekOrigin.End); ns.Send(writer.Span); writer.Dispose(); } /// /// Sends a synthetic BB message packet (header or content) for a bounty entry, /// built entirely from live MurderSystem data — no real BulletinMessage item required. /// Uses a resizable SpanWriter for single-pass writing with zero intermediate allocations. /// public static void SendBountyBBMessage(NetState ns, BaseBulletinBoard board, Serial messageSerial, PlayerMobile player, int bounty, DateTime lastMurderTime, bool content) { if (ns.CannotSendPackets()) { return; } // Build subject and time without heap allocation using var subjectBuilder = new ValueStringBuilder(stackalloc char[32]); subjectBuilder.Append(bounty); subjectBuilder.Append(" gold"); using var timeBuilder = new ValueStringBuilder(stackalloc char[16]); timeBuilder.Append(lastMurderTime, "MMM dd, yyyy"); // Reusable UTF-8 encoding buffer (768 = GetMaxByteCount(255), covers any WriteString call) Span textBuffer = stackalloc byte[768]; // Single-pass writing with resizable SpanWriter — auto-grows if needed var writer = new SpanWriter(stackalloc byte[1024], resize: true); writer.Write((byte)0x71); // Packet ID writer.Seek(2, SeekOrigin.Current); writer.Write((byte)(content ? 0x02 : 0x01)); // Command writer.Write(board.Serial); writer.Write(messageSerial); // Synthetic serial — must match the 0x3C entry if (!content) { writer.Write(Serial.Zero); // Thread serial (all root-level) } writer.WriteString(player.Name.AsSpan(), textBuffer); writer.WriteString(subjectBuilder.AsSpan(), textBuffer); writer.WriteString(timeBuilder.AsSpan(), textBuffer); if (content) { writer.Write((short)player.Body); writer.Write((short)player.Hue); // Write equipment directly from player items (no intermediate array) var equipCount = 0; for (var i = 0; i < player.Items.Count; i++) { if (player.Items[i].Layer >= Layer.OneHanded && player.Items[i].Layer <= Layer.Mount) { equipCount++; } } var equipLength = Math.Min(255, equipCount); writer.Write((byte)equipLength); var equipWritten = 0; for (var i = 0; i < player.Items.Count && equipWritten < equipLength; i++) { var item = player.Items[i]; if (item.Layer >= Layer.OneHanded && item.Layer <= Layer.Mount) { writer.Write((short)item.ItemID); writer.Write((short)item.Hue); equipWritten++; } } WriteContentLines(ref writer, textBuffer, player, bounty); } writer.WritePacketLength(); ns.Send(writer.Span); writer.Dispose(); } /// /// Writes all content lines directly to the packet writer, avoiding intermediate /// string/list allocations. Uses ValueStringBuilder for line construction and /// span slicing for word wrapping. /// private static void WriteContentLines(ref SpanWriter writer, Span textBuffer, PlayerMobile player, int bounty) { var isFemale = player.Body.IsFemale; var pronoun = isFemale ? "she" : "he"; var possessive = isFemale ? "her" : "his"; var objective = isFemale ? "her" : "him"; // Placeholder for line count — patched after all lines are written var lineCountPos = writer.Position; writer.Write((byte)0); var lineCount = 0; // Reusable builder for constructing each line using var lineBuilder = new ValueStringBuilder(stackalloc char[64]); // Title line (random, 6 variants matching uo98 bountyboard.m) switch (Utility.Random(6)) { case 0: { lineBuilder.Append($"Bounty for {player.RawName}!"); break; } case 1: { lineBuilder.Append($"{player.RawName} must die!"); break; } case 2: { lineBuilder.Append($"A price on {player.RawName}!"); break; } case 3: { lineBuilder.Append($"{player.RawName} outlawed!"); break; } case 4: { lineBuilder.Append($"Execute {player.RawName}!"); break; } default: { lineBuilder.Append($"WANTED: {player.RawName}!"); break; } } writer.WriteString(lineBuilder.AsSpan(), textBuffer, true); lineCount++; // Blank line writer.WriteString(ReadOnlySpan.Empty, textBuffer, true); lineCount++; // Build main paragraph with ValueStringBuilder var paraBuilder = new ValueStringBuilder(stackalloc char[256]); // Random verb phrase (18 variants matching uo98 bountyboard.m) var verb = Utility.Random(18) switch { 0 => "hath murdered one too many!", 1 => "shall not slay again!", 2 => "hath slain too many!", 3 => "cannot continue to kill!", 4 => "must be stopped.", 5 => "is a bloodthirsty monster.", 6 => "is a killer of the worst sort.", 7 => "hath no conscience!", 8 => "hath cowardly slain many.", 9 => "must die for all our sakes.", 10 => "sheds innocent blood!", 11 => "must fall to preserve us.", 12 => "must be taken care of.", 13 => "is a thug and must die.", 14 => "cannot be redeemed.", 15 => "is a shameless butcher.", 16 => "is a callous monster.", _ => "is a cruel, casual killer." }; // Random bounty intro phrase (7 variants matching uo98 bountyboard.m) var intro = Utility.Random(7) switch { 0 => " A bounty is hereby offered", 1 => " Lord British sets a price", 2 => " Claim the reward! 'Tis", 3 => " Lord Blackthorn set a price", 4 => " The Paladins set a price", 5 => " The Merchants set a price", _ => " Lord British's bounty " }; paraBuilder.Append($"The foul scum known as {player.RawName} {verb} For {pronoun} is responsible for {player.Kills} murders. {intro} of {bounty} gold pieces for {possessive} head!"); // Word-wrap at 28 chars and write each line directly (matching uo98 bountyboard.m CONST:28) WriteWordWrappedLines(ref writer, paraBuilder.AsSpan(), 28, textBuffer, ref lineCount); paraBuilder.Dispose(); // Physical description writer.WriteString(ReadOnlySpan.Empty, textBuffer, true); lineCount++; writer.WriteString(" A description:", textBuffer, true); lineCount++; lineBuilder.Reset(); lineBuilder.Append($" - {GetHairStyle(player.HairItemID)}"); writer.WriteString(lineBuilder.AsSpan(), textBuffer, true); lineCount++; lineBuilder.Reset(); lineBuilder.Append($" - {GetHairColor(player.HairHue)} hair"); writer.WriteString(lineBuilder.AsSpan(), textBuffer, true); lineCount++; lineBuilder.Reset(); lineBuilder.Append($" - {GetSkinTone(player.Hue)} skin"); writer.WriteString(lineBuilder.AsSpan(), textBuffer, true); lineCount++; // Closing instructions writer.WriteString(ReadOnlySpan.Empty, textBuffer, true); lineCount++; lineBuilder.Reset(); lineBuilder.Append($"If you kill {objective}, remove the"); writer.WriteString(lineBuilder.AsSpan(), textBuffer, true); lineCount++; writer.WriteString("head, and give it to a guard", textBuffer, true); lineCount++; writer.WriteString("to claim your reward.", textBuffer, true); lineCount++; // Patch line count var endPos = writer.Position; writer.Seek(lineCountPos, SeekOrigin.Begin); writer.Write((byte)Math.Min(255, lineCount)); writer.Seek(endPos, SeekOrigin.Begin); } /// /// Word-wraps text at maxWidth characters and writes each line directly to the packet. /// Uses span slicing instead of Substring to avoid allocations. /// private static void WriteWordWrappedLines(ref SpanWriter writer, scoped ReadOnlySpan text, int maxWidth, Span textBuffer, ref int lineCount) { // Pre-allocate buffer for last segment (needs trailing space) Span lastSegmentBuf = stackalloc char[maxWidth + 2]; var current = 0; while (current < text.Length) { var remaining = text.Length - current; if (remaining > maxWidth) { var length = maxWidth; while (length > 0 && text[current + length] != ' ') { length--; } if (length == 0) { length = maxWidth; // hard break — no space found } else { length++; // include the space } writer.WriteString(text.Slice(current, length), textBuffer, true); current += length; } else { // Last segment — append trailing space (matching original behavior) text.Slice(current, remaining).CopyTo(lastSegmentBuf); lastSegmentBuf[remaining] = ' '; writer.WriteString(lastSegmentBuf[..(remaining + 1)], textBuffer, true); current += remaining; } lineCount++; } } // Maps hair item IDs to style descriptions (from uo98 bountyboard.m lookup table) private static string GetHairStyle(int itemID) => itemID switch { 0x203B => "hair worn short", 0x203C => "hair worn long", 0x203D => "hair tied back", 0x2044 => "a mohawk hairstyle", 0x2045 => "pageboy hair", 0x2046 => "hair tied in buns", 0x2047 => "curly hair", 0x2048 => "receding hairline", 0x2049 => "hair in two pigtails", 0x204A => "shaved head and topknot", _ => "bald" }; // Maps hair hues to color descriptions (from uo98 bountyboard.m lookup table) // Human hair hues: 0x44E–0x47D. Index 0 (hue 0) = indeterminate, index 1+ maps to 0x44E+ private static string GetHairColor(int hue) => hue switch { 0 => "indeterminate color", 0x44E or 0x44F or 0x450 => "white", 0x451 or 0x452 or 0x453 => "graying", 0x454 or 0x455 => "black", 0x456 or 0x457 or 0x458 => "copper", 0x459 or 0x45A or 0x45B or 0x45C => "brown", 0x45D => "reddish brown", 0x45E or 0x45F or 0x460 => "blonde", 0x461 or 0x462 or 0x463 => "light brown", 0x464 or 0x465 => "golden brown", 0x466 or 0x467 or 0x468 => "golden", 0x469 or 0x46A or 0x46B => "bronze", 0x46C or 0x46D => "dark brown", 0x46E or 0x46F => "sandy", 0x470 or 0x471 or 0x472 => "honey-colored", 0x473 or 0x474 or 0x475 => "red", 0x476 or 0x477 or 0x478 => "nut brown", 0x479 or 0x47A or 0x47B => "rich brown", 0x47C or 0x47D => "very dark brown", _ => "outlandishly colored" }; // Maps body hues to skin tone descriptions (from uo98 bountyboard.m lookup table) // Human skin hues: 0x3EA–0x422 (may have 0x8000 flag). Index 1 = 0x3EA. private static string GetSkinTone(int hue) => (hue & 0x7FFF) switch { 0x3F1 or 0x3F2 or 0x3F8 or 0x3FF or 0x400 or 0x406 or 0x407 or 0x408 or 0x40D or 0x40E or 0x415 or 0x416 => "pale", 0x3EA or 0x3EB or 0x3F9 or 0x3FA or 0x3FB or 0x417 => "fair", 0x3F3 or 0x3F4 or 0x3FC or 0x401 or 0x402 or 0x409 or 0x40F or 0x410 or 0x411 or 0x418 => "tanned", 0x3EC or 0x3ED or 0x3EE or 0x3F5 or 0x403 or 0x412 or 0x413 or 0x419 or 0x421 => "copper", 0x3EF or 0x3F0 or 0x3F6 or 0x3F7 or 0x3FD or 0x3FE or 0x404 or 0x405 or 0x40A or 0x40B or 0x40C or 0x414 or 0x41A or 0x41B or 0x420 => "dark", 0x41C or 0x41D or 0x41E => "yellow", _ => "deathly" }; }