feat: T2A ping-pong mechanic and consider sins behaviour (#2356)
## Summary - Adds `Mobile.Murderer` virtual property and consolidates kill-threshold checks across the codebase - Tracks ping-pong count: how many times a player crosses the 5-kill murderer threshold (T2A/UOR/UOTD only, disabled on LBR+) - Adds `[CommandProperty]` to view a player's ping-pong count via the admin panel - After enough ping-pongs, player is permanently flagged as a murderer regardless of kill count - Accounts for perma-red players with low kills in murderer status transition notifications - Implements era-appropriate "I must consider my sins" speech responses: - **T2A**: contextual cliloc flavor text (502122–502126) - **UOR–AOS**: raw short/long-term murder counts + ping-pong count if applicable - **SE+**: localized stats message (1114370) - Refactors kill-report logic out of `Keywords.cs` into `PlayerMurderSystem.ReportKillsToSelf` ## Testing - [x] Thoroughly tested and self reviewed - [x] Test T2A "I must consider my sins" behaviour over all scenarios. - [x] Test UOR "I must consider my sins" behaviour over all scenarios. - [x] Test that LBR does not have ping pongs enabled (I must consider my sins) - [x] Test serialization cross over from v0 -> v1 increments 1 ping pong if player is already red. ## Notes * Manually setting kills to 5 does not trigger a ping pong, it must go through the actual murder system. This includes if the kills were manually set to 5 and then migrated (as manually setting kills to 5 never adds the player into the murder system - it only happens via ReportMurderer). This is arguably a bug in the existing system, but one that currently only ever happens via staff interaction. * Thieves guild SuspendOnMurder specifically checks for kills > 0. This means a person with 0 shorts but 5 ping pongs (flagged as murderer) can steal. This may be accurate, as according to a forum post this is how it works on UOSA which is the T2A gold standard. * This doesn't implement Pre-T2A behaviour which should be that "I must consider my sins" does nothing at all. The reason I didn't implement it for Pre-T2A is then it 100% have to sit behind a feature flag. I don't mind adding it as a feature flag, just let me know.
This commit is contained in:
parent
0d7b27fe7a
commit
6b6cc10771
7 changed files with 142 additions and 18 deletions
|
|
@ -5,7 +5,7 @@ using Server.Mobiles;
|
|||
|
||||
namespace Server.Engines.PlayerMurderSystem;
|
||||
|
||||
[SerializationGenerator(0)]
|
||||
[SerializationGenerator(1)]
|
||||
public partial class MurderContext
|
||||
{
|
||||
[SerializableField(0)]
|
||||
|
|
@ -24,6 +24,19 @@ public partial class MurderContext
|
|||
set => _shortTermMurders = Math.Max(value, 0);
|
||||
}
|
||||
|
||||
[SerializableField(3)]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private int _pingPong;
|
||||
|
||||
private void MigrateFrom(V0Content content)
|
||||
{
|
||||
_shortTermElapse = content.ShortTermElapse;
|
||||
_longTermElapse = content.LongTermElapse;
|
||||
_shortTermMurders = content.ShortTermMurders;
|
||||
// Players already at >= 5 kills have crossed the threshold at least once
|
||||
_pingPong = _player.Kills >= 5 ? 1 : 0;
|
||||
}
|
||||
|
||||
public PlayerMobile _player;
|
||||
|
||||
public PlayerMobile Player => _player;
|
||||
|
|
@ -65,6 +78,8 @@ public partial class MurderContext
|
|||
}
|
||||
}
|
||||
|
||||
public bool CanRemove() => _pingPong <= 0 && _shortTermMurders <= 0 && _player.Kills <= 0;
|
||||
|
||||
public bool CheckStart()
|
||||
{
|
||||
_nextElapse = DateTime.MaxValue;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ public class PlayerMurderSystem : GenericPersistence
|
|||
|
||||
public static TimeSpan LongTermMurderDuration => _longTermMurderDuration;
|
||||
|
||||
public static bool PingPongEnabled => Core.T2A && !Core.LBR;
|
||||
|
||||
public static void Configure()
|
||||
{
|
||||
_shortTermMurderDuration = ServerConfiguration.GetOrUpdateSetting("murderSystem.shortTermMurderDuration", TimeSpan.FromHours(8));
|
||||
|
|
@ -94,8 +96,11 @@ public class PlayerMurderSystem : GenericPersistence
|
|||
}
|
||||
else
|
||||
{
|
||||
_murderContexts.Remove(pm);
|
||||
_contextTerms.Remove(context);
|
||||
if (context.CanRemove())
|
||||
{
|
||||
_murderContexts.Remove(pm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +114,7 @@ public class PlayerMurderSystem : GenericPersistence
|
|||
context.DecayKills();
|
||||
_contextTerms.Remove(context);
|
||||
|
||||
if (pm.Kills <= 0 && context.ShortTermMurders <= 0)
|
||||
if (context.CanRemove())
|
||||
{
|
||||
_murderContexts.Remove(pm);
|
||||
}
|
||||
|
|
@ -168,6 +173,13 @@ public class PlayerMurderSystem : GenericPersistence
|
|||
return context;
|
||||
}
|
||||
|
||||
public static void ManuallySetPingPong(PlayerMobile player, int pingPong)
|
||||
{
|
||||
var context = GetOrCreateMurderContext(player);
|
||||
context.PingPong = Math.Max(pingPong, 0);
|
||||
UpdateMurderContext(context);
|
||||
}
|
||||
|
||||
public static void ManuallySetShortTermMurders(PlayerMobile player, int shortTermMurders)
|
||||
{
|
||||
var context = GetOrCreateMurderContext(player);
|
||||
|
|
@ -183,6 +195,11 @@ public class PlayerMurderSystem : GenericPersistence
|
|||
context.ShortTermMurders++;
|
||||
player.Kills++;
|
||||
|
||||
if (PingPongEnabled && player.Kills == 5)
|
||||
{
|
||||
context.PingPong++;
|
||||
}
|
||||
|
||||
context.ResetKillTime();
|
||||
UpdateMurderContext(context);
|
||||
}
|
||||
|
|
@ -193,7 +210,10 @@ public class PlayerMurderSystem : GenericPersistence
|
|||
|
||||
if (!context.CheckStart())
|
||||
{
|
||||
_murderContexts.Remove(player);
|
||||
if (context.CanRemove())
|
||||
{
|
||||
_murderContexts.Remove(player);
|
||||
}
|
||||
_contextTerms.Remove(context);
|
||||
}
|
||||
else if (player.NetState != null)
|
||||
|
|
@ -202,6 +222,50 @@ public class PlayerMurderSystem : GenericPersistence
|
|||
}
|
||||
}
|
||||
|
||||
internal static void ReportKillsToSelf(PlayerMobile player)
|
||||
{
|
||||
if (Core.Expansion == Expansion.None)
|
||||
{
|
||||
return; // no consider sins in pre-t2a
|
||||
}
|
||||
else if (Core.Expansion is Expansion.T2A)
|
||||
{
|
||||
if (player.ShortTermMurders >= 5)
|
||||
{
|
||||
player.SendLocalizedMessage(502126, "", 0x022); // If thou should return to the land of the living, the innocent shall wreak havoc upon thy soul
|
||||
}
|
||||
else if (PingPongEnabled && player.Murderer)
|
||||
{
|
||||
player.SendLocalizedMessage(502123, "", 0x022); // Thou art known throughout the land as a murderous brigand.
|
||||
}
|
||||
else if (player.ShortTermMurders > 0)
|
||||
{
|
||||
player.SendLocalizedMessage(502125, "", 0x59); // Although thou hast slain the innocent, thy deeds shall not bring retribution upon thy return to the living
|
||||
}
|
||||
else if (player.Kills > 0)
|
||||
{
|
||||
player.SendLocalizedMessage(502124, "", 0x59); // Fear not, thou hast not slain the innocent in some time...
|
||||
}
|
||||
else // no kills
|
||||
{
|
||||
player.SendLocalizedMessage(502122, "", 0x59); // Fear not, thou hast not slain the innocent.
|
||||
}
|
||||
}
|
||||
else if (!Core.SE)
|
||||
{
|
||||
player.SendMessage($"Short Term Murders : {player.ShortTermMurders}");
|
||||
player.SendMessage($"Long Term Murders : {player.Kills}");
|
||||
if (PingPongEnabled)
|
||||
{
|
||||
player.SendMessage($"Ping Pongs: {player.PingPong}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
player.SendLocalizedMessage(1114370, $"{player.ShortTermMurders}\t{player.Kills}");
|
||||
}
|
||||
}
|
||||
|
||||
private class MurdererTimer : Timer
|
||||
{
|
||||
public MurdererTimer() : base(TimeSpan.FromMinutes(5.0), TimeSpan.FromMinutes(5.0))
|
||||
|
|
@ -233,9 +297,14 @@ public class PlayerMurderSystem : GenericPersistence
|
|||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
if (_murderContexts.Remove((PlayerMobile)queue.Dequeue(), out var context))
|
||||
var pm = (PlayerMobile)queue.Dequeue();
|
||||
if (_murderContexts.TryGetValue(pm, out var ctx))
|
||||
{
|
||||
_contextTerms.Remove(context);
|
||||
if (ctx.CanRemove())
|
||||
{
|
||||
_murderContexts.Remove(pm);
|
||||
}
|
||||
_contextTerms.Remove(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,15 +163,17 @@ public class ReportMurdererGump : StaticGump<ReportMurdererGump>
|
|||
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 (pk.Kills == 5)
|
||||
if (!wasMurderer && killer.Murderer)
|
||||
{
|
||||
pk.SendLocalizedMessage(502134); // You are now known as a murderer!
|
||||
}
|
||||
else if (Stealing.SuspendOnMurder && pk.Kills == 1 && pk.NpcGuild == NpcGuild.ThievesGuild)
|
||||
// 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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ public partial class DisguiseKit : Item
|
|||
{
|
||||
from.SendLocalizedMessage(501702);
|
||||
}
|
||||
else if (Stealing.SuspendOnMurder && pm.Kills > 0)
|
||||
else if (Stealing.SuspendOnMurder && pm.Kills > 0) // could be perma-red: intentional
|
||||
{
|
||||
from.SendLocalizedMessage(501703);
|
||||
}
|
||||
|
|
|
|||
32
Projects/UOContent/Migrations/Server.Engines.PlayerMurderSystem.MurderContext.v1.json
generated
Normal file
32
Projects/UOContent/Migrations/Server.Engines.PlayerMurderSystem.MurderContext.v1.json
generated
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"version": 1,
|
||||
"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": "PingPong",
|
||||
"type": "int",
|
||||
"rule": "PrimitiveTypeMigrationRule",
|
||||
"ruleArguments": [
|
||||
""
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using Server.Engines.PlayerMurderSystem;
|
||||
using Server.Guilds;
|
||||
using Server.Gumps;
|
||||
using Server.Mobiles;
|
||||
|
|
@ -31,15 +32,7 @@ namespace Server.Misc
|
|||
{
|
||||
if (from is PlayerMobile player)
|
||||
{
|
||||
if (!Core.SE)
|
||||
{
|
||||
from.SendMessage($"Short Term Murders : {player.ShortTermMurders}");
|
||||
from.SendMessage($"Long Term Murders : {from.Kills}");
|
||||
}
|
||||
else
|
||||
{
|
||||
from.SendLocalizedMessage(1114370, $"{player.ShortTermMurders}\t{from.Kills}");
|
||||
}
|
||||
PlayerMurderSystem.ReportKillsToSelf(player);
|
||||
}
|
||||
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -354,6 +354,12 @@ namespace Server.Mobiles
|
|||
|
||||
public override bool NewGuildDisplay => Guilds.Guild.NewGuildSystem;
|
||||
|
||||
public override bool Murderer =>
|
||||
(Core.T2A && !Core.LBR
|
||||
&& PlayerMurderSystem.GetMurderContext(this, out var context)
|
||||
&& context.PingPong >= 5)
|
||||
|| base.Murderer;
|
||||
|
||||
public bool BedrollLogout { get; set; }
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
|
|
@ -700,6 +706,13 @@ namespace Server.Mobiles
|
|||
[CommandProperty(AccessLevel.GameMaster, canModify: true)]
|
||||
public ChampionTitleContext ChampionTitles => ChampionTitleSystem.GetOrCreateChampionTitleContext(this);
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int PingPong
|
||||
{
|
||||
get => PlayerMurderSystem.GetMurderContext(this, out var context) ? context.PingPong : 0;
|
||||
set => PlayerMurderSystem.ManuallySetPingPong(this, value);
|
||||
}
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int ShortTermMurders
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue