namespace Aeshnidae.Leaderboard; /// /// The boards over time. Once an hour the whole snapshot is written to the /// history table; after every refresh the movers are worked out against the /// snapshot a day and a week back - who gained what on each board - and any /// gain past a threshold in Settings is raised as an alert, for the staff /// channel, once per name, board and day. A character who advances faster /// than the game allows is what the alerts are for; the movers tables are the /// same numbers for everyone to see. /// public static class History { public static readonly (string Window, int Hours)[] Windows = { ("24h", 24), ("7d", 168) }; public static IReadOnlyList Movers { get; private set; } = Array.Empty(); public static DateTime? LastSnapshotUtc { get; private set; } public static string LastError { get; private set; } = ""; private static bool _snapshotTimeKnown; private static DateTime _lastPruneUtc; private static readonly HashSet _alerted = new(StringComparer.OrdinalIgnoreCase); private static string _alertedDay = ""; /// After a refresh: snapshot if the hour has turned, rebuild the movers, raise alerts. public static void Tick(IReadOnlyDictionary> snapshot) { if (!Mod.Settings.History.Enabled) return; try { var now = DateTime.UtcNow; if (!_snapshotTimeKnown) { LastSnapshotUtc = Db.LastSnapshotUtc(); _snapshotTimeKnown = true; } var hour = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0, DateTimeKind.Utc); if (LastSnapshotUtc is null || LastSnapshotUtc.Value < hour) { var firstOfDay = LastSnapshotUtc is null || LastSnapshotUtc.Value.Date < hour.Date; Db.WriteSnapshot(hour, snapshot); LastSnapshotUtc = hour; if (firstOfDay && Mod.Settings.History.WriteDailyFiles) WriteDailyFiles(hour, snapshot); } if (now - _lastPruneUtc > TimeSpan.FromHours(24)) { Db.PruneHistory(Mod.Settings.History.KeepDays); _lastPruneUtc = now; } var movers = new List(); foreach (var (window, hours) in Windows) { var baseline = Db.ReadBaseline(now.AddHours(-hours)); if (baseline is null || baseline.Value.Taken >= hour) continue; // no history yet, or only this hour's snapshot: nothing has moved from it foreach (var board in Boards.All) { if (!snapshot.TryGetValue(board.Key, out var rows)) continue; baseline.Value.Boards.TryGetValue(board.Key, out var before); movers.Add(new Movers(board.Key, window, hours, baseline.Value.Taken, MoversOf(rows, before))); } } Movers = movers; LastError = ""; RaiseAlerts(now); } catch (Exception ex) { LastError = ex.Message; ModManager.Log($"[{Mod.Name}] history failed: {ex.Message}", ModManager.LogLevel.Warn); } } /// /// Everyone on the board now who has more than they had at the baseline, /// most gained first. A name the baseline does not have is new to the board /// since then, and counts from zero, marked as such. /// private static List MoversOf(List now, Dictionary? before) { var gains = new List<(string Name, long From, long To)>(); foreach (var row in now) { var from = before is not null && before.TryGetValue(row.Name, out var v) ? v : 0; if (row.Value > from) gains.Add((row.Name, from, row.Value)); } var result = new List(); var rank = 0; long last = -1; var shown = 0; foreach (var (name, from, to) in gains.OrderByDescending(g => g.To - g.From).ThenBy(g => g.Name)) { shown++; var delta = to - from; if (delta != last) { rank = shown; last = delta; } result.Add(new MoverRow(rank, name, from, to, delta, before is null || !before.ContainsKey(name))); } return result; } public static string HistoryDir => Path.Combine(Mod.ModPath, "history"); public static string HistoryJsonPath => Path.Combine(Mod.ModPath, "history.json"); private static readonly JsonSerializerOptions FileJson = new() { WriteIndented = false }; /// /// The day's snapshot as a file of its own - history/YYYY-MM-DD.json, the /// full boards as they stood at the first snapshot of the day - and /// history.json, every name's value on every board day by day for the /// last PublishDays, for the website's history lookup. Both are what an /// admin opens later, when a name's rise wants looking into. /// public static void WriteDailyFiles(DateTime takenUtc, IReadOnlyDictionary> snapshot) { try { Directory.CreateDirectory(HistoryDir); var day = new { taken = takenUtc.ToString("O"), boards = Boards.All.Select(b => new { key = b.Key, title = b.Title, unit = b.Unit, rows = (snapshot.TryGetValue(b.Key, out var rows) ? rows : new List()) .Select(r => new { rank = r.Rank, name = r.Name, value = r.Value }), }), }; var dayPath = Path.Combine(HistoryDir, takenUtc.ToString("yyyy-MM-dd") + ".json"); File.WriteAllText(dayPath + ".tmp", JsonSerializer.Serialize(day, FileJson)); File.Move(dayPath + ".tmp", dayPath, overwrite: true); var (days, boards) = Db.ReadDailyAll(Mod.Settings.History.PublishDays); var all = new { generated = DateTime.UtcNow.ToString("O"), days, boards = Boards.All.Where(b => boards.ContainsKey(b.Key)).Select(b => new { key = b.Key, title = b.Title, unit = b.Unit, names = boards[b.Key], }), }; File.WriteAllText(HistoryJsonPath + ".tmp", JsonSerializer.Serialize(all, FileJson)); File.Move(HistoryJsonPath + ".tmp", HistoryJsonPath, overwrite: true); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not write the daily history files: {ex.Message}", ModManager.LogLevel.Warn); } } /// /top history: the files are rewritten from the table now, whatever the hour. public static void WriteDailyFilesNow() { var snapshot = Boards.Snapshot; if (snapshot.Count > 0) WriteDailyFiles(DateTime.UtcNow, snapshot); } public static Movers? Find(string boardKey, string window) => Movers.FirstOrDefault(m => m.BoardKey == boardKey && m.Window.Equals(window, StringComparison.OrdinalIgnoreCase)); /// A gain past a configured threshold is raised once per name, board and UTC day. private static void RaiseAlerts(DateTime now) { var alerts = Mod.Settings.History.Alerts; if (alerts.Count == 0) return; var today = now.ToString("yyyy-MM-dd"); if (_alertedDay != today) { _alerted.Clear(); _alertedDay = today; } foreach (var alert in alerts) { if (alert.Delta <= 0) continue; var window = Windows.FirstOrDefault(w => w.Hours == alert.Hours); if (window.Window is null) continue; var movers = Find(alert.Board, window.Window); if (movers is null) continue; var board = Boards.Find(alert.Board); foreach (var row in movers.Rows) { if (row.Delta < alert.Delta) break; // sorted by gain; nothing below is over the line if (!_alerted.Add($"{row.Name}|{alert.Board}")) continue; var text = $":rotating_light: **{row.Name}** gained **{row.Delta:N0}** {board?.Unit ?? alert.Board} in {window.Window} on {board?.Title ?? alert.Board}" + $" ({row.From:N0} -> {row.To:N0}; the line is {alert.Delta:N0}){(row.IsNew ? " - new to the board in that time" : "")}."; ModManager.Log($"[{Mod.Name}] alert: {text}", ModManager.LogLevel.Warn); Discord.PostAlert(text); } } } }