From d0d7be8de042582697745962d2b72c6aa8d6254e Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 17 Feb 2024 10:46:55 -0800 Subject: [PATCH] fix: Fixes codegenned spawners and adds RunUO spawner import support (#1682) > [!IMPORTANT] > This code change includes important fixes for all spawners after they were converted to codegen! > [!NOTE] > **Developer Note** > Usage/Description/Aliases attributes were moved to the core so they can be used by the command registration system. ### Summary - **Fixes spawners not registering their spawns on world load** - Changes `[GenerateSpawners` to `[ImportSpawners` - Removes `ConvertPremiumSpawners` - Adds Premium spawner import support to `[ImportSpawners` - Adds RunUO XML import support: https://github.com/ruaduck/xmlspawner/blob/ServUOMaster/XmlSpawner/XmlSpawner%20Core/SpawnerExporter.cs - Fixed an issue where not manually registering an alias meant it wasn't available as a command --- Projects/Server/Attributes.cs | 24 + Projects/Server/Commands.cs | 38 +- Projects/Server/Json/JsonConfig.cs | 1 + Projects/UOContent/Commands/Attributes.cs | 28 -- Projects/UOContent/Commands/Handlers.cs | 18 +- .../UOContent/Engines/Spawners/BaseSpawner.cs | 33 +- .../Commands/ConvertPremiumSpawners.cs | 257 ---------- .../Commands/GenerateSpawnersCommand.cs | 203 -------- .../Commands/ImportSpawnersCommand.cs | 469 ++++++++++++++++++ .../Engines/Spawners/SpawnerEntry.cs | 13 +- Projects/UOContent/Gumps/WhoGump.cs | 6 +- docs/commands/commands.7z | Bin 12448 -> 12426 bytes 12 files changed, 568 insertions(+), 522 deletions(-) delete mode 100644 Projects/UOContent/Commands/Attributes.cs delete mode 100644 Projects/UOContent/Engines/Spawners/Commands/ConvertPremiumSpawners.cs delete mode 100644 Projects/UOContent/Engines/Spawners/Commands/GenerateSpawnersCommand.cs create mode 100644 Projects/UOContent/Engines/Spawners/Commands/ImportSpawnersCommand.cs diff --git a/Projects/Server/Attributes.cs b/Projects/Server/Attributes.cs index e0458364d..e0ea130d2 100644 --- a/Projects/Server/Attributes.cs +++ b/Projects/Server/Attributes.cs @@ -125,6 +125,30 @@ public class ConstructibleAttribute : Attribute public AccessLevel AccessLevel { get; set; } } +[AttributeUsage(AttributeTargets.Method)] +public class UsageAttribute : Attribute +{ + public UsageAttribute(string usage) => Usage = usage; + + public string Usage { get; } +} + +[AttributeUsage(AttributeTargets.Method)] +public class DescriptionAttribute : Attribute +{ + public DescriptionAttribute(string description) => Description = description; + + public string Description { get; } +} + +[AttributeUsage(AttributeTargets.Method)] +public class AliasesAttribute : Attribute +{ + public AliasesAttribute(params string[] aliases) => Aliases = aliases; + + public string[] Aliases { get; } +} + [AttributeUsage(AttributeTargets.Property)] public class CommandPropertyAttribute : Attribute { diff --git a/Projects/Server/Commands.cs b/Projects/Server/Commands.cs index bf8c4bb9f..6819dfe91 100644 --- a/Projects/Server/Commands.cs +++ b/Projects/Server/Commands.cs @@ -15,6 +15,9 @@ using System; using System.Collections.Generic; +using System.Reflection; +using System.Runtime.InteropServices; +using Server.Logging; namespace Server; @@ -125,6 +128,8 @@ public class CommandInfoSorter : IComparer public static class CommandSystem { + private static readonly ILogger logger = LogFactory.GetLogger(typeof(CommandSystem)); + public static string Prefix { get; set; } = "["; public static Dictionary Entries { get; } = new(StringComparer.OrdinalIgnoreCase); @@ -194,7 +199,38 @@ public static class CommandSystem public static void Register(string command, AccessLevel access, CommandEventHandler handler) { - Entries[command] = new CommandEntry(command, handler, access); + DoRegister(command, access, handler); + + var mi = handler.Method; + var aliasesAttr = mi.GetCustomAttribute(typeof(AliasesAttribute), false) as AliasesAttribute; + var aliases = aliasesAttr?.Aliases; + + if (aliases == null) + { + return; + } + + foreach (var alias in aliases) + { + DoRegister(alias, access, handler); + } + } + + private static void DoRegister(string command, AccessLevel accessLevel, CommandEventHandler handler) + { + ref var commandEntry = ref CollectionsMarshal.GetValueRefOrAddDefault(Entries, command, out var exists); + if (exists) + { + if (commandEntry.AccessLevel == accessLevel && commandEntry.Handler == handler) + { + return; + } + + logger.Warning("Command {Command} already registered to {Handler}.", command, commandEntry.Handler.Method.Name); + return; + } + + commandEntry = new CommandEntry(command, handler, accessLevel); } public static bool Handle(Mobile from, string text, MessageType type = MessageType.Regular) diff --git a/Projects/Server/Json/JsonConfig.cs b/Projects/Server/Json/JsonConfig.cs index e38ae9637..f3b5fdda9 100644 --- a/Projects/Server/Json/JsonConfig.cs +++ b/Projects/Server/Json/JsonConfig.cs @@ -49,6 +49,7 @@ public static class JsonConfig options.Converters.Add(new IPEndPointConverterFactory()); options.Converters.Add(new TypeConverterFactory()); options.Converters.Add(new WorldLocationConverterFactory()); + options.Converters.Add(new TextDefinitionConverterFactory()); for (var i = 0; i < converters.Length; i++) { diff --git a/Projects/UOContent/Commands/Attributes.cs b/Projects/UOContent/Commands/Attributes.cs deleted file mode 100644 index 2abf56ce7..000000000 --- a/Projects/UOContent/Commands/Attributes.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; - -namespace Server -{ - [AttributeUsage(AttributeTargets.Method)] - public class UsageAttribute : Attribute - { - public UsageAttribute(string usage) => Usage = usage; - - public string Usage { get; } - } - - [AttributeUsage(AttributeTargets.Method)] - public class DescriptionAttribute : Attribute - { - public DescriptionAttribute(string description) => Description = description; - - public string Description { get; } - } - - [AttributeUsage(AttributeTargets.Method)] - public class AliasesAttribute : Attribute - { - public AliasesAttribute(params string[] aliases) => Aliases = aliases; - - public string[] Aliases { get; } - } -} diff --git a/Projects/UOContent/Commands/Handlers.cs b/Projects/UOContent/Commands/Handlers.cs index 0e20c1830..0201423b5 100644 --- a/Projects/UOContent/Commands/Handlers.cs +++ b/Projects/UOContent/Commands/Handlers.cs @@ -33,7 +33,6 @@ namespace Server.Commands Register("Where", AccessLevel.Counselor, Where_OnCommand); Register("AutoPageNotify", AccessLevel.Counselor, APN_OnCommand); - Register("APN", AccessLevel.Counselor, APN_OnCommand); Register("Animate", AccessLevel.GameMaster, Animate_OnCommand); @@ -47,12 +46,8 @@ namespace Server.Commands Register("Client", AccessLevel.Counselor, Client_OnCommand); Register("SMsg", AccessLevel.Counselor, StaffMessage_OnCommand); - Register("SM", AccessLevel.Counselor, StaffMessage_OnCommand); - Register("S", AccessLevel.Counselor, StaffMessage_OnCommand); Register("BCast", AccessLevel.GameMaster, BroadcastMessage_OnCommand); - Register("BC", AccessLevel.GameMaster, BroadcastMessage_OnCommand); - Register("B", AccessLevel.GameMaster, BroadcastMessage_OnCommand); Register("Bank", AccessLevel.GameMaster, Bank_OnCommand); @@ -736,14 +731,16 @@ namespace Server.Commands } } - [Usage("SMsg "), Aliases("S", "SM")] + [Usage("SMsg ")] + [Aliases("S", "SM")] [Description("Broadcasts a message to all online staff.")] public static void StaffMessage_OnCommand(CommandEventArgs e) { BroadcastMessage(AccessLevel.Counselor, e.Mobile.SpeechHue, $"[{e.Mobile.Name}] {e.ArgString}"); } - [Usage("BCast "), Aliases("B", "BC")] + [Usage("BCast ")] + [Aliases("B", "BC")] [Description("Broadcasts a message to everyone online.")] public static void BroadcastMessage_OnCommand(CommandEventArgs e) { @@ -764,7 +761,8 @@ namespace Server.Commands } } - [Usage("AutoPageNotify"), Aliases("APN")] + [Usage("AutoPageNotify")] + [Aliases("APN")] [Description("Toggles your auto-page-notify status.")] public static void APN_OnCommand(CommandEventArgs e) { @@ -782,8 +780,8 @@ namespace Server.Commands } } - [Usage("Animate "), - Description("Makes your character do a specified animation.")] + [Usage("Animate ")] + [Description("Makes your character do a specified animation.")] public static void Animate_OnCommand(CommandEventArgs e) { if (e.Length == 6) diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs index 1371567b9..446d08156 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using System.Reflection; using System.Text.Json; using ModernUO.Serialization; @@ -216,11 +215,8 @@ public abstract partial class BaseSpawner : Item, ISpawner if (spawn != null) { - Spawned.TryGetValue(spawn, out var entry); - - entry?.Spawned.Remove(spawn); - - Spawned.Remove(spawn); + Spawned.Remove(spawn, out var entry); + entry?.RemoveFromSpawned(spawn); } if (_running && !IsFull && _timer?.Running == false) @@ -419,12 +415,7 @@ public abstract partial class BaseSpawner : Item, ISpawner }; } - if (remove) - { - Spawned.Remove(spawned); - } - - return remove; + return remove && Spawned.Remove(spawned); } public void OnTick() @@ -683,7 +674,7 @@ public abstract partial class BaseSpawner : Item, ISpawner if (entity is Mobile m) { Spawned.Add(m, entry); - entry.Spawned.Add(m); + entry.AddToSpawned(m); var loc = m is BaseVendor ? Location : GetSpawnPosition(m, map); @@ -712,7 +703,7 @@ public abstract partial class BaseSpawner : Item, ISpawner else if (entity is Item item) { Spawned.Add(item, entry); - entry.Spawned.Add(item); + entry.AddToSpawned(item); var loc = GetSpawnPosition(item, map); @@ -884,8 +875,6 @@ public abstract partial class BaseSpawner : Item, ISpawner private void Deserialize(IGenericReader reader, int version) { - Spawned = new Dictionary(); - _guid = reader.ReadGuid(); _returnOnDeactivate = reader.ReadBool(); _entries = new List(reader.ReadInt()); @@ -910,9 +899,19 @@ public abstract partial class BaseSpawner : Item, ISpawner _end = _running ? reader.ReadDeltaTime() : Core.Now; } - [AfterDeserialization(false)] + [AfterDeserialization] private void AfterDeserialization() { + Spawned = new Dictionary(); + + foreach (var entry in Entries) + { + foreach (var spawned in entry.Spawned) + { + Spawned.Add(spawned, entry); + } + } + if (_running && _end > Core.Now) { DoTimer(_end - Core.Now); diff --git a/Projects/UOContent/Engines/Spawners/Commands/ConvertPremiumSpawners.cs b/Projects/UOContent/Engines/Spawners/Commands/ConvertPremiumSpawners.cs deleted file mode 100644 index 0fbc549fb..000000000 --- a/Projects/UOContent/Engines/Spawners/Commands/ConvertPremiumSpawners.cs +++ /dev/null @@ -1,257 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2023 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: ConvertPremiumSpawners.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 System.IO; -using System.Text.Json; -using Microsoft.Extensions.FileSystemGlobbing; -using Microsoft.Extensions.FileSystemGlobbing.Abstractions; -using Server.Json; -using Server.Logging; -using Server.Network; - -namespace Server.Engines.Spawners; - -public static class ConvertPremiumSpawners -{ - private static readonly ILogger logger = LogFactory.GetLogger(typeof(ConvertPremiumSpawners)); - - public static void Configure() - { - CommandSystem.Register("ConvertPremiumSpawners", AccessLevel.Developer, ConvertPremiumSpawners_OnCommand); - } - - [Usage("ConvertPremiumSpawners ")] - [Description("Converts Nerun's Premium Spawners to JSON.")] - private static void ConvertPremiumSpawners_OnCommand(CommandEventArgs args) - { - var from = args.Mobile; - - if (args.Arguments.Length != 2) - { - from.SendMessage("Usage: [ConvertPremiumSpawners "); - return; - } - - var inputDi = new DirectoryInfo(Core.BaseDirectory); - - var patternMatches = new Matcher() - .AddInclude(args.Arguments[0]) - .Execute(new DirectoryInfoWrapper(inputDi)) - .Files; - - List<(FileInfo, string)> files = []; - foreach (var match in patternMatches) - { - files.Add((new FileInfo(match.Path), match.Stem)); - } - - if (files.Count == 0) - { - from.SendMessage("ConvertPremiumSpawners: No files found."); - return; - } - - var outputDir = Path.Combine(Core.BaseDirectory, args.Arguments[1]); - var options = JsonConfig.GetOptions(new TextDefinitionConverterFactory()); - - for (var i = 0; i < files.Count; i++) - { - var (file, stem) = files[i]; - from.SendMessage($"ConvertPremiumSpawners: Converting spawners for {stem}..."); - - NetState.FlushAll(); - - try - { - var stemDir = stem[..^file.Name.Length]; - var fullOutputDir = Path.Combine(outputDir, stemDir); - PathUtility.EnsureDirectory(fullOutputDir); - - var outputFileName = $"{file.Name.AsSpan(0, file.Name.Length - file.Extension.Length)}.json"; - ParsePremiumSpawnerFile(file.FullName, Path.Combine(fullOutputDir, outputFileName), options); - } - catch (Exception e) - { - logger.Error(e.ToString()); - from.SendMessage($"ConvertPremiumSpawners: Exception parsing {stem}, file may not be in the correct format."); - } - } - } - - private static void ParsePremiumSpawnerFile(string inputFile, string outputFile, JsonSerializerOptions options) - { - var lines = File.ReadAllLines(inputFile); - - TimeSpan minTime = TimeSpan.MinValue; - TimeSpan maxTime = TimeSpan.MinValue; - int mapId = -1; - string spawnerId = null; - - var json = new List(); - - foreach (var line in lines) - { - if (line.StartsWithOrdinal("#")) - { - continue; - } - - if (line.StartsWith('*')) - { - var spawners = ParsePremiumSpawner(line.Split('|'), spawnerId, mapId, minTime, maxTime); - foreach (var spawner in spawners) - { - var dynamicJson = new DynamicJson - { - Type = "Spawner", - Data = new Dictionary() - }; - - spawner.ToJson(dynamicJson, options); - json.Add(dynamicJson); - spawner.Delete(); - } - - spawners.Clear(); - continue; - } - - var over = line.Split(' '); - switch (over[0].ToLowerInvariant()) - { - case "overrideid": - { - spawnerId = over[1]; - break; - } - case "overridemap": - { - mapId = int.Parse(over[1]); - break; - } - case "overridemintime": - { - minTime = GetTimeSpan(over[1]); - break; - } - case "overridemaxtime": - { - maxTime = GetTimeSpan(over[1]); - break; - } - } - } - - JsonConfig.Serialize(outputFile, json, options); - } - - private static List ParsePremiumSpawner( - string[] parts, - string spawnerIdOverride, - int mapIdOverride, - TimeSpan minTimeOverride, - TimeSpan maxTimeOverride - ) - { - var spawnersList = new List(); - var mapId = mapIdOverride != -1 ? mapIdOverride : int.Parse(parts[10]); - Map[] maps = mapId == 0 ? [Map.Felucca, Map.Trammel] : [Map.Maps[mapId - 1]]; - - foreach (var map in maps) - { - var spawner = new Spawner - { - X = int.Parse(parts[7]), - Y = int.Parse(parts[8]), - Z = int.Parse(parts[9]), - Map = map, - MinDelay = minTimeOverride != TimeSpan.MinValue ? minTimeOverride : GetTimeSpan(parts[11]), - MaxDelay = maxTimeOverride != TimeSpan.MinValue ? maxTimeOverride : GetTimeSpan(parts[12]), - WalkingRange = int.Parse(parts[13]), - HomeRange = int.Parse(parts[14]), - Name = $"Spawner ({spawnerIdOverride ?? parts[15]})", - Guid = Guid.NewGuid() - }; - - var totalCount = 0; - - for (var i = 0; i < 6; i++) - { - var count = int.Parse(parts[i + 16]); - totalCount += count; - spawner.Entries.AddRange(CreateSpawnerEntries(spawner, parts[i + 1], count)); - } - - if (spawner.Entries.Count == 0) - { - spawner.Delete(); - continue; - } - - spawner.Count = totalCount; - spawnersList.Add(spawner); - } - - return spawnersList; - } - - private static List CreateSpawnerEntries(BaseSpawner spawner, string typeList, int maxCount) - { - var list = new List(); - if (string.IsNullOrWhiteSpace(typeList)) - { - return list; - } - - foreach (var spawnType in typeList.Split(':')) - { - var actualType = AssemblyHandler.FindTypeByName(ConvertType(spawnType))?.Name ?? spawnType; - list.Add(new SpawnerEntry(spawner, actualType, 100, maxCount)); - } - - return list; - } - - private static TimeSpan GetTimeSpan(string time) - { - if (string.IsNullOrEmpty(time)) - { - return TimeSpan.MinValue; - } - - return char.ToLowerInvariant(time[^1]) switch - { - 'h' => TimeSpan.FromHours(double.Parse(time.AsSpan(0, time.Length - 1))), - 'm' => TimeSpan.FromMinutes(double.Parse(time.AsSpan(0, time.Length - 1))), - 's' => TimeSpan.FromSeconds(double.Parse(time.AsSpan(0, time.Length - 1))), - _ => TimeSpan.FromMinutes(double.Parse(time)) - }; - } - - private static string ConvertType(string type) - { - return type.ToLowerInvariant() switch - { - "treasurelevel1" => "treasurechestlevel1", - "treasurelevel2" => "treasurechestlevel2", - "treasurelevel3" => "treasurechestlevel3", - "treasurelevel4" => "treasurechestlevel4", - "treasurelevel5" => "treasurechestlevel5", - _ => type - }; - } -} \ No newline at end of file diff --git a/Projects/UOContent/Engines/Spawners/Commands/GenerateSpawnersCommand.cs b/Projects/UOContent/Engines/Spawners/Commands/GenerateSpawnersCommand.cs deleted file mode 100644 index 757dc8b6d..000000000 --- a/Projects/UOContent/Engines/Spawners/Commands/GenerateSpawnersCommand.cs +++ /dev/null @@ -1,203 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Text.Json; -using Microsoft.Extensions.FileSystemGlobbing; -using Microsoft.Extensions.FileSystemGlobbing.Abstractions; -using Server.Collections; -using Server.Json; -using Server.Logging; -using Server.Network; -using Server.Utilities; - -namespace Server.Engines.Spawners; - -public static class GenerateSpawnersCommand -{ - private static readonly ILogger logger = LogFactory.GetLogger(typeof(GenerateSpawnersCommand)); - - public static void Configure() - { - CommandSystem.Register("GenerateSpawners", AccessLevel.Developer, GenerateSpawners_OnCommand); - } - - [Usage("GenerateSpawners ")] - [Aliases("ImportSpawners", "GenSpawners", "GenSpawns")] - [Description("Generates spawners from JSON files. Supports basic globbing.")] - private static void GenerateSpawners_OnCommand(CommandEventArgs e) - { - var from = e.Mobile; - - if (e.Arguments.Length == 0) - { - from.SendMessage("Usage: [GenerateSpawners "); - return; - } - - var di = new DirectoryInfo(Core.BaseDirectory); - - var patternMatches = new Matcher() - .AddInclude(e.Arguments[0]) - .Execute(new DirectoryInfoWrapper(di)) - .Files; - - List files = []; - foreach (var match in patternMatches) - { - files.Add(new FileInfo(match.Path)); - } - - if (files.Count == 0) - { - from.SendMessage("GenerateSpawners: No files found matching the pattern"); - return; - } - - var watch = Stopwatch.StartNew(); - - var allSpawners = new Dictionary(); - foreach (var item in World.Items.Values) - { - if (item is ISpawner spawner) - { - allSpawners[spawner.Guid] = spawner; - } - } - - var options = JsonConfig.GetOptions(new TextDefinitionConverterFactory()); - var totalGenerated = 0; - var totalFailures = 0; - - for (var i = 0; i < files.Count; i++) - { - var file = files[i]; - from.SendMessage($"GenerateSpawners: Generating spawners from {file.Name}..."); - logger.Information("{User} is generating spawners from {File}", from, file.FullName); - - NetState.FlushAll(); - - try - { - var spawners = JsonConfig.Deserialize>(file.FullName); - ParseSpawnerList(spawners, options, allSpawners, out var generated, out var failed); - totalGenerated += generated; - totalFailures += failed; - } - catch (JsonException) - { - from.SendMessage( - $"GenerateSpawners: Exception parsing {file.FullName}, file may not be in the correct format." - ); - } - } - - watch.Stop(); - - logger.Information( - "Generated {Count} spawners ({Duration:F2} seconds, {Failures} failures)", - totalGenerated, - watch.Elapsed.TotalSeconds, - totalFailures - ); - - from.SendMessage( - $"GenerateSpawners: Generated {totalGenerated} spawners ({watch.Elapsed.TotalSeconds:F2} seconds, {totalFailures} failures)" - ); - } - - private static void ParseSpawnerList( - List spawners, - JsonSerializerOptions options, - Dictionary allSpawners, - out int totalGenerated, - out int failureCount - ) - { - failureCount = 0; - totalGenerated = 0; - - using var queue = PooledRefQueue.Create(); - for (var i = 0; i < spawners.Count; i++) - { - var json = spawners[i]; - var type = AssemblyHandler.FindTypeByName(json.Type); - - if (type == null || !typeof(BaseSpawner).IsAssignableFrom(type)) - { - logger.Error($"Invalid spawner type {json.Type ?? "(-null-)"} ({i})."); - failureCount++; - continue; - } - - json.GetProperty("location", options, out Point3D location); - json.GetProperty("map", options, out Map map); - - // Delete all spawners at this location. - // Probably shouldn't do this outside of migrations? Is there a better way to find/fix spawners? - foreach (var spawner in map.GetItemsAt(location)) - { - if (spawner.GetType() == type) - { - queue.Enqueue(spawner); - allSpawners.Remove(spawner.Guid); - } - } - - while (queue.Count > 0) - { - queue.Dequeue().Delete(); - } - - try - { - var spawner = type.CreateInstance(json, options); - - spawner!.MoveToWorld(location, map); - spawner!.Respawn(); - - if (allSpawners.Remove(spawner.Guid, out var oldSpawner)) - { - oldSpawner.Delete(); - } - - allSpawners.Add(spawner.Guid, spawner); - totalGenerated++; - } - catch (Exception ex) - { - json.GetProperty("guid", options, out Guid guid); - TraceException(ex, $"Failed to generate spawner {guid}."); - - failureCount++; - } - } - } - - private static void TraceException(Exception ex, string message = "") - { - try - { - using var op = new StreamWriter("spawner-errors.log", true); - op.WriteLine("# {0}", Core.Now); - - if (!string.IsNullOrEmpty(message)) - { - op.WriteLine(message); - } - - op.WriteLine(ex); - - op.WriteLine(); - op.WriteLine(); - } - catch - { - // ignored - } - -#if DEBUG - logger.Error(ex, message); -#endif - } -} diff --git a/Projects/UOContent/Engines/Spawners/Commands/ImportSpawnersCommand.cs b/Projects/UOContent/Engines/Spawners/Commands/ImportSpawnersCommand.cs new file mode 100644 index 000000000..6094de015 --- /dev/null +++ b/Projects/UOContent/Engines/Spawners/Commands/ImportSpawnersCommand.cs @@ -0,0 +1,469 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Xml; +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Extensions.FileSystemGlobbing.Abstractions; +using Server.Collections; +using Server.Json; +using Server.Logging; +using Server.Network; +using Server.Utilities; + +namespace Server.Engines.Spawners; + +public static class ImportSpawnersCommand +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ImportSpawnersCommand)); + + public static void Configure() + { + CommandSystem.Register("ImportSpawners", AccessLevel.Developer, GenerateSpawners_OnCommand); + } + + [Usage("ImportSpawners ")] + [Aliases("ImportSpawner", "GenerateSpawners", "GenSpawners")] + [Description("Imports JSON, Nerun Premium, and RunUO spawner files. Supports basic globbing.")] + private static void GenerateSpawners_OnCommand(CommandEventArgs e) + { + var from = e.Mobile; + + if (e.Arguments.Length == 0) + { + from.SendMessage("Usage: [GenerateSpawners "); + return; + } + + var di = new DirectoryInfo(Core.BaseDirectory); + + var patternMatches = new Matcher() + .AddInclude(e.Arguments[0]) + .Execute(new DirectoryInfoWrapper(di)) + .Files; + + List files = []; + foreach (var match in patternMatches) + { + files.Add(new FileInfo(match.Path)); + } + + if (files.Count == 0) + { + from.SendMessage("GenerateSpawners: No files found matching the pattern."); + return; + } + + var watch = Stopwatch.StartNew(); + + var allSpawners = new Dictionary(); + foreach (var item in World.Items.Values) + { + if (item is ISpawner spawner) + { + allSpawners[spawner.Guid] = spawner; + } + } + + var totalGenerated = 0; + var totalFailures = 0; + + for (var i = 0; i < files.Count; i++) + { + var file = files[i]; + from.SendMessage($"GenerateSpawners: Generating spawners from {file.Name}..."); + logger.Information("{User} is generating spawners from {File}", from, file.FullName); + + NetState.FlushAll(); + + if (file.Extension == ".json") + { + ImportJsonSpawners(from, file, allSpawners, ref totalGenerated, ref totalFailures); + } + else if (file.Extension == ".xml") + { + ImportXmlSpawners(from, file, allSpawners, ref totalGenerated, ref totalFailures); + } + else if (file.Extension == ".map") + { + ImportPremiumSpawners(from, file, allSpawners, ref totalGenerated, ref totalFailures); + } + else + { + from.SendMessage($"GenerateSpawners: Unsupported file type {file.Extension}."); + } + } + + watch.Stop(); + + logger.Information( + "Generated {Count} spawners ({Duration:F2} seconds, {Failures} failures)", + totalGenerated, + watch.Elapsed.TotalSeconds, + totalFailures + ); + + from.SendMessage( + $"GenerateSpawners: Generated {totalGenerated} spawners ({watch.Elapsed.TotalSeconds:F2} seconds, {totalFailures} failures)" + ); + } + + private static void ImportXmlSpawners( + Mobile from, + FileInfo file, + Dictionary allSpawners, + ref int totalGenerated, + ref int totalFailures + ) + { + XmlDocument doc = new XmlDocument(); + doc.Load(file.FullName); + + XmlElement root = doc["spawners"]; + + if (root == null) + { + return; + } + + var index = 0; + foreach (XmlElement node in root.GetElementsByTagName("spawner")) + { + try + { + int count = int.Parse(Utility.GetText(node["count"], "1")); + int homeRange = int.Parse(Utility.GetText(node["homerange"], "4")); + + int walkingRange = int.Parse(Utility.GetText(node["walkingrange"], "-1")); + + int team = int.Parse(Utility.GetText(node["team"], "0")); + + TimeSpan maxDelay = TimeSpan.Parse(Utility.GetText(node["maxdelay"], "10:00")); + TimeSpan minDelay = TimeSpan.Parse(Utility.GetText(node["mindelay"], "05:00")); + var creaturesNameNode = node["creaturesname"]; + List creatureNames = []; + if (creaturesNameNode != null) + { + foreach (XmlElement ele in creaturesNameNode.GetElementsByTagName("creaturename")) + { + if (ele != null) + { + creatureNames.Add(ele.InnerText); + } + } + } + + string name = Utility.GetText(node["name"], "Spawner"); + Point3D location = Point3D.Parse(Utility.GetText(node["location"], "Error")); + Map map = Map.Parse(Utility.GetText(node["map"], "Error")); + + Spawner spawner = new Spawner(count, minDelay, maxDelay, team, homeRange, creatureNames.ToArray()); + if (walkingRange >= 0) + { + spawner.WalkingRange = walkingRange; + } + + spawner.Name = name; + spawner.MoveToWorld(location, map); + if (spawner.Map == Map.Internal) + { + spawner.Delete(); + totalFailures++; + throw new Exception("Spawner created on Internal map."); + } + + spawner.Respawn(); + allSpawners.Add(spawner.Guid, spawner); + totalGenerated++; + } + catch (Exception ex) + { + TraceException(ex, $"Failed to generate spawner {index} in {file.FullName}."); + from.SendMessage( + $"GenerateSpawners: Exception parsing {file.FullName}, file may not be in the correct format." + ); + } + + index++; + } + } + + private static void ImportJsonSpawners( + Mobile from, + FileInfo file, + Dictionary allSpawners, + ref int totalGenerated, + ref int totalFailures + ) + { + var options = JsonConfig.GetOptions(); + try + { + var spawners = JsonConfig.Deserialize>(file.FullName); + using var queue = PooledRefQueue.Create(); + for (var i = 0; i < spawners.Count; i++) + { + var json = spawners[i]; + var type = AssemblyHandler.FindTypeByName(json.Type); + + if (type == null || !typeof(BaseSpawner).IsAssignableFrom(type)) + { + logger.Error($"Invalid spawner type {json.Type ?? "(-null-)"} ({i})."); + totalFailures++; + continue; + } + + json.GetProperty("location", options, out Point3D location); + json.GetProperty("map", options, out Map map); + + // Delete all spawners at this location. + // Probably shouldn't do this outside of migrations? Is there a better way to find/fix spawners? + foreach (var spawner in map.GetItemsAt(location)) + { + if (spawner.GetType() == type) + { + queue.Enqueue(spawner); + allSpawners.Remove(spawner.Guid); + } + } + + while (queue.Count > 0) + { + queue.Dequeue().Delete(); + } + + try + { + var spawner = type.CreateInstance(json, options); + + spawner!.MoveToWorld(location, map); + spawner!.Respawn(); + + if (allSpawners.Remove(spawner.Guid, out var oldSpawner)) + { + oldSpawner.Delete(); + } + + allSpawners.Add(spawner.Guid, spawner); + totalGenerated++; + } + catch (Exception ex) + { + json.GetProperty("guid", options, out Guid guid); + TraceException(ex, $"Failed to generate spawner {guid}."); + + totalFailures++; + } + } + } + catch (JsonException) + { + from.SendMessage( + $"GenerateSpawners: Exception parsing {file.FullName}, file may not be in the correct format." + ); + } + } + + private static void TraceException(Exception ex, string message = "") + { + try + { + using var op = new StreamWriter("spawner-errors.log", true); + op.WriteLine("# {0}", Core.Now); + + if (!string.IsNullOrEmpty(message)) + { + op.WriteLine(message); + } + + op.WriteLine(ex); + + op.WriteLine(); + op.WriteLine(); + } + catch + { + // ignored + } + +#if DEBUG + logger.Error(ex, message); +#endif + } + + private static void ImportPremiumSpawners( + Mobile from, + FileInfo file, + Dictionary allSpawners, + ref int totalGenerated, + ref int totalFailures + ) + { + var lines = File.ReadAllLines(file.FullName); + + TimeSpan minTimeOverride = TimeSpan.MinValue; + TimeSpan maxTimeOverride = TimeSpan.MinValue; + int mapIdOverride = -1; + string spawnerIdOverride = null; + + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + if (line.StartsWithOrdinal("#")) + { + continue; + } + + try + { + + if (line.StartsWith('*')) + { + var parts = line.Split('|'); + var mapId = mapIdOverride != -1 ? mapIdOverride : int.Parse(parts[10]); + Map[] maps = mapId == 0 ? [Map.Felucca, Map.Trammel] : [Map.Maps[mapId - 1]]; + + foreach (var map in maps) + { + try + { + List<(string, int)> spawnerEntries = []; + + var totalCount = 0; + for (var j = 0; j < 6; j++) + { + var count = int.Parse(parts[j + 16]); + totalCount += count; + spawnerEntries.AddRange(CreateSpawnerEntries(parts[j + 1], count)); + } + + if (spawnerEntries.Count == 0) + { + continue; + } + + var minDelay = GetTimeSpan(minTimeOverride, parts[11]); + var maxDelay = GetTimeSpan(maxTimeOverride, parts[12]); + var homeRange = int.Parse(parts[14]); + + var spawner = new Spawner(totalCount, minDelay, maxDelay, 0, homeRange) + { + WalkingRange = int.Parse(parts[13]), + Name = $"Spawner ({spawnerIdOverride ?? parts[15]})" + }; + + foreach (var (type, amount) in spawnerEntries) + { + spawner.AddEntry(type, amount: amount); + } + + spawner.MoveToWorld( + new Point3D(int.Parse(parts[7]), int.Parse(parts[8]), int.Parse(parts[9])), + map + ); + + spawner.Respawn(); + allSpawners.Add(spawner.Guid, spawner); + totalGenerated++; + } + catch (Exception ex) + { + TraceException(ex, $"Failed to generate spawner on line {i} in {file.FullName}."); + from.SendMessage( + $"GenerateSpawners: Exception parsing {file.FullName}, file may not be in the correct format." + ); + totalFailures++; + } + } + + continue; + } + + var over = line.Split(' '); + switch (over[0].ToLowerInvariant()) + { + case "overrideid": + { + spawnerIdOverride = over[1]; + break; + } + case "overridemap": + { + mapIdOverride = int.Parse(over[1]); + break; + } + case "overridemintime": + { + minTimeOverride = GetTimeSpan(over[1]); + break; + } + case "overridemaxtime": + { + maxTimeOverride = GetTimeSpan(over[1]); + break; + } + } + } + catch (Exception ex) + { + TraceException(ex, $"Failed to generate spawner on line {i} in {file.FullName}."); + from.SendMessage( + $"GenerateSpawners: Exception parsing {file.FullName}, file may not be in the correct format." + ); + return; + } + } + } + + private static List<(string, int)> CreateSpawnerEntries(string typeList, int maxCount) + { + var list = new List<(string, int)>(); + if (string.IsNullOrWhiteSpace(typeList)) + { + return list; + } + + foreach (var spawnType in typeList.Split(':')) + { + var actualType = AssemblyHandler.FindTypeByName(ConvertType(spawnType))?.Name ?? spawnType; + list.Add((actualType, maxCount)); + } + + return list; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static TimeSpan GetTimeSpan(TimeSpan timeOverride, string time) => + timeOverride != TimeSpan.MinValue ? timeOverride : GetTimeSpan(time); + + private static TimeSpan GetTimeSpan(string time) + { + if (string.IsNullOrEmpty(time)) + { + return TimeSpan.MinValue; + } + + return char.ToLowerInvariant(time[^1]) switch + { + 'h' => TimeSpan.FromHours(double.Parse(time.AsSpan(0, time.Length - 1))), + 'm' => TimeSpan.FromMinutes(double.Parse(time.AsSpan(0, time.Length - 1))), + 's' => TimeSpan.FromSeconds(double.Parse(time.AsSpan(0, time.Length - 1))), + _ => TimeSpan.FromMinutes(double.Parse(time)) + }; + } + + private static string ConvertType(string type) + { + return type.ToLowerInvariant() switch + { + "treasurelevel1" => "treasurechestlevel1", + "treasurelevel2" => "treasurechestlevel2", + "treasurelevel3" => "treasurechestlevel3", + "treasurelevel4" => "treasurechestlevel4", + "treasurelevel5" => "treasurechestlevel5", + _ => type + }; + } +} diff --git a/Projects/UOContent/Engines/Spawners/SpawnerEntry.cs b/Projects/UOContent/Engines/Spawners/SpawnerEntry.cs index bb09a677f..d75a0d907 100644 --- a/Projects/UOContent/Engines/Spawners/SpawnerEntry.cs +++ b/Projects/UOContent/Engines/Spawners/SpawnerEntry.cs @@ -76,14 +76,20 @@ public partial class SpawnerEntry if (e != null) { - e.Spawner = _parent; - Spawned.Add(e); - _parent.Spawned.TryAdd(e, this); } } } + [AfterDeserialization] + private void AfterDeserialization() + { + foreach (var e in Spawned) + { + e.Spawner = _parent; + } + } + [JsonIgnore] public EntryFlags Valid { get; set; } @@ -99,6 +105,7 @@ public partial class SpawnerEntry if (parent.OnDefragSpawn(spawned, false)) { Spawned.RemoveAt(i--); + _parent.MarkDirty(); } } } diff --git a/Projects/UOContent/Gumps/WhoGump.cs b/Projects/UOContent/Gumps/WhoGump.cs index c03672ec2..dd581e6e2 100644 --- a/Projects/UOContent/Gumps/WhoGump.cs +++ b/Projects/UOContent/Gumps/WhoGump.cs @@ -43,12 +43,12 @@ namespace Server.Gumps public static void Configure() { - CommandSystem.Register("Who", AccessLevel.Counselor, WhoList_OnCommand); CommandSystem.Register("WhoList", AccessLevel.Counselor, WhoList_OnCommand); } - [Usage("WhoList [filter]"), Aliases("Who"), - Description("Lists all connected clients. Optionally filters results by name.")] + [Usage("WhoList [filter]")] + [Aliases("Who")] + [Description("Lists all connected clients. Optionally filters results by name.")] private static void WhoList_OnCommand(CommandEventArgs e) { e.Mobile.SendGump(new WhoGump(e.Mobile, e.ArgString)); diff --git a/docs/commands/commands.7z b/docs/commands/commands.7z index b34b344c9e0887158154ce9dab087f9c1eb36aa3..a7067618d8f04dc727b5e6135391918814752566 100644 GIT binary patch delta 10104 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