namespace Aeshnidae.Towns; /// A town an allegiance owns. public sealed class TownRow { public uint Id { get; set; } public uint MonarchId { get; set; } public string MonarchName { get; set; } = ""; public string TownKey { get; set; } = ""; public DateTime CreatedAt { get; set; } } /// /// The towns' tables, in the shard database beside the characters and the bank they draw on /// (credentials from ACE's Config.js at runtime, as every mod here does): /// /// aeshnidae_town one row per town bought: its monarch, which town, when; abandoned_at /// set instead of deleting, so the history stays /// aeshnidae_town_treasury one row per allegiance (by monarch): its pyreals /// aeshnidae_town_ledger every donation, charter and grant: who, how much, when /// /// Money moves in one transaction each, and on the database's own terms: a donation debits the /// donor's account row in Aeshnidae.Bank's `aeshnidae_bank` only WHERE the balance covers it /// (the same row Bank itself adjusts - SkillMastery debits it the same way; cross-mod types are /// unsafe, the row is not) and credits the treasury in the same transaction; a charter checks and /// debits the treasury and writes the town in one. Nothing can leave one place without arriving in /// the other, and a charter cannot be bought twice. /// public static class TownDb { public const string TownTable = "aeshnidae_town"; public const string TreasuryTable = "aeshnidae_town_treasury"; public const string LedgerTable = "aeshnidae_town_ledger"; private const string BankTable = "aeshnidae_bank"; 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 `{TownTable}` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `monarch_Id` INT UNSIGNED NOT NULL, `monarch_Name` VARCHAR(64) NOT NULL, `town_Key` VARCHAR(32) NOT NULL, `created_at` DATETIME NOT NULL, `abandoned_at` DATETIME NULL, PRIMARY KEY (`id`), KEY `monarch` (`monarch_Id`, `abandoned_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `{TreasuryTable}` ( `monarch_Id` INT UNSIGNED NOT NULL, `pyreals` BIGINT NOT NULL DEFAULT 0, `updated_at` DATETIME NOT NULL, PRIMARY KEY (`monarch_Id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `{LedgerTable}` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `monarch_Id` INT UNSIGNED NOT NULL, `character_Id` INT UNSIGNED NOT NULL, `character_Name` VARCHAR(64) NOT NULL, `kind` VARCHAR(16) NOT NULL, `amount` BIGINT NOT NULL, `note` VARCHAR(128) NOT NULL DEFAULT '', `at` DATETIME NOT NULL, PRIMARY KEY (`id`), KEY `monarch` (`monarch_Id`, `at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"; cmd.ExecuteNonQuery(); Ready = true; } private static MySqlConnection Open() { var conn = new MySqlConnection(_connectionString); conn.Open(); return conn; } private static void Ledger(MySqlConnection conn, MySqlTransaction tx, uint monarch, uint character, string name, string kind, long amount, string note) { using var cmd = conn.CreateCommand(); cmd.Transaction = tx; cmd.CommandText = $"INSERT INTO `{LedgerTable}` (`monarch_Id`, `character_Id`, `character_Name`, `kind`, `amount`, `note`, `at`) VALUES (@m, @c, @n, @k, @a, @note, UTC_TIMESTAMP())"; cmd.Parameters.AddWithValue("@m", monarch); cmd.Parameters.AddWithValue("@c", character); cmd.Parameters.AddWithValue("@n", name); cmd.Parameters.AddWithValue("@k", kind); cmd.Parameters.AddWithValue("@a", amount); cmd.Parameters.AddWithValue("@note", note); cmd.ExecuteNonQuery(); } // ------------------------------------------------------------ towns public static List LoadTowns() { var list = new List(); using var conn = Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = $"SELECT `id`, `monarch_Id`, `monarch_Name`, `town_Key`, `created_at` FROM `{TownTable}` WHERE `abandoned_at` IS NULL"; using var r = cmd.ExecuteReader(); while (r.Read()) list.Add(new TownRow { Id = r.GetUInt32(0), MonarchId = r.GetUInt32(1), MonarchName = r.GetString(2), TownKey = r.GetString(3), CreatedAt = r.GetDateTime(4) }); return list; } public static long Treasury(uint monarch) { using var conn = Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = $"SELECT `pyreals` FROM `{TreasuryTable}` WHERE `monarch_Id` = @m"; cmd.Parameters.AddWithValue("@m", monarch); return cmd.ExecuteScalar() is { } v and not DBNull ? Convert.ToInt64(v) : 0; } public static long BankPyreals(uint accountId) { using var conn = Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = $"SELECT `amount` FROM `{BankTable}` WHERE `account_Id` = @a AND `currency` = 'Pyreal'"; cmd.Parameters.AddWithValue("@a", accountId); return cmd.ExecuteScalar() is { } v and not DBNull ? Convert.ToInt64(v) : 0; } /// /// Bank to treasury, one transaction: the donor's account balance is debited only where it /// covers the amount, and the treasury credited with exactly that. Returns the new treasury, /// or null when the bank did not hold enough (nothing moved). /// public static long? Donate(uint accountId, uint monarch, uint character, string name, long amount) { using var conn = Open(); using var tx = conn.BeginTransaction(); 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` = 'Pyreal' AND `amount` >= @x"; debit.Parameters.AddWithValue("@x", amount); debit.Parameters.AddWithValue("@a", accountId); if (debit.ExecuteNonQuery() != 1) { tx.Rollback(); return null; } } using (var credit = conn.CreateCommand()) { credit.Transaction = tx; credit.CommandText = $@"INSERT INTO `{TreasuryTable}` (`monarch_Id`, `pyreals`, `updated_at`) VALUES (@m, @x, UTC_TIMESTAMP()) ON DUPLICATE KEY UPDATE `pyreals` = `pyreals` + @x, `updated_at` = UTC_TIMESTAMP()"; credit.Parameters.AddWithValue("@m", monarch); credit.Parameters.AddWithValue("@x", amount); credit.ExecuteNonQuery(); } Ledger(conn, tx, monarch, character, name, "donate", amount, ""); tx.Commit(); return Treasury(monarch); } /// /// Buys a town: the treasury debited by the price and the town written, one transaction; `price` /// 0 is a grant (staff). Returns the new town's id, or the reason it could not be bought. /// public static (uint Id, string? Error) Charter(uint monarch, string monarchName, string townKey, long price, uint byCharacter, string byName) { using var conn = Open(); using var tx = conn.BeginTransaction(); using (var owned = conn.CreateCommand()) { owned.Transaction = tx; owned.CommandText = $"SELECT COUNT(*) FROM `{TownTable}` WHERE `monarch_Id` = @m AND `abandoned_at` IS NULL FOR UPDATE"; owned.Parameters.AddWithValue("@m", monarch); if (Convert.ToInt64(owned.ExecuteScalar()) > 0) { tx.Rollback(); return (0, "Your allegiance already has a town."); } } if (price > 0) { using var debit = conn.CreateCommand(); debit.Transaction = tx; debit.CommandText = $"UPDATE `{TreasuryTable}` SET `pyreals` = `pyreals` - @x, `updated_at` = UTC_TIMESTAMP() WHERE `monarch_Id` = @m AND `pyreals` >= @x"; debit.Parameters.AddWithValue("@x", price); debit.Parameters.AddWithValue("@m", monarch); if (debit.ExecuteNonQuery() != 1) { tx.Rollback(); return (0, $"The treasury does not hold the {price:N0} pyreals a charter costs."); } } uint id; using (var insert = conn.CreateCommand()) { insert.Transaction = tx; insert.CommandText = $"INSERT INTO `{TownTable}` (`monarch_Id`, `monarch_Name`, `town_Key`, `created_at`) VALUES (@m, @n, @k, UTC_TIMESTAMP()); SELECT LAST_INSERT_ID();"; insert.Parameters.AddWithValue("@m", monarch); insert.Parameters.AddWithValue("@n", monarchName); insert.Parameters.AddWithValue("@k", townKey); id = Convert.ToUInt32(insert.ExecuteScalar()); } Ledger(conn, tx, monarch, byCharacter, byName, price > 0 ? "charter" : "grant", -price, townKey); tx.Commit(); return (id, null); } public static bool Abandon(uint townId) { using var conn = Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = $"UPDATE `{TownTable}` SET `abandoned_at` = UTC_TIMESTAMP() WHERE `id` = @i AND `abandoned_at` IS NULL"; cmd.Parameters.AddWithValue("@i", townId); return cmd.ExecuteNonQuery() == 1; } /// Distinct live accounts among the monarch and everyone sworn under them. public static int AccountsInAllegiance(uint monarch) { using var conn = Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = @"SELECT COUNT(DISTINCT c.`account_Id`) FROM `character` c WHERE c.`is_Deleted` = 0 AND (c.`id` = @m OR c.`id` IN (SELECT `object_Id` FROM `biota_properties_i_i_d` WHERE `type` = 26 AND `value` = @m))"; cmd.Parameters.AddWithValue("@m", monarch); return Convert.ToInt32(cmd.ExecuteScalar()); } public static List<(string Name, string Kind, long Amount, DateTime At)> RecentLedger(uint monarch, int count) { var list = new List<(string, string, long, DateTime)>(); using var conn = Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = $"SELECT `character_Name`, `kind`, `amount`, `at` FROM `{LedgerTable}` WHERE `monarch_Id` = @m ORDER BY `id` DESC LIMIT @n"; cmd.Parameters.AddWithValue("@m", monarch); cmd.Parameters.AddWithValue("@n", count); using var r = cmd.ExecuteReader(); while (r.Read()) list.Add((r.GetString(0), r.GetString(1), r.GetInt64(2), r.GetDateTime(3))); return list; } }