namespace Aeshnidae.Leaderboard; public static class Commands { [CommandHandler("top", AccessLevel.Player, CommandHandlerFlag.RequiresWorld, -1, "Leaderboards: who is highest, richest, deadliest and most enlightened.", "/top the boards, with who leads each\n" + "/top [n] the top n of one board (default 10)\n" + "/top me where you stand on every board\n" + "/top me where you stand on one\n" + "/top movers [board] [24h|7d] [n] who gained most on a board over the last day or week\n" + "/top history [board] [days] a character's value day by day, from the snapshots")] public static void HandleTop(Session session, params string[] parameters) { var player = session.Player; if (player is null) return; if (!Db.Ready) { Reply(session, $"Leaderboards are unavailable: {Db.LastError}"); return; } if (Boards.Snapshot.Count == 0) { Reply(session, "The boards are still being built - try again in a moment."); return; } if (parameters.Length == 0) { Overview(session); return; } var first = parameters[0].ToLowerInvariant(); switch (first) { case "me": Me(session, player.Name); return; case "movers": Movers(session, parameters.Skip(1).ToArray(), player.Name); return; case "history": HistoryOf(session, parameters.Skip(1).ToArray()); return; case "writehistory" when session.AccessLevel >= AccessLevel.Admin: History.WriteDailyFilesNow(); Reply(session, $"Wrote {History.HistoryJsonPath} and today's file from the table."); return; case "refresh" when session.AccessLevel >= AccessLevel.Admin: Mod.RefreshNow(); Reply(session, $"Refreshed: {Boards.CharacterCount} characters{(Boards.LastRefreshError.Length > 0 ? $" - {Boards.LastRefreshError}" : "")}."); return; case "post" when session.AccessLevel >= AccessLevel.Admin: Discord.PostNow(); Reply(session, "Posting to Discord - /top hooks shows the result in a moment."); return; case "hooks" when session.AccessLevel >= AccessLevel.Admin: var sb = new StringBuilder(); sb.AppendLine($"Last refresh {Boards.LastRefreshUtc:HH:mm:ss} UTC, {Boards.CharacterCount} characters. Discord: {Discord.LastResult}. Alerts: {Discord.LastAlertResult}."); sb.AppendLine($"History: last snapshot {(History.LastSnapshotUtc is { } snap ? snap.ToString("yyyy-MM-dd HH:mm") + " UTC" : "none")}, {History.Movers.Count} movers tables{(History.LastError.Length > 0 ? $", last error: {History.LastError}" : "")}."); foreach (var s in Hooks.Status) sb.AppendLine($" {(s.Bound ? "ok " : "-- ")} {s.Target}: {s.Note}"); Reply(session, sb.ToString().TrimEnd()); return; } // /top [n | me] var count = Mod.Settings.Rows; var rest = parameters.ToList(); var mine = false; if (rest.Count > 1 && rest[^1].Equals("me", StringComparison.OrdinalIgnoreCase)) { mine = true; rest.RemoveAt(rest.Count - 1); } else if (rest.Count > 1 && int.TryParse(rest[^1], out var n)) { count = Math.Clamp(n, 1, Mod.Settings.MaxRows); rest.RemoveAt(rest.Count - 1); } var board = Boards.Find(string.Join(" ", rest)); if (board is null) { Reply(session, $"No board called '{string.Join(" ", rest)}'. Boards: {string.Join(", ", Boards.All.Select(b => b.Key))}."); return; } if (mine) { var pos = Boards.Position(board.Key, player.Name); Reply(session, pos is null ? $"{board.Title}: you are not on it yet." : $"{board.Title}: you are #{pos.Rank} of {Boards.Snapshot[board.Key].Count} with {pos.Value:N0} {board.Unit}{(pos.Extra.Length > 0 ? $" ({pos.Extra})" : "")}."); return; } ShowBoard(session, board, count, player.Name); } [CommandHandler("top-reload", AccessLevel.Admin, CommandHandlerFlag.None, 0, "Restart the Aeshnidae.Leaderboard mod, re-reading Settings.json.", "/top-reload")] public static void HandleReload(Session session, params string[] parameters) { var container = Mod.Container; if (container is null) { Reply(session, $"{Mod.Name} is not loaded."); return; } container.Restart(); Reply(session, $"{Mod.Name} restarted."); } /// /top movers [board] [24h|7d] [n]: the movers of one board, or the leader of each. private static void Movers(Session session, string[] args, string self) { var window = "24h"; var count = Mod.Settings.Rows; var rest = args.ToList(); for (var i = rest.Count - 1; i >= 0; i--) { if (rest[i].Equals("24h", StringComparison.OrdinalIgnoreCase) || rest[i].Equals("7d", StringComparison.OrdinalIgnoreCase)) { window = rest[i].ToLowerInvariant(); rest.RemoveAt(i); } else if (int.TryParse(rest[i], out var n)) { count = Math.Clamp(n, 1, Mod.Settings.MaxRows); rest.RemoveAt(i); } } if (History.Movers.Count == 0) { Reply(session, "No movers yet - the first snapshot is less than an hour old, or the history is off."); return; } var sb = new StringBuilder(); if (rest.Count == 0) { sb.AppendLine($"Most gained in the last {window} - /top movers {window} for the rest of a board:"); foreach (var board in Boards.All) { var m = History.Find(board.Key, window); var top = m?.Rows.FirstOrDefault(); sb.AppendLine(top is null ? $" {board.Key,-17} {board.Title}: nobody" : $" {board.Key,-17} {board.Title}: {top.Name} +{top.Delta:N0}"); } Reply(session, sb.ToString().TrimEnd()); return; } var wanted = Boards.Find(string.Join(" ", rest)); if (wanted is null) { Reply(session, $"No board called '{string.Join(" ", rest)}'. Boards: {string.Join(", ", Boards.All.Select(b => b.Key))}."); return; } var movers = History.Find(wanted.Key, window); if (movers is null || movers.Rows.Count == 0) { Reply(session, $"{wanted.Title}: nobody gained anything in the last {window}."); return; } sb.AppendLine($"{wanted.Title} - most gained in the last {window} (since {movers.BaselineTakenUtc:d MMM HH:mm} UTC), top {Math.Min(count, movers.Rows.Count)} of {movers.Rows.Count}:"); foreach (var row in movers.Rows.Take(count)) { var you = row.Name.Equals(self, StringComparison.OrdinalIgnoreCase) ? " <- you" : ""; sb.AppendLine($" {row.Rank,3}. {row.Name,-24} +{row.Delta,15:N0} ({row.From:N0} -> {row.To:N0}){(row.IsNew ? " new" : "")}{you}"); } Reply(session, sb.ToString().TrimEnd()); } /// /top history <name> [board] [days]: what the snapshots say a name had, day by day. private static void HistoryOf(Session session, string[] args) { var days = 14; var rest = args.ToList(); if (rest.Count > 1 && int.TryParse(rest[^1], out var n)) { days = Math.Clamp(n, 1, 400); rest.RemoveAt(rest.Count - 1); } if (rest.Count == 0) { Reply(session, "usage: /top history [board] [days]"); return; } // The name may be several words; a trailing board key is taken off it. Board? board = null; if (rest.Count > 1 && Boards.Find(rest[^1]) is { } found && found.Key.StartsWith(rest[^1].ToLowerInvariant().Replace(' ', '-'))) { board = found; rest.RemoveAt(rest.Count - 1); } var name = string.Join(" ", rest); Dictionary> daily; try { daily = Db.ReadDaily(name, days); } catch (Exception ex) { Reply(session, $"History unavailable: {ex.Message}"); return; } if (daily.Count == 0) { Reply(session, $"No snapshots have {name} on any board in the last {days} days."); return; } var sb = new StringBuilder(); var boards = board is null ? Boards.All.Where(b => daily.ContainsKey(b.Key)).ToList() : new List { board }; sb.AppendLine($"{name}, day by day (first snapshot of each UTC day), last {days} days:"); foreach (var b in boards) { if (!daily.TryGetValue(b.Key, out var byDay) || byDay.Count == 0) continue; if (board is null) { // One line per board: the first and last day and the gain between them. var first = byDay.First(); var last = byDay.Last(); sb.AppendLine($" {b.Title,-28} {first.Key} {first.Value,16:N0} -> {last.Key} {last.Value,16:N0} (+{last.Value - first.Value:N0})"); continue; } long? previous = null; foreach (var (day, value) in byDay) { sb.AppendLine($" {day} {value,16:N0}{(previous is { } p ? $" {(value - p >= 0 ? "+" : "")}{value - p:N0}" : "")}"); previous = value; } } if (board is null) sb.AppendLine(" /top history for every day of one board."); Reply(session, sb.ToString().TrimEnd()); } private static void Overview(Session session) { var sb = new StringBuilder(); sb.AppendLine($"Leaderboards - {Boards.CharacterCount} characters, refreshed {Ago(Boards.LastRefreshUtc)}. /top for the top ten, /top me for yourself."); foreach (var board in Boards.All) { var leader = Boards.Snapshot.TryGetValue(board.Key, out var rows) && rows.Count > 0 ? rows[0] : null; sb.AppendLine(leader is null ? $" {board.Key,-17} {board.Title}: nobody yet" : $" {board.Key,-17} {board.Title}: {leader.Name}, {leader.Value:N0}"); } Reply(session, sb.ToString().TrimEnd()); } private static void ShowBoard(Session session, Board board, int count, string self) { var rows = Boards.Snapshot.TryGetValue(board.Key, out var list) ? list : new List(); var sb = new StringBuilder(); sb.AppendLine($"{board.Title} - top {Math.Min(count, rows.Count)} of {rows.Count}{(board.SinceInstall ? ", counted since 16 Sep 2026" : "")}{(board.PerAccount ? ", per account" : "")}:"); if (rows.Count == 0) sb.AppendLine(" nobody yet."); foreach (var row in rows.Take(count)) { var you = row.Name.Equals(self, StringComparison.OrdinalIgnoreCase) ? " <- you" : ""; sb.AppendLine($" {row.Rank,3}. {row.Name,-24} {row.Value,16:N0}{(row.Extra.Length > 0 ? $" {row.Extra}" : "")}{you}"); } var mine = Boards.Position(board.Key, self); if (mine is not null && mine.Rank > count) sb.AppendLine($" ... you: #{mine.Rank} with {mine.Value:N0}"); Reply(session, sb.ToString().TrimEnd()); } private static void Me(Session session, string name) { var sb = new StringBuilder(); sb.AppendLine($"{name} on the boards:"); foreach (var board in Boards.All) { var pos = Boards.Position(board.Key, name); var total = Boards.Snapshot.TryGetValue(board.Key, out var rows) ? rows.Count : 0; sb.AppendLine(pos is null ? $" {board.Title,-28} -" : $" {board.Title,-28} #{pos.Rank} of {total}, {pos.Value:N0}"); } Reply(session, sb.ToString().TrimEnd()); } private static string Ago(DateTime utc) { if (utc == default) return "never"; var span = DateTime.UtcNow - utc; return span.TotalMinutes < 1 ? "just now" : $"{span.TotalMinutes:N0} min ago"; } private static void Reply(Session? session, string message) { if (session?.Player is null) { ModManager.Log(message); return; } foreach (var line in (message ?? "").Split('\n')) { var text = line.TrimEnd('\r'); if (!string.IsNullOrWhiteSpace(text)) session.Network.EnqueueSend(new GameMessageSystemChat(text, ChatMessageType.Broadcast)); } } }