ModernUO/Projects/UOContent/Engines/Player Murder System/ReportMurdererGump.cs
Jack 9f39198fab
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.
2026-03-22 12:35:16 -07:00

176 lines
5.2 KiB
C#

using System;
using System.Collections.Generic;
using ModernUO.CodeGeneratedEvents;
using Server.Gumps;
using Server.Misc;
using Server.Mobiles;
using Server.Network;
namespace Server.Engines.PlayerMurderSystem;
public class ReportMurdererGump : StaticGump<ReportMurdererGump>
{
private readonly List<Mobile> _killers;
private int _idx;
private ReportMurdererGump(List<Mobile> killers, int idx = 0) : base(0, 0)
{
_killers = killers;
_idx = idx;
}
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
public static void OnPlayerDeathEvent(PlayerMobile m)
{
List<Mobile> killers = null;
HashSet<Mobile> toGive = null;
// Guards won't take reports of the death of a thief!
var notInThievesGuild = m.NpcGuild != NpcGuild.ThievesGuild;
foreach (var ai in m.Aggressors)
{
if (ai.Attacker.Player && ai.CanReportMurder && !ai.Reported && !PlayerMurderSystem.IsRecentlyReported(m, ai.Attacker))
{
if (notInThievesGuild)
{
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))
{
toGive ??= new HashSet<Mobile>();
toGive.Add(ai.Attacker);
}
}
foreach (var ai in m.Aggressed)
{
if (ai.Defender.Player && Core.Now - ai.LastCombatTime < TimeSpan.FromSeconds(30.0))
{
toGive ??= new HashSet<Mobile>();
toGive.Add(ai.Defender);
}
}
if (toGive?.Count > 0)
{
foreach (var g in toGive)
{
var n = Notoriety.Compute(g, m);
var ourKarma = g.Karma;
var innocent = n == Notoriety.Innocent;
var criminal = n is Notoriety.Criminal or Notoriety.Murderer;
var fameAward = m.Fame / 200;
var karmaAward = 0;
if (innocent)
{
karmaAward = ourKarma > -2500 ? -850 : -110 - m.Karma / 100;
}
else if (criminal)
{
karmaAward = 50;
}
Titles.AwardFame(g, fameAward, false);
Titles.AwardKarma(g, karmaAward, true);
}
}
if (notInThievesGuild && killers?.Count > 0)
{
new GumpTimer(m, killers).Start();
}
}
protected override void BuildLayout(ref StaticGumpBuilder builder)
{
builder.SetNoClose();
builder.SetNoResize();
builder.AddBackground(265, 205, 320, 290, 5054);
builder.AddPage();
builder.AddImageTiled(225, 175, 50, 45, 0xCE); // Top left corner
builder.AddImageTiled(267, 175, 315, 44, 0xC9); // Top bar
builder.AddImageTiled(582, 175, 43, 45, 0xCF); // Top right corner
builder.AddImageTiled(225, 219, 44, 270, 0xCA); // Left side
builder.AddImageTiled(582, 219, 44, 270, 0xCB); // Right side
builder.AddImageTiled(225, 489, 44, 43, 0xCC); // Lower left corner
builder.AddImageTiled(267, 489, 315, 43, 0xE9); // Lower Bar
builder.AddImageTiled(582, 489, 43, 43, 0xCD); // Lower right corner
builder.AddPage(1);
builder.AddHtmlPlaceholder(260, 234, 300, 140, "killerName"); // Player's Name
builder.AddHtmlLocalized(260, 254, 300, 140, 1049066); // Would you like to report...
builder.AddButton(260, 300, 0xFA5, 0xFA7, 1);
builder.AddHtmlLocalized(300, 300, 300, 50, 1046362); // Yes
builder.AddButton(360, 300, 0xFA5, 0xFA7, 2);
builder.AddHtmlLocalized(400, 300, 300, 50, 1046363); // No
}
protected override void BuildStrings(ref GumpStringsBuilder builder)
{
builder.SetStringSlot("killerName", _killers[_idx].Name);
}
public override void OnResponse(NetState state, in RelayInfo info)
{
var from = (PlayerMobile)state.Mobile;
switch (info.ButtonID)
{
case 1:
{
PlayerMurderSystem.ReportMurder(from, _killers[_idx]);
break;
}
case 2:
{
break;
}
}
_idx++;
if (_idx < _killers.Count)
{
from.SendGump(new ReportMurdererGump(_killers, _idx));
}
}
private class GumpTimer : Timer
{
private readonly List<Mobile> _killers;
private readonly Mobile _victim;
public GumpTimer(Mobile victim, List<Mobile> killers) : base(TimeSpan.FromSeconds(4.0))
{
_victim = victim;
_killers = killers;
}
protected override void OnTick()
{
if (PlayerMurderSystem.BountiesEnabled && _victim is PlayerMobile pm)
{
pm.SendGump(new BountyReportMurdererGump(pm, _killers));
}
else
{
_victim.SendGump(new ReportMurdererGump(_killers));
}
}
}
}