namespace Aeshnidae.AllegianceBounty;
/// An active bounty entry: one quest, one allegiance, one day.
public class BountyEntry
{
public uint MonarchId { get; set; }
public string QuestName { get; set; } = "";
public string Flag { get; set; } = "";
public double Percent { get; set; }
public string CompletedBy { get; set; } = "";
public uint CompletedById { get; set; }
public DateTime CompletedAt { get; set; }
public DateTime ExpiresAt { get; set; }
public bool Expired(DateTime nowUtc) => ExpiresAt <= nowUtc;
}
///
/// Entries live in their own table in the shard database, keyed by the monarch's guid,
/// so a bounty survives a restart and dies on its own clock, not the server's.
///
/// The shard db is used rather than a side file so the rows are covered by the same
/// backups as the characters they belong to; the pattern is Aeshnidae.Bank's. Connection
/// details come from ACE's own Config.js at runtime - no credentials in the mod.
///
/// The hot path (every kill's GrantXP) never touches this class: Bounty keeps the active
/// entries in memory and only writes through here when one is gained.
///
public static class BountyDb
{
public const string TableName = "aeshnidae_allegiance_bounty";
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 `{TableName}` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`monarch_Id` INT UNSIGNED NOT NULL,
`quest_name` VARCHAR(128) NOT NULL,
`flag` VARCHAR(255) NOT NULL,
`percent` DOUBLE NOT NULL,
`completed_by` VARCHAR(64) NOT NULL,
`completed_by_Id` INT UNSIGNED NOT NULL,
`completed_at` DATETIME NOT NULL,
`expires_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `monarch_quest` (`monarch_Id`, `quest_name`),
KEY `expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
cmd.ExecuteNonQuery();
// The personal layer's table lives beside it (Personal.cs).
Personal.CreateTable(conn);
Ready = true;
}
/// A connection for the personal layer's own reads and writes.
public static MySqlConnection OpenConnection() => Open();
private static MySqlConnection Open()
{
var conn = new MySqlConnection(_connectionString);
conn.Open();
return conn;
}
/// Every entry that has not expired yet, for the cache at startup. Times are UTC.
public static List LoadActive()
{
var list = new List();
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
SELECT `monarch_Id`, `quest_name`, `flag`, `percent`, `completed_by`, `completed_by_Id`, `completed_at`, `expires_at`
FROM `{TableName}` WHERE `expires_at` > @now;";
cmd.Parameters.AddWithValue("@now", DateTime.UtcNow);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
list.Add(new BountyEntry
{
MonarchId = reader.GetUInt32(0),
QuestName = reader.GetString(1),
Flag = reader.GetString(2),
Percent = reader.GetDouble(3),
CompletedBy = reader.GetString(4),
CompletedById = reader.GetUInt32(5),
CompletedAt = DateTime.SpecifyKind(reader.GetDateTime(6), DateTimeKind.Utc),
ExpiresAt = DateTime.SpecifyKind(reader.GetDateTime(7), DateTimeKind.Utc),
});
}
return list;
}
///
/// Records an entry. One row per (allegiance, quest): a completion after the old
/// row expired overwrites it, which is the same as pruning it first.
///
public static void Upsert(BountyEntry e)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
INSERT INTO `{TableName}`
(`monarch_Id`, `quest_name`, `flag`, `percent`, `completed_by`, `completed_by_Id`, `completed_at`, `expires_at`)
VALUES (@m, @q, @f, @p, @by, @byid, @at, @exp)
ON DUPLICATE KEY UPDATE
`flag` = @f, `percent` = @p, `completed_by` = @by, `completed_by_Id` = @byid, `completed_at` = @at, `expires_at` = @exp;";
cmd.Parameters.AddWithValue("@m", e.MonarchId);
cmd.Parameters.AddWithValue("@q", e.QuestName);
cmd.Parameters.AddWithValue("@f", e.Flag);
cmd.Parameters.AddWithValue("@p", e.Percent);
cmd.Parameters.AddWithValue("@by", e.CompletedBy);
cmd.Parameters.AddWithValue("@byid", e.CompletedById);
cmd.Parameters.AddWithValue("@at", e.CompletedAt);
cmd.Parameters.AddWithValue("@exp", e.ExpiresAt);
cmd.ExecuteNonQuery();
}
/// Deletes every row past its expiry. Returns how many went.
public static int PruneExpired()
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"DELETE FROM `{TableName}` WHERE `expires_at` <= @now;";
cmd.Parameters.AddWithValue("@now", DateTime.UtcNow);
return cmd.ExecuteNonQuery();
}
}