namespace Aeshnidae.Leaderboard;
///
/// One character as the boards know them.
///
public sealed record CharacterRow(uint Id, string Name, uint AccountId, double LastLogin);
///
/// Everything one refresh reads from the databases, before any board is built.
/// Dictionaries are keyed by character id unless they say account.
///
public sealed class RawData
{
public List Characters { get; } = new();
/// Every character on the shard by id, staff and the excluded included: an allegiance counts all its members.
public Dictionary AllNames { get; } = new();
/// Character id to monarch id, for everyone sworn to anyone (a monarch is their own).
public Dictionary MonarchOf { get; } = new();
/// Monarch id to the allegiance's name, where one has been set.
public Dictionary AllegianceNames { get; } = new();
/// Allegiance experience each character has generated, by character id.
public Dictionary AllegianceXpGenerated { get; } = new();
public Dictionary> IntProps { get; } = new();
public Dictionary> Int64Props { get; } = new();
public Dictionary> BankByAccount { get; } = new();
public Dictionary MasteryRanks { get; } = new();
public Dictionary AuraRanks { get; } = new();
public Dictionary> Stats { get; } = new();
public long Int(int type, uint id) => IntProps.TryGetValue(type, out var d) && d.TryGetValue(id, out var v) ? v : 0;
public long Int64(int type, uint id) => Int64Props.TryGetValue(type, out var d) && d.TryGetValue(id, out var v) ? v : 0;
public long Bank(string currency, uint accountId) => BankByAccount.TryGetValue(currency, out var d) && d.TryGetValue(accountId, out var v) ? v : 0;
public long Stat(string stat, uint id) => Stats.TryGetValue(stat, out var d) && d.TryGetValue(id, out var v) ? v : 0;
}
///
/// The database side: the lifetime counters this mod keeps, and the one big read a
/// refresh makes across the shard, the auth database (for staff) and the sibling
/// mods' tables (bank balances, mastery ranks, aura ranks).
///
/// Counters are buffered in memory and flushed on a timer, because a kill happens
/// several times a second on a good pull and a row write per kill would be the
/// most expensive thing the server does. The buffer is what can be lost on a crash:
/// at most FlushSeconds of counting.
///
public static class Db
{
public const string StatsTable = "aeshnidae_stats";
public const string HistoryTable = "aeshnidae_leaderboard_history";
private static string _shard = "";
private static string _authDatabase = "";
public static bool Ready { get; private set; }
public static string LastError { get; private set; } = "";
private static readonly ConcurrentDictionary<(uint Character, string Stat), long> _pending = new();
public static void Initialize()
{
try
{
var cfg = ConfigManager.Config?.MySql?.Shard
?? throw new InvalidOperationException("shard database is not configured");
_shard = new MySqlConnectionStringBuilder
{
Server = cfg.Host,
Port = cfg.Port,
Database = cfg.Database,
UserID = cfg.Username,
Password = cfg.Password,
}.ConnectionString;
_authDatabase = ConfigManager.Config?.MySql?.Authentication?.Database ?? "";
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
CREATE TABLE IF NOT EXISTS `{StatsTable}` (
`character_Id` INT UNSIGNED NOT NULL,
`stat` VARCHAR(32) NOT NULL,
`value` BIGINT NOT NULL DEFAULT 0,
`updated` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`character_Id`, `stat`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
cmd.ExecuteNonQuery();
// The boards as they stood, hour by hour: every row of every board at
// the top of each hour, so a day's or a week's movement can be read
// off later. Hourly rows are thinned to one a day after a week.
using var history = conn.CreateCommand();
history.CommandText = $@"
CREATE TABLE IF NOT EXISTS `{HistoryTable}` (
`taken` DATETIME NOT NULL,
`board` VARCHAR(32) NOT NULL,
`name` VARCHAR(64) NOT NULL,
`value` BIGINT NOT NULL,
PRIMARY KEY (`board`, `name`, `taken`),
KEY `by_time` (`taken`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
history.ExecuteNonQuery();
Ready = true;
LastError = "";
}
catch (Exception ex)
{
Ready = false;
LastError = ex.Message;
ModManager.Log($"[{Mod.Name}] storage unavailable, leaderboards are off: {ex.Message}", ModManager.LogLevel.Error);
}
}
private static MySqlConnection Open()
{
var conn = new MySqlConnection(_shard);
conn.Open();
return conn;
}
// ------------------------------------------------------------ counters
public static void Add(uint characterId, string stat, long delta)
{
if (!Ready || delta == 0 || characterId == 0)
return;
_pending.AddOrUpdate((characterId, stat), delta, (_, v) => v + delta);
}
/// What has been counted since the last flush, for the live overlay.
public static long Pending(uint characterId, string stat) =>
_pending.TryGetValue((characterId, stat), out var v) ? v : 0;
public static void Flush()
{
if (!Ready || _pending.IsEmpty)
return;
var keys = _pending.Keys.ToList();
try
{
using var conn = Open();
foreach (var key in keys)
{
if (!_pending.TryRemove(key, out var delta) || delta == 0)
continue;
try
{
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
INSERT INTO `{StatsTable}` (`character_Id`, `stat`, `value`) VALUES (@c, @s, @v)
ON DUPLICATE KEY UPDATE `value` = `value` + @v;";
cmd.Parameters.AddWithValue("@c", key.Character);
cmd.Parameters.AddWithValue("@s", key.Stat);
cmd.Parameters.AddWithValue("@v", delta);
cmd.ExecuteNonQuery();
}
catch
{
// Put it back and try again next flush; nothing is lost.
_pending.AddOrUpdate(key, delta, (_, v) => v + delta);
throw;
}
}
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] flush failed, will retry: {ex.Message}", ModManager.LogLevel.Warn);
}
}
// ------------------------------------------------------------ history
/// When the most recent snapshot was taken, or null when there is none.
public static DateTime? LastSnapshotUtc()
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT MAX(`taken`) FROM `{HistoryTable}`;";
var value = cmd.ExecuteScalar();
return value is DateTime taken ? DateTime.SpecifyKind(taken, DateTimeKind.Utc) : null;
}
/// Writes every row of every board as of one instant.
public static void WriteSnapshot(DateTime takenUtc, IReadOnlyDictionary> snapshot)
{
using var conn = Open();
using var transaction = conn.BeginTransaction();
using var cmd = conn.CreateCommand();
cmd.Transaction = transaction;
cmd.CommandText = $"INSERT IGNORE INTO `{HistoryTable}` (`taken`, `board`, `name`, `value`) VALUES (@t, @b, @n, @v);";
var t = cmd.Parameters.Add("@t", MySqlDbType.DateTime);
var b = cmd.Parameters.Add("@b", MySqlDbType.VarChar);
var n = cmd.Parameters.Add("@n", MySqlDbType.VarChar);
var v = cmd.Parameters.Add("@v", MySqlDbType.Int64);
t.Value = takenUtc;
foreach (var (board, rows) in snapshot)
{
b.Value = board;
foreach (var row in rows)
{
n.Value = row.Name.Length > 64 ? row.Name[..64] : row.Name;
v.Value = row.Value;
cmd.ExecuteNonQuery();
}
}
transaction.Commit();
}
///
/// The boards as they stood at the latest snapshot taken at or before
/// : board, then name, to value. Null when there is
/// no snapshot that old, with the time the snapshot was taken.
///
public static (DateTime Taken, Dictionary> Boards)? ReadBaseline(DateTime asOfUtc)
{
using var conn = Open();
// The latest snapshot old enough; failing that - the history younger
// than the window - the earliest there is, so the movers say what has
// moved since the history began rather than nothing.
DateTime taken;
using (var when = conn.CreateCommand())
{
when.CommandText = $"SELECT COALESCE((SELECT MAX(`taken`) FROM `{HistoryTable}` WHERE `taken` <= @asOf), (SELECT MIN(`taken`) FROM `{HistoryTable}`));";
when.Parameters.AddWithValue("@asOf", asOfUtc);
if (when.ExecuteScalar() is not DateTime found)
return null;
taken = DateTime.SpecifyKind(found, DateTimeKind.Utc);
}
var boards = new Dictionary>();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT `board`, `name`, `value` FROM `{HistoryTable}` WHERE `taken` = @t;";
cmd.Parameters.AddWithValue("@t", taken);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var board = reader.GetString(0);
if (!boards.TryGetValue(board, out var rows))
boards[board] = rows = new Dictionary(StringComparer.OrdinalIgnoreCase);
rows[reader.GetString(1)] = reader.GetInt64(2);
}
return (taken, boards);
}
///
/// A name's value on each board at the first snapshot of each day, for the
/// last : board, then day (yyyy-MM-dd), to value.
///
public static Dictionary> ReadDaily(string name, int days)
{
var result = new Dictionary>();
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
SELECT h.board, DATE(h.taken), h.value FROM `{HistoryTable}` h
JOIN (SELECT DATE(`taken`) d, MIN(`taken`) first FROM `{HistoryTable}` WHERE `taken` >= @since GROUP BY DATE(`taken`)) f
ON f.first = h.taken
WHERE h.name = @n;";
cmd.Parameters.AddWithValue("@n", name);
cmd.Parameters.AddWithValue("@since", DateTime.UtcNow.Date.AddDays(-Math.Max(1, days)));
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var board = reader.GetString(0);
if (!result.TryGetValue(board, out var byDay))
result[board] = byDay = new SortedDictionary(StringComparer.Ordinal);
byDay[reader.GetDateTime(1).ToString("yyyy-MM-dd")] = reader.GetInt64(2);
}
return result;
}
///
/// Every name's value on every board at the first snapshot of each day, for
/// the last : the days in order, then board, then
/// name, to a value per day (null where the name was not on the board).
///
public static (List Days, Dictionary> Boards) ReadDailyAll(int days)
{
var dayIndex = new Dictionary(StringComparer.Ordinal);
var dayList = new List();
var boards = new Dictionary>();
using var conn = Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"SELECT DISTINCT DATE(`taken`) FROM `{HistoryTable}` WHERE `taken` >= @since ORDER BY 1;";
cmd.Parameters.AddWithValue("@since", DateTime.UtcNow.Date.AddDays(-Math.Max(1, days)));
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var day = reader.GetDateTime(0).ToString("yyyy-MM-dd");
dayIndex[day] = dayList.Count;
dayList.Add(day);
}
}
if (dayList.Count == 0)
return (dayList, boards);
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $@"
SELECT h.board, h.name, DATE(h.taken), h.value FROM `{HistoryTable}` h
JOIN (SELECT DATE(`taken`) d, MIN(`taken`) first FROM `{HistoryTable}` WHERE `taken` >= @since GROUP BY DATE(`taken`)) f
ON f.first = h.taken;";
cmd.Parameters.AddWithValue("@since", DateTime.UtcNow.Date.AddDays(-Math.Max(1, days)));
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var board = reader.GetString(0);
var name = reader.GetString(1);
if (!dayIndex.TryGetValue(reader.GetDateTime(2).ToString("yyyy-MM-dd"), out var index))
continue;
if (!boards.TryGetValue(board, out var names))
boards[board] = names = new Dictionary(StringComparer.Ordinal);
if (!names.TryGetValue(name, out var values))
names[name] = values = new long?[dayList.Count];
values[index] = reader.GetInt64(3);
}
}
return (dayList, boards);
}
///
/// Hourly snapshots older than a week are thinned to the one taken in the
/// first hour of each day; those older than go.
///
public static void PruneHistory(int keepDays)
{
using var conn = Open();
using (var thin = conn.CreateCommand())
{
thin.CommandText = $"DELETE FROM `{HistoryTable}` WHERE `taken` < @weekAgo AND HOUR(`taken`) <> 0;";
thin.Parameters.AddWithValue("@weekAgo", DateTime.UtcNow.AddDays(-8));
thin.ExecuteNonQuery();
}
if (keepDays > 0)
{
using var old = conn.CreateCommand();
old.CommandText = $"DELETE FROM `{HistoryTable}` WHERE `taken` < @oldest;";
old.Parameters.AddWithValue("@oldest", DateTime.UtcNow.AddDays(-keepDays));
old.ExecuteNonQuery();
}
}
// ------------------------------------------------------------ the read
public static RawData Read()
{
var data = new RawData();
using var conn = Open();
// The characters that count: not deleted, not staff (by account access
// level in the auth database), not on the exclusion list.
var staffJoin = Mod.Settings.ExcludeStaff && _authDatabase.Length > 0
? $"LEFT JOIN `{_authDatabase}`.`account` a ON a.accountId = c.account_Id"
: "";
var staffWhere = Mod.Settings.ExcludeStaff && _authDatabase.Length > 0
? "AND COALESCE(a.accessLevel, 0) = 0"
: "";
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $@"
SELECT c.id, c.name, c.account_Id, c.last_Login_Timestamp
FROM `character` c {staffJoin}
WHERE c.is_Deleted = 0 {staffWhere};";
using var reader = cmd.ExecuteReader();
var excluded = new HashSet(Mod.Settings.ExcludeNames, StringComparer.OrdinalIgnoreCase);
while (reader.Read())
{
var name = reader.GetString(1);
if (excluded.Contains(name))
continue;
data.Characters.Add(new CharacterRow(reader.GetUInt32(0), name, reader.GetUInt32(2), reader.GetDouble(3)));
}
}
// Allegiances count everyone sworn, staff and the excluded included, so
// the names and the monarch links are read for every character there is.
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT c.id, c.name FROM `character` c WHERE c.is_Deleted = 0;";
using var reader = cmd.ExecuteReader();
while (reader.Read())
data.AllNames[reader.GetUInt32(0)] = reader.GetString(1);
}
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $@"
SELECT i.object_Id, i.value FROM `biota_properties_i_i_d` i
JOIN `character` c ON c.id = i.object_Id AND c.is_Deleted = 0
WHERE i.type = {Boards.MonarchProperty};";
using var reader = cmd.ExecuteReader();
while (reader.Read())
data.MonarchOf[reader.GetUInt32(0)] = reader.GetUInt32(1);
}
// The allegiance's name, when the monarch has given it one, sits on the
// allegiance object - not a character - which points at its monarch.
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $@"
SELECT i.value, s.value FROM `biota_properties_string` s
JOIN `biota_properties_i_i_d` i ON i.object_Id = s.object_Id AND i.type = {Boards.MonarchProperty}
WHERE s.type = {Boards.AllegianceNameProperty};";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var name = reader.GetString(1).Trim();
if (name.Length > 0)
data.AllegianceNames[reader.GetUInt32(0)] = name;
}
}
foreach (var (id, value) in ReadProps(conn, "biota_properties_int64", Boards.AllegianceXpGeneratedProperty))
data.AllegianceXpGenerated[id] = value;
foreach (var type in Boards.IntPropertiesRead)
data.IntProps[type] = ReadProps(conn, "biota_properties_int", type);
foreach (var type in Boards.Int64PropertiesRead)
data.Int64Props[type] = ReadProps(conn, "biota_properties_int64", type);
foreach (var currency in new[] { "Radiance", "Resonance", "Luminance" })
data.BankByAccount[currency] = ReadBank(conn, currency);
ReadSum(conn, "aeshnidae_skill_mastery", data.MasteryRanks);
ReadSum(conn, "aeshnidae_resonance_auras", data.AuraRanks);
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"SELECT `character_Id`, `stat`, `value` FROM `{StatsTable}`;";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var stat = reader.GetString(1);
if (!data.Stats.TryGetValue(stat, out var d))
data.Stats[stat] = d = new Dictionary();
d[reader.GetUInt32(0)] = reader.GetInt64(2);
}
}
return data;
}
private static Dictionary ReadProps(MySqlConnection conn, string table, int type)
{
var result = new Dictionary();
using var cmd = conn.CreateCommand();
// Player biotas only: the character table is the list of those.
cmd.CommandText = $@"
SELECT p.object_Id, p.value FROM `{table}` p
JOIN `character` c ON c.id = p.object_Id
WHERE p.type = @t;";
cmd.Parameters.AddWithValue("@t", type);
using var reader = cmd.ExecuteReader();
while (reader.Read())
result[reader.GetUInt32(0)] = reader.GetInt64(1);
return result;
}
private static Dictionary ReadBank(MySqlConnection conn, string currency)
{
var result = new Dictionary();
try
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT `account_Id`, `amount` FROM `aeshnidae_bank` WHERE `currency` = @c;";
cmd.Parameters.AddWithValue("@c", currency);
using var reader = cmd.ExecuteReader();
while (reader.Read())
result[reader.GetUInt32(0)] = reader.GetInt64(1);
}
catch (MySqlException)
{
// No bank table yet: the Bank mod has never run here. Empty board, not a failure.
}
return result;
}
private static void ReadSum(MySqlConnection conn, string table, Dictionary into)
{
try
{
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT `character_Id`, SUM(`ranks`) FROM `{table}` GROUP BY `character_Id`;";
using var reader = cmd.ExecuteReader();
while (reader.Read())
into[reader.GetUInt32(0)] = reader.IsDBNull(1) ? 0 : reader.GetInt64(1);
}
catch (MySqlException)
{
// That mod has never run here; an empty board.
}
}
}