namespace Aeshnidae.BuffBot; /// /// The Town Tinker: one weenie standing in every main town, a few paces from that town's /// warden, whose tinkering skill the town buys and keeps buying (design settled 2026-10-08 - /// a town that keeps a tinker is a town where your gear survives the tenth tink). /// /// THERE IS NO NEW FLOW. You tinker exactly as you always did - salvage onto the item - and /// while you are within of the tinker, the skill ACE's own /// formula uses is the BETTER of yours and his. Never worse: when yours is higher the attempt /// is bit-for-bit stock ACE, and the mod does not even look at it. /// /// The skill is the TOWN's, found from the landblock the TINKER is standing in - never the /// player's - so the tinker in Holtburg lends Holtburg's skill to a visitor from anywhere. /// One number per town covers all four tinkering skills and imbues: a town buys a tinker, not /// a syllabus. /// /// The skills live in on the shard and are cached here; the cache is /// re-read on every enable, on tinker-reload, and whenever a step is bought or set. /// public static class Tinkers { /// /// A price no balance can meet, used as the saturating value for "beyond arithmetic". /// Not long.MaxValue, so that summing several of them still cannot wrap. /// /// This matters more here than anywhere else in the mod, because the tinker ladder is /// UNBOUNDED by design. SkillMastery learned this the hard way on 2026-09-22: a /// compounding price on an endless ladder left the range of a long, the unchecked cast of /// the double wrapped NEGATIVE, the negative price passed every affordability check, and /// the purchase paid the buyer instead of charging him. At 1.1 per step the Radiance price /// here crosses this line at step 332 (skill 3,320) and the pyreal price at step 339, so /// the practical effect is a hard wall at skill 3,310 - an order of magnitude past /// anything a tinkering chance notices - and /tinker says so in words rather than /// printing a number nobody can read. /// public const long Unaffordable = long.MaxValue / 4; private static Dictionary _rows = new(); /// Per-player burst state: one tink asks for the chance twice (dialog, then confirm). private static readonly Dictionary _told = new(); public static void Reload() { _rows = TinkerDb.LoadAll(); lock (_told) _told.Clear(); } public static IReadOnlyDictionary Rows => _rows; /// /// Put one town's row back into the cache. The dictionary is REPLACED rather than written /// in place: the tinkering patch reads it on the world thread and a console tinker-set /// runs on the remote console's own thread. /// private static void Remember(TinkerRow row) { var copy = new Dictionary(_rows) { [row.Landblock] = row }; _rows = copy; } public static bool IsTinker(uint wcid) => Mod.Settings.Tinkers?.Contains(wcid) == true; public static TinkerRow RowFor(uint landblock) => _rows.TryGetValue(landblock, out var row) ? row : new TinkerRow { Landblock = landblock }; /// /// The skill a town's tinker is working at. ForcedTinkerSkill, when it is 0 or more, /// overrides every town - that is the testing switch, and nothing can be paid while it /// is on. /// public static int SkillOf(uint landblock) => Mod.Settings.ForcedTinkerSkill >= 0 ? Mod.Settings.ForcedTinkerSkill : RowFor(landblock).Skill; public static bool Forced => Mod.Settings.ForcedTinkerSkill >= 0; public static int SkillPerStep => Math.Max(1, Mod.Settings.TinkerSkillPerStep); /// How many steps a skill of this size represents. public static int StepOf(int skill, int skillPerStep) => Math.Max(0, skill) / Math.Max(1, skillPerStep); /// The name a player sees over this town's tinker, and what /tinker calls him. public static string NameFor(uint landblock) { var town = Wardens.TownName(landblock); return town == "this town" ? "Town Tinker" : $"{town} Tinker"; } // ---------------------------------------------------------------- what a step costs /// /// What the step from - 1 up to costs. /// Geometric and ENDLESS: the first step's price times the growth multiplier once per /// step after it, both currencies owed in full. /// /// Saturating, never wrapping. A price that cannot be written in a long comes back as /// , which every caller refuses by name - the price is never /// allowed to become a number that arithmetic will mis-handle. /// public static (long Radiance, long Pyreals) Price(int step) { if (step <= 0) return (0, 0); return (One(step, Mod.Settings.TinkerRadianceFirstStep, Mod.Settings.TinkerRadianceGrowth), One(step, Mod.Settings.TinkerPyrealsFirstStep, Mod.Settings.TinkerPyrealsGrowth)); } /// What the next step from this skill costs. public static (long Radiance, long Pyreals) PriceFromSkill(int skill) => Price(StepOf(skill, SkillPerStep) + 1); private static long One(int step, long first, double growth) { if (first <= 0) return 0; var value = first * Math.Pow(Math.Max(1.0, growth), step - 1); if (double.IsNaN(value) || double.IsInfinity(value) || value >= Unaffordable) return Unaffordable; // Prices are shown to players, so they are rounded to something a player can read - // the same scale the warden tiers use. var scale = value >= 1_000_000 ? 100_000.0 : value >= 10_000 ? 1_000.0 : 1.0; var rounded = Math.Round(value / scale) * scale; return rounded >= Unaffordable ? Unaffordable : (long)Math.Max(1, rounded); } /// A price as a player should read it: a number, or plain words when it is beyond one. public static string Money(long amount) => amount >= Unaffordable ? "more than any balance can hold" : $"{amount:N0}"; // ---------------------------------------------------------------- finding one /// /// The Town Tinker nearest the player, within Settings.TinkerRange and in the player's /// own landblock. The skill belongs to the tinker's landblock, so the object itself is /// what we need, not the player's position. /// public static WorldObject? Nearest(Player player) { var block = player.CurrentLandblock; if (block is null || player.Location is null) return null; WorldObject? best = null; var bestDistance = Mod.Settings.TinkerRange; foreach (var wo in block.GetAllWorldObjectsForDiagnostics()) { if (!IsTinker(wo.WeenieClassId) || wo.IsDestroyed || wo.Location is null) continue; var distance = Math.Sqrt(Math.Pow(wo.Location.PositionX - player.Location.PositionX, 2) + Math.Pow(wo.Location.PositionY - player.Location.PositionY, 2) + Math.Pow(wo.Location.PositionZ - player.Location.PositionZ, 2)); if (distance <= bestDistance) { bestDistance = distance; best = wo; } } return best; } /// /// True the first time in the burst window. One tink asks for the chance twice - once for /// the chance-of-success dialog and once on confirm - and a player tinkering a stack /// works through it steadily, so the "steadies your hand" line is rate-limited the way /// the warden's refusal is. /// public static bool FirstInBurst(Player player) { var window = TimeSpan.FromSeconds(Math.Max(0.5, Mod.Settings.BurstSeconds)); var now = DateTime.UtcNow; lock (_told) { if (_told.TryGetValue(player.Guid.Full, out var at) && now - at <= window) return false; _told[player.Guid.Full] = now; if (_told.Count > 256) foreach (var stale in _told.Where(t => now - t.Value > window).Select(t => t.Key).ToList()) _told.Remove(stale); return true; } } // ---------------------------------------------------------------- the four skills /// /// The tinkering skills one number covers. ACE's own list, in the order a player thinks /// of them; Player.TinkeringSkills is the same four. /// public static readonly Skill[] TinkeringSkills = { Skill.ItemTinkering, Skill.WeaponTinkering, Skill.MagicItemTinkering, Skill.ArmorTinkering, }; /// A player's own four, for the comparison /tinker prints. public static string OwnSkills(Player player) { var parts = new List(); foreach (var skill in TinkeringSkills) { var own = player.GetCreatureSkill(skill); parts.Add(own.AdvancementClass < SkillAdvancementClass.Trained ? $"{Short(skill)} untrained" : $"{Short(skill)} {own.Current}"); } return string.Join(", ", parts); } private static string Short(Skill skill) => skill switch { Skill.ItemTinkering => "Item", Skill.WeaponTinkering => "Weapon", Skill.MagicItemTinkering => "Magic Item", Skill.ArmorTinkering => "Armor", _ => skill.ToString(), }; // ---------------------------------------------------------------- what /tinker prints public static List Report(Player player, uint landblock) { var row = RowFor(landblock); var skill = SkillOf(landblock); var town = Wardens.TownName(landblock); var lines = new List(); lines.Add(skill <= 0 ? $"{NameFor(landblock)} has no skill of his own yet{(Forced ? " (testing: every tinker is held at this skill)" : "")}." : $"{NameFor(landblock)} works at skill {skill}{(Forced ? " (testing: every tinker is held at this skill)" : "")}."); lines.Add($"Your own: {OwnSkills(player)}."); lines.Add(skill <= 0 ? "Until the town pays for some skill he changes nothing - every tink is your own skill, as it always was." : "Stand within " + $"{Mod.Settings.TinkerRange:N0}" + " m of him and salvage as you always do: the skill used is " + "whichever is higher, his or yours. You never do worse for being near him, and you do not need the skill trained."); if (Forced) { lines.Add("The tinkers are in testing. Nothing can be paid, and no town's own skill is being used."); return lines; } var (needR, needP) = PriceFromSkill(skill); if (needR >= Unaffordable || needP >= Unaffordable) { lines.Add($"The next {SkillPerStep} skill would cost more than any balance can hold. " + "This tinker is as sharp as arithmetic allows; there is nothing left to pay for."); } else { lines.Add($"The next {SkillPerStep} skill (to {skill + SkillPerStep}) costs {Money(needR)} Radiance and {Money(needP)} pyreals. " + "Every step costs more than the last, and there is no cap."); lines.Add($"Toward it: {row.RadiancePaid:N0} Radiance and {row.PyrealsPaid:N0} pyreals so far. " + "Both have to be full before the skill goes up."); lines.Add("/tinker pay radiance takes it from your bank; /tinker pay pyreals takes it from your pack. Anyone may pay."); } lines.Add(string.IsNullOrEmpty(row.OwnerAllegianceName) ? "Nobody maintains this tinker yet - the allegiance that finishes a step is named here." : $"Maintained by {row.OwnerAllegianceName}."); if (!string.IsNullOrEmpty(row.LastPatronName)) lines.Add($"Last paid by {row.LastPatronName}."); return lines; } // ---------------------------------------------------------------- paying /// /// /tinker pay. Runs on the world thread (the pyreals come out of the pack), takes /// nothing until every refusal has been made, and puts the pyreals back if the ledger /// will not have them. A line-for-line twin of Wardens.Pay on purpose: one pattern. /// public static List Pay(Player player, string currency, string rawAmount) { var lines = new List(); var tinker = Nearest(player); if (tinker?.Location is null) { lines.Add("There is no Town Tinker near you to pay."); return lines; } var landblock = tinker.Location.Landblock; var town = Wardens.TownName(landblock); if (Forced) { lines.Add($"The tinkers are in testing - every town's tinker is held at skill {Mod.Settings.ForcedTinkerSkill} and nothing can be paid yet."); return lines; } if (!TinkerDb.Ready) { lines.Add("The tinkers' ledger is not available right now. Nothing was taken."); return lines; } var radiance = currency.StartsWith("r", StringComparison.OrdinalIgnoreCase); var pyreals = currency.StartsWith("p", StringComparison.OrdinalIgnoreCase); if (!radiance && !pyreals) { lines.Add("Pay in 'radiance' or 'pyreals'."); return lines; } var row = RowFor(landblock); var (needRadiance, needPyreals) = PriceFromSkill(row.Skill); if (needRadiance >= Unaffordable || needPyreals >= Unaffordable) { lines.Add($"{town}'s tinker is at skill {row.Skill}. The next {SkillPerStep} would cost more than any " + "balance can hold, so there is nothing to pay toward. Nothing was taken."); return lines; } var outstanding = radiance ? needRadiance - row.RadiancePaid : needPyreals - row.PyrealsPaid; var balance = radiance ? WardenDb.RadianceBalance(player.Account.AccountId) : Pyreals.Carried(player); var what = radiance ? "Radiance in your bank" : "pyreals in your pack"; long amount; if (rawAmount.Equals("all", StringComparison.OrdinalIgnoreCase)) amount = Math.Min(balance, Math.Max(0, outstanding)); else if (!long.TryParse(rawAmount, NumberStyles.Any, CultureInfo.InvariantCulture, out amount)) { lines.Add("Give an amount, or 'all'."); return lines; } // Non-positive never reaches a debit: the guarded UPDATE would CREDIT the payer. if (amount <= 0) { lines.Add(outstanding <= 0 ? $"{town} has already paid all the {(radiance ? "Radiance" : "pyreals")} the next step needs." : "Give an amount above zero."); return lines; } if (amount > balance) { lines.Add($"You have {balance:N0} {what} - not {amount:N0}."); return lines; } if (outstanding > 0 && amount > outstanding) { lines.Add($"The next {SkillPerStep} skill needs only {outstanding:N0} more {(radiance ? "Radiance" : "pyreals")}. Pay that or less."); return lines; } var allegiance = player.Allegiance; var allegianceId = allegiance?.MonarchId ?? 0; var allegianceName = allegiance is null ? $"{player.Name} alone" : allegiance.AllegianceName ?? $"{PlayerManager.FindByGuid(new ObjectGuid(allegianceId))?.Name ?? "an"}'s allegiance"; long taken = 0; if (pyreals) { taken = Pyreals.Take(player, amount); if (taken == 0) { lines.Add("Your pack would not give up that many pyreals. Nothing was taken."); return lines; } } var result = TinkerDb.Contribute(landblock, radiance, amount, player.Account.AccountId, allegianceId, allegianceName, player.Name, Price, SkillPerStep, out var updated); if (!result.Ok) { if (taken > 0) Pyreals.Give(player, taken); lines.Add(result.Message); return lines; } // Change from the pack, now that the ledger has the amount it was promised. var change = taken - amount; if (change > 0) { var returned = Pyreals.Give(player, change); if (returned < change) { var stranded = change - returned; TinkerDb.Contribute(landblock, false, stranded, player.Account.AccountId, allegianceId, allegianceName, player.Name, Price, SkillPerStep, out var afterChange); if (afterChange is not null) updated = afterChange; lines.Add($"Your pack would not take {stranded:N0} pyreals of change, so it went into the pot as well."); } } if (updated is not null) Remember(updated); var unit = radiance ? "Radiance" : "pyreals"; lines.Add($"You put {amount:N0} {unit} toward {town}'s tinker."); // PayResult.Promoted means "a step turned over"; PayResult.Tier carries the new skill. if (result.Promoted) { Wardens.Announce(landblock, $"{NameFor(landblock)} sharpens his eye: he now works at skill {result.Tier}. " + $"Maintained by {allegianceName}."); ModManager.Log($"[{Mod.Name}] {town} (0x{landblock:X4}) tinker reached skill {result.Tier}, paid last by {player.Name} of {allegianceName}"); } else if (updated is not null) { var (r, p) = PriceFromSkill(updated.Skill); lines.Add(r >= Unaffordable || p >= Unaffordable ? $"The next {SkillPerStep} skill is beyond any balance; nothing more can be paid here." : $"Skill {updated.Skill + SkillPerStep} still needs {Math.Max(0, r - updated.RadiancePaid):N0} Radiance " + $"and {Math.Max(0, p - updated.PyrealsPaid):N0} pyreals."); } return lines; } }