ModernUO/Projects/UOContent/Mobiles/Guards/BaseGuard.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

336 lines
8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Runtime.CompilerServices;
using ModernUO.Serialization;
using Server.Engines.PlayerMurderSystem;
using Server.Items;
using Server.Misc;
namespace Server.Mobiles;
[SerializationGenerator(0)]
public abstract partial class BaseGuard : Mobile
{
public static bool GuardsInstantKill { get; private set; }
public static void Configure()
{
GuardsInstantKill = ServerConfiguration.GetSetting("guards.instantKill", true);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Spawn(Mobile caller, Mobile target, int amount = 1, bool onlyAdditional = false) =>
Spawn(caller.Region, target, amount, onlyAdditional);
public static void Spawn(Region region, Mobile target, int amount = 1, bool onlyAdditional = false)
{
if (target?.Deleted != false)
{
return;
}
foreach (var g in target.GetMobilesInRange<BaseGuard>(15))
{
if (g.Focus == null) // idling
{
g.Focus = target;
--amount;
}
else if (g.Focus == target && !onlyAdditional)
{
--amount;
}
}
while (amount-- > 0)
{
region.MakeGuard(target);
}
}
public static void TeleportTo(Mobile source, Point3D to)
{
Effects.SendLocationParticles(
EffectItem.Create(source.Location, source.Map, EffectItem.DefaultDuration),
0x3728,
10,
10,
2023
);
source.Location = to;
Effects.SendLocationParticles(
EffectItem.Create(to, source.Map, EffectItem.DefaultDuration),
0x3728,
10,
10,
5023
);
source.PlaySound(0x1FE);
}
private GuardIdleTimer _idleTimer;
private GuardAttackTimer _attackTimer;
public BaseGuard(Mobile target)
{
Title = "the guard";
if (target != null)
{
Location = target.Location;
Map = target.Map;
Effects.SendLocationParticles(
EffectItem.Create(Location, Map, EffectItem.DefaultDuration),
0x3728,
10,
10,
5023
);
Focus = target;
}
}
protected GuardAttackTimer AttackTimer
{
get => _attackTimer;
set
{
_attackTimer?.Stop();
_attackTimer = value;
_attackTimer?.Start();
}
}
protected GuardIdleTimer IdleTimer
{
get => _idleTimer;
set
{
_idleTimer?.Stop();
_idleTimer = value;
_idleTimer?.Start();
}
}
public abstract Mobile Focus { get; set; }
public override void OnAfterDelete()
{
AttackTimer = null;
IdleTimer = null;
base.OnAfterDelete();
}
public override bool OnBeforeDeath()
{
Effects.SendLocationParticles(
EffectItem.Create(Location, Map, EffectItem.DefaultDuration),
0x3728,
10,
10,
2023
);
PlaySound(0x1FE);
Delete();
return false;
}
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()
{
if (Focus != null)
{
AttackTimer = new GuardAttackTimer(this);
}
else
{
IdleTimer = new GuardIdleTimer(this);
}
}
}
public class GuardAvengeTimer : Timer
{
private readonly Mobile _focus;
public GuardAvengeTimer(Mobile focus) : base(TimeSpan.FromSeconds(2.5), TimeSpan.FromSeconds(1.0), 3) =>
_focus = focus;
protected override void OnTick() => BaseGuard.Spawn(_focus, _focus, 1, true);
}
public class GuardIdleTimer : Timer
{
private readonly BaseGuard _owner;
private int m_Stage;
public GuardIdleTimer(BaseGuard owner) : base(TimeSpan.FromSeconds(2.0), TimeSpan.FromSeconds(2.5)) =>
_owner = owner;
protected override void OnTick()
{
if (_owner.Deleted)
{
Stop();
return;
}
if (m_Stage++ % 4 == 0 || !_owner.Move(_owner.Direction))
{
_owner.Direction = (Direction)Utility.Random(8);
}
if (m_Stage > 16)
{
Effects.SendLocationParticles(
EffectItem.Create(_owner.Location, _owner.Map, EffectItem.DefaultDuration),
0x3728,
10,
10,
2023
);
_owner.PlaySound(0x1FE);
if (_owner.Spawner == null)
{
_owner.Delete();
}
else
{
BaseGuard.TeleportTo(_owner, _owner.Spawner.Location);
}
Stop();
}
}
}
public class GuardAttackTimer : Timer
{
private readonly BaseGuard _owner;
public GuardAttackTimer(BaseGuard owner) : base(TimeSpan.FromSeconds(0.25), TimeSpan.FromSeconds(0.1)) =>
_owner = owner;
public void DoOnTick()
{
OnTick();
}
protected override void OnTick()
{
if (_owner.Deleted)
{
Stop();
return;
}
_owner.Criminal = false;
_owner.Kills = 0;
_owner.Stam = _owner.StamMax;
var target = _owner.Focus;
if (target != null && (target.Deleted || !target.Alive || !_owner.CanBeHarmful(target)))
{
_owner.Focus = null;
Stop();
return;
}
if (target != null && _owner.Combatant != target)
{
_owner.Combatant = target;
}
if (target == null)
{
Stop();
}
else if (BaseGuard.GuardsInstantKill)
{
BaseGuard.TeleportTo(_owner, target.Location);
target.BoltEffect(0);
if (target is BaseCreature creature)
{
creature.NoKillAwards = true;
}
target.Damage(target.HitsMax, _owner);
target.Kill(); // just in case, maybe Damage is overridden on some shard
if (target.Corpse != null && !target.Player)
{
target.Corpse.Delete();
}
_owner.Focus = null;
Stop();
}
else
{
_owner.NonLethalAttack(target);
}
}
}