namespace Aeshnidae.SkillMastery;
///
/// Mastery ranks live in their own table in the shard database, keyed by character.
///
/// Deliberately nowhere near the skill itself. ResetSkill zeroes Ranks, InitLevel
/// and ExperienceSpent, enlightenment runs every skill through it, and Train,
/// Specialize, Untrain and Unspecialize all bare-assign InitLevel afterwards.
/// Anything kept inside the skill would be wiped by one of them. Living out here is
/// what makes mastery survive by construction rather than by special case.
///
/// Connection details come from ACE's own Config.js at runtime - no credentials in
/// the mod - and the shard db means mastery is covered by the same backups as the
/// characters it belongs to.
///
public static class MasteryDb
{
public const string TableName = "aeshnidae_skill_mastery";
private static string _connectionString = "";
public static bool Ready { get; private set; }
public static string LastError { get; private set; } = "";
public static void Initialize()
{
try
{
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}` (
`character_Id` INT UNSIGNED NOT NULL,
`skill` INT UNSIGNED NOT NULL,
`ranks` INT NOT NULL DEFAULT 0,
`applied_bonus` INT NOT NULL DEFAULT 0,
`updated` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`character_Id`, `skill`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
cmd.ExecuteNonQuery();
Ready = true;
LastError = "";
}
catch (Exception ex)
{
Ready = false;
LastError = ex.Message;
ModManager.Log($"[{Mod.Name}] storage unavailable, mastery is disabled: {ex.Message}",
ModManager.LogLevel.Error);
}
}
private static MySqlConnection Open()
{
var conn = new MySqlConnection(_connectionString);
conn.Open();
return conn;
}
// ---------------------------------------------------------------- radiance
//
// Mastery is paid for in Radiance, which lives in Aeshnidae.Bank's table on the same
// shard database. This talks to that table directly rather than to the Bank mod:
// the mod loader gives each mod its own collectible assembly context, and sharing
// types across that boundary is exactly the thing Mods\README.md warns will bite.
// The table's shape is stable and the guarded UPDATE below is the same statement
// BankDb.TryAdjust uses, so two writers cannot drive a balance negative between them.
//
// What this cannot see is Bank's in-memory buffer of earnings not yet flushed (up to
// its FlushSeconds, ten by default). A player who just killed something may be told
// they are a few thousand short for a few seconds. The refusal message says so.
private const string BankTable = "aeshnidae_bank";
private const string RadianceKey = "Radiance";
/// Banked Radiance for an account, as last written by Aeshnidae.Bank.
public static long RadianceBalance(uint accountId)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT `amount` FROM `{BankTable}` WHERE `account_Id` = @a AND `currency` = @c;";
cmd.Parameters.AddWithValue("@a", accountId);
cmd.Parameters.AddWithValue("@c", RadianceKey);
var result = cmd.ExecuteScalar();
return result is null or DBNull ? 0 : Convert.ToInt64(result);
}
///
/// Takes Radiance from an account, refusing rather than going negative. The guard is
/// in the UPDATE itself, so a purchase racing a transfer cannot overdraw.
///
public static bool TryDebitRadiance(uint accountId, long amount, out long remaining)
{
remaining = 0;
using var conn = Open();
using var tx = conn.BeginTransaction();
int rows;
using (var update = conn.CreateCommand())
{
update.Transaction = tx;
update.CommandText = $@"
UPDATE `{BankTable}` SET `amount` = `amount` - @d
WHERE `account_Id` = @a AND `currency` = @c AND `amount` - @d >= 0;";
update.Parameters.AddWithValue("@a", accountId);
update.Parameters.AddWithValue("@c", RadianceKey);
update.Parameters.AddWithValue("@d", amount);
rows = update.ExecuteNonQuery();
}
if (rows > 0)
{
using var select = conn.CreateCommand();
select.Transaction = tx;
select.CommandText = $"SELECT `amount` FROM `{BankTable}` WHERE `account_Id` = @a AND `currency` = @c;";
select.Parameters.AddWithValue("@a", accountId);
select.Parameters.AddWithValue("@c", RadianceKey);
remaining = Convert.ToInt64(select.ExecuteScalar() ?? 0L);
}
tx.Commit();
return rows > 0;
}
/// Every mastery row for a character: skill -> ranks.
public static Dictionary Load(uint characterId)
{
var result = new Dictionary();
if (!Ready)
return result;
try
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT `skill`, `ranks` FROM `{TableName}` WHERE `character_Id` = @c;";
cmd.Parameters.AddWithValue("@c", characterId);
using var reader = cmd.ExecuteReader();
while (reader.Read())
result[(Skill)reader.GetUInt32(0)] = reader.GetInt32(1);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] failed to read mastery for 0x{characterId:X8}: {ex.Message}",
ModManager.LogLevel.Error);
}
return result;
}
public static void Save(uint characterId, Skill skill, int ranks)
{
if (!Ready)
return;
try
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
INSERT INTO `{TableName}` (`character_Id`, `skill`, `ranks`)
VALUES (@c, @s, @r)
ON DUPLICATE KEY UPDATE `ranks` = @r;";
cmd.Parameters.AddWithValue("@c", characterId);
cmd.Parameters.AddWithValue("@s", (uint)skill);
cmd.Parameters.AddWithValue("@r", ranks);
cmd.ExecuteNonQuery();
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] failed to save mastery for 0x{characterId:X8} {skill}: {ex.Message}",
ModManager.LogLevel.Error);
}
}
}