From ef8057d38e1a2719b42efe8adafacf37454e1017 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:12:10 -0700 Subject: [PATCH] fix: T2A menus only offer what the chosen resource can make (#2695) ## Summary The T2A crafting menus offered items the chosen resource couldn't make: - **Tailoring honours the targeted cloth hue.** The menu was built from all cloth in the pack, but the craft consumes only the targeted hue, so it offered items that then failed on resources. `CanCraftItem` / `FilterEntries` / `AnyCraftableInCategory` now take the hue and count with the same helper the craft uses. - **Uncut cloth counts in the hued path.** The hue-aware count and consume matched only `Cloth`, so targeting uncut cloth gave an empty menu. The hued path now uses `CraftItem`'s equivalence table like the unhued path. `T2ACraftSystem` reuses that table instead of a narrower copy, which also covers the coloured woods and leathers on later-era shards. - **T2A tinkering only uses iron ingots.** Coloured ingots are refused with a re-prompt. The menu filter now uses the selected ingot, and Make Last for tinkering always resolves to iron, so a coloured choice left over from the gump flow can't be used. ## Testing - `dotnet build`: 0 warnings, 0 errors - New `T2AHuedFilterTests`: only cloth of the targeted hue counts, and uncut cloth of the same hue counts toward the recipe - No test for the tinkering target and message flow; not yet exercised in game --- .../Tests/Engines/Craft/T2AHuedFilterTests.cs | 92 +++++++++++++++++++ .../UOContent/Engines/Craft/Core/CraftItem.cs | 58 +++++++++--- .../Engines/Craft/T2A/T2ACraftSystem.cs | 63 +++++++------ .../Engines/Craft/T2A/T2ACraftToolTarget.cs | 7 ++ .../Engines/Craft/T2A/TailoringMenu.cs | 12 +-- .../Engines/Craft/T2A/TinkeringMenu.cs | 30 +++--- 6 files changed, 206 insertions(+), 56 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Engines/Craft/T2AHuedFilterTests.cs diff --git a/Projects/UOContent.Tests/Tests/Engines/Craft/T2AHuedFilterTests.cs b/Projects/UOContent.Tests/Tests/Engines/Craft/T2AHuedFilterTests.cs new file mode 100644 index 000000000..8a50ef4ac --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Craft/T2AHuedFilterTests.cs @@ -0,0 +1,92 @@ +using Server; +using Server.Engines.Craft; +using Server.Engines.Craft.T2A; +using Server.Items; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class T2AHuedFilterTests +{ + private static PlayerMobile CreatePlayer(Point3D location) + { + var m = new PlayerMobile(World.NewMobile); + m.DefaultMobileInit(); + m.MoveToWorld(location, Map.Felucca); + m.AddItem(new Backpack()); + return m; + } + + private static CraftItem MakeRecipe() + { + var item = new CraftItem(typeof(Shirt), "shirt", "shirt"); + item.AddRes(typeof(Cloth), "cloth", 8, "You do not have enough cloth."); + item.AddSkill(SkillName.Tailoring, 0.0, 100.0); + return item; + } + + private static CraftSystem GetTailoringSystem() + { + if (DefTailoring.CraftSystem == null) + { + DefTailoring.Initialize(); + } + + return DefTailoring.CraftSystem; + } + + [Fact] + public void CanCraftItem_WithHue_CountsOnlyMatchingHueCloth() + { + var player = CreatePlayer(new Point3D(4140, 500, 0)); + + try + { + var pack = player.Backpack; + pack.AddItem(new Cloth(5) { Hue = 0x21 }); + pack.AddItem(new Cloth(5) { Hue = 0x30 }); + + var system = GetTailoringSystem(); + var recipe = MakeRecipe(); + + // 10 cloth total, but no single hue reaches the 8 the recipe needs + Assert.True(T2ACraftSystem.CanCraftItem(player, recipe, system)); + Assert.False(T2ACraftSystem.CanCraftItem(player, recipe, system, null, 0x21)); + Assert.False(T2ACraftSystem.CanCraftItem(player, recipe, system, null, 0x30)); + + pack.AddItem(new Cloth(3) { Hue = 0x21 }); + Assert.True(T2ACraftSystem.CanCraftItem(player, recipe, system, null, 0x21)); + Assert.False(T2ACraftSystem.CanCraftItem(player, recipe, system, null, 0x30)); + } + finally + { + player.Delete(); + } + } + + [Fact] + public void CanCraftItem_WithHue_CountsSameHueUncutCloth() + { + var player = CreatePlayer(new Point3D(4140, 500, 0)); + + try + { + var pack = player.Backpack; + pack.AddItem(new Cloth(5) { Hue = 0x21 }); + pack.AddItem(new UncutCloth(3) { Hue = 0x21 }); + pack.AddItem(new UncutCloth(5) { Hue = 0x30 }); + + var system = GetTailoringSystem(); + var recipe = MakeRecipe(); + + Assert.True(T2ACraftSystem.CanCraftItem(player, recipe, system, null, 0x21)); + Assert.False(T2ACraftSystem.CanCraftItem(player, recipe, system, null, 0x30)); + } + finally + { + player.Delete(); + } + } +} diff --git a/Projects/UOContent/Engines/Craft/Core/CraftItem.cs b/Projects/UOContent/Engines/Craft/Core/CraftItem.cs index e99760bbc..0ba6b771a 100644 --- a/Projects/UOContent/Engines/Craft/Core/CraftItem.cs +++ b/Projects/UOContent/Engines/Craft/Core/CraftItem.cs @@ -635,15 +635,7 @@ namespace Server.Engines.Craft } // ****************** - for (var j = 0; types[i] == null && j < m_TypesTable.Length; ++j) - { - if (m_TypesTable[j][0] == baseType) - { - types[i] = m_TypesTable[j]; - } - } - - types[i] ??= [baseType]; + types[i] = GetEquivalentTypes(baseType) ?? [baseType]; amounts[i] = craftRes.Amount; // For stackable items that can be crafted more than one at a time @@ -1242,13 +1234,54 @@ namespace Server.Engines.Craft return true; } - private static int GetHuedAmount(Container pack, Type type, int hue) + /// + /// Returns the interchangeable resource types for (e.g. logs and boards), + /// or null when it has no equivalents. + /// + internal static Type[] GetEquivalentTypes(Type baseType) + { + for (var i = 0; i < m_TypesTable.Length; i++) + { + if (m_TypesTable[i][0] == baseType) + { + return m_TypesTable[i]; + } + } + + return null; + } + + private static bool IsHuedMatch(Item item, Type type, Type[] equivalents, int hue) + { + if (item.Hue != hue) + { + return false; + } + + if (equivalents == null) + { + return type.IsInstanceOfType(item); + } + + for (var i = 0; i < equivalents.Length; i++) + { + if (equivalents[i].IsInstanceOfType(item)) + { + return true; + } + } + + return false; + } + + internal static int GetHuedAmount(Container pack, Type type, int hue) { var total = 0; + var equivalents = GetEquivalentTypes(type); foreach (var item in pack.FindItems(true)) { - if (item.Hue == hue && type.IsInstanceOfType(item)) + if (IsHuedMatch(item, type, equivalents, hue)) { total += item.Amount; } @@ -1260,6 +1293,7 @@ namespace Server.Engines.Craft private static bool ConsumeHuedAmount(Container pack, Type type, int hue, int amount) { var remaining = amount; + var equivalents = GetEquivalentTypes(type); using var toDelete = PooledRefList.Create(); foreach (var item in pack.FindItems(true)) @@ -1269,7 +1303,7 @@ namespace Server.Engines.Craft break; } - if (item.Hue != hue || !type.IsInstanceOfType(item)) + if (!IsHuedMatch(item, type, equivalents, hue)) { continue; } diff --git a/Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs b/Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs index 785a319ad..a0fff9d0f 100644 --- a/Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs +++ b/Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs @@ -190,9 +190,11 @@ public static class T2ACraftSystem /// Checks if a player can craft a specific item, accounting for sub-resource types. /// When is non-null, checks against that specific sub-resource. /// When null, checks against ANY available sub-resource the player has sufficient skill and materials for. + /// A non-negative counts only same-hue stock for the primary resource, exactly as + /// the hue-aware craft path consumes it. /// public static bool CanCraftItem( - Mobile from, CraftItem itemDef, CraftSystem system, Type selectedResourceType = null + Mobile from, CraftItem itemDef, CraftSystem system, Type selectedResourceType = null, int hue = -1 ) { var pack = from.Backpack; @@ -214,6 +216,27 @@ public static class T2ACraftSystem var res = itemDef.Resources[i]; var resType = res.ItemType; + if (hue >= 0 && i == 0) + { + if (resCol.Init && resType == resCol.ResType && selectedResourceType != null) + { + var subRes = resCol.SearchFor(selectedResourceType); + if (subRes != null && from.Skills[system.MainSkill].Value < subRes.RequiredSkill) + { + return false; + } + + resType = selectedResourceType; + } + + if (CraftItem.GetHuedAmount(pack, resType, hue) < res.Amount) + { + return false; + } + + continue; + } + // If this resource is the base sub-resource type (e.g. IronIngot for blacksmithing), // handle sub-resource substitution if (resCol.Init && resType == resCol.ResType) @@ -250,17 +273,20 @@ public static class T2ACraftSystem /// /// Convenience overload that resolves a Type to a CraftItem first. /// - public static bool CanCraftItem(Mobile from, Type itemType, CraftSystem system, Type selectedResourceType = null) + public static bool CanCraftItem( + Mobile from, Type itemType, CraftSystem system, Type selectedResourceType = null, int hue = -1 + ) { var itemDef = system.CraftItems.SearchFor(itemType); - return itemDef != null && CanCraftItem(from, itemDef, system, selectedResourceType); + return itemDef != null && CanCraftItem(from, itemDef, system, selectedResourceType, hue); } /// /// Filters static template entries to only those the player can craft. /// public static ItemListEntry[] FilterEntries( - Mobile from, ItemListEntry[] staticEntries, Type[] types, CraftSystem system, Type selectedResourceType = null + Mobile from, ItemListEntry[] staticEntries, Type[] types, CraftSystem system, + Type selectedResourceType = null, int hue = -1 ) { var filtered = new ItemListEntry[staticEntries.Length]; @@ -271,7 +297,7 @@ public static class T2ACraftSystem var entry = staticEntries[i]; var typeIndex = entry.CraftIndex; if (typeIndex >= 0 && typeIndex < types.Length && - CanCraftItem(from, types[typeIndex], system, selectedResourceType)) + CanCraftItem(from, types[typeIndex], system, selectedResourceType, hue)) { filtered[count++] = entry; } @@ -294,12 +320,12 @@ public static class T2ACraftSystem /// Returns true if at least one item in the type array is craftable. /// public static bool AnyCraftableInCategory( - Mobile from, Type[] types, CraftSystem system, Type selectedResourceType = null + Mobile from, Type[] types, CraftSystem system, Type selectedResourceType = null, int hue = -1 ) { for (var i = 0; i < types.Length; i++) { - if (CanCraftItem(from, types[i], system, selectedResourceType)) + if (CanCraftItem(from, types[i], system, selectedResourceType, hue)) { return true; } @@ -308,28 +334,11 @@ public static class T2ACraftSystem return false; } - /// - /// Equivalent type pairs mirroring CraftItem.m_TypesTable — used so that menu filtering - /// counts boards when checking for logs, hides when checking for leather, etc. - /// - private static readonly Type[][] _equivalentTypes = - [ - [typeof(Log), typeof(Board)], - [typeof(Cloth), typeof(UncutCloth)], - [typeof(Items.Leather), typeof(Hides)] - ]; - + // Shares CraftItem's equivalence table so the menu counts exactly what the craft will consume private static int GetResourceAmount(Container pack, Type type) { - for (var i = 0; i < _equivalentTypes.Length; i++) - { - if (_equivalentTypes[i][0] == type) - { - return pack.GetAmount(_equivalentTypes[i]); - } - } - - return pack.GetAmount(type); + var equivalents = CraftItem.GetEquivalentTypes(type); + return equivalents != null ? pack.GetAmount(equivalents) : pack.GetAmount(type); } private static bool HasAnySufficientSubResource( diff --git a/Projects/UOContent/Engines/Craft/T2A/T2ACraftToolTarget.cs b/Projects/UOContent/Engines/Craft/T2A/T2ACraftToolTarget.cs index d7398f000..80978103c 100644 --- a/Projects/UOContent/Engines/Craft/T2A/T2ACraftToolTarget.cs +++ b/Projects/UOContent/Engines/Craft/T2A/T2ACraftToolTarget.cs @@ -36,6 +36,13 @@ public class T2ACraftToolTarget : Target var resIndex = lastMade.UseSubRes2 ? context.LastResourceIndex2 : context.LastResourceIndex; var type = resIndex > -1 ? res[resIndex].ItemType : null; + // T2A tinkering has no colored ingots; a stale index from the gump flow must not leak in + if (_system == DefTinkering.CraftSystem) + { + type = typeof(IronIngot); + T2ACraftSystem.SetLastResourceIndex(from, _system, type); + } + // Jewelry requires gem targeting — re-prompt instead of crafting directly if (typeof(BaseJewel).IsAssignableFrom(lastMade.ItemType)) { diff --git a/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs b/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs index a7a5feccc..704845c39 100644 --- a/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs +++ b/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs @@ -92,7 +92,7 @@ public class TailoringMenu : ItemListMenu }; private TailoringMenu(Mobile from, BaseTool tool, Category category, int hue = -1) - : base(GetQuestion(category), BuildFilteredEntries(from, category)) + : base(GetQuestion(category), BuildFilteredEntries(from, category, hue)) { _tool = tool; _category = category; @@ -198,11 +198,11 @@ public class TailoringMenu : ItemListMenu public static ItemListEntry[] Studded() => _studdedEntries ??= BuildStaticEntries(StuddedArmorTypes, "leather"); public static ItemListEntry[] Female() => _femaleEntries ??= BuildStaticEntries(FemaleArmorTypes, "leather"); - private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category) + private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category, int hue) { if (category is Category.Main or Category.LeatherMain) { - return BuildFilteredMainEntries(from, category); + return BuildFilteredMainEntries(from, category, hue); } var types = GetTypes(category); @@ -212,10 +212,10 @@ public class TailoringMenu : ItemListMenu return []; } - return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefTailoring.CraftSystem); + return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefTailoring.CraftSystem, null, hue); } - private static ItemListEntry[] BuildFilteredMainEntries(Mobile from, Category mainCategory) + private static ItemListEntry[] BuildFilteredMainEntries(Mobile from, Category mainCategory, int hue) { var system = DefTailoring.CraftSystem; var mainStatic = GetStaticEntries(mainCategory); @@ -231,7 +231,7 @@ public class TailoringMenu : ItemListMenu { var entry = mainStatic[i]; var types = GetTypes((Category)entry.CraftIndex); - if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system)) + if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system, null, hue)) { filtered[count++] = entry; } diff --git a/Projects/UOContent/Engines/Craft/T2A/TinkeringMenu.cs b/Projects/UOContent/Engines/Craft/T2A/TinkeringMenu.cs index e823d68ee..766b6a545 100644 --- a/Projects/UOContent/Engines/Craft/T2A/TinkeringMenu.cs +++ b/Projects/UOContent/Engines/Craft/T2A/TinkeringMenu.cs @@ -117,7 +117,7 @@ public class TinkeringMenu : ItemListMenu }; private TinkeringMenu(Mobile from, BaseTool tool, Category category, Type selectedResourceType) - : base(GetQuestion(category), BuildFilteredEntries(from, category)) + : base(GetQuestion(category), BuildFilteredEntries(from, category, selectedResourceType)) { _tool = tool; _category = category; @@ -224,16 +224,16 @@ public class TinkeringMenu : ItemListMenu public static ItemListEntry[] Rings() => _ringEntries ??= BuildStaticEntries(RingTypes, "ingots"); public static ItemListEntry[] KegItems() => _kegEntries ??= BuildStaticEntries(KegItemTypes, "kegs"); - private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category) + private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category, Type selectedResourceType) { if (category == Category.Main) { - return BuildFilteredMainEntries(from); + return BuildFilteredMainEntries(from, selectedResourceType); } if (category == Category.Jewelry) { - return BuildFilteredJewelryEntries(from); + return BuildFilteredJewelryEntries(from, selectedResourceType); } var types = GetTypes(category); @@ -243,7 +243,7 @@ public class TinkeringMenu : ItemListMenu return []; } - return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefTinkering.CraftSystem); + return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefTinkering.CraftSystem, selectedResourceType); } private static readonly ItemListEntry[] JewelrySubcategoryEntries = @@ -253,7 +253,7 @@ public class TinkeringMenu : ItemListMenu new("Rings", 0x108a, 0, (int)Category.Rings) ]; - private static ItemListEntry[] BuildFilteredJewelryEntries(Mobile from) + private static ItemListEntry[] BuildFilteredJewelryEntries(Mobile from, Type selectedResourceType) { var system = DefTinkering.CraftSystem; var filtered = new ItemListEntry[JewelrySubcategoryEntries.Length]; @@ -263,7 +263,7 @@ public class TinkeringMenu : ItemListMenu { var entry = JewelrySubcategoryEntries[i]; var types = GetTypes((Category)entry.CraftIndex); - if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system)) + if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system, selectedResourceType)) { filtered[count++] = entry; } @@ -282,7 +282,7 @@ public class TinkeringMenu : ItemListMenu return filtered; } - private static ItemListEntry[] BuildFilteredMainEntries(Mobile from) + private static ItemListEntry[] BuildFilteredMainEntries(Mobile from, Type selectedResourceType) { var system = DefTinkering.CraftSystem; var mainStatic = Main(); @@ -293,7 +293,7 @@ public class TinkeringMenu : ItemListMenu { var entry = mainStatic[i]; var types = GetTypes((Category)entry.CraftIndex); - if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system)) + if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system, selectedResourceType)) { filtered[count++] = entry; } @@ -407,9 +407,17 @@ public class TinkeringMenu : ItemListMenu return true; } - if (targeted is BaseIngot) + if (targeted is BaseIngot and not IronIngot) { - var menu = new TinkeringMenu(from, tool, Category.Main, targeted.GetType()); + // T2A tinkering predates colored ingots; metal items are always made from iron. + from.SendAsciiMessage("Tinkering can only use iron ingots. Please select iron ingots or wood."); + from.Target = new ResourceSelectTarget(from, tool); + return true; + } + + if (targeted is IronIngot) + { + var menu = new TinkeringMenu(from, tool, Category.Main, typeof(IronIngot)); if (menu.Entries.Length == 0) { from.SendAsciiMessage("You lack the skill and materials to craft anything.");