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