diff --git a/Distribution/Data/Decoration/BountyBoards/bountyboards.cfg b/Distribution/Data/Decoration/BountyBoards/bountyboards.cfg new file mode 100644 index 000000000..32fb378eb --- /dev/null +++ b/Distribution/Data/Decoration/BountyBoards/bountyboards.cfg @@ -0,0 +1,25 @@ +# Bounty Boards — placed when murderSystem.bountiesEnabled is true +# 0x1E5E = east-facing, 0x1E5F = south-facing + +BountyBoard 0x1E5E +2887 3482 17 +2734 2184 0 +2891 680 0 +5668 3123 13 + +BountyBoard 0x1E5F +5278 3981 37 +1442 1687 0 +2512 551 0 +600 2147 0 + +# Guards — one per board, standing in front +Spawner 0x1F13 (Spawn=WarriorGuard; Count=1; HomeRange=0; WalkingRange=5; Name=Bounty Board Guard Spawner) +2887 3487 17 +2734 2189 0 +2891 685 0 +5668 3128 13 +5283 3981 37 +1447 1687 0 +2517 551 0 +605 2147 0 diff --git a/Projects/UOContent/Commands/Object Creation/Decorate.cs b/Projects/UOContent/Commands/Object Creation/Decorate.cs index 8746fbf46..16c0da858 100644 --- a/Projects/UOContent/Commands/Object Creation/Decorate.cs +++ b/Projects/UOContent/Commands/Object Creation/Decorate.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using CommunityToolkit.HighPerformance; using Server.Collections; +using Server.Engines.PlayerMurderSystem; using Server.Engines.Quests.Haven; using Server.Engines.Quests.Necro; using Server.Engines.Spawners; @@ -40,6 +41,11 @@ namespace Server.Commands Generate("Data/Decoration/Malas", Map.Malas); Generate("Data/Decoration/Tokuno", Map.Tokuno); + if (PlayerMurderSystem.BountiesEnabled) + { + Generate("Data/Decoration/BountyBoards", Map.Felucca); + } + m_Mobile.SendMessage($"World generating complete. {m_Count} items were generated."); } @@ -588,6 +594,15 @@ namespace Server.Commands sp.HomeRange = Utility.ToInt32(m_Params[i].AsSpan()[++indexOf..]); } } + else if (m_Params[i].StartsWithOrdinal("WalkingRange")) + { + var indexOf = m_Params[i].IndexOfOrdinal('='); + + if (indexOf >= 0) + { + sp.WalkingRange = Utility.ToInt32(m_Params[i].AsSpan()[++indexOf..]); + } + } else if (m_Params[i].StartsWithOrdinal("Running")) { var indexOf = m_Params[i].IndexOfOrdinal('='); diff --git a/Projects/UOContent/Engines/Player Murder System/BountyBoard.cs b/Projects/UOContent/Engines/Player Murder System/BountyBoard.cs new file mode 100644 index 000000000..98538ac1b --- /dev/null +++ b/Projects/UOContent/Engines/Player Murder System/BountyBoard.cs @@ -0,0 +1,90 @@ +using System.Buffers; +using ModernUO.Serialization; +using Server.Engines.PlayerMurderSystem; +using Server.Mobiles; +using Server.Network; + +namespace Server.Items; + +[SerializationGenerator(0, false)] +public partial class BountyBoard : BaseBulletinBoard +{ + // Offset added to player serials (max 0x3FFFFFFF) to produce synthetic serials + // in the unused range (0x80000001–0xBFFFFFFF) for BB packets. Reversible by subtraction. + private const uint SyntheticSerialBase = 0x80000000; + + [Constructible] + public BountyBoard() : base(0x1E5E) => BoardName = "bounty board"; + + public override void OnSingleClick(Mobile from) + { + var count = PlayerMurderSystem.GetActiveBountyCount(); + if (count > 0) + { + LabelTo(from, $"a bounty board with {count} posted {(count == 1 ? "bounty" : "bounties")}"); + } + else + { + LabelTo(from, 1042679); // a bounty board with no bounties posted. + } + } + + public override void PostMessage(Mobile from, BulletinMessage thread, string subject, string[] lines) + { + from.SendLocalizedMessage(1062398); // You are not allowed to post to this bulletin board. + } + + public override void OnDoubleClick(Mobile from) + { + if (!CheckRange(from)) + { + from.LocalOverheadMessage(MessageType.Regular, 0x3B2, 1019045); // I can't reach that. + return; + } + + var state = from.NetState; + state.SendBBDisplayBoard(this); + BountyMessage.SendBountyContainerContent(state, this, SyntheticSerialBase); + } + + public override bool HandleBBRequest(int packetID, Mobile from, SpanReader reader) + { + switch (packetID) + { + case 3: // Request content + case 4: // Request header + { + var syntheticSerial = reader.ReadUInt32(); + var playerSerial = (Serial)(syntheticSerial - SyntheticSerialBase); + + if (World.FindMobile(playerSerial) is PlayerMobile player && + PlayerMurderSystem.GetMurderContext(player, out var context) && + context.Bounty > 0) + { + BountyMessage.SendBountyBBMessage( + from.NetState, + this, + (Serial)syntheticSerial, + player, + context.Bounty, + context.LastMurderTime, + packetID == 3 + ); + } + + return true; + } + case 5: // Post - blocked + { + from.SendLocalizedMessage(1062398); // You are not allowed to post to this bulletin board. + return true; + } + case 6: // Remove - blocked + { + return true; + } + } + + return false; + } +} diff --git a/Projects/UOContent/Engines/Player Murder System/BountyMessage.cs b/Projects/UOContent/Engines/Player Murder System/BountyMessage.cs new file mode 100644 index 000000000..3863f63c3 --- /dev/null +++ b/Projects/UOContent/Engines/Player Murder System/BountyMessage.cs @@ -0,0 +1,430 @@ +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"); + lineBuilder.Append(player.RawName); + lineBuilder.Append("!"); + break; + } + case 1: + { + lineBuilder.Append(player.RawName); + lineBuilder.Append(" must die!"); + break; + } + case 2: + { + lineBuilder.Append("A price on "); + lineBuilder.Append(player.RawName); + lineBuilder.Append('!'); + break; + } + case 3: + { + lineBuilder.Append(player.RawName); + lineBuilder.Append(" outlawed!"); + break; + } + case 4: + { + lineBuilder.Append("Execute "); + lineBuilder.Append(player.RawName); + lineBuilder.Append('!'); + break; + } + default: + { + lineBuilder.Append("WANTED: "); + lineBuilder.Append(player.RawName); + lineBuilder.Append('!'); + 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 "); + paraBuilder.Append(player.RawName); + paraBuilder.Append(' '); + paraBuilder.Append(verb); + paraBuilder.Append(" For "); + paraBuilder.Append(pronoun); + paraBuilder.Append(" is responsible for "); + paraBuilder.Append(player.Kills); + paraBuilder.Append(" murders. "); + paraBuilder.Append(intro); + paraBuilder.Append(" of "); + paraBuilder.Append(bounty); + paraBuilder.Append(" gold pieces for "); + paraBuilder.Append(possessive); + paraBuilder.Append(" 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(" - "); + lineBuilder.Append(GetHairStyle(player.HairItemID)); + writer.WriteString(lineBuilder.AsSpan(), textBuffer, true); + lineCount++; + + lineBuilder.Reset(); + lineBuilder.Append(" - "); + lineBuilder.Append(GetHairColor(player.HairHue)); + lineBuilder.Append(" hair"); + writer.WriteString(lineBuilder.AsSpan(), textBuffer, true); + lineCount++; + + lineBuilder.Reset(); + lineBuilder.Append(" - "); + lineBuilder.Append(GetSkinTone(player.Hue)); + lineBuilder.Append(" skin"); + writer.WriteString(lineBuilder.AsSpan(), textBuffer, true); + lineCount++; + + // Closing instructions + writer.WriteString(ReadOnlySpan.Empty, textBuffer, true); + lineCount++; + + lineBuilder.Reset(); + lineBuilder.Append("If you kill "); + lineBuilder.Append(objective); + lineBuilder.Append(", 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" + }; +} diff --git a/Projects/UOContent/Engines/Player Murder System/BountyReportMurdererGump.cs b/Projects/UOContent/Engines/Player Murder System/BountyReportMurdererGump.cs new file mode 100644 index 000000000..c099fd2b9 --- /dev/null +++ b/Projects/UOContent/Engines/Player Murder System/BountyReportMurdererGump.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using Server.Gumps; +using Server.Items; +using Server.Mobiles; +using Server.Network; + +namespace Server.Engines.PlayerMurderSystem; + +public class BountyReportMurdererGump : DynamicGump +{ + private readonly List _killers; + private readonly PlayerMobile _victim; + private int _idx; + + public BountyReportMurdererGump(PlayerMobile victim, List killers, int idx = 0) : base(0, 0) + { + _victim = victim; + _killers = killers; + _idx = idx; + } + + protected override void BuildLayout(ref DynamicGumpBuilder builder) + { + builder.SetNoClose(); + builder.SetNoResize(); + + builder.AddBackground(265, 205, 393, 270, 70000); + builder.AddImage(265, 205, 1140); + + builder.AddPage(); + + builder.AddHtml(325, 255, 300, 60, + $"Would you like to report {_killers[_idx].Name} as a murderer?"); + + var bountyMax = Banker.GetBalance(_victim); + + if (_killers[_idx].Kills >= 4 && bountyMax > 0) + { + builder.AddHtml(325, 325, 300, 60, $"Optional Bounty: [{bountyMax} max] "); + builder.AddImage(323, 343, 0x475); + builder.AddTextEntry(329, 346, 311, 16, 0, 1); + } + + builder.AddButton(385, 395, 0x47B, 0x47D, 1); + builder.AddButton(465, 395, 0x478, 0x47A, 2); + } + + public override void OnResponse(NetState state, in RelayInfo info) + { + var from = (PlayerMobile)state.Mobile; + + if (info.ButtonID == 1) + { + var killer = _killers[_idx]; + + if (PlayerMurderSystem.ReportMurder(from, killer) && killer is PlayerMobile pk) + { + var text = info.GetTextEntry(1); + if (!string.IsNullOrWhiteSpace(text) && int.TryParse(text, out var requested) && requested > 0) + { + var bounty = Math.Min(requested, Banker.GetBalance(from)); + if (bounty > 0 && Banker.Withdraw(from, bounty)) + { + PlayerMurderSystem.AddBounty(pk, bounty); + + pk.SendMessage( + $"{from.Name} has placed a bounty of {bounty} {(bounty == 1 ? "gold piece" : "gold pieces")} on your head!" + ); + + from.SendMessage($"You place a bounty of {bounty}gp on {pk.Name}'s head."); + } + } + } + } + + _idx++; + if (_idx < _killers.Count) + { + from.SendGump(new BountyReportMurdererGump(from, _killers, _idx)); + } + } +} diff --git a/Projects/UOContent/Engines/Player Murder System/MurderContext.cs b/Projects/UOContent/Engines/Player Murder System/MurderContext.cs index e09b94dd4..cecd7bf7e 100644 --- a/Projects/UOContent/Engines/Player Murder System/MurderContext.cs +++ b/Projects/UOContent/Engines/Player Murder System/MurderContext.cs @@ -5,7 +5,7 @@ using Server.Mobiles; namespace Server.Engines.PlayerMurderSystem; -[SerializationGenerator(1)] +[SerializationGenerator(2)] public partial class MurderContext { [SerializableField(0)] @@ -28,6 +28,14 @@ public partial class MurderContext [SerializedCommandProperty(AccessLevel.GameMaster)] private int _pingPongs; + [SerializableField(4)] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _bounty; + + [SerializableField(5)] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _lastMurderTime; + private void MigrateFrom(V0Content content) { _shortTermElapse = content.ShortTermElapse; @@ -37,6 +45,17 @@ public partial class MurderContext _pingPongs = _player.Kills >= 5 ? 1 : 0; } + private void MigrateFrom(V1Content content) + { + _shortTermElapse = content.ShortTermElapse; + _longTermElapse = content.LongTermElapse; + _shortTermMurders = content.ShortTermMurders; + _pingPongs = content.PingPongs; + // _bounty defaults to 0 + // Default to now so the bounty board date display is sensible for migrated contexts + _lastMurderTime = Core.Now; + } + public PlayerMobile _player; public PlayerMobile Player => _player; diff --git a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs index ff3fc49c7..fd03f7f8a 100644 --- a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs +++ b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs @@ -6,6 +6,7 @@ using ModernUO.CodeGeneratedEvents; using Server.Collections; using Server.Logging; using Server.Mobiles; +using Server.SkillHandlers; namespace Server.Engines.PlayerMurderSystem; @@ -21,6 +22,9 @@ public class PlayerMurderSystem : GenericPersistence // Only the players that are online private static readonly HashSet _contextTerms = new(MurderContext.EqualityComparer.Default); + private static readonly HashSet<(Mobile, Mobile)> _recentlyReported = new(); + private static TimeSpan _recentlyReportedDelay; + private static TimeSpan _shortTermMurderDuration; private static TimeSpan _longTermMurderDuration; @@ -31,10 +35,17 @@ public class PlayerMurderSystem : GenericPersistence public static bool PingPongEnabled => Core.T2A && !Core.LBR; + public static bool BountiesEnabled { get; private set; } + + private static TimeSpan _bountyExpiry; + public static void Configure() { _shortTermMurderDuration = ServerConfiguration.GetOrUpdateSetting("murderSystem.shortTermMurderDuration", TimeSpan.FromHours(8)); _longTermMurderDuration = ServerConfiguration.GetOrUpdateSetting("murderSystem.longTermMurderDuration", TimeSpan.FromHours(40)); + BountiesEnabled = ServerConfiguration.GetOrUpdateSetting("murderSystem.bountiesEnabled", !Core.LBR); + _recentlyReportedDelay = ServerConfiguration.GetOrUpdateSetting("murderSystem.recentlyReportedDelay", TimeSpan.FromMinutes(10)); + _bountyExpiry = ServerConfiguration.GetOrUpdateSetting("murderSystem.bountyExpiry", TimeSpan.FromDays(14)); _playerMurderPersistence = new PlayerMurderSystem(); } @@ -173,6 +184,63 @@ public class PlayerMurderSystem : GenericPersistence return context; } + public static int GetBounty(PlayerMobile player) => + GetMurderContext(player, out var context) ? context.Bounty : 0; + + public static void AddBounty(PlayerMobile player, int amount) + { + if (GetMurderContext(player, out var context)) + { + context.Bounty += amount; + } + } + + public static void ClearBounty(PlayerMobile player) + { + if (GetMurderContext(player, out var context)) + { + context.Bounty = 0; + } + } + + public static int GetActiveBountyCount() + { + var neverExpire = _bountyExpiry == TimeSpan.Zero; + var cutoff = neverExpire ? DateTime.MinValue : Core.Now - _bountyExpiry; + var count = 0; + + foreach (var (player, context) in _murderContexts) + { + if (player.Murderer && context.Bounty > 0 && (neverExpire || context.LastMurderTime >= cutoff)) + { + count++; + } + } + + return count; + } + + // Reused across calls — safe because the server is single-threaded. + private static readonly List<(PlayerMobile Player, int Bounty)> _activeBountyCache = []; + + public static List<(PlayerMobile Player, int Bounty)> GetActiveBounties() + { + _activeBountyCache.Clear(); + var neverExpire = _bountyExpiry == TimeSpan.Zero; + var cutoff = neverExpire ? DateTime.MinValue : Core.Now - _bountyExpiry; + + foreach (var (player, context) in _murderContexts) + { + if (player.Murderer && context.Bounty > 0 && (neverExpire || context.LastMurderTime >= cutoff)) + { + _activeBountyCache.Add((player, context.Bounty)); + } + } + + _activeBountyCache.Sort(static (a, b) => b.Bounty.CompareTo(a.Bounty)); + return _activeBountyCache; + } + public static void ManuallySetPingPong(PlayerMobile player, int pingPong) { var context = GetOrCreateMurderContext(player); @@ -200,10 +268,52 @@ public class PlayerMurderSystem : GenericPersistence context.PingPongs++; } + context.LastMurderTime = Core.Now; context.ResetKillTime(); UpdateMurderContext(context); } + public static bool IsRecentlyReported(Mobile reporter, Mobile killer) => + _recentlyReported.Contains((reporter, killer)); + + public static bool ReportMurder(PlayerMobile reporter, Mobile killer) + { + if (killer?.Deleted != false || killer is not PlayerMobile pk) + { + return false; + } + + if (!_recentlyReported.Add((reporter, killer))) + { + return false; + } + + Timer.DelayCall( + _recentlyReportedDelay, + static (f, k) => _recentlyReported.Remove((f, k)), + reporter, + killer + ); + + var wasMurderer = killer.Murderer; + OnPlayerMurder(pk); + + pk.SendLocalizedMessage(1049067); // You have been reported for murder! + + if (!wasMurderer && killer.Murderer) + { + pk.SendLocalizedMessage(502134); // You are now known as a murderer! + } + + // with the introduction of PingPongs, a red can technically have 1 kill. + if (Stealing.SuspendOnMurder && pk.Kills == 1 && pk.NpcGuild == NpcGuild.ThievesGuild) + { + pk.SendLocalizedMessage(501562); // You have been suspended by the Thieves Guild. + } + + return true; + } + private static void UpdateMurderContext(MurderContext context) { var player = context.Player; diff --git a/Projects/UOContent/Engines/Player Murder System/ReportMurdererGump.cs b/Projects/UOContent/Engines/Player Murder System/ReportMurdererGump.cs index 40e84d9ca..6eeb7bcf8 100644 --- a/Projects/UOContent/Engines/Player Murder System/ReportMurdererGump.cs +++ b/Projects/UOContent/Engines/Player Murder System/ReportMurdererGump.cs @@ -5,16 +5,11 @@ using Server.Gumps; using Server.Misc; using Server.Mobiles; using Server.Network; -using Server.SkillHandlers; namespace Server.Engines.PlayerMurderSystem; public class ReportMurdererGump : StaticGump { - // Recently reported - private static TimeSpan _recentlyReportedDelay; - private static readonly HashSet<(Mobile, Mobile)> _recentlyReported = new(); - private readonly List _killers; private int _idx; @@ -24,11 +19,6 @@ public class ReportMurdererGump : StaticGump _idx = idx; } - public static void Initialize() - { - _recentlyReportedDelay = ServerConfiguration.GetOrUpdateSetting("murderSystem.recentlyReportedDelay", TimeSpan.FromMinutes(10)); - } - [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] public static void OnPlayerDeathEvent(PlayerMobile m) { @@ -40,19 +30,16 @@ public class ReportMurdererGump : StaticGump foreach (var ai in m.Aggressors) { - if (ai.Attacker.Player && ai.CanReportMurder && !ai.Reported) + if (ai.Attacker.Player && ai.CanReportMurder && !ai.Reported && !PlayerMurderSystem.IsRecentlyReported(m, ai.Attacker)) { - if (!_recentlyReported.Contains((m, ai.Attacker))) + if (notInThievesGuild) { - if (notInThievesGuild) - { - killers ??= new List(); - killers.Add(ai.Attacker); - } - - ai.Reported = true; - ai.CanReportMurder = false; + killers ??= new List(); + killers.Add(ai.Attacker); } + + ai.Reported = true; + ai.CanReportMurder = false; } if (ai.Attacker.Player && Core.Now - ai.LastCombatTime < TimeSpan.FromSeconds(30.0)) @@ -147,39 +134,7 @@ public class ReportMurdererGump : StaticGump { case 1: { - var killer = _killers[_idx]; - if (killer?.Deleted == false) - { - if (_recentlyReported.Add((from, killer))) - { - Timer.DelayCall( - _recentlyReportedDelay, - static (f, k) => _recentlyReported.Remove((f, k)), - from, - killer - ); - } - - if (killer is PlayerMobile pk) - { - // Increment their short term murders, their kills, and reset the murder decay time - var wasMurderer = killer.Murderer; - PlayerMurderSystem.OnPlayerMurder(pk); - - pk.SendLocalizedMessage(1049067); // You have been reported for murder! - - if (!wasMurderer && killer.Murderer) - { - pk.SendLocalizedMessage(502134); // You are now known as a murderer! - } - // with the introduction of PingPongs, a red can technically have 1 kill. - if (Stealing.SuspendOnMurder && pk.Kills == 1 && pk.NpcGuild == NpcGuild.ThievesGuild) - { - pk.SendLocalizedMessage(501562); // You have been suspended by the Thieves Guild. - } - } - } - + PlayerMurderSystem.ReportMurder(from, _killers[_idx]); break; } case 2: @@ -208,7 +163,14 @@ public class ReportMurdererGump : StaticGump protected override void OnTick() { - _victim.SendGump(new ReportMurdererGump(_killers)); + if (PlayerMurderSystem.BountiesEnabled && _victim is PlayerMobile pm) + { + pm.SendGump(new BountyReportMurdererGump(pm, _killers)); + } + else + { + _victim.SendGump(new ReportMurdererGump(_killers)); + } } } } diff --git a/Projects/UOContent/Items/Body Parts/Head.cs b/Projects/UOContent/Items/Body Parts/Head.cs index f797cdd83..971c5713c 100644 --- a/Projects/UOContent/Items/Body Parts/Head.cs +++ b/Projects/UOContent/Items/Body Parts/Head.cs @@ -1,4 +1,6 @@ +using System; using ModernUO.Serialization; +using Server.Mobiles; namespace Server.Items { @@ -9,7 +11,7 @@ namespace Server.Items Tournament } - [SerializationGenerator(1, false)] + [SerializationGenerator(2, false)] public partial class Head : Item { [InvalidateProperties] @@ -22,6 +24,14 @@ namespace Server.Items [SerializedCommandProperty(AccessLevel.GameMaster)] private HeadType _headType; + [SerializableField(2)] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private PlayerMobile _bountyTarget; + + [SerializableField(3)] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _carvedTime; + [Constructible] public Head(string playerName) : this(HeadType.Regular, playerName) { @@ -32,6 +42,7 @@ namespace Server.Items { _headType = headType; _playerName = playerName; + _carvedTime = Core.Now; } public override string DefaultName @@ -52,10 +63,21 @@ namespace Server.Items } } + // Pre-codegen legacy data (v1 only — playerName + headType) private void Deserialize(IGenericReader reader, int version) { _playerName = reader.ReadString(); _headType = (HeadType)reader.ReadEncodedInt(); + // _bountyTarget and _carvedTime default to null / DateTime.MinValue } + + private void MigrateFrom(V1Content content) + { + _playerName = content.PlayerName; + _headType = content.HeadType; + // _bountyTarget defaults to null + // _carvedTime defaults to DateTime.MinValue (treated as expired) + } + } } diff --git a/Projects/UOContent/Items/Bulletin Boards/BulletinBoard.cs b/Projects/UOContent/Items/Bulletin Boards/BulletinBoard.cs index d8d785445..79cf074fc 100644 --- a/Projects/UOContent/Items/Bulletin Boards/BulletinBoard.cs +++ b/Projects/UOContent/Items/Bulletin Boards/BulletinBoard.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Runtime.CompilerServices; using ModernUO.Serialization; using Server.Collections; @@ -163,7 +164,9 @@ namespace Server.Items public virtual bool CheckRange(Mobile from) => from.AccessLevel >= AccessLevel.GameMaster || from.Map == Map && from.InRange(GetWorldLocation(), 2); - public void PostMessage(Mobile from, BulletinMessage thread, string subject, string[] lines) + public virtual bool HandleBBRequest(int packetID, Mobile from, SpanReader reader) => false; + + public virtual void PostMessage(Mobile from, BulletinMessage thread, string subject, string[] lines) { if (thread != null) { diff --git a/Projects/UOContent/Items/Bulletin Boards/BulletinBoardPackets.cs b/Projects/UOContent/Items/Bulletin Boards/BulletinBoardPackets.cs index bcf218690..ff4de5106 100644 --- a/Projects/UOContent/Items/Bulletin Boards/BulletinBoardPackets.cs +++ b/Projects/UOContent/Items/Bulletin Boards/BulletinBoardPackets.cs @@ -58,6 +58,11 @@ namespace Server.Network return; } + if (board.HandleBBRequest(packetID, from, reader)) + { + return; + } + switch (packetID) { case 3: @@ -192,7 +197,16 @@ namespace Server.Network } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void UpdateLengthCounters(this string text, ref int maxLength, ref int longestTextLine, bool pad = false) + internal static void UpdateLengthCounters(this string text, ref int maxLength, ref int longestTextLine, bool pad = false) + { + var line = Math.Min(255, text.Length); + var byteCount = TextEncoding.UTF8.GetMaxByteCount(line) + (pad ? 3 : 2); // 1 + length + terminator + maxLength += byteCount; + longestTextLine = Math.Max(byteCount, longestTextLine); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void UpdateLengthCounters(this ReadOnlySpan text, ref int maxLength, ref int longestTextLine, bool pad = false) { var line = Math.Min(255, text.Length); var byteCount = TextEncoding.UTF8.GetMaxByteCount(line) + (pad ? 3 : 2); // 1 + length + terminator @@ -273,7 +287,24 @@ namespace Server.Network writer.Dispose(); } - private static void WriteString(this ref SpanWriter writer, string text, Span buffer, bool pad = false) + internal static void WriteString(this ref SpanWriter writer, string text, Span buffer, bool pad = false) + { + var tail = pad ? 2 : 1; + var length = Math.Min(pad ? 253 : 254, text.GetBytesUtf8(buffer)); + writer.Write((byte)(length + tail)); + writer.Write(buffer[..length]); + + if (pad) + { + writer.Write((ushort)0); // Compensating for an old client bug + } + else + { + writer.Write((byte)0); // Terminator + } + } + + internal static void WriteString(this ref SpanWriter writer, scoped ReadOnlySpan text, Span buffer, bool pad = false) { var tail = pad ? 2 : 1; var length = Math.Min(pad ? 253 : 254, text.GetBytesUtf8(buffer)); diff --git a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs index 9eb6ad7ef..534f44b64 100644 --- a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs +++ b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs @@ -6,6 +6,7 @@ using Server.ContextMenus; using Server.Engines.PartySystem; using Server.Engines.Quests.Doom; using Server.Engines.Quests.Haven; +using Server.Engines.PlayerMurderSystem; using Server.Guilds; using Server.Misc; using Server.Mobiles; @@ -388,7 +389,14 @@ public partial class Corpse : Container, ICarvable new LeftArm().MoveToWorld(Location, Map); new RightLeg().MoveToWorld(Location, Map); new RightArm().MoveToWorld(Location, Map); - new Head(dead.Name).MoveToWorld(Location, Map); + var head = new Head(dead.Name); + + if (PlayerMurderSystem.BountiesEnabled && dead is PlayerMobile bountyTarget) + { + head.BountyTarget = bountyTarget; + } + + head.MoveToWorld(Location, Map); SetFlag(CorpseFlag.Carved, true); diff --git a/Projects/UOContent/Migrations/Server.Engines.PlayerMurderSystem.MurderContext.v2.json b/Projects/UOContent/Migrations/Server.Engines.PlayerMurderSystem.MurderContext.v2.json new file mode 100644 index 000000000..fee2646e5 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.PlayerMurderSystem.MurderContext.v2.json @@ -0,0 +1,48 @@ +{ + "version": 2, + "type": "Server.Engines.PlayerMurderSystem.MurderContext", + "properties": [ + { + "name": "ShortTermElapse", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "LongTermElapse", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "ShortTermMurders", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "PingPongs", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Bounty", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "LastMurderTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.BountyBoard.v0.json b/Projects/UOContent/Migrations/Server.Items.BountyBoard.v0.json new file mode 100644 index 000000000..0f6c9ba87 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.BountyBoard.v0.json @@ -0,0 +1,4 @@ +{ + "version": 0, + "type": "Server.Items.BountyBoard" +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.Head.v2.json b/Projects/UOContent/Migrations/Server.Items.Head.v2.json new file mode 100644 index 000000000..0aaf53c22 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.Head.v2.json @@ -0,0 +1,32 @@ +{ + "version": 2, + "type": "Server.Items.Head", + "properties": [ + { + "name": "PlayerName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "HeadType", + "type": "Server.Items.HeadType", + "rule": "EnumMigrationRule" + }, + { + "name": "BountyTarget", + "type": "Server.Mobiles.PlayerMobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "CarvedTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/Guards/BaseGuard.cs b/Projects/UOContent/Mobiles/Guards/BaseGuard.cs index f180f9935..ad63338fd 100644 --- a/Projects/UOContent/Mobiles/Guards/BaseGuard.cs +++ b/Projects/UOContent/Mobiles/Guards/BaseGuard.cs @@ -1,7 +1,9 @@ using System; using System.Runtime.CompilerServices; using ModernUO.Serialization; +using Server.Engines.PlayerMurderSystem; using Server.Items; +using Server.Misc; namespace Server.Mobiles; @@ -141,6 +143,58 @@ public abstract partial class BaseGuard : Mobile public abstract void NonLethalAttack(Mobile target); + public override bool OnDragDrop(Mobile from, Item dropped) + { + if (PlayerMurderSystem.BountiesEnabled && dropped is Head head && head.PlayerName != null) + { + var target = head.BountyTarget; + + if (target == null || Core.Now - head.CarvedTime > TimeSpan.FromHours(24)) + { + SayNonBountyHeadResponse(); + head.Delete(); + return true; + } + + Say(500670); // Ah, a head! Let me check to see if there is a bounty on this. + head.Delete(); + ClaimBounty(from, target); + return true; + } + + return base.OnDragDrop(from, dropped); + } + + private void ClaimBounty(Mobile from, PlayerMobile target) + { + var bounty = PlayerMurderSystem.GetBounty(target); + + if (bounty > 0) + { + PlayerMurderSystem.ClearBounty(target); + Banker.Deposit(from, bounty); + Titles.AwardKarma(from, 2000, true); + + Say(1042855, $"{target.Name}\t{bounty}"); // The bounty on ~1_PLAYER_NAME~ was ~2_AMOUNT~ gold, and has been credited to your account. + } + else + { + Say(1042854, target.Name); // There was no bounty on ~1_PLAYER_NAME~. + } + } + + private void SayNonBountyHeadResponse() + { + if (Utility.Random(5) == 0) + { + Say(500661 + Utility.Random(9)); // 500661–500669: silly guard responses + } + else + { + Say(500654 + Utility.Random(7)); // 500654–500660: normal guard responses + } + } + [AfterDeserialization] private void AfterDeserialization() {