namespace Aeshnidae.Trials;
/// One recorded run - completed or voided.
public sealed class RunRow
{
public uint Id { get; set; }
public uint CharacterId { get; set; }
public string CharacterName { get; set; } = "";
public ushort Landblock { get; set; }
public int Tier { get; set; }
public float Seconds { get; set; }
public int Kills { get; set; }
public string Status { get; set; } = "";
public string Reason { get; set; } = "";
public DateTime StartedAt { get; set; }
public DateTime FinishedAt { get; set; }
public string Week { get; set; } = "";
public string Time => Format(Seconds);
public static string Format(double seconds)
{
var t = TimeSpan.FromSeconds(Math.Max(0, seconds));
return t.TotalHours >= 1 ? $"{(int)t.TotalHours}:{t.Minutes:00}:{t.Seconds:00}" : $"{t.Minutes}:{t.Seconds:00}";
}
}
/// One weekly trophy: awarded, and claimed when the winner was in the world to take it.
public sealed class AwardRow
{
public uint Id { get; set; }
public string Week { get; set; } = "";
public ushort Landblock { get; set; }
public uint CharacterId { get; set; }
public string CharacterName { get; set; } = "";
public int Tier { get; set; }
public float Seconds { get; set; }
public uint RunId { get; set; }
public long Xp { get; set; }
public DateTime AwardedAt { get; set; }
public DateTime? ClaimedAt { get; set; }
public string Time => RunRow.Format(Seconds);
}
///
/// The two tables, in the shard database so the rows are covered by the same backups
/// as the characters they belong to (the Aeshnidae.Bank / AllegianceBounty pattern).
/// Connection details come from ACE's own Config.js at runtime - no credentials here.
///
/// `aeshnidae_trial_run` is every attempt that ended - status 'complete' with its time,
/// or 'void' with the reason - so the cooldown survives a restart and the log is honest.
/// Records are the complete rows: best per (character, landblock, tier) and server best
/// per (landblock, tier). `aeshnidae_trial_award` is one row per (week, landblock): the
/// trophy, claimed at once when the winner was online, else at their next login. Its
/// unique key is what makes `trial-award` safe to run twice.
///
/// Nothing on a tick touches this class: a run is memory until it ends.
///
public static class TrialDb
{
public const string RunTable = "aeshnidae_trial_run";
public const string AwardTable = "aeshnidae_trial_award";
private static string _connectionString = "";
public static bool Ready { get; private set; }
public static void Initialize()
{
var cfg = ConfigManager.Config?.MySql?.Shard
?? throw new InvalidOperationException("shard database is not configured");
_connectionString = new MySqlConnectionStringBuilder
{
Server = cfg.Host,
Port = cfg.Port,
Database = cfg.Database,
UserID = cfg.Username,
Password = cfg.Password,
}.ConnectionString;
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
CREATE TABLE IF NOT EXISTS `{RunTable}` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`character_Id` INT UNSIGNED NOT NULL,
`character_Name` VARCHAR(64) NOT NULL,
`landblock` SMALLINT UNSIGNED NOT NULL,
`tier` INT NOT NULL,
`seconds` FLOAT NOT NULL,
`kills` INT NOT NULL,
`status` VARCHAR(16) NOT NULL,
`reason` VARCHAR(96) NOT NULL DEFAULT '',
`started_at` DATETIME NOT NULL,
`finished_at` DATETIME NOT NULL,
`week` VARCHAR(8) NOT NULL,
PRIMARY KEY (`id`),
KEY `board` (`landblock`, `status`, `tier`, `seconds`),
KEY `character` (`character_Id`, `landblock`, `started_at`),
KEY `week` (`week`, `landblock`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `{AwardTable}` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`week` VARCHAR(8) NOT NULL,
`landblock` SMALLINT UNSIGNED NOT NULL,
`character_Id` INT UNSIGNED NOT NULL,
`character_Name` VARCHAR(64) NOT NULL,
`tier` INT NOT NULL,
`seconds` FLOAT NOT NULL,
`run_Id` INT UNSIGNED NOT NULL,
`xp` BIGINT NOT NULL,
`awarded_at` DATETIME NOT NULL,
`claimed_at` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `week_landblock` (`week`, `landblock`),
KEY `pending` (`character_Id`, `claimed_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
cmd.ExecuteNonQuery();
Ready = true;
}
private static MySqlConnection Open()
{
var conn = new MySqlConnection(_connectionString);
conn.Open();
return conn;
}
// ------------------------------------------------------------ runs
/// Records an ended run and returns its id.
public static uint InsertRun(RunRow r)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
INSERT INTO `{RunTable}`
(`character_Id`, `character_Name`, `landblock`, `tier`, `seconds`, `kills`, `status`, `reason`, `started_at`, `finished_at`, `week`)
VALUES (@cid, @cname, @lb, @tier, @sec, @kills, @status, @reason, @start, @finish, @week);
SELECT LAST_INSERT_ID();";
cmd.Parameters.AddWithValue("@cid", r.CharacterId);
cmd.Parameters.AddWithValue("@cname", r.CharacterName);
cmd.Parameters.AddWithValue("@lb", r.Landblock);
cmd.Parameters.AddWithValue("@tier", r.Tier);
cmd.Parameters.AddWithValue("@sec", r.Seconds);
cmd.Parameters.AddWithValue("@kills", r.Kills);
cmd.Parameters.AddWithValue("@status", r.Status);
cmd.Parameters.AddWithValue("@reason", r.Reason.Length > 96 ? r.Reason[..96] : r.Reason);
cmd.Parameters.AddWithValue("@start", r.StartedAt);
cmd.Parameters.AddWithValue("@finish", r.FinishedAt);
cmd.Parameters.AddWithValue("@week", r.Week);
return Convert.ToUInt32(cmd.ExecuteScalar());
}
/// When this character last started an attempt at this landblock (UTC), or null.
public static DateTime? LastAttempt(uint characterId, ushort landblock)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT MAX(`started_at`) FROM `{RunTable}` WHERE `character_Id` = @cid AND `landblock` = @lb;";
cmd.Parameters.AddWithValue("@cid", characterId);
cmd.Parameters.AddWithValue("@lb", landblock);
var v = cmd.ExecuteScalar();
return v is DateTime dt ? DateTime.SpecifyKind(dt, DateTimeKind.Utc) : null;
}
/// A character's best complete run at a landblock and tier, or null.
public static RunRow? PersonalBest(uint characterId, ushort landblock, int tier)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"{SelectRun} WHERE `character_Id` = @cid AND `landblock` = @lb AND `tier` = @tier AND `status` = 'complete'
ORDER BY `seconds` ASC, `finished_at` ASC LIMIT 1;";
cmd.Parameters.AddWithValue("@cid", characterId);
cmd.Parameters.AddWithValue("@lb", landblock);
cmd.Parameters.AddWithValue("@tier", tier);
return ReadRuns(cmd).FirstOrDefault();
}
/// A character's best complete run at each tier of each landblock.
public static List PersonalBests(uint characterId)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"{SelectRun} r WHERE `character_Id` = @cid AND `status` = 'complete'
AND `seconds` = (SELECT MIN(`seconds`) FROM `{RunTable}` b WHERE b.`character_Id` = r.`character_Id` AND b.`landblock` = r.`landblock` AND b.`tier` = r.`tier` AND b.`status` = 'complete')
ORDER BY `landblock`, `tier`;";
cmd.Parameters.AddWithValue("@cid", characterId);
return Distinct(ReadRuns(cmd), r => (r.Landblock, r.Tier));
}
/// The server's best complete run at a landblock and tier, or null.
public static RunRow? ServerBest(ushort landblock, int tier)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"{SelectRun} WHERE `landblock` = @lb AND `tier` = @tier AND `status` = 'complete'
ORDER BY `seconds` ASC, `finished_at` ASC LIMIT 1;";
cmd.Parameters.AddWithValue("@lb", landblock);
cmd.Parameters.AddWithValue("@tier", tier);
return ReadRuns(cmd).FirstOrDefault();
}
/// The server's best complete run at every (landblock, tier) that has one.
public static List ServerBests()
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"{SelectRun} r WHERE `status` = 'complete'
AND `seconds` = (SELECT MIN(`seconds`) FROM `{RunTable}` b WHERE b.`landblock` = r.`landblock` AND b.`tier` = r.`tier` AND b.`status` = 'complete')
ORDER BY `landblock`, `tier`, `finished_at`;";
return Distinct(ReadRuns(cmd), r => (r.Landblock, r.Tier));
}
///
/// The top complete runs at a landblock, all tiers, one row per character (their
/// best), fastest first.
///
public static List Top(ushort landblock, int count)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"{SelectRun} r WHERE `landblock` = @lb AND `status` = 'complete'
AND `seconds` = (SELECT MIN(`seconds`) FROM `{RunTable}` b WHERE b.`character_Id` = r.`character_Id` AND b.`landblock` = r.`landblock` AND b.`status` = 'complete')
ORDER BY `seconds` ASC, `finished_at` ASC LIMIT @n;";
cmd.Parameters.AddWithValue("@lb", landblock);
cmd.Parameters.AddWithValue("@n", Math.Max(1, count) * 2);
return Distinct(ReadRuns(cmd), r => r.CharacterId).Take(count).ToList();
}
/// The fastest complete run of a week at a landblock, any tier, or null.
public static RunRow? WeekBest(string week, ushort landblock)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"{SelectRun} WHERE `week` = @week AND `landblock` = @lb AND `status` = 'complete'
ORDER BY `seconds` ASC, `finished_at` ASC LIMIT 1;";
cmd.Parameters.AddWithValue("@week", week);
cmd.Parameters.AddWithValue("@lb", landblock);
return ReadRuns(cmd).FirstOrDefault();
}
/// Records held by a character: how many (landblock, tier) server bests are theirs.
public static List RecordsHeldBy(uint characterId) =>
ServerBests().Where(r => r.CharacterId == characterId).ToList();
private const string SelectRun =
$"SELECT `id`, `character_Id`, `character_Name`, `landblock`, `tier`, `seconds`, `kills`, `status`, `reason`, `started_at`, `finished_at`, `week` FROM `{RunTable}`";
private static List ReadRuns(MySqlCommand cmd)
{
var list = new List();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
list.Add(new RunRow
{
Id = reader.GetUInt32(0),
CharacterId = reader.GetUInt32(1),
CharacterName = reader.GetString(2),
Landblock = reader.GetUInt16(3),
Tier = reader.GetInt32(4),
Seconds = reader.GetFloat(5),
Kills = reader.GetInt32(6),
Status = reader.GetString(7),
Reason = reader.GetString(8),
StartedAt = DateTime.SpecifyKind(reader.GetDateTime(9), DateTimeKind.Utc),
FinishedAt = DateTime.SpecifyKind(reader.GetDateTime(10), DateTimeKind.Utc),
Week = reader.GetString(11),
});
}
return list;
}
/// Ties on seconds come back as several rows; the earliest finish keeps the record.
private static List Distinct(List rows, Func key) where TKey : notnull
{
var seen = new HashSet();
var result = new List();
foreach (var r in rows.OrderBy(r => r.Seconds).ThenBy(r => r.FinishedAt))
{
if (seen.Add(key(r)))
result.Add(r);
}
return result;
}
// ------------------------------------------------------------ awards
/// Records a trophy. Returns false when this (week, landblock) already has one.
public static bool InsertAward(AwardRow a)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
INSERT IGNORE INTO `{AwardTable}`
(`week`, `landblock`, `character_Id`, `character_Name`, `tier`, `seconds`, `run_Id`, `xp`, `awarded_at`, `claimed_at`)
VALUES (@week, @lb, @cid, @cname, @tier, @sec, @run, @xp, @at, @claimed);";
cmd.Parameters.AddWithValue("@week", a.Week);
cmd.Parameters.AddWithValue("@lb", a.Landblock);
cmd.Parameters.AddWithValue("@cid", a.CharacterId);
cmd.Parameters.AddWithValue("@cname", a.CharacterName);
cmd.Parameters.AddWithValue("@tier", a.Tier);
cmd.Parameters.AddWithValue("@sec", a.Seconds);
cmd.Parameters.AddWithValue("@run", a.RunId);
cmd.Parameters.AddWithValue("@xp", a.Xp);
cmd.Parameters.AddWithValue("@at", a.AwardedAt);
cmd.Parameters.AddWithValue("@claimed", (object?)a.ClaimedAt ?? DBNull.Value);
var n = cmd.ExecuteNonQuery();
if (n > 0)
{
cmd.CommandText = "SELECT LAST_INSERT_ID();";
cmd.Parameters.Clear();
a.Id = Convert.ToUInt32(cmd.ExecuteScalar());
}
return n > 0;
}
public static bool WeekAwarded(string week, ushort landblock)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT COUNT(*) FROM `{AwardTable}` WHERE `week` = @week AND `landblock` = @lb;";
cmd.Parameters.AddWithValue("@week", week);
cmd.Parameters.AddWithValue("@lb", landblock);
return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
}
public static void MarkClaimed(uint awardId, DateTime whenUtc)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"UPDATE `{AwardTable}` SET `claimed_at` = @at WHERE `id` = @id;";
cmd.Parameters.AddWithValue("@at", whenUtc);
cmd.Parameters.AddWithValue("@id", awardId);
cmd.ExecuteNonQuery();
}
/// Trophies a character has not yet received, oldest first.
public static List PendingFor(uint characterId)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"{SelectAward} WHERE `character_Id` = @cid AND `claimed_at` IS NULL ORDER BY `awarded_at`;";
cmd.Parameters.AddWithValue("@cid", characterId);
return ReadAwards(cmd);
}
/// Every trophy a character has won.
public static List AwardsOf(uint characterId)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"{SelectAward} WHERE `character_Id` = @cid ORDER BY `awarded_at` DESC;";
cmd.Parameters.AddWithValue("@cid", characterId);
return ReadAwards(cmd);
}
/// The most recent trophies, newest first.
public static List RecentAwards(int count)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"{SelectAward} ORDER BY `awarded_at` DESC, `landblock` LIMIT @n;";
cmd.Parameters.AddWithValue("@n", Math.Max(1, count));
return ReadAwards(cmd);
}
private const string SelectAward =
$"SELECT `id`, `week`, `landblock`, `character_Id`, `character_Name`, `tier`, `seconds`, `run_Id`, `xp`, `awarded_at`, `claimed_at` FROM `{AwardTable}`";
private static List ReadAwards(MySqlCommand cmd)
{
var list = new List();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
list.Add(new AwardRow
{
Id = reader.GetUInt32(0),
Week = reader.GetString(1),
Landblock = reader.GetUInt16(2),
CharacterId = reader.GetUInt32(3),
CharacterName = reader.GetString(4),
Tier = reader.GetInt32(5),
Seconds = reader.GetFloat(6),
RunId = reader.GetUInt32(7),
Xp = reader.GetInt64(8),
AwardedAt = DateTime.SpecifyKind(reader.GetDateTime(9), DateTimeKind.Utc),
ClaimedAt = reader.IsDBNull(10) ? null : DateTime.SpecifyKind(reader.GetDateTime(10), DateTimeKind.Utc),
});
}
return list;
}
}