## Summary Overhauls the fame and karma system to be more era-accurate, based on original design documents and publish notes. ### Karma on player kill → karma on murder report - Removes karma gain/loss on player kill (was immediate on death) - Karma is now set to `Kills * -1000` on murder **report** instead - Fame on player kill now uses the same formula as monster kills (`Fame / 100`) This karma loss on murder report behaviour was tested on both the demo and live servers, behaviour was matching in terms of karma loss on report. Official UO servers karma loss AMOUNT match with my memory of T2A/UOR with one caveat - it's doubled on live servers (-2000 * kill count). I'm not sure when this changed and this behaviour has always had very poor and incorrect documentation, even 10-20 years ago. I was obsessed with the dread lord title on OSI and the only way I knew how to get it was reach 10 kills then macro them off. Even in publish 16 (when "The Murderer" title was removed) it still required 10 kills. Maybe it changed to -2000*Kills in AOS - that's where I've put the era gating diff. ### Era gates - **Karma lock** (ankh toggle + auto-lock on negative karma) gated to `Core.UOTD && !Core.AOS` — [didn't exist before Jan 28, 2001](https://web.archive.org/web/20010128092700/http://update.uo.com/design_300.html) - **Felucca fame/karma +30% bonus** gated to `Core.LBR` — [added in Publish 16, July 2002](https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2002-2/publish-16-part-2-5-23rd-july/) - **Fame/karma splitting** among damage dealers gated to `Core.UOR` — pre-UOR awards go to last hit only ### Skill karma penalties - **Provocation** on innocent NPC: NPC says cliloc 501591, karma loss (floor -7500) - **Stealing** attempt: karma loss on every attempt (floor -5000). Stealing did not cause karma loss at all before! - **Summon Daemon**: karma loss on successful cast (floor -7000) - **Corpse carving** (human): -70 for innocent corpses (floor -7000), -20 for freely-aggressable (floor -2000) - **Bounty head turn-in**: karma gain capped at 2000 (was awarding flat +2000) ### Beneficial action karma - **Beneficial spells** (heal, cure, etc.): `AwardKarma(caster, target.Karma / 5)` — healing good targets raises karma, healing evil targets lowers it - source is UO98 demo scripts - **Bandages**: same formula but gain only (skipped if target karma ≤ 0) - see stratics link ("only ever gain karma, not lose it") Sources: UO98 Demo scripts and playing, [Fame and Karma wiki](https://uo.com/wiki/ultima-online-wiki/player/fame-and-karma/), [UO design doc (Jan 2001)](https://web.archive.org/web/20010128092700/http://update.uo.com/design_300.html), [Publish 16](https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2002-2/publish-16-part-2-5-23rd-july/), [Stratics healing reference](https://web.archive.org/web/20001209014200fw_/http://uo.stratics.com/heal.shtml) ### Refactoring - Extracted `Titles.ComputeKillAwards(killed, map)` shared by player kill and creature kill paths - Extracted `Titles.SetKarma(m, value, message)` for direct karma assignment (used by murder report) - Extracted `SendKarmaMessage` and `CheckKarmaLock` helpers from `AwardKarma`
436 lines
13 KiB
C#
436 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Runtime.InteropServices;
|
|
using ModernUO.CodeGeneratedEvents;
|
|
using Server.Collections;
|
|
using Server.Logging;
|
|
using Server.Misc;
|
|
using Server.Mobiles;
|
|
using Server.SkillHandlers;
|
|
|
|
namespace Server.Engines.PlayerMurderSystem;
|
|
|
|
public class PlayerMurderSystem : GenericPersistence
|
|
{
|
|
private static PlayerMurderSystem _playerMurderPersistence;
|
|
|
|
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 HashSet<(Mobile, Mobile)> _recentlyReported = new();
|
|
private static TimeSpan _recentlyReportedDelay;
|
|
|
|
private static TimeSpan _shortTermMurderDuration;
|
|
|
|
private static TimeSpan _longTermMurderDuration;
|
|
|
|
public static TimeSpan ShortTermMurderDuration => _shortTermMurderDuration;
|
|
|
|
public static TimeSpan LongTermMurderDuration => _longTermMurderDuration;
|
|
|
|
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();
|
|
}
|
|
|
|
public static void Initialize()
|
|
{
|
|
EventSink.Disconnected += OnDisconnected;
|
|
}
|
|
|
|
[OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))]
|
|
public static void OnPlayerDeleted(Mobile m)
|
|
{
|
|
if (m is PlayerMobile pm && _murderContexts.Remove(pm, out var context))
|
|
{
|
|
_contextTerms.Remove(context);
|
|
}
|
|
}
|
|
|
|
public PlayerMurderSystem() : base("PlayerMurders", 10)
|
|
{
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
var context = GetOrCreateMurderContext(player);
|
|
|
|
// 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);
|
|
}
|
|
|
|
[OnEvent(nameof(PlayerMobile.PlayerLoginEvent))]
|
|
public static void OnLogin(PlayerMobile pm)
|
|
{
|
|
if (!GetMurderContext(pm, out var context))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (context.CheckStart())
|
|
{
|
|
_contextTerms.Add(context);
|
|
}
|
|
else
|
|
{
|
|
_contextTerms.Remove(context);
|
|
if (context.CanRemove())
|
|
{
|
|
_murderContexts.Remove(pm);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void OnDisconnected(Mobile m)
|
|
{
|
|
if (m is not PlayerMobile pm || !_murderContexts.TryGetValue(pm, out var context))
|
|
{
|
|
return;
|
|
}
|
|
|
|
context.DecayKills();
|
|
_contextTerms.Remove(context);
|
|
|
|
if (context.CanRemove())
|
|
{
|
|
_murderContexts.Remove(pm);
|
|
}
|
|
}
|
|
|
|
public override 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);
|
|
}
|
|
}
|
|
|
|
public override 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 GetMurderContext(PlayerMobile player, out MurderContext context)
|
|
{
|
|
if (player != null && _murderContexts.TryGetValue(player, out context))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
context = null;
|
|
return false;
|
|
}
|
|
|
|
public static MurderContext GetOrCreateMurderContext(PlayerMobile player)
|
|
{
|
|
if (player == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
ref var context = ref CollectionsMarshal.GetValueRefOrAddDefault(_murderContexts, player, out var exists);
|
|
if (!exists)
|
|
{
|
|
context = new MurderContext(player);
|
|
}
|
|
|
|
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);
|
|
context.PingPongs = Math.Max(pingPong, 0);
|
|
UpdateMurderContext(context);
|
|
}
|
|
|
|
public static void ManuallySetShortTermMurders(PlayerMobile player, int shortTermMurders)
|
|
{
|
|
var context = GetOrCreateMurderContext(player);
|
|
context.ShortTermMurders = shortTermMurders;
|
|
|
|
context.ResetKillTime();
|
|
UpdateMurderContext(context);
|
|
}
|
|
|
|
public static void OnPlayerMurder(PlayerMobile player)
|
|
{
|
|
var context = GetOrCreateMurderContext(player);
|
|
context.ShortTermMurders++;
|
|
player.Kills++;
|
|
|
|
if (PingPongEnabled && player.Kills == 5)
|
|
{
|
|
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);
|
|
|
|
Titles.SetKarma(pk, pk.Kills * (Core.AOS ? -2000 : -1000), true);
|
|
|
|
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;
|
|
|
|
if (!context.CheckStart())
|
|
{
|
|
if (context.CanRemove())
|
|
{
|
|
_murderContexts.Remove(player);
|
|
}
|
|
_contextTerms.Remove(context);
|
|
}
|
|
else if (player.NetState != null)
|
|
{
|
|
_contextTerms.Add(context);
|
|
}
|
|
}
|
|
|
|
public static void ReportKillsToSelf(PlayerMobile player)
|
|
{
|
|
if (Core.SA)
|
|
{
|
|
player.SendLocalizedMessage(1114370, $"{player.ShortTermMurders}\t{player.Kills}");
|
|
}
|
|
else if (Core.SE)
|
|
{
|
|
player.SendMessage($"Short Term Murders: {player.ShortTermMurders} Long Term Murders: {player.Kills}");
|
|
}
|
|
else if (Core.AOS)
|
|
{
|
|
player.SendMessage($"Short Term Murders: {player.ShortTermMurders}");
|
|
player.SendMessage($"Long Term Murders: {player.Kills}");
|
|
if (PingPongEnabled)
|
|
{
|
|
player.SendMessage($"Ping Pongs: {player.PingPongs}");
|
|
}
|
|
}
|
|
else if (!Core.T2A)
|
|
{
|
|
// No "i must consider my sins" before t2a
|
|
}
|
|
else if (PingPongEnabled && player.PingPongs >= 5)
|
|
{
|
|
// Thou art known throughout the land as a murderous brigand.
|
|
player.SendLocalizedMessage(502123, "", player.ShortTermMurders >= 5 ? 0x22 : 0x59);
|
|
}
|
|
else if (player.ShortTermMurders >= 5)
|
|
{
|
|
// If thou should return to the land of the living, the innocent shall wreak havoc upon thy soul
|
|
player.SendLocalizedMessage(502126, "", 0x22);
|
|
}
|
|
else if (player.ShortTermMurders > 0)
|
|
{
|
|
// Although thou hast slain the innocent, thy deeds shall not bring retribution upon thy return to the living
|
|
player.SendLocalizedMessage(502125, "", 0x59);
|
|
}
|
|
else if (player.Kills > 0)
|
|
{
|
|
// Fear not, thou hast not slain the innocent in some time...
|
|
player.SendLocalizedMessage(502124, "", 0x59);
|
|
}
|
|
else // no kills
|
|
{
|
|
// Fear not, thou hast not slain the innocent.
|
|
player.SendLocalizedMessage(502122, "", 0x59);
|
|
}
|
|
}
|
|
|
|
private class MurdererTimer : Timer
|
|
{
|
|
public MurdererTimer() : base(TimeSpan.FromMinutes(5.0), TimeSpan.FromMinutes(5.0))
|
|
{
|
|
}
|
|
|
|
public static void Initialize()
|
|
{
|
|
new MurdererTimer().Start();
|
|
}
|
|
|
|
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)
|
|
{
|
|
var pm = (PlayerMobile)queue.Dequeue();
|
|
if (_murderContexts.TryGetValue(pm, out var ctx))
|
|
{
|
|
if (ctx.CanRemove())
|
|
{
|
|
_murderContexts.Remove(pm);
|
|
}
|
|
_contextTerms.Remove(ctx);
|
|
}
|
|
}
|
|
}
|
|
|
|
~MurdererTimer()
|
|
{
|
|
PlayerMurderSystem.logger.Error($"{nameof(MurdererTimer)} is no longer running!");
|
|
}
|
|
}
|
|
}
|