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:
parent
7be8b5f7f6
commit
9f39198fab
16 changed files with 996 additions and 60 deletions
|
|
@ -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('=');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
430
Projects/UOContent/Engines/Player Murder System/BountyMessage.cs
Normal file
430
Projects/UOContent/Engines/Player Murder System/BountyMessage.cs
Normal 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: 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"
|
||||
};
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
48
Projects/UOContent/Migrations/Server.Engines.PlayerMurderSystem.MurderContext.v2.json
generated
Normal file
48
Projects/UOContent/Migrations/Server.Engines.PlayerMurderSystem.MurderContext.v2.json
generated
Normal 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": [
|
||||
""
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
4
Projects/UOContent/Migrations/Server.Items.BountyBoard.v0.json
generated
Normal file
4
Projects/UOContent/Migrations/Server.Items.BountyBoard.v0.json
generated
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"version": 0,
|
||||
"type": "Server.Items.BountyBoard"
|
||||
}
|
||||
32
Projects/UOContent/Migrations/Server.Items.Head.v2.json
generated
Normal file
32
Projects/UOContent/Migrations/Server.Items.Head.v2.json
generated
Normal 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": [
|
||||
""
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue