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;
}
}