namespace Aeshnidae.Expedition; /// /// The quest's two tables, in the shard database beside the characters they belong to (the /// Trials / AllegianceBounty pattern; credentials from ACE's own Config.js at runtime). /// /// aeshnidae_expedition_cycle one row per cycle: its act and its state as JSON /// (meters, milestones told, zones reclaimed - later acts /// add their own fields without a schema change). The open /// cycle is the newest row with no finished_at. /// aeshnidae_expedition_contribution per character per cycle: contribution and kills. /// /// Nothing on a hot path touches this class: the state lives in memory (Expedition) and is /// written every Settings.FlushSeconds, off the world thread, and at shutdown. /// public static class ExpeditionDb { public const string CycleTable = "aeshnidae_expedition_cycle"; public const string ContributionTable = "aeshnidae_expedition_contribution"; 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 `{CycleTable}` ( `cycle` INT UNSIGNED NOT NULL AUTO_INCREMENT, `act` INT NOT NULL, `state` MEDIUMTEXT NOT NULL, `started_at` DATETIME NOT NULL, `updated_at` DATETIME NOT NULL, `finished_at` DATETIME NULL, PRIMARY KEY (`cycle`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `{ContributionTable}` ( `cycle` INT UNSIGNED NOT NULL, `character_Id` INT UNSIGNED NOT NULL, `character_Name` VARCHAR(64) NOT NULL, `contribution` DOUBLE NOT NULL, `kills` INT NOT NULL, `updated_at` DATETIME NOT NULL, PRIMARY KEY (`cycle`, `character_Id`), KEY `board` (`cycle`, `contribution`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"; cmd.ExecuteNonQuery(); Ready = true; } private static MySqlConnection Open() { var conn = new MySqlConnection(_connectionString); conn.Open(); return conn; } /// The open cycle's (number, act, state JSON, started), or null when there is none. public static (int Cycle, int Act, string State, DateTime Started)? LoadOpenCycle() { using var conn = Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = $"SELECT `cycle`, `act`, `state`, `started_at` FROM `{CycleTable}` WHERE `finished_at` IS NULL ORDER BY `cycle` DESC LIMIT 1"; using var r = cmd.ExecuteReader(); if (!r.Read()) return null; return ((int)r.GetUInt32(0), r.GetInt32(1), r.GetString(2), r.GetDateTime(3)); } /// Starts a new cycle row and returns its number. public static int InsertCycle(int act, string state, DateTime started) { using var conn = Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = $"INSERT INTO `{CycleTable}` (`act`, `state`, `started_at`, `updated_at`) VALUES (@act, @state, @started, @started); SELECT LAST_INSERT_ID();"; cmd.Parameters.AddWithValue("@act", act); cmd.Parameters.AddWithValue("@state", state); cmd.Parameters.AddWithValue("@started", started); return Convert.ToInt32(cmd.ExecuteScalar()); } public static void SaveCycle(int cycle, int act, string state, DateTime? finished) { using var conn = Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = $"UPDATE `{CycleTable}` SET `act` = @act, `state` = @state, `updated_at` = UTC_TIMESTAMP(), `finished_at` = @finished WHERE `cycle` = @cycle"; cmd.Parameters.AddWithValue("@act", act); cmd.Parameters.AddWithValue("@state", state); cmd.Parameters.AddWithValue("@finished", (object?)finished ?? DBNull.Value); cmd.Parameters.AddWithValue("@cycle", cycle); cmd.ExecuteNonQuery(); } public static List LoadContributions(int cycle) { var list = new List(); using var conn = Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = $"SELECT `character_Id`, `character_Name`, `contribution`, `kills` FROM `{ContributionTable}` WHERE `cycle` = @cycle"; cmd.Parameters.AddWithValue("@cycle", cycle); using var r = cmd.ExecuteReader(); while (r.Read()) list.Add(new Contribution { CharacterId = r.GetUInt32(0), Name = r.GetString(1), Points = r.GetDouble(2), Kills = r.GetInt32(3) }); return list; } /// Writes these characters' totals (absolute values, not increments). public static void SaveContributions(int cycle, IEnumerable rows) { using var conn = Open(); foreach (var c in rows) { using var cmd = conn.CreateCommand(); cmd.CommandText = $@" INSERT INTO `{ContributionTable}` (`cycle`, `character_Id`, `character_Name`, `contribution`, `kills`, `updated_at`) VALUES (@cycle, @id, @name, @points, @kills, UTC_TIMESTAMP()) ON DUPLICATE KEY UPDATE `character_Name` = VALUES(`character_Name`), `contribution` = VALUES(`contribution`), `kills` = VALUES(`kills`), `updated_at` = VALUES(`updated_at`)"; cmd.Parameters.AddWithValue("@cycle", cycle); cmd.Parameters.AddWithValue("@id", c.CharacterId); cmd.Parameters.AddWithValue("@name", c.Name); cmd.Parameters.AddWithValue("@points", c.Points); cmd.Parameters.AddWithValue("@kills", c.Kills); cmd.ExecuteNonQuery(); } } } /// One character's part in a cycle. public sealed class Contribution { public uint CharacterId { get; set; } public string Name { get; set; } = ""; public double Points { get; set; } public int Kills { get; set; } }