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