feat: Adds pre-T2A-pub15 bounty system (#2377)

# Bounty Boards

<img width="717" height="382" alt="image" src="https://github.com/user-attachments/assets/455e5206-47d8-4449-805c-19b143d059e5" />
<img width="918" height="637" alt="image" src="https://github.com/user-attachments/assets/e78e1ca2-62b8-4abf-8f69-21438bb1b759" />
<img width="340" height="296" alt="image" src="https://github.com/user-attachments/assets/fd17d7e6-8c53-47df-ac58-a89ea3ef665e" />

## Setup
* Setup as part of decorate when bounty system is enabled
  * Several bounty board locations with a WarriorGuard spawner in front of the board. Guard spawns and idles around 5 range.

## Tests

### ReportBountyMurdererGump
* Follows same behaviour as ReportMurdererGump
* Extracted common logic
* Didn't use staticgump due to several dynamic parts including input
* Optional bounty with validation >0 and <bankbox.total
* Murder report is honored either way

### Bounty boards
* Open bounty board with many bounties, no bounties
* Keep a bounty board open, invalidate a bounty by turning in the head, then try to click on the post of the now invalid post. As expected: does nothing
* Bounty messages use the last murder time as their post date and expire in 14 days
* Bounty boards/messages are not reliant on serialization; they use serialized fields from MurderContext to build messages when clicked.
* Bounty messages use synthetic serials so they do not have to persist bounty messages as items. They are constructed and sent as raw packets when needed.
* Players only appear on the bounty board if they are a murderer (though a non-murderer can technically still have a bounty if they decayed kills)
* Tested skin/hair color descriptions vs a dozen spot checks

### Head turn in behaviour
* Guard accepts head
  * Bounty -> gives bounty
  * No bounty -> generic response
  * Expired head (24h) -> generic response

NOTE: CUO "latest" has a bug with bounty/bulletinmessages that causes overflow outside of the container. It has nothing to do with this PR. It is fixed here in CUO https://github.com/ClassicUO/ClassicUO/pull/1871

# Murderer title

Bounty system and "murderer" title eliminated in [pub16](https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2002-2/publish-16-part-2-5-23rd-july/). So UO:LBR and before had bounties and murderer title.

# Pre-T2A caveat

There were differences in pre-T2A, but this cannot be currently implemented because we do not have any pre-T2A systems in general, so at least for now, behavior is consistent with later eras. Pre-T2A was a whole other ballgame, but the bounty system still worked similarly.
This commit is contained in:
Jack 2026-03-23 08:35:16 +13:00 committed by GitHub
parent 7be8b5f7f6
commit 9f39198fab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 996 additions and 60 deletions

View file

@ -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

View file

@ -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('=');

View file

@ -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 (0x800000010xBFFFFFFF) 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;
}
}

View file

@ -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
{
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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<byte> 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();
}
/// <summary>
/// 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.
/// </summary>
private static void WriteContentLines(ref SpanWriter writer, Span<byte> 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<char>.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<char>.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<char>.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);
}
/// <summary>
/// Word-wraps text at maxWidth characters and writes each line directly to the packet.
/// Uses span slicing instead of Substring to avoid allocations.
/// </summary>
private static void WriteWordWrappedLines(ref SpanWriter writer, scoped ReadOnlySpan<char> text, int maxWidth,
Span<byte> textBuffer, ref int lineCount)
{
// Pre-allocate buffer for last segment (needs trailing space)
Span<char> 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: 0x44E0x47D. 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: 0x3EA0x422 (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"
};
}

View file

@ -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<Mobile> _killers;
private readonly PlayerMobile _victim;
private int _idx;
public BountyReportMurdererGump(PlayerMobile victim, List<Mobile> 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,
$"<BIG>Would you like to report {_killers[_idx].Name} as a murderer?</BIG>");
var bountyMax = Banker.GetBalance(_victim);
if (_killers[_idx].Kills >= 4 && bountyMax > 0)
{
builder.AddHtml(325, 325, 300, 60, $"<BIG>Optional Bounty: [{bountyMax} max] </BIG>");
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));
}
}
}

View file

@ -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;

View file

@ -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<MurderContext> _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;

View file

@ -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<ReportMurdererGump>
{
// Recently reported
private static TimeSpan _recentlyReportedDelay;
private static readonly HashSet<(Mobile, Mobile)> _recentlyReported = new();
private readonly List<Mobile> _killers;
private int _idx;
@ -24,11 +19,6 @@ public class ReportMurdererGump : StaticGump<ReportMurdererGump>
_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<ReportMurdererGump>
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<Mobile>();
killers.Add(ai.Attacker);
}
ai.Reported = true;
ai.CanReportMurder = false;
killers ??= new List<Mobile>();
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<ReportMurdererGump>
{
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<ReportMurdererGump>
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));
}
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)
{

View file

@ -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<char> 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<byte> buffer, bool pad = false)
internal static void WriteString(this ref SpanWriter writer, string text, Span<byte> 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<char> text, Span<byte> buffer, bool pad = false)
{
var tail = pad ? 2 : 1;
var length = Math.Min(pad ? 253 : 254, text.GetBytesUtf8(buffer));

View file

@ -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);

View file

@ -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": [
""
]
}
]
}

View file

@ -0,0 +1,4 @@
{
"version": 0,
"type": "Server.Items.BountyBoard"
}

View file

@ -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": [
""
]
}
]
}

View file

@ -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)); // 500661500669: silly guard responses
}
else
{
Say(500654 + Utility.Random(7)); // 500654500660: normal guard responses
}
}
[AfterDeserialization]
private void AfterDeserialization()
{