namespace Aeshnidae.Bank;
///
/// Balances live in their own table in the shard database, keyed by account id so
/// every character on an account shares one balance.
///
/// The shard db is used rather than a side file so balances are covered by the
/// same backups as the characters they belong to. Connection details come from
/// ACE's own Config.js at runtime - no credentials in the mod.
///
public static class BankDb
{
public const string TableName = "aeshnidae_bank";
private static string _connectionString = "";
private static readonly object _lock = new();
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}` (
`account_Id` INT UNSIGNED NOT NULL,
`currency` VARCHAR(32) NOT NULL,
`amount` BIGINT NOT NULL DEFAULT 0,
`updated` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`account_Id`, `currency`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
cmd.ExecuteNonQuery();
// Per-CHARACTER switches, unlike balances which are per account. Auto-banking
// luminance is a choice about how one character plays - a mule that should hoard
// and a main that should bank are the same account - so it cannot live beside
// the balance it feeds.
using var flags = conn.CreateCommand();
flags.CommandText = $@"
CREATE TABLE IF NOT EXISTS `{FlagsTableName}` (
`character_Id` INT UNSIGNED NOT NULL,
`flag` VARCHAR(32) NOT NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`character_Id`, `flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
flags.ExecuteNonQuery();
Ready = true;
}
public const string FlagsTableName = "aeshnidae_bank_flags";
public static bool GetFlag(uint characterId, string flag)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT `enabled` FROM `{FlagsTableName}` WHERE `character_Id` = @c AND `flag` = @f;";
cmd.Parameters.AddWithValue("@c", characterId);
cmd.Parameters.AddWithValue("@f", flag);
var result = cmd.ExecuteScalar();
return result is not (null or DBNull) && Convert.ToInt32(result) != 0;
}
public static void SetFlag(uint characterId, string flag, bool enabled)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
INSERT INTO `{FlagsTableName}` (`character_Id`, `flag`, `enabled`) VALUES (@c, @f, @e)
ON DUPLICATE KEY UPDATE `enabled` = @e;";
cmd.Parameters.AddWithValue("@c", characterId);
cmd.Parameters.AddWithValue("@f", flag);
cmd.Parameters.AddWithValue("@e", enabled ? 1 : 0);
cmd.ExecuteNonQuery();
}
private static MySqlConnection Open()
{
var conn = new MySqlConnection(_connectionString);
conn.Open();
return conn;
}
public static long GetBalance(uint accountId, CurrencyKind kind)
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT `amount` FROM `{TableName}` WHERE `account_Id` = @a AND `currency` = @c;";
cmd.Parameters.AddWithValue("@a", accountId);
cmd.Parameters.AddWithValue("@c", kind.ToString());
var result = cmd.ExecuteScalar();
return result is null or DBNull ? 0 : Convert.ToInt64(result);
}
public static Dictionary GetAllBalances(uint accountId)
{
var balances = Enum.GetValues().ToDictionary(k => k, _ => 0L);
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT `currency`, `amount` FROM `{TableName}` WHERE `account_Id` = @a;";
cmd.Parameters.AddWithValue("@a", accountId);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
if (Enum.TryParse(reader.GetString(0), out var kind))
balances[kind] = reader.GetInt64(1);
}
return balances;
}
///
/// Applies a signed change and returns the new balance, refusing to go negative.
///
/// The UPDATE carries its own `amount + @d >= 0` guard so two characters on the
/// same account withdrawing at once cannot drive the balance below zero - the
/// second one matches no rows and reports failure.
///
public static bool TryAdjust(uint accountId, CurrencyKind kind, long delta, out long newBalance)
{
lock (_lock)
{
using var conn = Open();
using var tx = conn.BeginTransaction();
using (var insert = conn.CreateCommand())
{
insert.Transaction = tx;
insert.CommandText =
$"INSERT IGNORE INTO `{TableName}` (`account_Id`, `currency`, `amount`) VALUES (@a, @c, 0);";
insert.Parameters.AddWithValue("@a", accountId);
insert.Parameters.AddWithValue("@c", kind.ToString());
insert.ExecuteNonQuery();
}
int rows;
using (var update = conn.CreateCommand())
{
update.Transaction = tx;
update.CommandText = $@"
UPDATE `{TableName}` SET `amount` = `amount` + @d
WHERE `account_Id` = @a AND `currency` = @c AND `amount` + @d >= 0;";
update.Parameters.AddWithValue("@a", accountId);
update.Parameters.AddWithValue("@c", kind.ToString());
update.Parameters.AddWithValue("@d", delta);
rows = update.ExecuteNonQuery();
}
long balance;
using (var select = conn.CreateCommand())
{
select.Transaction = tx;
select.CommandText =
$"SELECT `amount` FROM `{TableName}` WHERE `account_Id` = @a AND `currency` = @c;";
select.Parameters.AddWithValue("@a", accountId);
select.Parameters.AddWithValue("@c", kind.ToString());
balance = Convert.ToInt64(select.ExecuteScalar() ?? 0L);
}
tx.Commit();
newBalance = balance;
return rows > 0;
}
}
}