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();
}
}