namespace Aeshnidae.Challenge; /// One creature as the power index describes it. Only the fields a roster needs are read. public class IndexedCreature { [JsonPropertyName("wcid")] public uint Wcid { get; set; } [JsonPropertyName("name")] public string Name { get; set; } = ""; [JsonPropertyName("level")] public int Level { get; set; } /// null for the 3,777 creatures the index classifies out rather than scoring. [JsonPropertyName("score")] public double? Score { get; set; } [JsonPropertyName("dpmVsPlayer")] public double Dpm { get; set; } [JsonPropertyName("ttkSeconds")] public double Ttk { get; set; } [JsonPropertyName("health")] public int Health { get; set; } [JsonPropertyName("meleeDefense")] public int MeleeDefense { get; set; } [JsonPropertyName("isCaster")] public bool IsCaster { get; set; } [JsonPropertyName("placed")] public bool Placed { get; set; } [JsonPropertyName("immobile")] public bool Immobile { get; set; } [JsonPropertyName("creatureType")] public string CreatureType { get; set; } = ""; [JsonPropertyName("classification")] public string? Classification { get; set; } public override string ToString() => $"{Name} ({Wcid}) {Score:0.###}"; } /// /// The creature power index, loaded once. /// /// It lives in ACE's content folder rather than in this mod's folder, and /// C:\Aeshnidae\ops\build-creature-index.md says why: the data outlives the mod that reads /// it first, a refresh should not need a mod deploy, and mod source is published under the /// AGPL while generated content is not. This class is the only place that knows the file's /// shape. /// /// What is NOT done here, deliberately: nothing is recomputed. The score, dpmVsPlayer and /// ttkSeconds are read as the builder wrote them, so the index's documented weaknesses are /// visible in one place (its README) rather than half-corrected in two. /// public static class Index { private static readonly object _lock = new(); private static List _all = new(); private static string _status = "not loaded"; private static string _generated = ""; public static string Status { get { lock (_lock) return _status; } } public static int Count { get { lock (_lock) return _all.Count; } } public static string Generated { get { lock (_lock) return _generated; } } /// Every scored creature, in index order. Never mutated after load. public static List All { get { lock (_lock) return _all; } } public static IndexedCreature? Find(uint wcid) { lock (_lock) return _all.FirstOrDefault(c => c.Wcid == wcid); } /// Where the index file is: an absolute IndexFile as given, else under ACE's content folder. public static string Path() { var file = (Mod.Settings.IndexFile ?? "").Trim(); if (string.IsNullOrWhiteSpace(file)) file = "data/creature-index.json"; if (System.IO.Path.IsPathRooted(file)) return file; var content = PropertyManager.GetString("content_folder").Item ?? "Content"; return System.IO.Path.Combine(content, file.Replace('/', System.IO.Path.DirectorySeparatorChar)); } /// /// Reads the index. Returns the number of scored creatures kept, 0 on any failure - /// and a failure is not fatal: with no index there are no rosters, so a challenge copy /// opens stock and says so in the log rather than throwing on a player's teleport. /// public static int Load() { var path = Path(); try { if (!File.Exists(path)) { Set(new List(), "", $"not found: {path}"); ModManager.Log($"[{Mod.Name}] creature index not found at {path} - no rosters can be drawn", ModManager.LogLevel.Warn); return 0; } // 10.7 MB of compact JSON. Streamed rather than ReadAllText so the string is // never on the large-object heap, and read once at load rather than per run. using var stream = File.OpenRead(path); var document = JsonSerializer.Deserialize(stream, new JsonSerializerOptions { PropertyNameCaseInsensitive = true, NumberHandling = JsonNumberHandling.AllowReadingFromString, }); var scored = (document?.Creatures ?? new List()) .Where(c => c.Score is > 0 && c.Wcid > 0) .ToList(); Set(scored, document?.Generated ?? "", $"{scored.Count:N0} scored creatures from {path}"); ModManager.Log($"[{Mod.Name}] creature index: {scored.Count:N0} scored creatures " + $"(generated {document?.Generated}), from {path}"); return scored.Count; } catch (Exception ex) { Set(new List(), "", $"{ex.GetType().Name}: {ex.Message}"); ModManager.Log($"[{Mod.Name}] could not read the creature index at {path}: {ex.Message}", ModManager.LogLevel.Warn); return 0; } } private static void Set(List all, string generated, string status) { lock (_lock) { _all = all; _generated = generated; _status = status; } } public static void Unload() => Set(new List(), "", "unloaded"); private class IndexFile { [JsonPropertyName("generated")] public string Generated { get; set; } = ""; [JsonPropertyName("creatures")] public List Creatures { get; set; } = new(); } }