namespace Aeshnidae.BuffBot;
///
/// The Town Tinker's two hooks.
///
/// RecipeManager.GetTinkerChance the whole of it: the skill the formula uses, and the
/// trained gate. See for why this is
/// a prefix over a copied formula rather than a transpiler
/// or a reverse patch on its own.
/// WorldObjectFactory.CreateNewWorldObjects
/// names each tinker for the town it stands in. One weenie
/// is placed in twenty towns, so the name cannot be in the
/// weenie; it is written onto the instance as the landblock
/// loads, which is the only moment the landblock is known
/// and the only place the name is needed.
///
/// NOTHING ELSE on the tinkering path gates a skill. Read for this, 2026-10-08:
/// * RecipeManager.UseObjectOnTarget (line ~43) - busy / combat mode / same object / no
/// recipe / VerifyRequirements, then GetRecipeChance. No skill of its own.
/// * RecipeManager.VerifyRequirements (line ~769, ~825) - entirely data-driven from the
/// recipe's own requirement rows; no tinkering recipe carries a skill requirement row.
/// * RecipeManager.GetRecipeChance (line ~158) - its trained gate at line 185 is on the
/// NON-tinkering branch; a tinkering recipe returns out of it at line 161 before reaching
/// it.
/// * RecipeManager.HandleRecipe (line ~355) - re-verifies requirements, rolls, and grants
/// proficiency only when the recipe HAS a skill; no gate.
/// * Player_Crafting.cs - the only AdvancementClass test (line 315) is in the SALVAGING
/// path (how much material a bag yields), which is not the tinkering path and is
/// deliberately left alone: the tinker lends skill at the anvil, not at the salvage bag.
///
[HarmonyPatch]
public static class TinkerPatches
{
///
/// The skill substitution. Returns true (run stock ACE) in every case where the tinker
/// changes nothing, which is the common case: no tinker in range, the town has bought no
/// skill, or the player's own skill is already the better of the two.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(RecipeManager), nameof(RecipeManager.GetTinkerChance))]
public static bool PreGetTinkerChance(Player player, WorldObject tool, WorldObject target, Recipe recipe,
ref double? __result)
{
try
{
if (player is null || tool is null || target is null || recipe is null)
return true;
var tinker = Tinkers.Nearest(player);
if (tinker?.Location is null)
return true; // nobody to help: stock ACE
var landblock = tinker.Location.Landblock;
var townSkill = Tinkers.SkillOf(landblock);
var recipeSkill = (Skill)recipe.Skill;
var own = player.GetCreatureSkill(recipeSkill);
if (own is null)
return true;
var trained = own.AdvancementClass >= SkillAdvancementClass.Trained;
if (townSkill <= 0)
{
// The town has bought nothing, so there is nothing to lend. Stock ACE decides,
// which for an untrained player means its own refusal - say why the tinker was
// no help, so the player is not left guessing at a silent NPC.
if (!trained && Tinkers.FirstInBurst(player))
player.SendMessage($"{Tinkers.NameFor(landblock)} has no skill of his own yet - this town has paid for none. /tinker");
return true;
}
// Never worse. A trained player whose own skill is higher gets stock ACE, exactly
// as if the tinker were not there.
if (trained && townSkill <= own.Current)
return true;
if (!Formula.Ok)
{
// The guard failed: our copy of the formula may no longer be ACE's, so it is
// not used. Stock decides, and an untrained player is told why the tinker
// could not step in rather than only seeing ACE's "you are not trained".
if (!trained && Tinkers.FirstInBurst(player))
player.SendMessage($"{Tinkers.NameFor(landblock)} cannot work today. Ask a developer to look at the tinkers.");
return true;
}
var effective = Math.Max(own.Current, (uint)Math.Max(0, townSkill));
var chance = Formula.Chance(player, tool, target, recipe, effective);
// The floor: whatever stock would have said, the player gets at least that. This
// is what keeps the foolproof salvages certain and keeps any upstream change our
// copy does not model from costing the player anything. Only asked when the player
// is trained - untrained, stock returns null and messages the player.
if (trained && Formula.StockAvailable)
{
try
{
var stock = Formula.Stock(player, tool, target, recipe);
if (stock.HasValue && stock.Value > chance)
chance = stock.Value;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] stock tinker chance threw, using the tinker's own: {ex.Message}",
ModManager.LogLevel.Warn);
}
}
// Do not silently change the odds.
if (Tinkers.FirstInBurst(player))
player.SendMessage(trained
? $"{Tinkers.NameFor(landblock)} steadies your hand: {recipeSkill.ToSentence()} {effective} (yours is {own.Current})."
: $"{Tinkers.NameFor(landblock)} steadies your hand: {recipeSkill.ToSentence()} {effective}. " +
"You are not trained in it, and with him beside you that does not matter.");
__result = chance;
return false;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] the tinker's skill could not be applied, ACE's own rule stands: {ex.Message}",
ModManager.LogLevel.Warn);
return true;
}
}
///
/// Names each tinker for its town as the landblock's statics are built - "Holtburg
/// Tinker", "Cragstone Tinker" - from one weenie placed in twenty towns.
///
/// This runs once per landblock load, not per object, and touches nothing but instances of
/// a tinker wcid. Landblock statics are rebuilt from the world database on every load and
/// are never written back to the shard, so the name is a presentation detail and cannot
/// persist into anything.
///
[HarmonyPostfix]
[HarmonyPatch(typeof(WorldObjectFactory), nameof(WorldObjectFactory.CreateNewWorldObjects),
// Biota here is the SHARD biota, not the ACE.Entity.Models one GlobalUsings
// pins - the overload's own alias. Spelled out so the patch cannot bind to
// a method that does not exist.
new[] { typeof(List), typeof(List), typeof(uint?) })]
public static void PostCreateNewWorldObjects(List __result)
{
try
{
if (__result is null)
return;
foreach (var wo in __result)
{
if (wo is null || !Tinkers.IsTinker(wo.WeenieClassId) || wo.Location is null)
continue;
var name = Tinkers.NameFor(wo.Location.Landblock);
if (!string.Equals(wo.Name, name, StringComparison.Ordinal))
wo.Name = name;
}
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not name a town's tinker, it keeps the weenie's name: {ex.Message}",
ModManager.LogLevel.Warn);
}
}
}