namespace Aeshnidae.Codex; public sealed record Section(string Title, IReadOnlyList Pages); /// /// Where the pages come from. Each mod that has something to show carries a static /// class named CodexPage in its root namespace: /// /// public static class CodexPage /// { /// public static string Title => "Bank"; /// public static string[] Pages(Player player) => ...; // each one page of text /// } /// /// This mod finds those by reflection through the mod host, in Settings.Sections /// order, and calls them. Reflection because mods live in separate collectible load /// contexts and cannot share a type - but Player and string are ACE's and everyone's, /// so a call that passes one and returns the other crosses the boundary safely. The /// same reason Aeshnidae.AdminAudit hooks its siblings the way it does. /// /// A provider that throws, is missing or is disabled simply contributes nothing; the /// book is never held up by one section. /// internal static class Providers { private sealed record Bound(Assembly Assembly, string Title, MethodInfo Pages); private static readonly Dictionary _cache = new(StringComparer.Ordinal); public static List
Render(Player player) { var result = new List
(); foreach (var modName in Mod.Settings.Sections ?? Array.Empty()) { try { var bound = Resolve(modName); if (bound is null) continue; if (bound.Pages.Invoke(null, new object[] { player }) is not string[] pages || pages.Length == 0) continue; result.Add(new Section(bound.Title, pages)); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] section from {modName} failed: {(ex.InnerException ?? ex).Message}", ModManager.LogLevel.Warn); } } return result; } /// Every section the settings name, and whether it currently answers. public static IEnumerable<(string Mod, string Status)> Status() { foreach (var modName in Mod.Settings.Sections ?? Array.Empty()) { string status; try { var bound = Resolve(modName); status = bound is null ? "no CodexPage (mod off, or none written)" : $"\"{bound.Title}\""; } catch (Exception ex) { status = ex.Message; } yield return (modName, status); } } private static Bound? Resolve(string modName) { var container = ModManager.GetModContainerByName(modName, allowPartial: false); if (container is null || container.Status != ModStatus.Active || container.ModAssembly is null) return null; // A hot-reloaded mod is a new assembly with the same name; the cache is keyed // on the name and checked against the assembly so a stale MethodInfo - which // would keep the dead assembly alive - is never used. if (_cache.TryGetValue(modName, out var cached) && ReferenceEquals(cached.Assembly, container.ModAssembly)) return cached; var type = container.ModAssembly.GetType($"{modName}.CodexPage", throwOnError: false); if (type is null) { _cache.Remove(modName); return null; } var title = type.GetProperty("Title", BindingFlags.Public | BindingFlags.Static)?.GetValue(null) as string ?? modName.Split('.').Last(); var pages = type.GetMethod("Pages", BindingFlags.Public | BindingFlags.Static, new[] { typeof(Player) }); if (pages is null || pages.ReturnType != typeof(string[])) { _cache.Remove(modName); return null; } var bound = new Bound(container.ModAssembly, title, pages); _cache[modName] = bound; return bound; } public static void Clear() => _cache.Clear(); }