diff --git a/Projects/Server/Utilities/Utility.cs b/Projects/Server/Utilities/Utility.cs index 6ef4aa72c..448e9adf1 100644 --- a/Projects/Server/Utilities/Utility.cs +++ b/Projects/Server/Utilities/Utility.cs @@ -1479,4 +1479,49 @@ public static partial class Utility return DateTime.SpecifyKind(local - tz.GetUtcOffset(local), DateTimeKind.Utc); } + + public static string FormatTimeCompact(this TimeSpan ts, bool showSeconds = false) + { + using var sb = ValueStringBuilder.Create(); + if (ts.Days >= 1) + { + sb.Append($"{ts.Days}d"); + } + + if (sb.Length > 0) + { + sb.Append($" {ts.Hours}h"); + } + else if (ts.Hours >= 1) + { + sb.Append($"{ts.Hours}h"); + } + + if (sb.Length > 0) + { + sb.Append($" {ts.Minutes}m"); + } + else if (ts.Minutes >= 1) + { + sb.Append($"{ts.Minutes}m"); + } + + if (showSeconds) + { + if (sb.Length > 0) + { + sb.Append($" {ts.Seconds}s"); + } + else if (ts.Seconds >= 1) + { + sb.Append($"{ts.Seconds}s"); + } + } + else if (sb.Length == 0) + { + sb.Append("0m"); + } + + return sb.ToString(); + } } diff --git a/Projects/UOContent/Gumps/AdminGump.cs b/Projects/UOContent/Gumps/AdminGump.cs index fa22ceca6..e9c91dba3 100644 --- a/Projects/UOContent/Gumps/AdminGump.cs +++ b/Projects/UOContent/Gumps/AdminGump.cs @@ -92,12 +92,12 @@ namespace Server.Gumps AddPage(0); - AddBackground(0, 0, 420, 440, 5054); + AddBackground(0, 0, 420, 480, 5054); AddBlackAlpha(10, 10, 170, 100); AddBlackAlpha(190, 10, 220, 100); - AddBlackAlpha(10, 120, 400, 260); - AddBlackAlpha(10, 390, 400, 40); + AddBlackAlpha(10, 120, 400, 300); + AddBlackAlpha(10, 430, 400, 40); AddPageButton( 10, @@ -141,7 +141,7 @@ namespace Server.Gumps if (notice != null) { - AddHtml(12, 392, 396, 36, notice.Color(LabelColor32)); + AddHtml(20, 440, 396, 36, notice.Color(LabelColor32)); } switch (pageType) @@ -619,6 +619,14 @@ namespace Server.Gumps AddButtonLabeled(20, y, GetButtonID(7, 12), "Kill"); AddButtonLabeled(200, y, GetButtonID(7, 13), "Resurrect"); + y += 20; + + AddButtonLabeled(20, y, GetButtonID(7, 15), "Jail"); + AddButtonLabeled(200, y, GetButtonID(7, 16), "Unjail"); + y += 25; + + AddLabel(20, y, LabelHue, "Jail Reason:"); + AddTextField(100, y, 300, 20, 1); break; } @@ -3801,6 +3809,33 @@ namespace Server.Gumps sendGump = false; break; } + case 15: + { + var reason = info.GetTextEntry(1)?.Trim(); + + if (string.IsNullOrWhiteSpace(reason)) + { + reason = ""; + } + + CommandLogging.WriteLine( + from, + $"{from.AccessLevel} {CommandLogging.Format(from)} jailing {CommandLogging.Format(m)} - Reason: {reason}" + ); + InvokeCommand($"Jail {m.Name} \"{reason}\""); + notice = $"Player has been sent to jail. Reason: {reason}"; + break; + } + case 16: + { + CommandLogging.WriteLine( + from, + $"{from.AccessLevel} {CommandLogging.Format(from)} unjailing {CommandLogging.Format(m)}" + ); + InvokeCommand($"Unjail {m.Name}"); + notice = "Player has been unjailed."; + break; + } } if (sendGump) diff --git a/Projects/UOContent/Migrations/Server.Systems.JailSystem.JailRecord.v0.json b/Projects/UOContent/Migrations/Server.Systems.JailSystem.JailRecord.v0.json new file mode 100644 index 000000000..acbdc3297 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Systems.JailSystem.JailRecord.v0.json @@ -0,0 +1,43 @@ +{ + "version": 0, + "type": "Server.Systems.JailSystem.JailRecord", + "properties": [ + { + "name": "JailCount", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "LastJailed", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "JailEndTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "LastJailReason", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "JailedBy", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Systems/JailSystem/JailRecord.cs b/Projects/UOContent/Systems/JailSystem/JailRecord.cs new file mode 100644 index 000000000..c57ef8622 --- /dev/null +++ b/Projects/UOContent/Systems/JailSystem/JailRecord.cs @@ -0,0 +1,25 @@ +using System; +using ModernUO.Serialization; + +namespace Server.Systems.JailSystem; + +[SerializationGenerator(0)] +public partial class JailRecord +{ + [SerializableField(0)] + private int _jailCount; + + [SerializableField(1)] + private DateTime _lastJailed; + + [SerializableField(2)] + private DateTime _jailEndTime; + + [SerializableField(3)] + private string _lastJailReason; + + [SerializableField(4)] + private Mobile _jailedBy; + + public bool IsCurrentlyJailed => JailEndTime > Core.Now; +} diff --git a/Projects/UOContent/Systems/JailSystem/JailRecordGump.cs b/Projects/UOContent/Systems/JailSystem/JailRecordGump.cs new file mode 100644 index 000000000..45b9ce172 --- /dev/null +++ b/Projects/UOContent/Systems/JailSystem/JailRecordGump.cs @@ -0,0 +1,90 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2025 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: JailRecordGump.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using Server.Gumps; +using Server.Mobiles; + +namespace Server.Systems.JailSystem; + +public class JailRecordGump : StaticGump +{ + private readonly PlayerMobile _player; + private readonly JailRecord _record; + + public JailRecordGump(PlayerMobile player, JailRecord record) : base(0, 0) + { + _player = player; + _record = record; + } + + protected override void BuildLayout(ref StaticGumpBuilder builder) + { + builder.SetNoResize(); + + builder.AddPage(); + builder.AddBackground(0, 0, 330, 300, 9200); + builder.AddAlphaRegion(10, 10, 310, 280); + + builder.AddHtml(20, 20, 360, 30, "JAIL RECORD"); + builder.AddHtmlPlaceholder(20, 60, 360, 25, "playerName"); + builder.AddHtmlPlaceholder(20, 80, 360, 25, "jailCount"); + builder.AddHtmlPlaceholder(20, 100, 360, 25, "lastJailed"); + builder.AddHtmlPlaceholder(20, 120, 290, 25, "jailReason"); + + builder.AddHtmlPlaceholder(20, 150, 360, 25, "jailStatus"); + builder.AddHtmlPlaceholder(20, 170, 360, 25, "jailTime"); + + builder.AddHtml(20, 200, 360, 25, "If you believe you were jailed in error,"); + builder.AddHtml(20, 220, 360, 25, "please contact staff through normal channels."); + builder.AddHtml(20, 240, 360, 25, "Each time you are jailed, the wait increases."); + builder.AddHtml(20, 260, 360, 25, "Follow all shard rules to avoid future jail time."); + } + + protected override void BuildStrings(ref GumpStringsBuilder builder) + { + builder.SetHtmlText("playerName", $"Player: {_player.Name}", 0xFFFF00, 5); + builder.SetHtmlText("jailCount", $"Jail Count: {_record.JailCount}", 0xFFFFFF, 5); + + if (_record.LastJailed > DateTime.MinValue) + { + builder.SetHtmlText("lastJailed", $"Last Jailed: {_record.LastJailed:yyyy/MM/dd HH:mm}", 0xFFFFFF, 5); + } + else + { + builder.SetHtmlText("lastJailed", "Last Jailed: Never", 0xFFFFFF, 5); + } + + if (string.IsNullOrWhiteSpace(_record.LastJailReason)) + { + builder.SetHtmlText("jailReason", "Jail Reason: None given.", 0xFFFFFF, 4); + } + else + { + builder.SetHtmlText("jailReason", $"Jail Reason: {_record.LastJailReason}", 0xFFFFFF, 4); + } + + if (_record.IsCurrentlyJailed) + { + builder.SetHtmlText("jailStatus", "Status: Currently Jailed", 0xFF6666, 5); + builder.SetHtmlText("jailTime", $"Jail Time: {(_record.JailEndTime - Core.Now).FormatTimeCompact()}", 0xFF6666, 5); + } + else + { + builder.SetHtmlText("jailStatus", "Status: Not Jailed", 0x00FF00, 5); + builder.SetStringSlot("jailTime", ""); + } + } +} diff --git a/Projects/UOContent/Regions/JailRegion.cs b/Projects/UOContent/Systems/JailSystem/JailRegion.cs similarity index 100% rename from Projects/UOContent/Regions/JailRegion.cs rename to Projects/UOContent/Systems/JailSystem/JailRegion.cs diff --git a/Projects/UOContent/Systems/JailSystem/JailSystem.cs b/Projects/UOContent/Systems/JailSystem/JailSystem.cs new file mode 100644 index 000000000..a2592470e --- /dev/null +++ b/Projects/UOContent/Systems/JailSystem/JailSystem.cs @@ -0,0 +1,427 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2025 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: JailSystem.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Collections.Generic; +using Server.Mobiles; +using Server.Network; +using Server.Commands; +using Server.Gumps; + +namespace Server.Systems.JailSystem; + +public class JailSystem : GenericPersistence +{ + // Jail locations (modify if using custom maps) + // felucca void + private static readonly Point3D[] JailLocations = + [ + new(5276, 1164, 0), + new(5286, 1164, 0), + new(5296, 1164, 0), + new(5306, 1164, 0), + new(5276, 1174, 0), + new(5286, 1174, 0), + new(5296, 1174, 0), + new(5306, 1174, 0), + new(5283, 1184, 0), + new(5304, 1184, 0) + ]; + + // Jail map, change this for custom maps + public static readonly Map JailMap = Map.Felucca; + private static readonly JailRecord EmptyRecord = new(); + + private static readonly HashSet CurrentlyBeingJailed = []; + private static readonly Dictionary PlayerJailRecords = []; + private static readonly Dictionary JailTimers = []; + + // Jail time scales from 5 minutes to 12 hours based on the number of offenses + private static readonly TimeSpan MinJailTime = TimeSpan.FromMinutes(5); + private static readonly TimeSpan MaxJailTime = TimeSpan.FromHours(12); + + // Release location, change this for custom maps + private static readonly Point3D ReleaseLocation = new(1444, 1697, 10); // Britain Bank + public static readonly Map ReleaseMap = Map.Felucca; + + private static JailSystem Instance; + + // [jail [reason] - Jail with time escalation per offense (GM only) + // [unjail - Manual release from jail regardless of time (GM only) + // [jailinfo - Check jail status and history (GM only) + // [jailrecord - Checks their own jail record stats (player access) + + public static void Configure() + { + Instance = new JailSystem(); + CommandSystem.Register("Jail", AccessLevel.GameMaster, Jail_OnCommand); + CommandSystem.Register("Unjail", AccessLevel.GameMaster, Unjail_OnCommand); + CommandSystem.Register("JailInfo", AccessLevel.Counselor, JailInfo_OnCommand); + CommandSystem.Register("JailRecord", AccessLevel.Player, MyJailInfo_OnCommand); + } + + public JailSystem() : base("Jail", 3) + { + } + + // Can be used by other systems to check player jail status + public static bool IsPlayerJailed(PlayerMobile player) => + PlayerJailRecords.GetValueOrDefault(player)?.IsCurrentlyJailed == true; + + private static TimeSpan CalculateJailTime(int jailCount) + { + var totalMinutes = MinJailTime + (jailCount - 1) * (MaxJailTime - MinJailTime) / 9.0; + + return totalMinutes.Clamp(MinJailTime, MaxJailTime); + } + + public static void JailPlayer(Mobile from, PlayerMobile player, string reason = "") + { + if (!CurrentlyBeingJailed.Add(player)) + { + return; + } + + if (!PlayerJailRecords.TryGetValue(player, out var record)) + { + PlayerJailRecords[player] = record = new JailRecord(); + } + + record.JailCount++; + record.LastJailed = Core.Now; + record.LastJailReason = reason; + record.JailedBy = from; + + var jailTime = CalculateJailTime(record.JailCount); + record.JailEndTime = Core.Now + jailTime; + + player.Frozen = true; + player.SendMessage(0x35, "You are being sent to jail!"); + player.PlaySound(0x204); + + CommandLogging.WriteLine(from, $"Player {player.Name} jailed for: {reason} (Offense #{record.JailCount}, {jailTime.TotalMinutes} minutes)"); + + foreach (var ns in NetState.Instances) + { + if (ns.Mobile is PlayerMobile staff && staff.AccessLevel >= AccessLevel.Counselor) + { + staff.SendMessage(0x35, $"Player {player.Name} has been jailed for {jailTime.TotalMinutes} minutes. Reason: {reason} (Offense #{record.JailCount})"); + } + } + + Timer.DelayCall(TimeSpan.FromSeconds(2.0), DismountPlayer, from, player, jailTime); + } + + private static void DismountPlayer(Mobile from, PlayerMobile player, TimeSpan jailTime) + { + if (player.Mount != null) + { + var mount = player.Mount; + mount.Rider = null; + player.SendMessage(0x35, "You have been dismounted."); + if (mount is BaseCreature bc) + { + if (bc.Summoned) + { + bc.Dispel(bc); + } + else // Stable the pet + { + bc.ControlTarget = null; + bc.ControlOrder = OrderType.Stay; + bc.Internalize(); + + bc.SetControlMaster(null); + + bc.IsStabled = true; + bc.StabledBy = from; + player.AddStabled(bc); + } + } + } + + CommandLogging.WriteLine(from, $"Player {player.Name} dismounted before jail teleport"); + + Timer.DelayCall(TimeSpan.FromSeconds(3.0), TeleportToJail, from, player, jailTime); + } + + private static void TeleportToJail(Mobile from, PlayerMobile player, TimeSpan jailTime) + { + var jailLocation = JailLocations.RandomElement(); + + player.MoveToWorld(jailLocation, JailMap); + + player.SendMessage(0x35, "Use [jailrecord to pull up your record."); + player.SendMessage(0x35, "Please contact staff if you believe this was a mistake."); + + CommandLogging.WriteLine(from, $"Player {player.Name} teleported to jail at {jailLocation}"); + + Timer.DelayCall(TimeSpan.FromSeconds(5.0), UnfreezePlayer, from, player, jailTime); + } + + private static void UnfreezePlayer(Mobile from, PlayerMobile player, TimeSpan jailTime) + { + player.Frozen = false; + + CommandLogging.WriteLine(from, $"Player {player.Name} unfrozen in jail"); + + CurrentlyBeingJailed.Remove(player); + + var releaseTimer = Timer.DelayCall(jailTime, ReleasePlayer, from, player); + JailTimers[player] = releaseTimer; + } + + private static void ReleasePlayer(Mobile from, PlayerMobile player) + { + if (PlayerJailRecords.TryGetValue(player, out var record)) + { + record.JailEndTime = Core.Now; + } + + JailTimers.Remove(player); + + // Freeze player for release sequence + player.Frozen = true; + player.SendMessage(0x35, "You have been released from jail!"); + player.PlaySound(0x1FF); + + if (from != null) + { + CommandLogging.WriteLine(from, $"Player {player.Name} released from jail, starting teleport sequence"); + } + + foreach (var ns in NetState.Instances) + { + if (ns.Mobile is PlayerMobile staff && staff.AccessLevel >= AccessLevel.Counselor) + { + staff.SendMessage(0x35, $"Player {player.Name} has been released from jail."); + } + } + + Timer.DelayCall(TimeSpan.FromSeconds(5.0), TeleportFromJail, from, player); + } + + private static void TeleportFromJail(Mobile from, PlayerMobile player) + { + player.MoveToWorld(ReleaseLocation, ReleaseMap); + + if (from != null) + { + CommandLogging.WriteLine(player, $"Player {player.Name} teleported from jail to {ReleaseLocation}"); + } + + Timer.DelayCall(TimeSpan.FromSeconds(5.0), UnfreezeFromRelease, from, player); + } + + private static void UnfreezeFromRelease(Mobile from, PlayerMobile player) + { + player.Frozen = false; + player.SendMessage(0x35, "Welcome back!"); + player.SendMessage(0x35, "Please follow the shard rules."); + player.SendMessage(0x35, "Have a nice day!"); + + if (from != null) + { + CommandLogging.WriteLine(from, $"Player {player.Name} unfrozen after jail release"); + } + } + + [Usage("Jail [reason]")] + [Description("Jails a player with freeze/teleport/unfreeze sequence.")] + private static void Jail_OnCommand(CommandEventArgs e) + { + if (e.Length < 1) + { + e.Mobile.SendMessage(0x35, "Usage: [jail [reason]"); + return; + } + + var reason = e.GetString(1); + var playerName = e.GetString(0); + var playerSerial = Serial.TryParse(playerName, null, out var serial) ? serial : Serial.MinusOne; + + PlayerMobile player = null; + + foreach (var m in World.Mobiles.Values) + { + if (m is PlayerMobile pm && (pm.Serial == playerSerial || m.RawName.InsensitiveEquals(playerName))) + { + player = pm; + break; + } + } + + if (player == null) + { + e.Mobile.SendMessage(0x35, $"Player '{playerName}' not found."); + return; + } + + if (player.AccessLevel > AccessLevel.Player) + { + e.Mobile.SendMessage(0x35, "You cannot jail staff members."); + return; + } + + if (IsPlayerJailed(player)) + { + e.Mobile.SendMessage(0x35, $"Player {player.Name} is already jailed."); + return; + } + + JailPlayer(e.Mobile, player, reason); + e.Mobile.SendMessage(0x35, $"Player {player.Name} is being jailed. Reason: {reason}"); + } + + [Usage("Unjail ")] + [Description("Manually releases a player from jail.")] + private static void Unjail_OnCommand(CommandEventArgs e) + { + var from = e.Mobile; + if (e.Length < 1) + { + from.SendMessage(0x35, "Usage: [unjail "); + return; + } + + var playerName = e.GetString(0); + var playerSerial = Serial.TryParse(playerName, null, out var serial) ? serial : Serial.MinusOne; + + PlayerMobile player = null; + + foreach (var m in World.Mobiles.Values) + { + if (m is PlayerMobile pm && (pm.Serial == playerSerial || m.RawName.InsensitiveEquals(playerName))) + { + player = pm; + break; + } + } + + if (player == null) + { + from.SendMessage(0x35, $"Player '{playerName}' not found."); + return; + } + + if (!IsPlayerJailed(player)) + { + from.SendMessage(0x35, $"Player {player.Name} is not currently jailed."); + return; + } + + if (JailTimers.Remove(player, out var value)) + { + value.Stop(); + } + + ReleasePlayer(from, player); + from.SendMessage(0x35, $"Player {player.Name} has been manually released from jail."); + } + + [Usage("JailInfo ")] + [Description("Shows jail information for a player.")] + private static void JailInfo_OnCommand(CommandEventArgs e) + { + if (e.Length < 1) + { + e.Mobile.SendMessage(0x35, "Usage: [jailinfo "); + return; + } + + var playerName = e.GetString(0); + var playerSerial = Serial.TryParse(playerName, null, out var serial) ? serial : Serial.MinusOne; + + PlayerMobile player = null; + + foreach (var m in World.Mobiles.Values) + { + if (m is PlayerMobile pm && (pm.Serial == playerSerial || m.RawName.InsensitiveEquals(playerName))) + { + player = pm; + break; + } + } + + if (player == null) + { + e.Mobile.SendMessage(0x35, $"Player '{playerName}' not found."); + return; + } + + e.Mobile.SendGump(new JailRecordGump(player, PlayerJailRecords.GetValueOrDefault(player, EmptyRecord))); + } + + private static readonly Dictionary JailRecordCooldowns = new(); + private static readonly TimeSpan JailRecordCooldown = TimeSpan.FromSeconds(30); + + [Usage("JailRecord")] + [Description("Shows your own jail information.")] + private static void MyJailInfo_OnCommand(CommandEventArgs e) + { + var player = (PlayerMobile)e.Mobile; + + if (JailRecordCooldowns.TryGetValue(player, out var cooldown)) + { + var timeSinceLastUse = Core.Now - cooldown; + + if (timeSinceLastUse < JailRecordCooldown) + { + var remaining = JailRecordCooldown - timeSinceLastUse; + e.Mobile.SendMessage(0x35, $"You must wait {remaining.TotalSeconds:F0} seconds."); + return; + } + } + + JailRecordCooldowns[player] = Core.Now; + + player.SendGump(new JailRecordGump(player, PlayerJailRecords.GetValueOrDefault(player, EmptyRecord))); + } + + public override void Serialize(IGenericWriter writer) + { + writer.WriteEncodedInt(0); // version + writer.WriteEncodedInt(PlayerJailRecords.Count); + foreach (var (m, record) in PlayerJailRecords) + { + writer.Write(m); + record.Serialize(writer); + } + } + + public override void Deserialize(IGenericReader reader) + { + var version = reader.ReadEncodedInt(); + + var count = reader.ReadEncodedInt(); + for (var i = 0; i < count; i++) + { + var player = reader.ReadEntity(); + var record = new JailRecord(); + record.Deserialize(reader); + + if (player != null) + { + PlayerJailRecords[player] = record; + if (record.IsCurrentlyJailed) + { + CurrentlyBeingJailed.Add(player); + var jailTime = record.JailEndTime - Core.Now; + JailTimers[player] = Timer.DelayCall(jailTime, ReleasePlayer, record.JailedBy, player); + } + } + } + } +} diff --git a/docs/commands/commands.7z b/docs/commands/commands.7z index 0da10ed6d..6db187201 100644 Binary files a/docs/commands/commands.7z and b/docs/commands/commands.7z differ