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:
Jack 2026-03-11 19:10:27 +13:00 committed by GitHub
parent 0d7b27fe7a
commit 6b6cc10771
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 142 additions and 18 deletions

View file

@ -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);
}
}
}