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.");