namespace Aeshnidae.BuffBot;
/// One town's warden: what it has been paid for and who pays for it.
public sealed class WardenRow
{
public uint Landblock { get; set; }
public int Tier { get; set; }
public long RadiancePaid { get; set; }
public long PyrealsPaid { get; set; }
public uint OwnerAllegianceId { get; set; }
public string OwnerAllegianceName { get; set; } = "";
public string LastPatronName { get; set; } = "";
}
/// What a contribution did.
public sealed record PayResult(bool Ok, string Message, bool Promoted = false, int Tier = 0);
///
/// The wardens' table, in the shard database beside the characters and the bank it draws on
/// (credentials from ACE's Config.js at runtime, as every mod here does):
///
/// aeshnidae_town_warden one row per town, keyed by the landblock the warden STANDS in
/// (not the payer's): its tier, the pot toward the next tier in
/// each currency, the allegiance that finished the last tier, and
/// who paid last.
///
/// Radiance is taken straight out of Aeshnidae.Bank's `aeshnidae_bank` row with the same
/// guarded UPDATE that BankDb.TryAdjust and SkillMastery's MasteryDb use - cross-mod types
/// are unsafe, the row is not - so two buyers cannot drive a balance negative between them.
/// What this cannot see is Bank's in-memory buffer of earnings not yet flushed (ten seconds
/// by default); a player who just killed something may read as a few thousand short for a
/// moment, and the refusal says so.
///
/// A contribution and the tier it may complete happen in ONE transaction, with the row held
/// under SELECT ... FOR UPDATE, so two players paying the last of a tier at the same instant
/// cannot buy it twice or lose one of the payments.
///
public static class WardenDb
{
public const string TableName = "aeshnidae_town_warden";
private const string BankTable = "aeshnidae_bank";
private const string RadianceKey = "Radiance";
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}` (
`landblock` INT UNSIGNED NOT NULL,
`tier` INT NOT NULL DEFAULT 0,
`radiance_paid` BIGINT NOT NULL DEFAULT 0,
`pyreals_paid` BIGINT NOT NULL DEFAULT 0,
`owner_allegiance_Id` INT UNSIGNED NOT NULL DEFAULT 0,
`owner_allegiance_name` VARCHAR(64) NOT NULL DEFAULT '',
`last_patron_name` VARCHAR(64) NOT NULL DEFAULT '',
`updated` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`landblock`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
cmd.ExecuteNonQuery();
Ready = true;
LastError = "";
}
catch (Exception ex)
{
Ready = false;
LastError = ex.Message;
ModManager.Log($"[{Mod.Name}] warden storage unavailable, every town reads as tier 0: {ex.Message}",
ModManager.LogLevel.Error);
}
}
private static MySqlConnection Open()
{
var conn = new MySqlConnection(_connectionString);
conn.Open();
return conn;
}
/// Every town's row, for the in-memory cache.
public static Dictionary LoadAll()
{
var result = new Dictionary();
if (!Ready)
return result;
try
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"SELECT `landblock`, `tier`, `radiance_paid`, `pyreals_paid`,
`owner_allegiance_Id`, `owner_allegiance_name`, `last_patron_name`
FROM `{TableName}`;";
using var r = cmd.ExecuteReader();
while (r.Read())
result[r.GetUInt32(0)] = new WardenRow
{
Landblock = r.GetUInt32(0),
Tier = r.GetInt32(1),
RadiancePaid = r.GetInt64(2),
PyrealsPaid = r.GetInt64(3),
OwnerAllegianceId = r.GetUInt32(4),
OwnerAllegianceName = r.GetString(5),
LastPatronName = r.GetString(6),
};
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not read {TableName}: {ex.Message}", ModManager.LogLevel.Error);
}
return result;
}
/// Banked Radiance for an account, as last written by Aeshnidae.Bank.
public static long RadianceBalance(uint accountId)
{
if (!Ready)
return 0;
try
{
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);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not read a Radiance balance: {ex.Message}", ModManager.LogLevel.Error);
return 0;
}
}
/// Admin: write a tier straight in, for testing. Pots and owner untouched.
public static bool SetTier(uint landblock, int tier)
{
if (!Ready)
return false;
try
{
using var conn = Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"INSERT INTO `{TableName}` (`landblock`, `tier`) VALUES (@l, @t)
ON DUPLICATE KEY UPDATE `tier` = @t;";
cmd.Parameters.AddWithValue("@l", landblock);
cmd.Parameters.AddWithValue("@t", tier);
cmd.ExecuteNonQuery();
return true;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not set the tier of 0x{landblock:X4}: {ex.Message}", ModManager.LogLevel.Error);
return false;
}
}
///
/// Put of one currency into a town's pot and, if that finishes
/// the next tier, buy it - all in one transaction.
///
/// Radiance is debited from the payer's bank row here. PYREALS ARE NOT: the caller has
/// already taken them out of the pack (only the world thread may touch inventory), so a
/// refusal from this method means the caller must put them back. That is why the row is
/// locked and every refusal happens before anything is written.
///
public static PayResult Contribute(uint landblock, bool radiance, long amount, uint accountId,
uint allegianceId, string allegianceName, string patronName,
Func price, int maxTier,
out WardenRow? row)
{
row = null;
// A debit is a debit: a zero or negative amount would credit the payer through the
// same guarded UPDATE, and would add nothing to the pot. Refused before anything opens.
if (amount <= 0)
return new PayResult(false, "Give an amount above zero.");
if (!Ready)
return new PayResult(false, "The wardens' ledger is not available right now.");
try
{
using var conn = Open();
using var tx = conn.BeginTransaction();
using (var seed = conn.CreateCommand())
{
seed.Transaction = tx;
seed.CommandText = $"INSERT IGNORE INTO `{TableName}` (`landblock`) VALUES (@l);";
seed.Parameters.AddWithValue("@l", landblock);
seed.ExecuteNonQuery();
}
int tier;
long potRadiance, potPyreals;
uint ownerId;
string ownerName, lastPatron;
using (var read = conn.CreateCommand())
{
read.Transaction = tx;
read.CommandText = $@"SELECT `tier`, `radiance_paid`, `pyreals_paid`, `owner_allegiance_Id`,
`owner_allegiance_name`, `last_patron_name`
FROM `{TableName}` WHERE `landblock` = @l FOR UPDATE;";
read.Parameters.AddWithValue("@l", landblock);
using var r = read.ExecuteReader();
if (!r.Read())
{
r.Close();
tx.Rollback();
return new PayResult(false, "This town has no warden on the ledger.");
}
tier = r.GetInt32(0);
potRadiance = r.GetInt64(1);
potPyreals = r.GetInt64(2);
ownerId = r.GetUInt32(3);
ownerName = r.GetString(4);
lastPatron = r.GetString(5);
}
if (tier >= maxTier)
{
tx.Rollback();
return new PayResult(false, $"This town's warden is already at tier {tier}, the highest there is. Nothing more can be paid.");
}
if (radiance)
{
using var debit = conn.CreateCommand();
debit.Transaction = tx;
debit.CommandText = $@"UPDATE `{BankTable}` SET `amount` = `amount` - @x, `updated` = UTC_TIMESTAMP()
WHERE `account_Id` = @a AND `currency` = @c AND `amount` - @x >= 0;";
debit.Parameters.AddWithValue("@a", accountId);
debit.Parameters.AddWithValue("@c", RadianceKey);
debit.Parameters.AddWithValue("@x", amount);
if (debit.ExecuteNonQuery() == 0)
{
tx.Rollback();
return new PayResult(false, $"Your bank does not hold {amount:N0} Radiance. (Radiance earned in the last few seconds may not be banked yet.)");
}
potRadiance += amount;
}
else
potPyreals += amount;
var (needRadiance, needPyreals) = price(tier + 1);
var promoted = potRadiance >= needRadiance && potPyreals >= needPyreals;
if (promoted)
{
tier += 1;
potRadiance -= needRadiance;
potPyreals -= needPyreals;
ownerId = allegianceId;
ownerName = allegianceName;
}
lastPatron = patronName;
using (var write = conn.CreateCommand())
{
write.Transaction = tx;
write.CommandText = $@"UPDATE `{TableName}` SET `tier` = @t, `radiance_paid` = @rp, `pyreals_paid` = @pp,
`owner_allegiance_Id` = @oi, `owner_allegiance_name` = @on,
`last_patron_name` = @lp
WHERE `landblock` = @l;";
write.Parameters.AddWithValue("@t", tier);
write.Parameters.AddWithValue("@rp", potRadiance);
write.Parameters.AddWithValue("@pp", potPyreals);
write.Parameters.AddWithValue("@oi", ownerId);
write.Parameters.AddWithValue("@on", ownerName ?? "");
write.Parameters.AddWithValue("@lp", lastPatron ?? "");
write.Parameters.AddWithValue("@l", landblock);
write.ExecuteNonQuery();
}
tx.Commit();
row = new WardenRow
{
Landblock = landblock,
Tier = tier,
RadiancePaid = potRadiance,
PyrealsPaid = potPyreals,
OwnerAllegianceId = ownerId,
OwnerAllegianceName = ownerName ?? "",
LastPatronName = lastPatron ?? "",
};
return new PayResult(true, "", promoted, tier);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] a contribution to 0x{landblock:X4} failed: {ex.Message}", ModManager.LogLevel.Error);
return new PayResult(false, "The wardens' ledger could not record that. Nothing was taken.");
}
}
}