namespace Aeshnidae.BuffBot; /// One town's tinker: how much skill it has been paid for, and who pays for it. public sealed class TinkerRow { public uint Landblock { get; set; } public int Skill { 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; } = ""; } /// /// The tinkers' table, in the shard database beside the wardens' and the bank they both draw /// on (credentials from ACE's Config.js at runtime, as every mod here does): /// /// aeshnidae_town_tinker one row per town, keyed by the landblock the tinker STANDS in /// (not the payer's): its skill, the pot toward the next +10 in /// each currency, the allegiance that finished the last step, and /// who paid last. /// /// A separate table rather than more columns on : the warden /// and the tinker are bought independently, they are paid for in separate transactions, and /// a row that two features lock would make one of them wait on the other for no reason. The /// schema, the FOR UPDATE discipline and the pyreal handling are deliberately the wardens' /// to the letter, so there is one pattern here and not two. /// /// Radiance is taken straight out of Aeshnidae.Bank's `aeshnidae_bank` row with the same /// guarded UPDATE WardenDb, 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 step it may complete happen in ONE transaction, with the row held /// under SELECT ... FOR UPDATE, so two players paying the last of a step at the same instant /// cannot buy it twice or lose one of the payments. /// public static class TinkerDb { public const string TableName = "aeshnidae_town_tinker"; 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, `skill` 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}] tinker storage unavailable, every town's tinker reads as skill 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`, `skill`, `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 TinkerRow { Landblock = r.GetUInt32(0), Skill = 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; } /// Admin: write a skill straight in, for testing. Pots and owner untouched. public static bool SetSkill(uint landblock, int skill) { if (!Ready) return false; try { using var conn = Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = $@"INSERT INTO `{TableName}` (`landblock`, `skill`) VALUES (@l, @s) ON DUPLICATE KEY UPDATE `skill` = @s;"; cmd.Parameters.AddWithValue("@l", landblock); cmd.Parameters.AddWithValue("@s", skill); cmd.ExecuteNonQuery(); return true; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not set the tinker skill of 0x{landblock:X4}: {ex.Message}", ModManager.LogLevel.Error); return false; } } /// /// Put of one currency into a town's tinker pot and, if that /// finishes the next +, buy it - all in one /// transaction. More than one step can turn over in a single payment, and does: a lump /// sum that covers three steps buys three (the loop below), priced one step at a time so /// the compounding is paid in full for each. /// /// 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 skillPerStep, out TinkerRow? 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 tinkers' 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 skill; long potRadiance, potPyreals; uint ownerId; string ownerName, lastPatron; using (var read = conn.CreateCommand()) { read.Transaction = tx; read.CommandText = $@"SELECT `skill`, `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 tinker on the ledger."); } skill = r.GetInt32(0); potRadiance = r.GetInt64(1); potPyreals = r.GetInt64(2); ownerId = r.GetUInt32(3); ownerName = r.GetString(4); lastPatron = r.GetString(5); } var step = Tinkers.StepOf(skill, skillPerStep) + 1; var (needNow, _) = price(step); // There is no cap on the skill, so the only thing that can stop a payment is // arithmetic: past ~step 331 the price leaves the range of a long. Saturated // prices are refused by name rather than letting a wrapped number decide. if (needNow >= Tinkers.Unaffordable) { tx.Rollback(); return new PayResult(false, $"This town's tinker is at skill {skill}. The next step costs more than " + "any balance can hold - there is nothing left to pay toward."); } 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.)"); } // Saturating, never wrapping. The pot cannot realistically approach this, // because the biggest single payment is bounded by a player's balance - but a // pot that wrapped negative would read as "nothing paid" and quietly eat it. potRadiance = potRadiance > Tinkers.Unaffordable - amount ? Tinkers.Unaffordable : potRadiance + amount; } else potPyreals = potPyreals > Tinkers.Unaffordable - amount ? Tinkers.Unaffordable : potPyreals + amount; // A lump sum may cover several steps. Each is priced on its own, so the // compounding is never skipped, and the loop stops the moment a step is either // unpaid or beyond arithmetic. var bought = 0; while (true) { var next = Tinkers.StepOf(skill, skillPerStep) + 1; var (needRadiance, needPyreals) = price(next); if (needRadiance >= Tinkers.Unaffordable || needPyreals >= Tinkers.Unaffordable) break; if (potRadiance < needRadiance || potPyreals < needPyreals) break; skill += Math.Max(1, skillPerStep); potRadiance -= needRadiance; potPyreals -= needPyreals; bought++; ownerId = allegianceId; ownerName = allegianceName; } lastPatron = patronName; using (var write = conn.CreateCommand()) { write.Transaction = tx; write.CommandText = $@"UPDATE `{TableName}` SET `skill` = @s, `radiance_paid` = @rp, `pyreals_paid` = @pp, `owner_allegiance_Id` = @oi, `owner_allegiance_name` = @on, `last_patron_name` = @lp WHERE `landblock` = @l;"; write.Parameters.AddWithValue("@s", skill); 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 TinkerRow { Landblock = landblock, Skill = skill, RadiancePaid = potRadiance, PyrealsPaid = potPyreals, OwnerAllegianceId = ownerId, OwnerAllegianceName = ownerName ?? "", LastPatronName = lastPatron ?? "", }; return new PayResult(true, "", bought > 0, skill); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] a tinker contribution to 0x{landblock:X4} failed: {ex.Message}", ModManager.LogLevel.Error); return new PayResult(false, "The tinkers' ledger could not record that. Nothing was taken."); } } }