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; 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", "IS")] [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 ) { var doc = new XmlDocument(); doc.Load(file.FullName); var root = doc["spawners"]; if (root == null) { return; } var index = 0; foreach (XmlElement node in root.GetElementsByTagName("spawner")) { try { var count = int.Parse(Utility.GetText(node["count"], "1")); var homeRange = int.Parse(Utility.GetText(node["homerange"], "4")); var walkingRange = int.Parse(Utility.GetText(node["walkingrange"], "-1")); var team = int.Parse(Utility.GetText(node["team"], "0")); var maxDelay = TimeSpan.Parse(Utility.GetText(node["maxdelay"], "10:00")); var 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); } } } var name = Utility.GetText(node["name"], "Spawner"); var location = Point3D.Parse(Utility.GetText(node["location"], "Error")); var map = Map.Parse(Utility.GetText(node["map"], "Error")); var spawner = new Spawner(count, minDelay, maxDelay, team, spawnedNames: creatureNames.ToArray()); if (walkingRange >= 0) { spawner.WalkingRange = walkingRange; } spawner.Name = name; spawner.MoveToWorld(location, map); spawner.HomeRange = homeRange; 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++; } } // Test seam: wraps ImportJsonSpawners without requiring a live Mobile (from = null is safe // because from is only used in the JsonException path, which won't be hit for valid files). internal static void ImportFile(FileInfo file, Dictionary allSpawners) { var generated = 0; var failures = 0; ImportJsonSpawners(null, file, allSpawners, ref generated, ref failures); } private static void ImportJsonSpawners( Mobile from, FileInfo file, Dictionary allSpawners, ref int totalGenerated, ref int totalFailures ) { List dtos; try { // DTO deserialization constructs NO world objects — a malformed file fails as GC only. dtos = JsonConfig.Deserialize>(file.FullName, SpawnerJsonSerializer.Options); } catch (JsonException) { from?.SendMessage( $"GenerateSpawners: Exception parsing {file.FullName}, file may not be in the correct format." ); return; } if (dtos == null || dtos.Count == 0) { from?.SendMessage($"GenerateSpawners: Skipping empty spawner file {file.Name}"); logger.Information("{User} is skipping empty spawner file {File}", from, file.FullName); return; } using var queue = PooledRefQueue.Create(); for (var i = 0; i < dtos.Count; i++) { var dto = dtos[i]; var location = dto.Location; var map = dto.Map; if (map == null || map == Map.Internal) { logger.Error("Spawner {Guid} ({Index}) has no valid map; skipping.", dto.Guid, i); totalFailures++; continue; } BaseSpawner spawner; try { spawner = dto.ToSpawner(); // constructs the single Item, now referenced } catch (Exception ex) { TraceException(ex, $"Failed to build spawner {dto.Guid}."); totalFailures++; continue; } var type = spawner.GetType(); // Delete all existing spawners of the same concrete type at this location. foreach (var existing in map.GetItemsAt(location)) { if (existing.GetType() == type && existing != spawner) { queue.Enqueue(existing); allSpawners.Remove(existing.Guid); } } while (queue.Count > 0) { queue.Dequeue().Delete(); } try { 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) { TraceException(ex, $"Failed to generate spawner {spawner.Guid}."); spawner.Delete(); totalFailures++; } } } 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); var minTimeOverride = TimeSpan.MinValue; var maxTimeOverride = TimeSpan.MinValue; var 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.Veridia, Map.Underworld] : [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) { 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.HomeRange = homeRange; 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 }; } }