497 lines
16 KiB
C#
497 lines
16 KiB
C#
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 <relative search pattern to distribution>")]
|
|
[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 <relative search pattern to distribution>");
|
|
return;
|
|
}
|
|
|
|
var di = new DirectoryInfo(Core.BaseDirectory);
|
|
|
|
var patternMatches = new Matcher()
|
|
.AddInclude(e.Arguments[0])
|
|
.Execute(new DirectoryInfoWrapper(di))
|
|
.Files;
|
|
|
|
List<FileInfo> 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<Guid, ISpawner>();
|
|
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<Guid, ISpawner> 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<string> 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<Guid, ISpawner> allSpawners)
|
|
{
|
|
var generated = 0;
|
|
var failures = 0;
|
|
ImportJsonSpawners(null, file, allSpawners, ref generated, ref failures);
|
|
}
|
|
|
|
private static void ImportJsonSpawners(
|
|
Mobile from,
|
|
FileInfo file,
|
|
Dictionary<Guid, ISpawner> allSpawners,
|
|
ref int totalGenerated,
|
|
ref int totalFailures
|
|
)
|
|
{
|
|
List<SpawnerDto> dtos;
|
|
try
|
|
{
|
|
// DTO deserialization constructs NO world objects — a malformed file fails as GC only.
|
|
dtos = JsonConfig.Deserialize<List<SpawnerDto>>(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<Item>.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<BaseSpawner>(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<Guid, ISpawner> 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
|
|
};
|
|
}
|
|
}
|