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