fix: Overhauls murder system (#1419)

## MAJOR CHANGE (API BREAKING)

Added a player murder system to facilitate reporting murders. This should make it easier to extend to create a bounty system or  other related game content. Player murders will be saved in a folder called _PlayerMurders_.

### Motivation

The motivation was two-fold, performance, and bug fixes.

First, murders are one of two systems that do a pre-world-save check on _every mobile in the game_ to decay kills and set their expiring murders. This is taxing since it freezes the world and makes world saves take longer. Every mobile has ShortTermMurders even though it is a player concept. And next, 90%+ of players are not murderers but had an ever increasing MurderElapse time that was being tracked against GameTime. These properties were also serialized unnecessarily for all mobs.

Second, when I tried to optimize/refactor the code, it was obvious that the system has bugs.

### Major API Changes
- [X] Created a player murder system and moved `ShortTermMurders`, `ShortTermElapse`, and `LongTermElapse` to the system.
- [X] Added convenience property `PlayerMobile.ShortTermMurders`.
- [X] Added convenience properties `PlayerMobile.ShortTermMurderExpiration` and `PlayerMobile.LongTermMurderExpiration`
- [X] Moved ReportMurdererGump.cs
- [X] Adds an `EventSink.PlayerDeleted` event.

### Notes
The system currently does not support NPCs. To support expiring murders on NPCs I highly recommend a different architecture for large servers (500k+ mobs including players). Specifically switching from looping through all MurderContext to a time-order link list.
This commit is contained in:
Kamron Batman 2023-07-15 22:42:49 -07:00 committed by GitHub
parent d65e5bb37b
commit 26f784f45d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 650 additions and 335 deletions

View file

@ -109,6 +109,9 @@ public static partial class EventSink
public static event Action<NetState, int> DeleteRequest; public static event Action<NetState, int> DeleteRequest;
public static void InvokeDeleteRequest(NetState state, int index) => DeleteRequest?.Invoke(state, index); public static void InvokeDeleteRequest(NetState state, int index) => DeleteRequest?.Invoke(state, index);
public static event Action<Mobile> PlayerDeleted;
public static void InvokePlayerDeleted(Mobile m) => PlayerDeleted?.Invoke(m);
public static event Action ServerStarted; public static event Action ServerStarted;
public static void InvokeServerStarted() => ServerStarted?.Invoke(); public static void InvokeServerStarted() => ServerStarted?.Invoke();

View file

@ -15,4 +15,16 @@ public partial class Mobile
StableMigrations[m] = stabled; StableMigrations[m] = stabled;
} }
} }
// Migrating murders to the murder system
public static Dictionary<Mobile, int> MurderMigrations { get; private set; }
public static void AddToMurderMigrations(Mobile m, int shortTermMurders)
{
if (shortTermMurders > 0)
{
MurderMigrations ??= new Dictionary<Mobile, int>();
MurderMigrations[m] = shortTermMurders;
}
}
} }

View file

@ -281,7 +281,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
private int m_Hunger; private int m_Hunger;
private bool m_InDeltaQueue; private bool m_InDeltaQueue;
private int m_Kills, m_ShortTermMurders; private int m_Kills;
private string m_Language; private string m_Language;
private int m_LightLevel; private int m_LightLevel;
private Point3D m_Location; private Point3D m_Location;
@ -1626,19 +1626,6 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
} }
} }
[CommandProperty(AccessLevel.GameMaster)]
public int ShortTermMurders
{
get => m_ShortTermMurders;
set
{
if (m_ShortTermMurders != value)
{
m_ShortTermMurders = Math.Max(value, 0);
}
}
}
[CommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] [CommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)]
public virtual bool Criminal public virtual bool Criminal
{ {
@ -2277,7 +2264,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public virtual void Serialize(IGenericWriter writer) public virtual void Serialize(IGenericWriter writer)
{ {
writer.Write(34); // version writer.Write(35); // version
writer.WriteDeltaTime(LastStrGain); writer.WriteDeltaTime(LastStrGain);
writer.WriteDeltaTime(LastIntGain); writer.WriteDeltaTime(LastIntGain);
@ -2313,18 +2300,6 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
writer.Write(Corpse); writer.Write(Corpse);
// writer.Write(CreationTime);
// if (Stabled == null)
// {
// writer.Write(0);
// }
// else
// {
// Stabled.Tidy();
// writer.Write(Stabled);
// }
writer.Write(CantWalk); writer.Write(CantWalk);
VirtueInfo.Serialize(writer, Virtues); VirtueInfo.Serialize(writer, Virtues);
@ -2332,11 +2307,6 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
writer.Write(Thirst); writer.Write(Thirst);
writer.Write(BAC); writer.Write(BAC);
writer.Write(m_ShortTermMurders);
// writer.Write( m_ShortTermElapse );
// writer.Write( m_LongTermElapse );
// writer.Write( m_Followers );
writer.Write(m_FollowersMax); writer.Write(m_FollowersMax);
writer.Write(MagicDamageAbsorb); writer.Write(MagicDamageAbsorb);
@ -6074,21 +6044,10 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
switch (version) switch (version)
{ {
case 34: case 35: // Moved short term murders to PlayerMurderSystem
{ case 34: // Moved Stabled to PlayerMobile
// Moved Stabled to PlayerMobile case 33: // Removed created
goto case 33; case 32: // Removed StuckMenu
}
case 33:
{
// Removed created
goto case 32;
}
case 32:
{
// Removed StuckMenu
goto case 31;
}
case 31: case 31:
{ {
LastStrGain = reader.ReadDeltaTime(); LastStrGain = reader.ReadDeltaTime();
@ -6183,7 +6142,11 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
} }
case 16: case 16:
{ {
m_ShortTermMurders = reader.ReadInt(); if (version < 35)
{
// Migrated to PlayerMurderSystem
AddToMurderMigrations(this, reader.ReadInt());
}
if (version <= 24) if (version <= 24)
{ {

View file

@ -255,6 +255,8 @@ public static class AccountHandler
acct.Comments.Add(new AccountComment("System", $"Character #{index + 1} {m} deleted by {state}")); acct.Comments.Add(new AccountComment("System", $"Character #{index + 1} {m} deleted by {state}"));
m.Delete(); m.Delete();
EventSink.InvokePlayerDeleted(m);
state.SendCharacterListUpdate(acct); state.SendCharacterListUpdate(acct);
return; return;
} }

View file

@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using ModernUO.Serialization;
using Server.Mobiles;
namespace Server.Engines.PlayerMurderSystem;
[SerializationGenerator(0)]
public partial class MurderContext
{
[SerializableField(0)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private TimeSpan _shortTermElapse = TimeSpan.MaxValue;
[SerializableField(1)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private TimeSpan _longTermElapse = TimeSpan.MaxValue;
[SerializableProperty(2)]
[CommandProperty(AccessLevel.GameMaster)]
public int ShortTermMurders
{
get => _shortTermMurders;
set => _shortTermMurders = Math.Max(value, 0);
}
[DirtyTrackingEntity]
public PlayerMobile _player;
public PlayerMobile Player => _player;
// Wall clock time for next short or long term expiration
internal DateTime _nextElapse;
public MurderContext(PlayerMobile player) => _player = player;
public void ResetKillTime(bool isShort = true, bool isLong = true)
{
var gameTime = _player.GameTime;
if (isShort)
{
ShortTermElapse = gameTime + PlayerMurderSystem.ShortTermMurderDuration;
}
if (isLong)
{
LongTermElapse = gameTime + PlayerMurderSystem.LongTermMurderDuration;
}
}
public void DecayKills()
{
var gameTime = _player.GameTime;
if (ShortTermElapse < gameTime)
{
ShortTermElapse += PlayerMurderSystem.ShortTermMurderDuration;
if (ShortTermMurders > 0)
{
--ShortTermMurders;
}
}
if (LongTermElapse < gameTime)
{
LongTermElapse += PlayerMurderSystem.LongTermMurderDuration;
if (_player.Kills > 0)
{
--_player.Kills;
}
}
}
public bool CheckStart()
{
_nextElapse = DateTime.MaxValue;
var now = Core.Now;
var gameTime = _player.GameTime;
if (ShortTermMurders > 0)
{
_nextElapse = now + (ShortTermElapse - gameTime);
}
if (_player.Kills > 0)
{
var timeUntilLong = now + (LongTermElapse - gameTime);
if (_nextElapse > timeUntilLong)
{
_nextElapse = timeUntilLong;
}
}
return _nextElapse != DateTime.MaxValue;
}
public class EqualityComparer : IEqualityComparer<MurderContext>
{
public static EqualityComparer Default { get; } = new ();
public bool Equals(MurderContext x, MurderContext y) => x?._player == y?._player;
public int GetHashCode(MurderContext context) => context._player?.GetHashCode() ?? 0;
}
}

View file

@ -0,0 +1,222 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using Server.Collections;
using Server.Logging;
using Server.Mobiles;
namespace Server.Engines.PlayerMurderSystem;
public static class PlayerMurderSystem
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(PlayerMurderSystem));
// All of the players with murders
private static readonly Dictionary<PlayerMobile, MurderContext> _murderContexts = new();
// Only the players that are online
private static readonly HashSet<MurderContext> _contextTerms = new(MurderContext.EqualityComparer.Default);
private static readonly Timer _murdererTimer = new MurdererTimer();
private static TimeSpan _shortTermMurderDuration;
private static TimeSpan _longTermMurderDuration;
public static TimeSpan ShortTermMurderDuration => _shortTermMurderDuration;
public static TimeSpan LongTermMurderDuration => _longTermMurderDuration;
public static void Configure()
{
GenericPersistence.Register("PlayerMurders", Serialize, Deserialize);
_shortTermMurderDuration = ServerConfiguration.GetOrUpdateSetting("murderSystem.shortTermMurderDuration", TimeSpan.FromHours(8));
_longTermMurderDuration = ServerConfiguration.GetOrUpdateSetting("murderSystem.longTermMurderDuration", TimeSpan.FromHours(40));
}
public static void Initialize()
{
EventSink.Disconnected += OnDisconnected;
EventSink.Login += OnLogin;
EventSink.PlayerDeleted += OnPlayerDeleted;
_murdererTimer.Start();
}
private static void OnPlayerDeleted(Mobile m)
{
if (m is PlayerMobile pm && _murderContexts.Remove(pm, out var context))
{
_contextTerms.Remove(context);
}
}
// Only used for migrations!
public static void MigrateContext(PlayerMobile player, TimeSpan shortTerm, TimeSpan longTerm)
{
if (!World.Loading)
{
logger.Error(
$"Attempted to call MigrateContext outside of world loading.{Environment.NewLine}{{StackTrace}}",
new StackTrace()
);
return;
}
if (GetOrCreateContext(player, out var context))
{
// We make a big assumption that by the time this is called, the Mobile/PlayerMobile info is deserialized
if (Mobile.MurderMigrations?.TryGetValue(player, out var shortTermMurders) == true)
{
context.ShortTermMurders = shortTermMurders;
}
context.ShortTermElapse = shortTerm;
context.LongTermElapse = longTerm;
UpdateMurderContext(context);
}
}
private static void OnLogin(Mobile m)
{
if (m is not PlayerMobile pm || !GetContext(pm, out var context))
{
return;
}
if (context.CheckStart())
{
_contextTerms.Add(context);
}
else
{
_murderContexts.Remove(pm);
_contextTerms.Remove(context);
}
}
private static void OnDisconnected(Mobile m)
{
if (m is PlayerMobile pm && _murderContexts.Remove(pm, out var context))
{
_contextTerms.Remove(context);
}
}
private static void Deserialize(IGenericReader reader)
{
var version = reader.ReadEncodedInt();
var count = reader.ReadEncodedInt();
for (var i = 0; i < count; ++i)
{
var context = new MurderContext(reader.ReadEntity<PlayerMobile>());
context.Deserialize(reader);
_murderContexts.Add(context.Player, context);
}
}
private static void Serialize(IGenericWriter writer)
{
writer.WriteEncodedInt(0); // version
writer.WriteEncodedInt(_murderContexts.Count);
foreach (var (m, context) in _murderContexts)
{
writer.Write(m);
context.Serialize(writer);
}
}
public static bool GetContext(PlayerMobile player, out MurderContext context) =>
_murderContexts.TryGetValue(player, out context);
public static bool GetOrCreateContext(PlayerMobile player, out MurderContext context)
{
if (!_murderContexts.TryGetValue(player, out context))
{
context = _murderContexts[player] = new MurderContext(player);
}
return true;
}
public static void ManuallySetShortTermMurders(PlayerMobile player, int shortTermMurders, bool resetKillTime = true)
{
if (GetOrCreateContext(player, out var context))
{
context.ShortTermMurders = shortTermMurders;
UpdateMurderContext(context, resetKillTime);
}
}
public static void OnPlayerMurder(PlayerMobile player, bool resetKillTime = false)
{
if (GetOrCreateContext(player, out var context))
{
context.ShortTermMurders++;
player.Kills++;
UpdateMurderContext(context, resetKillTime);
}
}
private static void UpdateMurderContext(MurderContext context, bool resetKillTime = false)
{
var player = context.Player;
// Either we are resetting their decay time, or they got their first kill
context.ResetKillTime(
context.ShortTermMurders > 0 && (!resetKillTime || context.ShortTermElapse == TimeSpan.MaxValue),
player.Kills > 0 && (!resetKillTime || context.LongTermElapse == TimeSpan.MaxValue)
);
if (context.CheckStart())
{
if (player.NetState != null)
{
_contextTerms.Add(context);
}
}
else
{
_murderContexts.Remove(player);
_contextTerms.Remove(context);
}
}
private class MurdererTimer : Timer
{
public MurdererTimer() : base(TimeSpan.FromMinutes(5.0), TimeSpan.FromMinutes(5.0))
{
}
protected override void OnTick()
{
if (_contextTerms.Count == 0)
{
return;
}
using var queue = PooledRefQueue<Mobile>.Create();
foreach (var context in _contextTerms)
{
context.DecayKills();
if (!context.CheckStart())
{
queue.Enqueue(context.Player);
}
}
while (queue.Count > 0)
{
if (_murderContexts.Remove((PlayerMobile)queue.Dequeue(), out var context))
{
_contextTerms.Remove(context);
}
}
}
}
}

View file

@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using Server.Gumps;
using Server.Misc;
using Server.Mobiles;
using Server.Network;
using Server.SkillHandlers;
namespace Server.Engines.PlayerMurderSystem;
public class ReportMurdererGump : Gump
{
// Recently reported
private static TimeSpan _recentlyReportedDelay;
private static readonly HashSet<(Mobile, Mobile)> _recentlyReported = new();
private readonly List<Mobile> _killers;
private int _idx;
private ReportMurdererGump(List<Mobile> killers, int idx = 0) : base(0, 0)
{
_killers = killers;
_idx = idx;
BuildGump();
}
public static void Initialize()
{
_recentlyReportedDelay = ServerConfiguration.GetOrUpdateSetting("murderSystem.recentlyReportedDelay", TimeSpan.FromMinutes(10));
EventSink.PlayerDeath += OnPlayerDeath;
}
public static void OnPlayerDeath(Mobile m)
{
List<Mobile> killers = null;
HashSet<Mobile> toGive = null;
// Guards won't take reports of the death of a thief!
bool notInThievesGuild = m is not PlayerMobile { NpcGuild: NpcGuild.ThievesGuild };
foreach (var ai in m.Aggressors)
{
if (ai.Attacker.Player && ai.CanReportMurder && !ai.Reported)
{
if (!Core.SE || !_recentlyReported.Contains((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();
}
}
private void BuildGump()
{
AddBackground(265, 205, 320, 290, 5054);
Closable = false;
Resizable = false;
AddPage(0);
AddImageTiled(225, 175, 50, 45, 0xCE); // Top left corner
AddImageTiled(267, 175, 315, 44, 0xC9); // Top bar
AddImageTiled(582, 175, 43, 45, 0xCF); // Top right corner
AddImageTiled(225, 219, 44, 270, 0xCA); // Left side
AddImageTiled(582, 219, 44, 270, 0xCB); // Right side
AddImageTiled(225, 489, 44, 43, 0xCC); // Lower left corner
AddImageTiled(267, 489, 315, 43, 0xE9); // Lower Bar
AddImageTiled(582, 489, 43, 43, 0xCD); // Lower right corner
AddPage(1);
AddHtml(260, 234, 300, 140, _killers[_idx].Name); // Player's Name
AddHtmlLocalized(260, 254, 300, 140, 1049066); // Would you like to report...
AddButton(260, 300, 0xFA5, 0xFA7, 1);
AddHtmlLocalized(300, 300, 300, 50, 1046362); // Yes
AddButton(360, 300, 0xFA5, 0xFA7, 2);
AddHtmlLocalized(400, 300, 300, 50, 1046363); // No
}
public override void OnResponse(NetState state, RelayInfo info)
{
var from = (PlayerMobile)state.Mobile;
switch (info.ButtonID)
{
case 1:
{
var killer = _killers[_idx];
if (killer?.Deleted == false)
{
if (Core.SE)
{
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
PlayerMurderSystem.OnPlayerMurder(pk, true);
pk.SendLocalizedMessage(1049067); // You have been reported for murder!
if (pk.Kills == 5)
{
pk.SendLocalizedMessage(502134); // You are now known as a murderer!
}
else if (Stealing.SuspendOnMurder && pk.Kills == 1 && pk.NpcGuild == NpcGuild.ThievesGuild)
{
pk.SendLocalizedMessage(501562); // You have been suspended by the Thieves Guild.
}
}
}
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()
{
_victim.SendGump(new ReportMurdererGump(_killers));
}
}
}

View file

@ -1,200 +0,0 @@
using System;
using System.Collections.Generic;
using Server.Misc;
using Server.Mobiles;
using Server.Network;
using Server.SkillHandlers;
namespace Server.Gumps
{
public class ReportMurdererGump : Gump
{
private readonly List<Mobile> m_Killers;
private int m_Idx;
private ReportMurdererGump(List<Mobile> killers, int idx = 0) : base(0, 0)
{
m_Killers = killers;
m_Idx = idx;
BuildGump();
}
public static void Initialize()
{
EventSink.PlayerDeath += EventSink_PlayerDeath;
}
public static void EventSink_PlayerDeath(Mobile m)
{
var killers = new List<Mobile>();
var toGive = new List<Mobile>();
foreach (var ai in m.Aggressors)
{
if (ai.Attacker.Player && ai.CanReportMurder && !ai.Reported)
{
if (!Core.SE || !((PlayerMobile)m).RecentlyReported.Contains(ai.Attacker))
{
killers.Add(ai.Attacker);
ai.Reported = true;
ai.CanReportMurder = false;
}
}
if (ai.Attacker.Player && Core.Now - ai.LastCombatTime < TimeSpan.FromSeconds(30.0) &&
!toGive.Contains(ai.Attacker))
{
toGive.Add(ai.Attacker);
}
}
foreach (var ai in m.Aggressed)
{
if (ai.Defender.Player && Core.Now - ai.LastCombatTime < TimeSpan.FromSeconds(30.0) &&
!toGive.Contains(ai.Defender))
{
toGive.Add(ai.Defender);
}
}
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 (m is PlayerMobile mobile && mobile.NpcGuild == NpcGuild.ThievesGuild)
{
return;
}
if (killers.Count > 0)
{
new GumpTimer(m, killers).Start();
}
}
private void BuildGump()
{
AddBackground(265, 205, 320, 290, 5054);
Closable = false;
Resizable = false;
AddPage(0);
AddImageTiled(225, 175, 50, 45, 0xCE); // Top left corner
AddImageTiled(267, 175, 315, 44, 0xC9); // Top bar
AddImageTiled(582, 175, 43, 45, 0xCF); // Top right corner
AddImageTiled(225, 219, 44, 270, 0xCA); // Left side
AddImageTiled(582, 219, 44, 270, 0xCB); // Right side
AddImageTiled(225, 489, 44, 43, 0xCC); // Lower left corner
AddImageTiled(267, 489, 315, 43, 0xE9); // Lower Bar
AddImageTiled(582, 489, 43, 43, 0xCD); // Lower right corner
AddPage(1);
AddHtml(260, 234, 300, 140, m_Killers[m_Idx].Name); // Player's Name
AddHtmlLocalized(260, 254, 300, 140, 1049066); // Would you like to report...
AddButton(260, 300, 0xFA5, 0xFA7, 1);
AddHtmlLocalized(300, 300, 300, 50, 1046362); // Yes
AddButton(360, 300, 0xFA5, 0xFA7, 2);
AddHtmlLocalized(400, 300, 300, 50, 1046363); // No
}
public static void ReportedListExpiry_Callback(PlayerMobile from, Mobile killer)
{
if (from.RecentlyReported.Contains(killer))
{
from.RecentlyReported.Remove(killer);
}
}
public override void OnResponse(NetState state, RelayInfo info)
{
var from = (PlayerMobile)state.Mobile;
switch (info.ButtonID)
{
case 1:
{
var killer = m_Killers[m_Idx];
if (killer?.Deleted == false)
{
killer.Kills++;
killer.ShortTermMurders++;
if (Core.SE)
{
from.RecentlyReported.Add(killer);
Timer.StartTimer(TimeSpan.FromMinutes(10), () => ReportedListExpiry_Callback(from, killer));
}
if (killer is PlayerMobile pk)
{
pk.ResetKillTime();
pk.SendLocalizedMessage(1049067); // You have been reported for murder!
if (pk.Kills == 5)
{
pk.SendLocalizedMessage(502134); // You are now known as a murderer!
}
else if (Stealing.SuspendOnMurder && pk.Kills == 1 && pk.NpcGuild == NpcGuild.ThievesGuild)
{
pk.SendLocalizedMessage(501562); // You have been suspended by the Thieves Guild.
}
}
}
break;
}
case 2:
{
break;
}
}
m_Idx++;
if (m_Idx < m_Killers.Count)
{
from.SendGump(new ReportMurdererGump( m_Killers, m_Idx));
}
}
private class GumpTimer : Timer
{
private readonly List<Mobile> m_Killers;
private readonly Mobile m_Victim;
public GumpTimer(Mobile victim, List<Mobile> killers) : base(TimeSpan.FromSeconds(4.0))
{
m_Victim = victim;
m_Killers = killers;
}
protected override void OnTick()
{
m_Victim.SendGump(new ReportMurdererGump(m_Killers));
}
}
}
}

View file

@ -95,23 +95,11 @@ namespace Server.Gumps
AddRadio(30, 175, 9727, 9730, false, 0); AddRadio(30, 175, 9727, 9730, false, 0);
AddHtmlLocalized(65, 178, 300, 25, 1060016, 0x7FFF); // I'd rather stay dead, you scoundrel!!! AddHtmlLocalized(65, 178, 300, 25, 1060016, 0x7FFF); // I'd rather stay dead, you scoundrel!!!
AddHtmlLocalized( // Wishing to rejoin the living, are you? I can restore your body... for a price of course...
30, AddHtmlLocalized(30, 20, 360, 35, 1060017, 0x7FFF);
20,
360,
35,
1060017,
0x7FFF
); // Wishing to rejoin the living, are you? I can restore your body... for a price of course...
AddHtmlLocalized( // Do you accept the fee, which will be withdrawn from your bank?
30, AddHtmlLocalized(30, 105, 345, 40, 1060018, 0x5B2D);
105,
345,
40,
1060018,
0x5B2D
); // Do you accept the fee, which will be withdrawn from your bank?
AddImage(65, 72, 5605); AddImage(65, 72, 5605);
@ -156,20 +144,16 @@ namespace Server.Gumps
{ {
if (Banker.Withdraw(from, m_Price)) if (Banker.Withdraw(from, m_Price))
{ {
from.SendLocalizedMessage( // ~1_AMOUNT~ gold has been withdrawn from your bank box.
1060398, from.SendLocalizedMessage(1060398, m_Price.ToString());
m_Price.ToString()
); // ~1_AMOUNT~ gold has been withdrawn from your bank box. // You have ~1_AMOUNT~ gold in cash remaining in your bank box.
from.SendLocalizedMessage( from.SendLocalizedMessage(1060022, Banker.GetBalance(from).ToString());
1060022,
Banker.GetBalance(from).ToString()
); // You have ~1_AMOUNT~ gold in cash remaining in your bank box.
} }
else else
{ {
from.SendLocalizedMessage( // Unfortunately, you do not have enough cash in your bank to cover the cost of the healing.
1060020 from.SendLocalizedMessage(1060020);
); // Unfortunately, you do not have enough cash in your bank to cover the cost of the healing.
return; return;
} }
} }
@ -198,12 +182,14 @@ namespace Server.Gumps
}; };
} }
if (m_FromSacrifice && from is PlayerMobile mobile) var player = from as PlayerMobile;
{
mobile.AvailableResurrects -= 1;
var pack = mobile.Backpack; if (m_FromSacrifice && player != null)
var corpse = mobile.Corpse; {
player.AvailableResurrects -= 1;
var pack = player.Backpack;
var corpse = player.Corpse;
if (pack != null && corpse != null) if (pack != null && corpse != null)
{ {
@ -228,9 +214,9 @@ namespace Server.Gumps
Titles.AwardFame(from, -amount, true); Titles.AwardFame(from, -amount, true);
} }
if (!Core.AOS && from.ShortTermMurders >= 5) if (!Core.AOS && player?.ShortTermMurders >= 5)
{ {
var loss = (100.0 - (4.0 + from.ShortTermMurders / 5.0)) / 100.0; // 5 to 15% loss var loss = (100.0 - (4.0 + player.ShortTermMurders / 5.0)) / 100.0; // 5 to 15% loss
if (loss < 0.85) if (loss < 0.85)
{ {

View file

@ -0,0 +1,24 @@
{
"version": 0,
"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": [
""
]
}
]
}

View file

@ -29,17 +29,17 @@ namespace Server.Misc
} }
case 0x0032: // *i must consider my sins* case 0x0032: // *i must consider my sins*
{ {
if (!Core.SE) if (from is PlayerMobile player)
{ {
from.SendMessage($"Short Term Murders : {from.ShortTermMurders}"); if (!Core.SE)
from.SendMessage($"Long Term Murders : {from.Kills}"); {
} from.SendMessage($"Short Term Murders : {player.ShortTermMurders}");
else from.SendMessage($"Long Term Murders : {from.Kills}");
{ }
from.SendMessage( else
0x3B2, {
$"Short Term Murders: {from.ShortTermMurders} Long Term Murders: {from.Kills}" from.SendLocalizedMessage(1114370, $"{player.ShortTermMurders}\t{from.Kills}");
); }
} }
break; break;

View file

@ -10,6 +10,7 @@ using Server.Engines.Help;
using Server.Engines.MLQuests; using Server.Engines.MLQuests;
using Server.Engines.MLQuests.Gumps; using Server.Engines.MLQuests.Gumps;
using Server.Engines.PartySystem; using Server.Engines.PartySystem;
using Server.Engines.PlayerMurderSystem;
using Server.Engines.Quests; using Server.Engines.Quests;
using Server.Ethics; using Server.Ethics;
using Server.Factions; using Server.Factions;
@ -175,7 +176,6 @@ namespace Server.Mobiles
private DateTime m_LastYoungHeal = DateTime.MinValue; private DateTime m_LastYoungHeal = DateTime.MinValue;
private DateTime m_LastYoungMessage = DateTime.MinValue; private DateTime m_LastYoungMessage = DateTime.MinValue;
private TimeSpan m_LongTermElapse;
private MountBlock _mountBlock; private MountBlock _mountBlock;
@ -191,7 +191,6 @@ namespace Server.Mobiles
private int m_NonAutoreinsuredItems; private int m_NonAutoreinsuredItems;
private DateTime m_SavagePaintExpiration; private DateTime m_SavagePaintExpiration;
private TimeSpan m_ShortTermElapse;
private DateTime[] m_StuckMenuUses; private DateTime[] m_StuckMenuUses;
@ -201,13 +200,10 @@ namespace Server.Mobiles
{ {
VisibilityList = new List<Mobile>(); VisibilityList = new List<Mobile>();
PermaFlags = new List<Mobile>(); PermaFlags = new List<Mobile>();
RecentlyReported = new List<Mobile>();
BOBFilter = new BOBFilter(); BOBFilter = new BOBFilter();
m_GameTime = TimeSpan.Zero; m_GameTime = TimeSpan.Zero;
m_ShortTermElapse = TimeSpan.FromHours(8.0);
m_LongTermElapse = TimeSpan.FromHours(40.0);
JusticeProtectors = new List<Mobile>(); JusticeProtectors = new List<Mobile>();
m_GuildRank = RankDefinition.Lowest; m_GuildRank = RankDefinition.Lowest;
@ -730,6 +726,23 @@ namespace Server.Mobiles
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public ChampionTitleInfo ChampionTitles { get; private set; } public ChampionTitleInfo ChampionTitles { get; private set; }
[CommandProperty(AccessLevel.GameMaster)]
public int ShortTermMurders
{
get => PlayerMurderSystem.GetOrCreateContext(this, out var context) ? context.ShortTermMurders : 0;
set => PlayerMurderSystem.ManuallySetShortTermMurders(this, value);
}
[CommandProperty(AccessLevel.GameMaster)]
public DateTime ShortTermMurderExpiration => PlayerMurderSystem.GetOrCreateContext(this, out var context)
? Core.Now + (context.ShortTermElapse - GameTime)
: DateTime.MinValue;
[CommandProperty(AccessLevel.GameMaster)]
public DateTime LongTermMurderExpiration => PlayerMurderSystem.GetOrCreateContext(this, out var context)
? Core.Now + (context.LongTermElapse - GameTime)
: DateTime.MinValue;
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public int KnownRecipes => m_AcquiredRecipes?.Count ?? 0; public int KnownRecipes => m_AcquiredRecipes?.Count ?? 0;
@ -2885,6 +2898,7 @@ namespace Server.Mobiles
switch (version) switch (version)
{ {
case 31: // Removed Short/Long Term Elapse
case 30: case 30:
{ {
Stabled = reader.ReadEntitySet<Mobile>(true); Stabled = reader.ReadEntitySet<Mobile>(true);
@ -3132,8 +3146,14 @@ namespace Server.Mobiles
} }
case 1: case 1:
{ {
m_LongTermElapse = reader.ReadTimeSpan(); if (version < 31)
m_ShortTermElapse = reader.ReadTimeSpan(); {
var longTermElapse = reader.ReadTimeSpan();
var shortTermElapse = reader.ReadTimeSpan();
PlayerMurderSystem.MigrateContext(this, shortTermElapse, longTermElapse);
}
m_GameTime = reader.ReadTimeSpan(); m_GameTime = reader.ReadTimeSpan();
goto case 0; goto case 0;
} }
@ -3181,7 +3201,6 @@ namespace Server.Mobiles
} }
} }
CheckKillDecay();
CheckAtrophies(); CheckAtrophies();
if (Hidden) // Hiding is the only buff where it has an effect that's serialized. if (Hidden) // Hiding is the only buff where it has an effect that's serialized.
@ -3194,7 +3213,7 @@ namespace Server.Mobiles
{ {
base.Serialize(writer); base.Serialize(writer);
writer.Write(30); // version writer.Write(31); // version
if (Stabled == null) if (Stabled == null)
{ {
@ -3332,19 +3351,16 @@ namespace Server.Mobiles
writer.Write((int)Flags); writer.Write((int)Flags);
writer.Write(m_LongTermElapse);
writer.Write(m_ShortTermElapse);
writer.Write(GameTime); writer.Write(GameTime);
} }
// Do we need to run an after serialize? // Do we need to run an after serialize?
public override bool ShouldExecuteAfterSerialize => ShouldKillDecay() || ShouldAtrophy(); public override bool ShouldExecuteAfterSerialize => ShouldAtrophy();
public override void AfterSerialize() public override void AfterSerialize()
{ {
base.AfterSerialize(); base.AfterSerialize();
CheckKillDecay();
CheckAtrophies(); CheckAtrophies();
} }
@ -3368,35 +3384,6 @@ namespace Server.Mobiles
ChampionTitleInfo.CheckAtrophy(this); ChampionTitleInfo.CheckAtrophy(this);
} }
public bool ShouldKillDecay() => m_ShortTermElapse < GameTime || m_LongTermElapse < GameTime;
public void CheckKillDecay()
{
if (m_ShortTermElapse < GameTime)
{
m_ShortTermElapse += TimeSpan.FromHours(8);
if (ShortTermMurders > 0)
{
--ShortTermMurders;
}
}
if (m_LongTermElapse < GameTime)
{
m_LongTermElapse += TimeSpan.FromHours(40);
if (Kills > 0)
{
--Kills;
}
}
}
public void ResetKillTime()
{
m_ShortTermElapse = GameTime + TimeSpan.FromHours(8);
m_LongTermElapse = GameTime + TimeSpan.FromHours(40);
}
public override bool CanSee(Mobile m) public override bool CanSee(Mobile m)
{ {
if (m is CharacterStatue statue) if (m is CharacterStatue statue)

View file

@ -1,4 +1,4 @@
{ {
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json",
"version": "0.9.7" "version": "0.9.8"
} }