namespace Aeshnidae.Leaderboard; public sealed record Row(int Rank, string Name, long Value, string Extra); /// One row of a movers table: what a name gained on a board over a window. public sealed record MoverRow(int Rank, string Name, long From, long To, long Delta, bool IsNew); /// A movers table: one board over one window, from the snapshot the window starts at. public sealed record Movers(string BoardKey, string Window, int Hours, DateTime BaselineTakenUtc, List Rows); /// /// One board: what it ranks and where the number comes from. Character boards have /// a row per character; account boards (the bank balances, which are per account) /// have a row per account, named for the account's most recently played character. /// public sealed class Board { public string Key { get; init; } = ""; public string Title { get; init; } = ""; /// What the number is, for the readouts: "level", "Radiance", "kills". public string Unit { get; init; } = ""; public bool PerAccount { get; init; } /// A row per allegiance, named for the allegiance (or its monarch), built from every sworn character. public bool PerAllegiance { get; init; } /// Counted by this mod since it was installed, rather than read from the character. public bool SinceInstall { get; init; } public Func Value { get; init; } = (_, _, _) => 0; public Func Extra { get; init; } = (_, _, _) => ""; } /// /// The boards themselves, the snapshot they are built into, and the refresh. /// /// A refresh is one read of the databases (Db.Read) and then, for every character /// who is online, the live value off the Player object instead - ACE writes player /// biotas to the database on a timer, so the database is minutes behind for anyone /// in the world and the live read closes that gap. Everyone else is exactly as they /// logged out. /// public static class Boards { public const string RadianceEarned = "radiance_earned"; public const string RadianceSpent = "radiance_spent"; public const string ResonanceEarned = "resonance_earned"; public const string ResonanceSpent = "resonance_spent"; public const string LuminanceEarned = "luminance_earned"; public const string LuminanceSpent = "luminance_spent"; private const int Level = 25, NumDeaths = 43, CreatureKills = 207, PlayerKillsPk = 208, Enlightenment = 390; private const int TotalExperience = 1, AvailableLuminance = 6; /// PropertyInstanceId.Monarch: a sworn character's monarch; a monarch's own is itself. public const int MonarchProperty = 26; /// PropertyString.AllegianceName, on the allegiance object. public const int AllegianceNameProperty = 47; /// PropertyInt64.AllegianceXPGenerated: what a character has passed up the tree over their life. public const int AllegianceXpGeneratedProperty = 9001; public static readonly int[] IntPropertiesRead = { Level, NumDeaths, CreatureKills, PlayerKillsPk, Enlightenment }; public static readonly int[] Int64PropertiesRead = { TotalExperience, AvailableLuminance }; public static readonly IReadOnlyList All = new List { new() { Key = "level", Title = "Level", Unit = "level", Value = (d, c, p) => p?.Level ?? d.Int(Level, c.Id), Extra = (d, c, p) => { var e = p?.Enlightenment ?? d.Int(Enlightenment, c.Id); return e > 0 ? $"enlightened x{e}" : ""; }, }, new() { Key = "enlightenment", Title = "Enlightenments", Unit = "times", Value = (d, c, p) => p?.Enlightenment ?? d.Int(Enlightenment, c.Id), }, new() { Key = "xp", Title = "Lifetime experience", Unit = "experience", Value = (d, c, p) => p?.TotalExperience ?? d.Int64(TotalExperience, c.Id), }, new() { Key = "radiance", Title = "Radiance banked", Unit = "Radiance", PerAccount = true, Value = (d, c, _) => d.Bank("Radiance", c.AccountId), }, new() { Key = "resonance", Title = "Resonance banked", Unit = "Resonance", PerAccount = true, Value = (d, c, _) => d.Bank("Resonance", c.AccountId), }, new() { Key = "luminance", Title = "Luminance", Unit = "Luminance", Value = (d, c, p) => (p?.AvailableLuminance ?? d.Int64(AvailableLuminance, c.Id)) + d.Bank("Luminance", c.AccountId), Extra = (d, c, p) => $"{(p?.AvailableLuminance ?? d.Int64(AvailableLuminance, c.Id)):N0} on hand, {d.Bank("Luminance", c.AccountId):N0} banked", }, new() { Key = "radiance-earned", Title = "Radiance earned", Unit = "Radiance", SinceInstall = true, Value = (d, c, _) => Stat(d, c, RadianceEarned) }, new() { Key = "radiance-spent", Title = "Radiance spent on mastery", Unit = "Radiance", SinceInstall = true, Value = (d, c, _) => Stat(d, c, RadianceSpent) }, new() { Key = "resonance-earned", Title = "Resonance earned", Unit = "Resonance", SinceInstall = true, Value = (d, c, _) => Stat(d, c, ResonanceEarned) }, new() { Key = "resonance-spent", Title = "Resonance spent on auras", Unit = "Resonance", SinceInstall = true, Value = (d, c, _) => Stat(d, c, ResonanceSpent) }, new() { Key = "luminance-earned", Title = "Luminance earned", Unit = "Luminance", SinceInstall = true, Value = (d, c, _) => Stat(d, c, LuminanceEarned) }, new() { Key = "luminance-spent", Title = "Luminance spent", Unit = "Luminance", SinceInstall = true, Value = (d, c, _) => Stat(d, c, LuminanceSpent) }, new() { Key = "kills", Title = "Creature kills", Unit = "kills", Value = (d, c, p) => p?.CreatureKills ?? d.Int(CreatureKills, c.Id), }, new() { Key = "deaths", Title = "Deaths", Unit = "deaths", Value = (d, c, p) => p?.NumDeaths ?? d.Int(NumDeaths, c.Id), Extra = (d, c, p) => { var k = p?.CreatureKills ?? d.Int(CreatureKills, c.Id); var n = p?.NumDeaths ?? d.Int(NumDeaths, c.Id); return n > 0 ? $"{(double)k / n:N0} kills per death" : ""; }, }, new() { Key = "pk", Title = "Player kills", Unit = "kills", Value = (d, c, p) => p?.PlayerKillsPk ?? d.Int(PlayerKillsPk, c.Id), }, new() { Key = "mastery", Title = "Mastery points", Unit = "points", Value = (d, c, _) => d.MasteryRanks.TryGetValue(c.Id, out var v) ? v : 0, }, new() { Key = "auras", Title = "Aura ranks", Unit = "ranks", Value = (d, c, _) => d.AuraRanks.TryGetValue(c.Id, out var v) ? v : 0, }, new() { // Value and Extra take the monarch as the character; the rows are built by AllegianceRows. Key = "allegiance-size", Title = "Largest allegiances", Unit = "members", PerAllegiance = true, Value = (d, c, _) => d.MonarchOf.Count(pair => pair.Value == c.Id), }, new() { Key = "allegiance-xp", Title = "Allegiance experience", Unit = "experience", PerAllegiance = true, Value = (d, c, _) => d.MonarchOf.Where(pair => pair.Value == c.Id).Sum(pair => d.AllegianceXpGenerated.TryGetValue(pair.Key, out var v) ? v : 0), }, }; private static long Stat(RawData d, CharacterRow c, string stat) => d.Stat(stat, c.Id) + Db.Pending(c.Id, stat); public static Board? Find(string text) { var wanted = (text ?? "").Trim().ToLowerInvariant().Replace(' ', '-'); return All.FirstOrDefault(b => b.Key == wanted) ?? All.FirstOrDefault(b => b.Key.StartsWith(wanted) || b.Title.StartsWith(wanted, StringComparison.OrdinalIgnoreCase)); } // ------------------------------------------------------------ snapshot /// Every row of every board, sorted, complete - the readouts take the top of it. public static IReadOnlyDictionary> Snapshot { get; private set; } = new Dictionary>(); public static DateTime LastRefreshUtc { get; private set; } public static string LastRefreshError { get; private set; } = ""; public static int CharacterCount { get; private set; } private static readonly object _refreshLock = new(); public static void Refresh() { if (!Db.Ready) return; lock (_refreshLock) { try { var data = Db.Read(); var online = PlayerManager.GetAllOnline().ToDictionary(p => p.Guid.Full, p => p); var snapshot = new Dictionary>(); foreach (var board in All) { var rows = board.PerAllegiance ? AllegianceRows(board, data) : board.PerAccount ? AccountRows(board, data, online) : CharacterRows(board, data, online); snapshot[board.Key] = rows; } Snapshot = snapshot; CharacterCount = data.Characters.Count; LastRefreshUtc = DateTime.UtcNow; LastRefreshError = ""; History.Tick(snapshot); if (Mod.Settings.WriteJson) WriteJson(); } catch (Exception ex) { LastRefreshError = ex.Message; ModManager.Log($"[{Mod.Name}] refresh failed: {ex}", ModManager.LogLevel.Error); } } } private static List CharacterRows(Board board, RawData data, Dictionary online) { var scored = new List<(CharacterRow C, long V, string X)>(); foreach (var c in data.Characters) { online.TryGetValue(c.Id, out var player); var value = Safe(() => board.Value(data, c, player)); if (value <= 0) continue; scored.Add((c, value, Safe(() => board.Extra(data, c, player)) ?? "")); } return Rank(scored.OrderByDescending(s => s.V).ThenBy(s => s.C.Name).Select(s => (s.C.Name, s.V, s.X))); } /// One row per account, named for its most recently played character. private static List AccountRows(Board board, RawData data, Dictionary online) { var scored = new List<(string Name, long V, string X)>(); foreach (var group in data.Characters.GroupBy(c => c.AccountId)) { var face = group.FirstOrDefault(c => online.ContainsKey(c.Id)) ?? group.OrderByDescending(c => c.LastLogin).First(); var value = Safe(() => board.Value(data, face, null)); if (value <= 0) continue; var others = group.Count() - 1; scored.Add((face.Name, value, others > 0 ? $"and {others} other character{(others == 1 ? "" : "s")}" : "")); } return Rank(scored.OrderByDescending(s => s.V).ThenBy(s => s.Name)); } /// /// One row per allegiance - every character sworn to a monarch, the monarch /// included, whether or not they are on the other boards - named for the /// allegiance when the monarch has named it, else for the monarch. A monarch /// with nobody sworn is not an allegiance. /// private static List AllegianceRows(Board board, RawData data) { var scored = new List<(string Name, long V, string X)>(); foreach (var monarchId in data.MonarchOf.Values.Distinct()) { var members = data.MonarchOf.Count(pair => pair.Value == monarchId); if (members < 2 || !data.AllNames.TryGetValue(monarchId, out var monarchName)) continue; var monarch = new CharacterRow(monarchId, monarchName, 0, 0); var value = Safe(() => board.Value(data, monarch, null)); if (value <= 0) continue; var name = data.AllegianceNames.TryGetValue(monarchId, out var named) ? named : $"{monarchName}'s allegiance"; var extra = board.Key == "allegiance-size" ? $"monarch {monarchName}" : $"monarch {monarchName}, {members:N0} members"; scored.Add((name, value, extra)); } return Rank(scored.OrderByDescending(s => s.V).ThenBy(s => s.Name)); } private static List Rank(IEnumerable<(string Name, long V, string X)> ordered) { var rows = new List(); var rank = 0; long last = -1; var shown = 0; foreach (var (name, value, extra) in ordered) { shown++; if (value != last) { rank = shown; last = value; } // ties share a rank rows.Add(new Row(rank, name, value, extra)); } return rows; } private static T? Safe(Func f) { try { return f(); } catch { return default; } } /// A character's rank on one board, or null if they are not on it. public static Row? Position(string boardKey, string name) => Snapshot.TryGetValue(boardKey, out var rows) ? rows.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) : null; // ------------------------------------------------------------ json public static string JsonPath => Path.Combine(Mod.ModPath, "leaderboard.json"); private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false }; private static void WriteJson() { try { var payload = new { generated = LastRefreshUtc.ToString("O"), refreshMinutes = Mod.Settings.RefreshMinutes, characters = CharacterCount, boards = All.Select(b => new { key = b.Key, title = b.Title, unit = b.Unit, perAccount = b.PerAccount, perAllegiance = b.PerAllegiance, sinceInstall = b.SinceInstall, rows = (Snapshot.TryGetValue(b.Key, out var rows) ? rows : new List()) .Take(Mod.Settings.MaxRows) .Select(r => new { rank = r.Rank, name = r.Name, value = r.Value, extra = r.Extra }), }), movers = History.Movers.Select(m => new { board = m.BoardKey, window = m.Window, hours = m.Hours, since = m.BaselineTakenUtc.ToString("O"), rows = m.Rows.Take(Mod.Settings.MaxRows) .Select(r => new { rank = r.Rank, name = r.Name, from = r.From, to = r.To, delta = r.Delta, isNew = r.IsNew }), }), }; var tmp = JsonPath + ".tmp"; File.WriteAllText(tmp, JsonSerializer.Serialize(payload, JsonOptions)); File.Move(tmp, JsonPath, overwrite: true); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not write {JsonPath}: {ex.Message}", ModManager.LogLevel.Warn); } } }