From 16bf3016fbc7adae7ecab78981de2fc188f530c1 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:27:22 -0700 Subject: [PATCH] feat: Pre-Publish 14 Crafting (supersedes #2181, #2381) (#2476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds authentic **T2A-era (pre-UO:Third-Dawn) packet-based crafting menus**, enabled via the **`t2aCraftMenus` server setting** (read once at startup; default **`!Core.UOTD`**, so a pre-UO:TD shard gets them automatically). When enabled, double-clicking a crafting tool opens the classic `0x7C`/`0x7D` item-list menu — skill- and material-filtered — instead of the modern gump, covering all 8 tool/skill crafts (blacksmithy, tailoring, tinkering, carpentry, alchemy, bowcraft/fletching, inscription, cartography). It is **not** a runtime/admin-flippable feature flag. This is the **definitive, reconciled** branch and **supersedes**: - **#2181** (Delphi — `T2A_CraftingMenus`): the original effort. - **#2381** (Jack/UOLL — `t2a_crafting_menus`): the research-grounded superset (Delphi's base + 12 corrections), rebased onto current `main`. Original authorship is preserved across the cherry-picked history: foundation commit **@Delphi79**, mechanic fixes **@jackuoll (Jack Ward)**, reconciliation/fixes/docs mine. ## How it was built 1. Cherry-picked Jack's 13 commits onto current `main` (superset of Delphi's; only 2 trivial FeatureFlags conflicts). 2. Applied targeted fixes (below) with tests. 3. Full convention audit, build, and test pass. Grounded in independent historical research plus Jack's deep dive. Maintainer reference: `dev-docs/t2a-crafting.md`. ## Mechanics (highlights) - Double-click tool → target resource → skill/material-filtered menu → craft. Resource pre-selection per skill; make-last by targeting the tool. - **Stacked-gem jewelry:** target a gem stack → the **full stack** is consumed and the piece is named by count ("a 1000 diamond ring"); count persists (`BaseJewel` serialization **v4 → v5**, new `_gemCount`). - **Tool-less inscription & cartography** (skill-list invoked; no pen/sextant); inscription consumes reagents+scroll on success and failure, mana only on success. - **Tailoring matching-hue consumption:** targeting hued cloth/leather consumes only that hue. Crafted items take color from their **`CraftResource`** (not the dyed hue), so dyed leather/cloth don't tint the product; in T2A only colored ingots/ore color items (metal armor/shields). - **Half-resources on failed non-scroll crafts** (pre-UO:TD). - **Maker's mark** always prompted for exceptional items, via the shared `QueryMakersMarkGump`. - Server-side menu infra changes are additive (`ItemListEntry.CraftIndex`, `Entries` setter, `HasSent`). ## Notable changes on top of the cherry-pick - **Toggle is a startup server setting, not a feature flag.** Removed `ContentFeatureFlags.T2ACraftMenus` (and its admin-flippable plumbing); the value is read once via `ServerConfiguration.GetSetting("t2aCraftMenus", !Core.UOTD)` into `T2ACraftSystem.Enabled`. Since the default tracks the era and it can't be flipped at runtime, there's no incoherent "menus-on / UO:TD-era" state. - **Stacked-gem consumption (B3a/B3):** consume the full `PendingGemCount` (was deliberately consuming 1 while naming by the stack), null-safe gem type, plain-piece fallback + message. New `T2AJewelGemCraftTests`. - **Convention audit:** `new List()` → `PooledRefList` on the hue-aware consume path; removed dead code. ## Decisions & deviations - `make-last` kept as **QoL** (post-T2A gump-era feature). - `half-on-failure` (non-scroll) kept as a **reconstruction** (not OSI-confirmed). - **Stacked-gem** behavior set per shard authority (overrides the "single gem" reconstruction). - **Cooking** out of scope (no T2A crafting menu existed for it). - **No colored items from dyed materials:** crafted color comes from the `CraftResource` type. Pre-AOS leather has no colored variant, so leather is always uncolored; weapons retain resource color only in AOS+ (unchanged, intended). ## Test plan - Automated: `dotnet build ModernUO.slnx -c Debug` clean; `dotnet test Projects/UOContent.Tests` → **421 passed** (incl. 3 new jewelry tests). - Manual (needs a running T2A shard + client): - [ ] Each of the 8 skills opens the correct menu; empty-menu guard fires. - [ ] Make-last repeats the last craft (jewelry re-prompts gem). - [ ] Jewelry consumes the full targeted gem stack and names by count. - [ ] Cartography consumes blank maps only with T2A enabled / maps+scrolls when disabled. - [ ] Tailoring consumes only the targeted-hue material; crafted items are not tinted by dyed cloth/leather. - [ ] Maker's-mark prompt on exceptional. - [ ] Failed non-scroll craft consumes half resources. - [ ] Inscription: reagents+scroll on success/failure, mana only on success. - [ ] T2A disabled: gump crafting unchanged. ## Credits Co-authored-by: @Delphi79 Co-authored-by: @jackuoll --- Projects/Server/Menus/BaseMenu.cs | 34 + Projects/Server/Menus/ItemListMenu.cs | 31 +- Projects/Server/Menus/QuestionMenu.cs | 24 +- .../Items/Jewels/T2AJewelGemCraftTests.cs | 134 ++++ .../Commands/MovementDebugCommands.cs | 1 - .../Configuration/ExpansionConfiguration.cs | 2 + .../Engines/ConPVP/Gumps/LadderGump.cs | 2 - .../Engines/ConPVP/TournamentBracketItem.cs | 1 - .../Engines/Craft/Core/CraftContext.cs | 8 + .../UOContent/Engines/Craft/Core/CraftGump.cs | 1 - .../Engines/Craft/Core/CraftGumpItem.cs | 5 +- .../UOContent/Engines/Craft/Core/CraftItem.cs | 699 ++++++++++++++++-- .../Engines/Craft/Core/CraftSystem.cs | 13 + .../UOContent/Engines/Craft/Core/Enhance.cs | 25 +- .../Engines/Craft/Core/QueryMakersMarkGump.cs | 16 +- .../UOContent/Engines/Craft/Core/Repair.cs | 13 +- .../UOContent/Engines/Craft/Core/Resmelt.cs | 7 +- .../UOContent/Engines/Craft/DefAlchemy.cs | 5 + .../UOContent/Engines/Craft/DefCartography.cs | 18 +- .../UOContent/Engines/Craft/DefInscription.cs | 18 +- .../UOContent/Engines/Craft/DefTailoring.cs | 7 + .../UOContent/Engines/Craft/DefTinkering.cs | 39 +- .../Engines/Craft/T2A/AlchemyMenu.cs | 262 +++++++ .../Engines/Craft/T2A/BlacksmithMenu.cs | 624 ++++++++++++++++ .../Engines/Craft/T2A/BowFletchingMenu.cs | 95 +++ .../Engines/Craft/T2A/CarpentryMenu.cs | 298 ++++++++ .../Engines/Craft/T2A/CartographyMenu.cs | 110 +++ .../Engines/Craft/T2A/InscriptionMenu.cs | 334 +++++++++ .../Engines/Craft/T2A/T2ACraftSystem.cs | 351 +++++++++ .../Engines/Craft/T2A/T2ACraftToolTarget.cs | 71 ++ .../Engines/Craft/T2A/TailoringMenu.cs | 375 ++++++++++ .../Engines/Craft/T2A/TinkeringMenu.cs | 529 +++++++++++++ .../Factions/Gumps/FactionImbueGump.cs | 2 +- .../UOContent/Engines/ML Quests/MLQuest.cs | 1 - .../Engines/ML Quests/MLQuestEntry.cs | 1 - .../Engines/Plants/PlantPourTarget.cs | 1 - .../Engines/Plants/PollinateTarget.cs | 1 - .../BountyReportMurdererGump.cs | 1 - .../Engines/Veteran Rewards/RewardSystem.cs | 1 - .../UOContent/Gumps/VendorInventoryGump.cs | 2 - Projects/UOContent/Items/Jewels/BaseJewel.cs | 139 +++- Projects/UOContent/Items/Jewels/Ring.cs | 13 + .../Items/Skill Items/Tools/BaseTool.cs | 9 +- .../Migrations/Server.Items.BaseJewel.v5.json | 64 ++ .../Server.Items.WeddingRing.v0.json | 4 + .../NPC/Guildmasters/TinkerGuildmaster.cs | 1 - .../Network/Packets/IncomingPlayerPackets.cs | 32 +- Projects/UOContent/Skills/Cartography.cs | 25 + Projects/UOContent/Skills/Inscribe.cs | 112 ++- dev-docs/t2a-crafting.md | 141 ++++ 50 files changed, 4483 insertions(+), 219 deletions(-) create mode 100644 Projects/Server/Menus/BaseMenu.cs create mode 100644 Projects/UOContent.Tests/Tests/Items/Jewels/T2AJewelGemCraftTests.cs create mode 100644 Projects/UOContent/Engines/Craft/T2A/AlchemyMenu.cs create mode 100644 Projects/UOContent/Engines/Craft/T2A/BlacksmithMenu.cs create mode 100644 Projects/UOContent/Engines/Craft/T2A/BowFletchingMenu.cs create mode 100644 Projects/UOContent/Engines/Craft/T2A/CarpentryMenu.cs create mode 100644 Projects/UOContent/Engines/Craft/T2A/CartographyMenu.cs create mode 100644 Projects/UOContent/Engines/Craft/T2A/InscriptionMenu.cs create mode 100644 Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs create mode 100644 Projects/UOContent/Engines/Craft/T2A/T2ACraftToolTarget.cs create mode 100644 Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs create mode 100644 Projects/UOContent/Engines/Craft/T2A/TinkeringMenu.cs create mode 100644 Projects/UOContent/Migrations/Server.Items.BaseJewel.v5.json create mode 100644 Projects/UOContent/Migrations/Server.Items.WeddingRing.v0.json create mode 100644 Projects/UOContent/Skills/Cartography.cs create mode 100644 dev-docs/t2a-crafting.md diff --git a/Projects/Server/Menus/BaseMenu.cs b/Projects/Server/Menus/BaseMenu.cs new file mode 100644 index 000000000..6a6c8f5b8 --- /dev/null +++ b/Projects/Server/Menus/BaseMenu.cs @@ -0,0 +1,34 @@ +using Server.Network; + +namespace Server.Menus; + +public abstract class BaseMenu : IMenu +{ + private static int _nextSerial; + + public int Serial { get; } + + public abstract int EntryLength { get; } + + public BaseMenu() + { + var serial = ++_nextSerial; + if (serial <= 0) + { + serial = 1; + _nextSerial = 1; + } + + Serial = serial; + } + + public abstract void SendTo(NetState state); + + public virtual void OnCancel(NetState state) + { + } + + public virtual void OnResponse(NetState state, int index) + { + } +} diff --git a/Projects/Server/Menus/ItemListMenu.cs b/Projects/Server/Menus/ItemListMenu.cs index 6c5422fa0..9f71f7d7d 100644 --- a/Projects/Server/Menus/ItemListMenu.cs +++ b/Projects/Server/Menus/ItemListMenu.cs @@ -4,11 +4,12 @@ namespace Server.Menus.ItemLists; public class ItemListEntry { - public ItemListEntry(string name, int itemID, int hue = 0) + public ItemListEntry(string name, int itemID, int hue = 0, int craftIndex = 0) { Name = name?.Trim() ?? ""; ItemID = itemID; Hue = hue; + CraftIndex = craftIndex; } public string Name { get; } @@ -16,43 +17,25 @@ public class ItemListEntry public int ItemID { get; } public int Hue { get; } + + public int CraftIndex { get; } } -public class ItemListMenu : IMenu +public class ItemListMenu : BaseMenu { - private static int m_NextSerial; - public ItemListMenu(string question, ItemListEntry[] entries) { Question = question.Trim(); Entries = entries; - - do - { - Serial = m_NextSerial++; - Serial &= 0x7FFFFFFF; - } while (Serial == 0); - - Serial = (int)((uint)Serial | 0x80000000); } public string Question { get; } public ItemListEntry[] Entries { get; set; } - public int Serial { get; } + public override int EntryLength => Entries.Length; - public int EntryLength => Entries.Length; - - public virtual void OnCancel(NetState state) - { - } - - public virtual void OnResponse(NetState state, int index) - { - } - - public void SendTo(NetState state) + public override void SendTo(NetState state) { state.AddMenu(this); state.SendDisplayItemListMenu(this); diff --git a/Projects/Server/Menus/QuestionMenu.cs b/Projects/Server/Menus/QuestionMenu.cs index a1a9acfec..7a52e0af1 100644 --- a/Projects/Server/Menus/QuestionMenu.cs +++ b/Projects/Server/Menus/QuestionMenu.cs @@ -2,39 +2,21 @@ using Server.Network; namespace Server.Menus.Questions; -public class QuestionMenu : IMenu +public class QuestionMenu : BaseMenu { - private static int m_NextSerial; - public QuestionMenu(string question, string[] answers) { Question = question?.Trim() ?? ""; Answers = answers; - - do - { - Serial = ++m_NextSerial; - Serial &= 0x7FFFFFFF; - } while (Serial == 0); } public string Question { get; } public string[] Answers { get; } - public int Serial { get; } + public override int EntryLength => Answers.Length; - public int EntryLength => Answers.Length; - - public virtual void OnCancel(NetState state) - { - } - - public virtual void OnResponse(NetState state, int index) - { - } - - public void SendTo(NetState state) + public override void SendTo(NetState state) { state.AddMenu(this); state.SendDisplayQuestionMenu(this); diff --git a/Projects/UOContent.Tests/Tests/Items/Jewels/T2AJewelGemCraftTests.cs b/Projects/UOContent.Tests/Tests/Items/Jewels/T2AJewelGemCraftTests.cs new file mode 100644 index 000000000..c4a944cd0 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Jewels/T2AJewelGemCraftTests.cs @@ -0,0 +1,134 @@ +using Server; +using Server.Engines.Craft; +using Server.Items; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class T2AJewelGemCraftTests +{ + // Y=500 keeps us inside Felucca bounds; X offset avoids other sequential tests. + private static PlayerMobile CreatePlayerMobile(Map map, Point3D location) + { + var m = new PlayerMobile(World.NewMobile); + m.DefaultMobileInit(); + m.MoveToWorld(location, map); + m.AddItem(new Backpack()); + return m; + } + + // Builds a minimal tinkering ring recipe (2 iron ingots) without relying on + // DefTinkering.InitCraftList, which is gated on the feature flag at startup. + private static CraftItem MakeRingRecipe() + { + var item = new CraftItem(typeof(GoldRing), "ring", "gold ring"); + item.AddRes(typeof(IronIngot), "iron ingot", 2, "You do not have enough ingots."); + return item; + } + + // Ensures DefTinkering.CraftSystem is available (not called by the test fixture). + private static CraftSystem GetOrInitTinkeringSystem() + { + if (DefTinkering.CraftSystem == null) + { + DefTinkering.Initialize(); + } + + return DefTinkering.CraftSystem; + } + + [Fact] + public void OnCraft_ConsumesEntireTargetedGemStack_AndNamesByCount() + { + var map = Map.Felucca; + var player = CreatePlayerMobile(map, new Point3D(4100, 500, 0)); + var ring = new GoldRing(); + + try + { + var pack = player.Backpack; + pack.AddItem(new IronIngot(10)); + pack.AddItem(new Diamond(50)); // a stack of 50 diamonds + + var system = GetOrInitTinkeringSystem(); + var context = system.GetContext(player); + context.PendingGemType = GemType.Diamond; + context.PendingGemCount = 50; + + ring.OnCraft(1, false, player, system, typeof(IronIngot), null, MakeRingRecipe(), 0); + + Assert.Equal(0, pack.GetAmount(typeof(Diamond))); // all 50 consumed + Assert.Equal(GemType.Diamond, ring.GemType); + Assert.Equal(50, ring.GemCount); + // pending state cleared so the next craft starts fresh + Assert.Equal(GemType.None, context.PendingGemType); + Assert.Equal(0, context.PendingGemCount); + } + finally + { + ring.Delete(); + player.Delete(); + } + } + + [Fact] + public void OnCraft_WithUnsetGemContext_LeavesPlainPiece() + { + var map = Map.Felucca; + var player = CreatePlayerMobile(map, new Point3D(4120, 500, 0)); + var ring = new GoldRing(); + + try + { + player.Backpack.AddItem(new IronIngot(10)); + + var system = GetOrInitTinkeringSystem(); + var context = system.GetContext(player); + context.PendingGemType = GemType.None; // no gem targeted + context.PendingGemCount = 0; + + ring.OnCraft(1, false, player, system, typeof(IronIngot), null, MakeRingRecipe(), 0); + + Assert.Equal(GemType.None, ring.GemType); + Assert.Equal(0, ring.GemCount); + } + finally + { + ring.Delete(); + player.Delete(); + } + } + + [Fact] + public void OnCraft_WhenGemsUnavailableAtCraftTime_CraftsPlainPiece() + { + var map = Map.Felucca; + var player = CreatePlayerMobile(map, new Point3D(4140, 500, 0)); + var ring = new GoldRing(); + + try + { + player.Backpack.AddItem(new IronIngot(10)); + // deliberately do NOT add any diamonds + + var system = GetOrInitTinkeringSystem(); + var context = system.GetContext(player); + context.PendingGemType = GemType.Diamond; + context.PendingGemCount = 5; + + ring.OnCraft(1, false, player, system, typeof(IronIngot), null, MakeRingRecipe(), 0); + + Assert.Equal(GemType.None, ring.GemType); + Assert.Equal(0, ring.GemCount); + Assert.Equal(GemType.None, context.PendingGemType); + Assert.Equal(0, context.PendingGemCount); + } + finally + { + ring.Delete(); + player.Delete(); + } + } +} diff --git a/Projects/UOContent/Commands/MovementDebugCommands.cs b/Projects/UOContent/Commands/MovementDebugCommands.cs index 090ada367..90dbb0596 100644 --- a/Projects/UOContent/Commands/MovementDebugCommands.cs +++ b/Projects/UOContent/Commands/MovementDebugCommands.cs @@ -1,4 +1,3 @@ -using Server.Commands; using Server.Network; using Server.Targeting; diff --git a/Projects/UOContent/Configuration/ExpansionConfiguration.cs b/Projects/UOContent/Configuration/ExpansionConfiguration.cs index 2399a58b5..3ae24bbca 100644 --- a/Projects/UOContent/Configuration/ExpansionConfiguration.cs +++ b/Projects/UOContent/Configuration/ExpansionConfiguration.cs @@ -1,3 +1,4 @@ +using Server.Engines.Craft.T2A; using Server.Network; namespace Server @@ -12,6 +13,7 @@ namespace Server Mobile.VisibleDamageType = visibleDamage ? VisibleDamageType.Related : VisibleDamageType.None; Mobile.GuildClickMessage = ServerConfiguration.GetSetting("guildClickMessage", !Core.AOS); Mobile.AsciiClickMessage = ServerConfiguration.GetSetting("asciiClickMessage", !Core.AOS); + T2ACraftSystem.Enabled = ServerConfiguration.GetSetting("t2aCraftMenus", !Core.UOTD); Mobile.ActionDelay = ServerConfiguration.GetSetting("actionDelay", Core.AOS ? 1000 : 500); diff --git a/Projects/UOContent/Engines/ConPVP/Gumps/LadderGump.cs b/Projects/UOContent/Engines/ConPVP/Gumps/LadderGump.cs index 387a7b211..430351c0e 100644 --- a/Projects/UOContent/Engines/ConPVP/Gumps/LadderGump.cs +++ b/Projects/UOContent/Engines/ConPVP/Gumps/LadderGump.cs @@ -1,7 +1,5 @@ using System; -using System.Collections.Generic; using ModernUO.Serialization; -using Server.Collections; using Server.Gumps; using Server.Network; diff --git a/Projects/UOContent/Engines/ConPVP/TournamentBracketItem.cs b/Projects/UOContent/Engines/ConPVP/TournamentBracketItem.cs index 4f43845e8..f4a8ebdbd 100644 --- a/Projects/UOContent/Engines/ConPVP/TournamentBracketItem.cs +++ b/Projects/UOContent/Engines/ConPVP/TournamentBracketItem.cs @@ -1,5 +1,4 @@ using ModernUO.Serialization; -using Server.Gumps; namespace Server.Engines.ConPVP; diff --git a/Projects/UOContent/Engines/Craft/Core/CraftContext.cs b/Projects/UOContent/Engines/Craft/Core/CraftContext.cs index a0877fa6e..8bd215839 100644 --- a/Projects/UOContent/Engines/Craft/Core/CraftContext.cs +++ b/Projects/UOContent/Engines/Craft/Core/CraftContext.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using Server.Items; namespace Server.Engines.Craft { @@ -31,6 +32,13 @@ namespace Server.Engines.Craft public CraftMarkOption MarkOption { get; set; } + // T2A: last hue used for hue-aware crafting (tailoring cloth) + public int LastHue { get; set; } = -1; + + // T2A jewelry: transient gem info set by GemSelectTarget, consumed by BaseJewel.OnCraft + public GemType PendingGemType { get; set; } + public int PendingGemCount { get; set; } + public CraftItem LastMade { get diff --git a/Projects/UOContent/Engines/Craft/Core/CraftGump.cs b/Projects/UOContent/Engines/Craft/Core/CraftGump.cs index 67591a289..8becdb80d 100644 --- a/Projects/UOContent/Engines/Craft/Core/CraftGump.cs +++ b/Projects/UOContent/Engines/Craft/Core/CraftGump.cs @@ -673,7 +673,6 @@ public class CraftGump : DynamicGump } context.DoNotColor = !context.DoNotColor; - _from.SendGump(new CraftGump(_from, _craftSystem, _tool, null, _page)); break; diff --git a/Projects/UOContent/Engines/Craft/Core/CraftGumpItem.cs b/Projects/UOContent/Engines/Craft/Core/CraftGumpItem.cs index 25cab64b6..c8e9a5fc2 100644 --- a/Projects/UOContent/Engines/Craft/Core/CraftGumpItem.cs +++ b/Projects/UOContent/Engines/Craft/Core/CraftGumpItem.cs @@ -310,8 +310,7 @@ public class CraftGumpItem : DynamicGump // Back Button if (info.ButtonID == 0) { - var craftGump = new CraftGump(from, _craftSystem, _tool, null); - from.SendGump(craftGump); + CraftItem.ShowCraftMenu(from, _craftSystem, _tool, null); } else // Make Button { @@ -319,7 +318,7 @@ public class CraftGumpItem : DynamicGump if (num > 0) { - from.SendGump(new CraftGump(from, _craftSystem, _tool, num)); + CraftItem.ShowCraftMenu(from, _craftSystem, _tool, num); } else { diff --git a/Projects/UOContent/Engines/Craft/Core/CraftItem.cs b/Projects/UOContent/Engines/Craft/Core/CraftItem.cs index 4d5ed00b7..5a31aec3c 100644 --- a/Projects/UOContent/Engines/Craft/Core/CraftItem.cs +++ b/Projects/UOContent/Engines/Craft/Core/CraftItem.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Generic; +using Server.Collections; using Server.Commands; using Server.Factions; using Server.Gumps; using Server.Items; using Server.Logging; using Server.Mobiles; +using Server.Engines.Craft.T2A; namespace Server.Engines.Craft { @@ -59,25 +61,37 @@ namespace Server.Engines.Craft 0x192C, 0x192D, 0x192E, 0x129F, 0x1930, 0x1931, 0x1932, 0x1934 }; - private static readonly Type[][] m_TypesTable = + private static readonly Type[][] m_TypesTable = InitTypesTable(); + + private static Type[][] InitTypesTable() { - new[] { typeof(Log), typeof(Board) }, - new[] { typeof(HeartwoodLog), typeof(HeartwoodBoard) }, - new[] { typeof(BloodwoodLog), typeof(BloodwoodBoard) }, - new[] { typeof(FrostwoodLog), typeof(FrostwoodBoard) }, - new[] { typeof(OakLog), typeof(OakBoard) }, - new[] { typeof(AshLog), typeof(AshBoard) }, - new[] { typeof(YewLog), typeof(YewBoard) }, - new[] { typeof(Leather), typeof(Hides) }, - new[] { typeof(SpinedLeather), typeof(SpinedHides) }, - new[] { typeof(HornedLeather), typeof(HornedHides) }, - new[] { typeof(BarbedLeather), typeof(BarbedHides) }, - new[] { typeof(BlankMap), typeof(BlankScroll) }, - new[] { typeof(Cloth), typeof(UncutCloth) }, - new[] { typeof(CheeseWheel), typeof(CheeseWedge) }, - new[] { typeof(Pumpkin), typeof(SmallPumpkin) }, - new[] { typeof(WoodenBowlOfPeas), typeof(PewterBowlOfPeas) } - }; + List types = + [ + [typeof(Log), typeof(Board)], + [typeof(HeartwoodLog), typeof(HeartwoodBoard)], + [typeof(BloodwoodLog), typeof(BloodwoodBoard)], + [typeof(FrostwoodLog), typeof(FrostwoodBoard)], + [typeof(OakLog), typeof(OakBoard)], + [typeof(AshLog), typeof(AshBoard)], + [typeof(YewLog), typeof(YewBoard)], + [typeof(Leather), typeof(Hides)], + [typeof(SpinedLeather), typeof(SpinedHides)], + [typeof(HornedLeather), typeof(HornedHides)], + [typeof(BarbedLeather), typeof(BarbedHides)], + [typeof(Cloth), typeof(UncutCloth)], + [typeof(CheeseWheel), typeof(CheeseWedge)], + [typeof(Pumpkin), typeof(SmallPumpkin)], + [typeof(WoodenBowlOfPeas), typeof(PewterBowlOfPeas)] + ]; + + // Gump-based crafting allows blank scrolls as a substitute for blank maps in cartography + if (!T2ACraftSystem.Enabled) + { + types.Add([typeof(BlankMap), typeof(BlankScroll)]); + } + + return types.ToArray(); + } private static readonly Type[] m_ColoredItemTable = { @@ -668,6 +682,10 @@ namespace Server.Engines.Craft { amounts[i] = 0; } + else if (isFailure && !Core.UOTD) + { + amounts[i] -= amounts[i] / 2; + } } // We adjust the amount of each resource to consume the max possible @@ -921,14 +939,22 @@ namespace Server.Engines.Craft if (!allRequiredSkills || chance <= 0.0) { from.EndAction(); - from.SendGump( - new CraftGump( - from, - craftSystem, - tool, - 1044153 // You don't have the required skills to attempt this item. - ) - ); + if (T2ACraftSystem.Enabled) + { + from.SendAsciiMessage("You lack the required skill to craft this item."); + } + else + { + from.SendGump( + new CraftGump( + from, + craftSystem, + tool, + 1044153 // You don't have the required skills to attempt this item. + ) + ); + } + return; } @@ -951,7 +977,7 @@ namespace Server.Engines.Craft if (badCraft > 0) { from.EndAction(); - from.SendGump(new CraftGump(from, craftSystem, tool, badCraft)); + ShowCraftMenu(from, craftSystem, tool, badCraft); return; } @@ -962,7 +988,7 @@ namespace Server.Engines.Craft if (!ConsumeRes(from, typeRes, craftSystem, ref resHue, ref maxAmount, ConsumeType.None, ref message)) { from.EndAction(); - from.SendGump(new CraftGump(from, craftSystem, tool, message)); + ShowCraftMenu(from, craftSystem, tool, message); return; } @@ -971,7 +997,7 @@ namespace Server.Engines.Craft if (!ConsumeAttributes(from, ref message, false)) { from.EndAction(); - from.SendGump(new CraftGump(from, craftSystem, tool, message)); + ShowCraftMenu(from, craftSystem, tool, message); return; } @@ -986,6 +1012,293 @@ namespace Server.Engines.Craft new InternalTimer(from, craftSystem, this, typeRes, tool, iRandom).Start(); } + /// + /// Hue-aware craft entry point. Used by tailoring to carry the targeted resource hue + /// through the craft timer to CompleteCraft, ensuring only matching-hue resources are consumed. + /// + public void Craft(Mobile from, CraftSystem craftSystem, Type typeRes, BaseTool tool, int resHue) + { + if (!from.BeginAction()) + { + from.SendLocalizedMessage(500119); // You must wait to perform another action + return; + } + + if (RequiredExpansion != Expansion.None && from.NetState?.SupportsExpansion(RequiredExpansion) != true) + { + from.EndAction(); + from.SendGump( + new CraftGump( + from, + craftSystem, + tool, + RequiredExpansionMessage(RequiredExpansion) + ) + ); + return; + } + + var chance = GetSuccessChance(from, typeRes, craftSystem, false, out var allRequiredSkills); + + if (!allRequiredSkills || chance <= 0.0) + { + from.EndAction(); + from.SendAsciiMessage("You lack the required skill to craft this item."); + return; + } + + if (Recipe != null && (from as PlayerMobile)?.HasRecipe(Recipe) == false) + { + from.EndAction(); + from.SendGump( + new CraftGump( + from, + craftSystem, + tool, + 1072847 // You must learn that recipe from a scroll. + ) + ); + return; + } + + var badCraft = craftSystem.CanCraft(from, tool, ItemType); + + if (badCraft > 0) + { + from.EndAction(); + ShowCraftMenu(from, craftSystem, tool, badCraft); + return; + } + + // Dry run: check hued resources are available + if (!CheckHuedRes(from, typeRes, craftSystem, resHue)) + { + from.EndAction(); + // You don't have the resources required to make that item. + from.SendLocalizedMessage(502925); + return; + } + + TextDefinition message = null; + if (!ConsumeAttributes(from, ref message, false)) + { + from.EndAction(); + ShowCraftMenu(from, craftSystem, tool, message); + return; + } + + var context = craftSystem.GetContext(from); + context?.OnMade(this); + + var iMin = craftSystem.MinCraftEffect; + var iMax = craftSystem.MaxCraftEffect - iMin + 1; + var iRandom = Utility.Random(iMax); + iRandom += iMin + 1; + new InternalTimer(from, craftSystem, this, typeRes, tool, iRandom, resHue).Start(); + } + + /// + /// Checks if the backpack has enough of the specified resource type matching the target hue. + /// Used as a dry-run check before starting the craft timer. + /// + private bool CheckHuedRes(Mobile from, Type typeRes, CraftSystem craftSystem, int targetHue) + { + var ourPack = from.Backpack; + if (ourPack == null) + { + return false; + } + + var resCol = UseSubRes2 ? craftSystem.CraftSubRes2 : craftSystem.CraftSubRes; + + for (var i = 0; i < Resources.Count; i++) + { + var craftRes = Resources[i]; + var baseType = craftRes.ItemType; + + // Resource mutation + if (baseType == resCol.ResType && typeRes != null) + { + baseType = typeRes; + } + + // For the primary resource, count only items matching the target hue + if (targetHue >= 0 && i == 0) + { + var amount = GetHuedAmount(ourPack, baseType, targetHue); + if (amount < craftRes.Amount) + { + return false; + } + } + else + { + if (ourPack.GetAmount(baseType) < craftRes.Amount) + { + return false; + } + } + } + + return true; + } + + /// + /// Consumes resources from the backpack, restricting the primary resource to items matching the target hue. + /// Returns true on success. On success, the exact targetHue is used as the resHue output. + /// + private bool ConsumeHuedRes( + Mobile from, Type typeRes, CraftSystem craftSystem, int targetHue, + ref int resHue, ConsumeType consumeType + ) + { + var ourPack = from.Backpack; + if (ourPack == null) + { + return false; + } + + if (NeedHeat && !Find(from, m_HeatSources)) + { + return false; + } + + if (NeedOven && !Find(from, m_Ovens)) + { + return false; + } + + if (NeedMill && !Find(from, m_Mills)) + { + return false; + } + + var resCol = UseSubRes2 ? craftSystem.CraftSubRes2 : craftSystem.CraftSubRes; + + for (var i = 0; i < Resources.Count; i++) + { + var craftRes = Resources[i]; + var baseType = craftRes.ItemType; + var amount = craftRes.Amount; + + // Resource mutation + if (baseType == resCol.ResType && typeRes != null) + { + baseType = typeRes; + + var subResource = resCol.SearchFor(baseType); + if (subResource != null && from.Skills[craftSystem.MainSkill].Base < subResource.RequiredSkill) + { + return false; + } + } + + if (consumeType == ConsumeType.Half) + { + amount = Math.Max(1, amount / 2); + } + + // For the primary resource, filter by hue + if (targetHue >= 0 && i == 0) + { + if (consumeType == ConsumeType.None) + { + // Dry run: just check amount + if (GetHuedAmount(ourPack, baseType, targetHue) < amount) + { + return false; + } + } + else + { + // Actual consumption: consume only matching-hue items + if (!ConsumeHuedAmount(ourPack, baseType, targetHue, amount)) + { + return false; + } + } + } + else + { + // Non-hued resource: normal consumption + if (consumeType == ConsumeType.None) + { + if (ourPack.GetAmount(baseType) < amount) + { + return false; + } + } + else + { + if (!ourPack.ConsumeTotal(baseType, amount)) + { + return false; + } + } + } + } + + resHue = targetHue; + return true; + } + + private static int GetHuedAmount(Container pack, Type type, int hue) + { + var total = 0; + + foreach (var item in pack.FindItems(true)) + { + if (item.Hue == hue && type.IsInstanceOfType(item)) + { + total += item.Amount; + } + } + + return total; + } + + private static bool ConsumeHuedAmount(Container pack, Type type, int hue, int amount) + { + var remaining = amount; + using var toDelete = PooledRefList.Create(); + + foreach (var item in pack.FindItems(true)) + { + if (remaining <= 0) + { + break; + } + + if (item.Hue != hue || !type.IsInstanceOfType(item)) + { + continue; + } + + if (item.Amount <= remaining) + { + remaining -= item.Amount; + toDelete.Add(item); + } + else + { + item.Amount -= remaining; + remaining = 0; + } + } + + if (remaining > 0) + { + return false; + } + + for (var i = 0; i < toDelete.Count; i++) + { + toDelete[i].Delete(); + } + + return true; + } + private static TextDefinition RequiredExpansionMessage(Expansion expansion) { return expansion switch @@ -1007,7 +1320,7 @@ namespace Server.Engines.Craft { if (tool?.Deleted == false && tool.UsesRemaining > 0) { - from.SendGump(new CraftGump(from, craftSystem, tool, badCraft)); + ShowCraftMenu(from, craftSystem, tool, badCraft); } else { @@ -1036,7 +1349,7 @@ namespace Server.Engines.Craft { if (tool?.Deleted == false && tool.UsesRemaining > 0) { - from.SendGump(new CraftGump(from, craftSystem, tool, checkMessage)); + ShowCraftMenu(from, craftSystem, tool, checkMessage); } else if (checkMessage.Number > 0) { @@ -1076,7 +1389,7 @@ namespace Server.Engines.Craft { if (tool?.Deleted == false && tool.UsesRemaining > 0) { - from.SendGump(new CraftGump(from, craftSystem, tool, message)); + ShowCraftMenu(from, craftSystem, tool, message); } else if (message != null) { @@ -1093,29 +1406,32 @@ namespace Server.Engines.Craft return; } - tool.UsesRemaining--; - - if (craftSystem is DefBlacksmithy) + if (tool != null) { - var hammer = from.FindItemOnLayer(Layer.OneHanded); - if (hammer != null && hammer != tool) + tool.UsesRemaining--; + + if (craftSystem is DefBlacksmithy) { - hammer.UsesRemaining--; - if (hammer.UsesRemaining < 1) + var hammer = from.FindItemOnLayer(Layer.OneHanded); + if (hammer != null && hammer != tool) { - hammer.Delete(); + hammer.UsesRemaining--; + if (hammer.UsesRemaining < 1) + { + hammer.Delete(); + } } } - } - if (tool.UsesRemaining < 1 && tool.BreakOnDepletion) - { - toolBroken = true; - } + if (tool.UsesRemaining < 1 && tool.BreakOnDepletion) + { + toolBroken = true; + } - if (toolBroken) - { - tool.Delete(); + if (toolBroken) + { + tool.Delete(); + } } Item item; @@ -1229,7 +1545,18 @@ namespace Server.Engines.Craft } else if (tool?.Deleted == false && tool.UsesRemaining > 0) { - from.SendGump(new CraftGump(from, craftSystem, tool, num)); + if (T2ACraftSystem.Enabled) + { + if (num > 0) + { + from.SendLocalizedMessage(num); + } + ShowCraftMenu(from, craftSystem, tool); + } + else + { + ShowCraftMenu(from, craftSystem, tool, num); + } } else if (num > 0) { @@ -1243,13 +1570,12 @@ namespace Server.Engines.Craft { if (tool?.Deleted == false && tool.UsesRemaining > 0) { - from.SendGump(new CraftGump(from, craftSystem, tool, 1044153)); + from.SendAsciiMessage("You lack the required skill to craft this item."); } else { from.SendLocalizedMessage(1044153); // You don't have the required skills to attempt this item. } - return; } @@ -1260,7 +1586,7 @@ namespace Server.Engines.Craft { if (tool?.Deleted == false && tool.UsesRemaining > 0) { - from.SendGump(new CraftGump(from, craftSystem, tool, message)); + ShowCraftMenu(from, craftSystem, tool, message); } else if (message != null) { @@ -1277,24 +1603,226 @@ namespace Server.Engines.Craft return; } - tool.UsesRemaining--; - - if (tool.UsesRemaining < 1 && tool.BreakOnDepletion) + if (tool != null) { - toolBroken = true; - } + tool.UsesRemaining--; - if (toolBroken) - { - tool.Delete(); + if (tool.UsesRemaining < 1 && tool.BreakOnDepletion) + { + toolBroken = true; + } + + if (toolBroken) + { + tool.Delete(); + } } // SkillCheck failed. num = craftSystem.PlayEndingEffect(from, true, true, toolBroken, endquality, false, this); - if (!tool.Deleted && tool.UsesRemaining > 0) + if (tool?.Deleted == false && tool.UsesRemaining > 0) { - from.SendGump(new CraftGump(from, craftSystem, tool, num)); + ShowCraftMenu(from, craftSystem, tool, num); + } + else if (num > 0) + { + from.SendLocalizedMessage(num); + } + } + + /// + /// Hue-aware CompleteCraft. Uses ConsumeHuedRes to consume only resources matching + /// the target hue, and applies that hue to the crafted item. + /// + public void CompleteCraft( + int quality, bool makersMark, Mobile from, CraftSystem craftSystem, Type typeRes, + BaseTool tool, CustomCraft customCraft, int targetHue + ) + { + var badCraft = craftSystem.CanCraft(from, tool, ItemType); + + if (badCraft > 0) + { + if (tool?.Deleted == false && tool.UsesRemaining > 0) + { + ShowCraftMenu(from, craftSystem, tool, badCraft); + } + else + { + from.SendLocalizedMessage(badCraft); + } + + return; + } + + // Dry-run check with hue filtering + var checkResHue = 0; + if (!ConsumeHuedRes(from, typeRes, craftSystem, targetHue, ref checkResHue, ConsumeType.None)) + { + if (tool?.Deleted == false && tool.UsesRemaining > 0) + { + // You don't have the resources required to make that item. + ShowCraftMenu(from, craftSystem, tool, 502925); + } + else + { + from.SendLocalizedMessage(502925); + } + + return; + } + + TextDefinition checkMessage = null; + if (!ConsumeAttributes(from, ref checkMessage, false)) + { + return; + } + + var toolBroken = false; + var endquality = 1; + var resHue = 0; + var num = 0; + + if (CheckSkills(from, typeRes, craftSystem, ref quality, out var allRequiredSkills)) + { + var consumeType = UseAllRes ? ConsumeType.Half : ConsumeType.All; + + if (!ConsumeHuedRes(from, typeRes, craftSystem, targetHue, ref resHue, consumeType)) + { + if (tool?.Deleted == false && tool.UsesRemaining > 0) + { + ShowCraftMenu(from, craftSystem, tool, 502925); + } + else + { + from.SendLocalizedMessage(502925); + } + + return; + } + + if (tool != null) + { + tool.UsesRemaining--; + + if (craftSystem is DefBlacksmithy) + { + var hammer = from.FindItemOnLayer(Layer.OneHanded); + if (hammer != null && hammer != tool) + { + hammer.UsesRemaining--; + if (hammer.UsesRemaining < 1) + { + hammer.Delete(); + } + } + } + + if (tool.UsesRemaining < 1 && tool.BreakOnDepletion) + { + toolBroken = true; + } + + if (toolBroken) + { + tool.Delete(); + } + } + + Item item; + if (customCraft != null) + { + item = customCraft.CompleteCraft(out num); + } + else + { + item = ItemType.CreateInstance(); + } + + if (item != null) + { + if (item is ICraftable craftable) + { + endquality = craftable.OnCraft(quality, makersMark, from, craftSystem, typeRes, tool, this, resHue); + } + else if (item.Hue == 0) + { + item.Hue = resHue; + } + + from.AddToBackpack(item); + + num = craftSystem.PlayEndingEffect(from, false, true, toolBroken, endquality, false, this); + + if (T2ACraftSystem.Enabled) + { + if (tool?.Deleted == false && tool.UsesRemaining > 0) + { + if (num > 0) + { + from.SendLocalizedMessage(num); + } + + ShowCraftMenu(from, craftSystem, tool); + } + else if (num > 0) + { + from.SendLocalizedMessage(num); + } + } + else if (tool?.Deleted == false && tool.UsesRemaining > 0) + { + ShowCraftMenu(from, craftSystem, tool, num); + } + else if (num > 0) + { + from.SendLocalizedMessage(num); + } + + return; + } + } + + if (!allRequiredSkills) + { + if (tool?.Deleted == false && tool.UsesRemaining > 0) + { + from.SendAsciiMessage("You lack the required skill to craft this item."); + } + else + { + from.SendLocalizedMessage(1044153); + } + + return; + } + + var failConsumeType = UseAllRes ? ConsumeType.Half : ConsumeType.All; + + // Failure: consume resources (half on failure) + ConsumeHuedRes(from, typeRes, craftSystem, targetHue, ref resHue, failConsumeType); + + if (tool != null) + { + tool.UsesRemaining--; + + if (tool.UsesRemaining < 1 && tool.BreakOnDepletion) + { + toolBroken = true; + } + + if (toolBroken) + { + tool.Delete(); + } + } + + num = craftSystem.PlayEndingEffect(from, true, true, toolBroken, endquality, false, this); + + if (tool?.Deleted == false && tool.UsesRemaining > 0) + { + ShowCraftMenu(from, craftSystem, tool, num); } else if (num > 0) { @@ -1310,11 +1838,12 @@ namespace Server.Engines.Craft private readonly int m_iCountMax; private readonly BaseTool m_Tool; private readonly Type m_TypeRes; + private readonly int m_ResHue; private int m_iCount; public InternalTimer( Mobile from, CraftSystem craftSystem, CraftItem craftItem, Type typeRes, BaseTool tool, - int iCountMax + int iCountMax, int resHue = -1 ) : base(TimeSpan.Zero, TimeSpan.FromSeconds(craftSystem.Delay), iCountMax) { m_From = from; @@ -1324,6 +1853,7 @@ namespace Server.Engines.Craft m_CraftSystem = craftSystem; m_TypeRes = typeRes; m_Tool = tool; + m_ResHue = resHue; } protected override void OnTick() @@ -1346,7 +1876,7 @@ namespace Server.Engines.Craft { if (m_Tool?.Deleted == false && m_Tool.UsesRemaining > 0) { - m_From.SendGump(new CraftGump(m_From, m_CraftSystem, m_Tool, badCraft)); + ShowCraftMenu(m_From, m_CraftSystem, m_Tool, badCraft); } else { @@ -1395,7 +1925,9 @@ namespace Server.Engines.Craft makersMark = m_CraftItem.IsMarkable(m_CraftItem.ItemType); } - if (makersMark && context.MarkOption == CraftMarkOption.PromptForMark) + // T2A menus always prompt for maker's mark (no auto-mark/don't-mark options) + if (makersMark && + (T2ACraftSystem.Enabled || context.MarkOption == CraftMarkOption.PromptForMark)) { m_From.SendGump( new QueryMakersMarkGump( @@ -1403,7 +1935,8 @@ namespace Server.Engines.Craft m_CraftItem, m_CraftSystem, m_TypeRes, - m_Tool + m_Tool, + m_ResHue ) ); } @@ -1414,9 +1947,41 @@ namespace Server.Engines.Craft makersMark = false; } - m_CraftItem.CompleteCraft(quality, makersMark, m_From, m_CraftSystem, m_TypeRes, m_Tool, null); + if (m_ResHue >= 0) + { + m_CraftItem.CompleteCraft( + quality, makersMark, m_From, m_CraftSystem, m_TypeRes, m_Tool, null, m_ResHue + ); + } + else + { + m_CraftItem.CompleteCraft(quality, makersMark, m_From, m_CraftSystem, m_TypeRes, m_Tool, null); + } } } } + + public static void ShowCraftMenu(Mobile from, CraftSystem system, BaseTool tool, TextDefinition message = null) + { + if (T2ACraftSystem.Enabled) + { + // T2A: Don't reopen menu. Player double-clicks tool to restart. + if (message != null) + { + if (message.Number > 0) + { + from.SendLocalizedMessage(message.Number); + } + else if (!string.IsNullOrEmpty(message.String)) + { + from.SendMessage(message.String); + } + } + + return; + } + + from.SendGump(new CraftGump(from, system, tool, message)); + } } } diff --git a/Projects/UOContent/Engines/Craft/Core/CraftSystem.cs b/Projects/UOContent/Engines/Craft/Core/CraftSystem.cs index 1f4250bbb..0760c1979 100644 --- a/Projects/UOContent/Engines/Craft/Core/CraftSystem.cs +++ b/Projects/UOContent/Engines/Craft/Core/CraftSystem.cs @@ -54,6 +54,8 @@ namespace Server.Engines.Craft public virtual CraftECA ECA => CraftECA.ChanceMinusSixty; + public virtual bool RequiresTool => true; + public bool Resmelt { get; set; } public bool Repair { get; set; } @@ -103,6 +105,17 @@ namespace Server.Engines.Craft } } + public void CreateItem( + Mobile from, Type type, Type typeRes, BaseTool tool, CraftItem realCraftItem, int hue + ) + { + // Verify if the type is in the list of the craftable item + if (CraftItems.SearchFor(type) != null) + { + realCraftItem.Craft(from, this, typeRes, tool, hue); + } + } + public int RandomRecipe() { if (m_Recipes.Count == 0) diff --git a/Projects/UOContent/Engines/Craft/Core/Enhance.cs b/Projects/UOContent/Engines/Craft/Core/Enhance.cs index 342027665..44a78c854 100644 --- a/Projects/UOContent/Engines/Craft/Core/Enhance.cs +++ b/Projects/UOContent/Engines/Craft/Core/Enhance.cs @@ -1,5 +1,4 @@ using System; -using Server.Gumps; using Server.Items; using Server.Targeting; @@ -348,7 +347,7 @@ namespace Server.Engines.Craft if (from.Skills[craftSystem.MainSkill].Value < res.RequiredSkill) { - from.SendGump(new CraftGump(from, craftSystem, tool, res.Message)); + CraftItem.ShowCraftMenu(from, craftSystem, tool, res.Message); } else { @@ -362,29 +361,13 @@ namespace Server.Engines.Craft } else { - from.SendGump( - new CraftGump( - from, - craftSystem, - tool, - // You must select a special material in order to enhance an item with its properties. - 1061010 - ) - ); + CraftItem.ShowCraftMenu(from, craftSystem, tool, 1061010); } } } else { - from.SendGump( - new CraftGump( - from, - craftSystem, - tool, - // You must select a special material in order to enhance an item with its properties. - 1061010 - ) - ); + CraftItem.ShowCraftMenu(from, craftSystem, tool, 1061010); } } @@ -435,7 +418,7 @@ namespace Server.Engines.Craft _ => message }; - from.SendGump(new CraftGump(from, m_CraftSystem, m_Tool, message)); + CraftItem.ShowCraftMenu(from, m_CraftSystem, m_Tool, message); } } } diff --git a/Projects/UOContent/Engines/Craft/Core/QueryMakersMarkGump.cs b/Projects/UOContent/Engines/Craft/Core/QueryMakersMarkGump.cs index e2bbd57ea..b249fd4ce 100644 --- a/Projects/UOContent/Engines/Craft/Core/QueryMakersMarkGump.cs +++ b/Projects/UOContent/Engines/Craft/Core/QueryMakersMarkGump.cs @@ -12,17 +12,20 @@ public class QueryMakersMarkGump : StaticGump private readonly int _quality; private readonly BaseTool _tool; private readonly Type _typeRes; + private readonly int _resHue; public override bool Singleton => true; - public QueryMakersMarkGump(int quality, CraftItem craftItem, CraftSystem craftSystem, Type typeRes, BaseTool tool) - : base(100, 200) + public QueryMakersMarkGump( + int quality, CraftItem craftItem, CraftSystem craftSystem, Type typeRes, BaseTool tool, int resHue = -1 + ) : base(100, 200) { _quality = quality; _craftItem = craftItem; _craftSystem = craftSystem; _typeRes = typeRes; _tool = tool; + _resHue = resHue; } protected override void BuildLayout(ref StaticGumpBuilder builder) @@ -55,6 +58,13 @@ public class QueryMakersMarkGump : StaticGump from.SendLocalizedMessage(501809); // Cancelled mark. } - _craftItem.CompleteCraft(_quality, makersMark, from, _craftSystem, _typeRes, _tool, null); + if (_resHue >= 0) + { + _craftItem.CompleteCraft(_quality, makersMark, from, _craftSystem, _typeRes, _tool, null, _resHue); + } + else + { + _craftItem.CompleteCraft(_quality, makersMark, from, _craftSystem, _typeRes, _tool, null); + } } } diff --git a/Projects/UOContent/Engines/Craft/Core/Repair.cs b/Projects/UOContent/Engines/Craft/Core/Repair.cs index 057ff4a7b..ed988c459 100644 --- a/Projects/UOContent/Engines/Craft/Core/Repair.cs +++ b/Projects/UOContent/Engines/Craft/Core/Repair.cs @@ -1,5 +1,5 @@ using System; -using Server.Gumps; +using Server.Engines.Craft.T2A; using Server.Items; using Server.Mobiles; using Server.Targeting; @@ -482,8 +482,15 @@ namespace Server.Engines.Craft if (!usingDeed) { - var context = m_CraftSystem.GetContext(from); - from.SendGump(new CraftGump(from, m_CraftSystem, m_Tool, number)); + if (T2ACraftSystem.Enabled) + { + from.SendLocalizedMessage(number); + CraftItem.ShowCraftMenu(from, m_CraftSystem, m_Tool); + } + else + { + CraftItem.ShowCraftMenu(from, m_CraftSystem, m_Tool, number); + } } else { diff --git a/Projects/UOContent/Engines/Craft/Core/Resmelt.cs b/Projects/UOContent/Engines/Craft/Core/Resmelt.cs index 8ea625822..8c7bbc814 100644 --- a/Projects/UOContent/Engines/Craft/Core/Resmelt.cs +++ b/Projects/UOContent/Engines/Craft/Core/Resmelt.cs @@ -1,5 +1,4 @@ using Server.Ethics; -using Server.Gumps; using Server.Items; using Server.Targeting; @@ -20,7 +19,7 @@ namespace Server.Engines.Craft if (num > 0 && num != 1044267) { - from.SendGump(new CraftGump(from, craftSystem, tool, num)); + CraftItem.ShowCraftMenu(from, craftSystem, tool, num); } else { @@ -142,7 +141,7 @@ namespace Server.Engines.Craft } } - from.SendGump(new CraftGump(from, m_CraftSystem, m_Tool, num)); + CraftItem.ShowCraftMenu(from, m_CraftSystem, m_Tool, num); } else { @@ -173,7 +172,7 @@ namespace Server.Engines.Craft _ => 1044272 }; - from.SendGump(new CraftGump(from, m_CraftSystem, m_Tool, message)); + CraftItem.ShowCraftMenu(from, m_CraftSystem, m_Tool, message); } } } diff --git a/Projects/UOContent/Engines/Craft/DefAlchemy.cs b/Projects/UOContent/Engines/Craft/DefAlchemy.cs index f2cbd5a35..ef7e4ad8a 100644 --- a/Projects/UOContent/Engines/Craft/DefAlchemy.cs +++ b/Projects/UOContent/Engines/Craft/DefAlchemy.cs @@ -9,6 +9,11 @@ public class DefAlchemy : CraftSystem public static void Initialize() { + if (CraftSystem != null) + { + return; // Already initialized + } + CraftSystem = new DefAlchemy(); } diff --git a/Projects/UOContent/Engines/Craft/DefCartography.cs b/Projects/UOContent/Engines/Craft/DefCartography.cs index 6b020e491..1442b5676 100644 --- a/Projects/UOContent/Engines/Craft/DefCartography.cs +++ b/Projects/UOContent/Engines/Craft/DefCartography.cs @@ -1,4 +1,5 @@ using System; +using Server.Engines.Craft.T2A; using Server.Items; namespace Server.Engines.Craft; @@ -20,18 +21,23 @@ public class DefCartography : CraftSystem public static CraftSystem CraftSystem { get; private set; } + public override bool RequiresTool => !T2ACraftSystem.Enabled; + public override double GetChanceAtMin(CraftItem item) => 0.0; public override int CanCraft(Mobile from, BaseTool tool, Type itemType) { - if (tool?.Deleted != false || tool.UsesRemaining < 0) + if (RequiresTool) { - return 1044038; // You have worn out your tool! - } + if (tool?.Deleted != false || tool.UsesRemaining < 0) + { + return 1044038; // You have worn out your tool! + } - if (!BaseTool.CheckAccessible(tool, from)) - { - return 1044263; // The tool must be on your person to use. + if (!BaseTool.CheckAccessible(tool, from)) + { + return 1044263; // The tool must be on your person to use. + } } return 0; diff --git a/Projects/UOContent/Engines/Craft/DefInscription.cs b/Projects/UOContent/Engines/Craft/DefInscription.cs index d4b33b717..34f421a8f 100644 --- a/Projects/UOContent/Engines/Craft/DefInscription.cs +++ b/Projects/UOContent/Engines/Craft/DefInscription.cs @@ -1,5 +1,6 @@ using System; using Server.Engines.BulkOrders; +using Server.Engines.Craft.T2A; using Server.Items; using Server.Spells; @@ -39,18 +40,23 @@ public class DefInscription : CraftSystem public static CraftSystem CraftSystem { get; private set; } + public override bool RequiresTool => !T2ACraftSystem.Enabled; + public override double GetChanceAtMin(CraftItem item) => 0.0; public override int CanCraft(Mobile from, BaseTool tool, Type typeItem) { - if (tool?.Deleted != false || tool.UsesRemaining < 0) + if (RequiresTool) { - return 1044038; // You have worn out your tool! - } + if (tool?.Deleted != false || tool.UsesRemaining < 0) + { + return 1044038; // You have worn out your tool! + } - if (!BaseTool.CheckAccessible(tool, from)) - { - return 1044263; // The tool must be on your person to use. + if (!BaseTool.CheckAccessible(tool, from)) + { + return 1044263; // The tool must be on your person to use. + } } var scroll = typeItem?.CreateEntityInstance(); diff --git a/Projects/UOContent/Engines/Craft/DefTailoring.cs b/Projects/UOContent/Engines/Craft/DefTailoring.cs index 3aedde3fb..423896f9b 100644 --- a/Projects/UOContent/Engines/Craft/DefTailoring.cs +++ b/Projects/UOContent/Engines/Craft/DefTailoring.cs @@ -664,6 +664,13 @@ public class DefTailoring : CraftSystem AddSubRes(typeof(HornedLeather), 1049152, 80.0, 1044462, 1049311); AddSubRes(typeof(BarbedLeather), 1049153, 99.0, 1044462, 1049311); + // Add Bolt of Cloth for pre-AOS expansions only + if (!Core.AOS) + { + index = AddCraft(typeof(BoltOfCloth), 1015283, 1044286, 0.0, 25.0, typeof(Cloth), 1044286, 50, 1044287); + // 1015283: group (Sashes & Aprons), 1044286: name (Cloth), 0.0-25.0: skill, 50: amount, 1044287: message + } + MarkOption = true; Repair = Core.AOS; CanEnhance = Core.AOS; diff --git a/Projects/UOContent/Engines/Craft/DefTinkering.cs b/Projects/UOContent/Engines/Craft/DefTinkering.cs index 836ca0978..4c41d52df 100644 --- a/Projects/UOContent/Engines/Craft/DefTinkering.cs +++ b/Projects/UOContent/Engines/Craft/DefTinkering.cs @@ -1,6 +1,6 @@ using System; +using Server.Engines.Craft.T2A; using Server.Factions; -using Server.Gumps; using Server.Items; using Server.Targeting; @@ -71,7 +71,7 @@ public class DefTinkering : CraftSystem public override bool RetainsColorFrom(CraftItem item, Type type) { - if (!type.IsSubclassOf(typeof(BaseIngot))) + if (!Core.UOTD || !type.IsSubclassOf(typeof(BaseIngot))) { return false; } @@ -377,15 +377,30 @@ public class DefTinkering : CraftSystem SetNeededExpansion(index, Expansion.SE); } - AddJewelrySet(GemType.StarSapphire, typeof(StarSapphire)); - AddJewelrySet(GemType.Emerald, typeof(Emerald)); - AddJewelrySet(GemType.Sapphire, typeof(Sapphire)); - AddJewelrySet(GemType.Ruby, typeof(Ruby)); - AddJewelrySet(GemType.Citrine, typeof(Citrine)); - AddJewelrySet(GemType.Amethyst, typeof(Amethyst)); - AddJewelrySet(GemType.Tourmaline, typeof(Tourmaline)); - AddJewelrySet(GemType.Amber, typeof(Amber)); - AddJewelrySet(GemType.Diamond, typeof(Diamond)); + if (!T2ACraftSystem.Enabled) + { + // AOS+ jewelry with gem resources + AddJewelrySet(GemType.StarSapphire, typeof(StarSapphire)); + AddJewelrySet(GemType.Emerald, typeof(Emerald)); + AddJewelrySet(GemType.Sapphire, typeof(Sapphire)); + AddJewelrySet(GemType.Ruby, typeof(Ruby)); + AddJewelrySet(GemType.Citrine, typeof(Citrine)); + AddJewelrySet(GemType.Amethyst, typeof(Amethyst)); + AddJewelrySet(GemType.Tourmaline, typeof(Tourmaline)); + AddJewelrySet(GemType.Amber, typeof(Amber)); + AddJewelrySet(GemType.Diamond, typeof(Diamond)); + } + else + { + // T2A jewelry — ingot-only resources, gem selected via targeting + AddCraft(typeof(GoldNecklace), 1044049, "gold necklace", 40.0, 90.0, typeof(IronIngot), 1044036, 2, 1044037); + AddCraft(typeof(SilverNecklace), 1044049, "silver necklace", 40.0, 90.0, typeof(IronIngot), 1044036, 2, 1044037); + AddCraft(typeof(GoldEarrings), 1044049, "gold earrings", 40.0, 90.0, typeof(IronIngot), 1044036, 2, 1044037); + AddCraft(typeof(SilverEarrings), 1044049, "silver earrings", 40.0, 90.0, typeof(IronIngot), 1044036, 2, 1044037); + AddCraft(typeof(GoldRing), 1044049, "gold ring", 40.0, 90.0, typeof(IronIngot), 1044036, 2, 1044037); + AddCraft(typeof(SilverRing), 1044049, "silver ring", 40.0, 90.0, typeof(IronIngot), 1044036, 2, 1044037); + AddCraft(typeof(WeddingRing), 1044049, "wedding ring", 40.0, 90.0, typeof(IronIngot), 1044036, 2, 1044037); + } index = AddCraft(typeof(AxleGears), 1044051, 1024177, 0.0, 0.0, typeof(Axle), 1044169, 1, 1044253); AddRes(index, typeof(Gears), 1044254, 1, 1044253); @@ -714,7 +729,7 @@ public abstract class TrapCraft : CustomCraft if (tool?.Deleted == false && tool.UsesRemaining > 0) { - from.SendGump(new CraftGump(from, m_TrapCraft.CraftSystem, tool, message)); + CraftItem.ShowCraftMenu(from, m_TrapCraft.CraftSystem, tool, message); } else if (message > 0) { diff --git a/Projects/UOContent/Engines/Craft/T2A/AlchemyMenu.cs b/Projects/UOContent/Engines/Craft/T2A/AlchemyMenu.cs new file mode 100644 index 000000000..fedc0639f --- /dev/null +++ b/Projects/UOContent/Engines/Craft/T2A/AlchemyMenu.cs @@ -0,0 +1,262 @@ +using System; +using Server.Items; +using Server.Menus.ItemLists; +using Server.Network; + +namespace Server.Engines.Craft.T2A; + +public class AlchemyMenu : ItemListMenu +{ + private enum Category + { + Main, + Refresh, + Agility, + NightSight, + Heal, + Strength, + Poison, + Cure, + Explosion + } + + private static readonly Type[] RefreshTypes = [typeof(RefreshPotion), typeof(TotalRefreshPotion)]; + + private static readonly Type[] AgilityTypes = [typeof(AgilityPotion), typeof(GreaterAgilityPotion)]; + + private static readonly Type[] NightSightTypes = [typeof(NightSightPotion)]; + + private static readonly Type[] HealTypes = [typeof(LesserHealPotion), typeof(HealPotion), typeof(GreaterHealPotion)]; + + private static readonly Type[] StrengthTypes = [typeof(StrengthPotion), typeof(GreaterStrengthPotion)]; + + private static readonly Type[] PoisonTypes = + [ + typeof(LesserPoisonPotion), typeof(PoisonPotion), typeof(GreaterPoisonPotion), typeof(DeadlyPoisonPotion) + ]; + + private static readonly Type[] CureTypes = [typeof(LesserCurePotion), typeof(CurePotion), typeof(GreaterCurePotion)]; + + private static readonly Type[] ExplosionTypes = + [ + typeof(LesserExplosionPotion), typeof(ExplosionPotion), typeof(GreaterExplosionPotion) + ]; + + private static ItemListEntry[] _mainEntries; + private static ItemListEntry[] _refreshEntries; + private static ItemListEntry[] _agilityEntries; + private static ItemListEntry[] _nightSightEntries; + private static ItemListEntry[] _healEntries; + private static ItemListEntry[] _strengthEntries; + private static ItemListEntry[] _poisonEntries; + private static ItemListEntry[] _cureEntries; + private static ItemListEntry[] _explosionEntries; + + private readonly Category _category; + private readonly BaseTool _tool; + + public AlchemyMenu(Mobile from, BaseTool tool) : this(from, tool, Category.Main) + { + } + + private static string GetQuestion(Category category) => category switch + { + Category.Main => "What kind of potion?", + _ => "Which potion would you like to make?" + }; + + private AlchemyMenu(Mobile from, BaseTool tool, Category category) + : base(GetQuestion(category), BuildFilteredEntries(from, category)) + { + _tool = tool; + _category = category; + } + + private static string FormatItemName(Type type) + { + var name = type.Name; + + if (name.EndsWith("Potion")) + { + name = name[..^6]; + } + + Span buffer = stackalloc char[name.Length * 2]; + var pos = 0; + + for (var i = 0; i < name.Length; i++) + { + if (i > 0 && char.IsUpper(name[i])) + { + buffer[pos++] = ' '; + } + + buffer[pos++] = char.ToLower(name[i]); + } + + return new string(buffer[..pos]); + } + + private static ItemListEntry[] BuildStaticEntries(Type[] types) + { + var entries = new ItemListEntry[types.Length]; + var count = 0; + var craftItems = DefAlchemy.CraftSystem.CraftItems; + + for (var i = 0; i < types.Length; i++) + { + var itemDef = craftItems.SearchFor(types[i]); + if (itemDef == null) + { + continue; + } + + entries[count++] = new ItemListEntry(FormatItemName(types[i]), itemDef.ItemId, 0, i); + } + + if (count < entries.Length) + { + Array.Resize(ref entries, count); + } + + return entries; + } + + private static ItemListEntry[] GetStaticEntries(Category category) => category switch + { + Category.Main => Main(), + Category.Refresh => _refreshEntries ??= BuildStaticEntries(RefreshTypes), + Category.Agility => _agilityEntries ??= BuildStaticEntries(AgilityTypes), + Category.NightSight => _nightSightEntries ??= BuildStaticEntries(NightSightTypes), + Category.Heal => _healEntries ??= BuildStaticEntries(HealTypes), + Category.Strength => _strengthEntries ??= BuildStaticEntries(StrengthTypes), + Category.Poison => _poisonEntries ??= BuildStaticEntries(PoisonTypes), + Category.Cure => _cureEntries ??= BuildStaticEntries(CureTypes), + Category.Explosion => _explosionEntries ??= BuildStaticEntries(ExplosionTypes), + _ => null + }; + + private static Type[] GetTypes(Category category) => category switch + { + Category.Refresh => RefreshTypes, + Category.Agility => AgilityTypes, + Category.NightSight => NightSightTypes, + Category.Heal => HealTypes, + Category.Strength => StrengthTypes, + Category.Poison => PoisonTypes, + Category.Cure => CureTypes, + Category.Explosion => ExplosionTypes, + _ => null + }; + + public static ItemListEntry[] Main() => _mainEntries ??= + [ + new ItemListEntry("Refresh", 0xF0B, 0, (int)Category.Refresh), + new ItemListEntry("Agility", 0xF08, 0, (int)Category.Agility), + new ItemListEntry("Night Sight", 0xF06, 0, (int)Category.NightSight), + new ItemListEntry("Heal", 0xF0C, 0, (int)Category.Heal), + new ItemListEntry("Strength", 0xF09, 0, (int)Category.Strength), + new ItemListEntry("Poison", 0xF0A, 0, (int)Category.Poison), + new ItemListEntry("Cure", 0xF07, 0, (int)Category.Cure), + new ItemListEntry("Explosion", 0xF0D, 0, (int)Category.Explosion) + ]; + + private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category) + { + if (category == Category.Main) + { + return BuildFilteredMainEntries(from); + } + + var types = GetTypes(category); + var staticEntries = GetStaticEntries(category); + if (types == null || staticEntries == null) + { + return []; + } + + return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefAlchemy.CraftSystem); + } + + private static ItemListEntry[] BuildFilteredMainEntries(Mobile from) + { + var system = DefAlchemy.CraftSystem; + var mainStatic = Main(); + var filtered = new ItemListEntry[mainStatic.Length]; + var count = 0; + + for (var i = 0; i < mainStatic.Length; i++) + { + var entry = mainStatic[i]; + var types = GetTypes((Category)entry.CraftIndex); + if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system)) + { + filtered[count++] = entry; + } + } + + if (count == 0) + { + return []; + } + + if (count < filtered.Length) + { + Array.Resize(ref filtered, count); + } + + return filtered; + } + + private void CraftPotion(Mobile from, Type potionType) + { + if ((from.Backpack?.GetAmount(typeof(Bottle)) ?? 0) == 0) + { + from.SendAsciiMessage("You need an empty bottle to make a potion."); + return; + } + + var itemDef = DefAlchemy.CraftSystem.CraftItems.SearchFor(potionType); + + if (itemDef == null) + { + return; + } + + var num = DefAlchemy.CraftSystem.CanCraft(from, _tool, itemDef.ItemType); + + if (num > 0) + { + from.SendLocalizedMessage(num); + return; + } + + var res = itemDef.Resources[0]; + DefAlchemy.CraftSystem.CreateItem(from, itemDef.ItemType, res.ItemType, _tool, itemDef); + } + + public override void OnResponse(NetState state, int index) + { + var from = state.Mobile; + var craftIndex = Entries[index].CraftIndex; + + if (_category == Category.Main) + { + var menu = new AlchemyMenu(from, _tool, (Category)craftIndex); + if (menu.Entries.Length == 0) + { + from.SendAsciiMessage("You lack the skill and materials to craft anything in that category."); + return; + } + + from.SendMenu(menu); + return; + } + + var types = GetTypes(_category); + if (types != null && craftIndex >= 0 && craftIndex < types.Length) + { + CraftPotion(from, types[craftIndex]); + } + } +} diff --git a/Projects/UOContent/Engines/Craft/T2A/BlacksmithMenu.cs b/Projects/UOContent/Engines/Craft/T2A/BlacksmithMenu.cs new file mode 100644 index 000000000..a711e540e --- /dev/null +++ b/Projects/UOContent/Engines/Craft/T2A/BlacksmithMenu.cs @@ -0,0 +1,624 @@ +using System; +using Server.Items; +using Server.Menus.ItemLists; +using Server.Network; +using Server.Targeting; + +namespace Server.Engines.Craft.T2A; + +public class BlacksmithMenu : ItemListMenu +{ + private enum Category + { + Main, + Shields, + Weapons, + Armor, + Blades, + Axes, + Maces, + Polearms, + Platemail, + Chainmail, + Ringmail, + Helmets + } + + private static readonly Type[] RingmailTypes = + [ + typeof(RingmailGloves), typeof(RingmailLegs), typeof(RingmailArms), typeof(RingmailChest) + ]; + + private static readonly Type[] ChainmailTypes = + [ + typeof(ChainCoif), typeof(ChainLegs), typeof(ChainChest) + ]; + + private static readonly Type[] PlatemailTypes = + [ + typeof(Bascinet), typeof(CloseHelm), typeof(Helmet), typeof(NorseHelm), typeof(PlateHelm), + typeof(PlateArms), typeof(PlateGloves), typeof(PlateGorget), typeof(PlateLegs), + typeof(PlateChest), typeof(FemalePlateChest) + ]; + + private static readonly Type[] ShieldTypes = + [ + typeof(Buckler), typeof(BronzeShield), typeof(HeaterShield), typeof(MetalShield), + typeof(MetalKiteShield), typeof(WoodenKiteShield) + ]; + + private static readonly Type[] BladeTypes = + [ + typeof(Broadsword), typeof(Cutlass), typeof(Dagger), typeof(Katana), + typeof(Kryss), typeof(Longsword), typeof(Scimitar), typeof(VikingSword) + ]; + + private static readonly Type[] AxeTypes = + [ + typeof(Axe), typeof(BattleAxe), typeof(DoubleAxe), typeof(ExecutionersAxe), + typeof(LargeBattleAxe), typeof(TwoHandedAxe), typeof(WarAxe) + ]; + + private static readonly Type[] PolearmTypes = + [ + typeof(Bardiche), typeof(Halberd), typeof(WarFork), typeof(ShortSpear), typeof(Spear) + ]; + + private static readonly Type[] MaceTypes = + [ + typeof(HammerPick), typeof(Mace), typeof(Maul), typeof(WarMace), typeof(WarHammer) + ]; + + // Non-category actions at the top of the main menu (Repair, Smelt). + // Category CraftIndex values are offset by this count. + private const int MainActionCount = 2; + + private static ItemListEntry[] _mainEntries; + private static ItemListEntry[] _weaponEntries; + private static ItemListEntry[] _armorEntries; + private static ItemListEntry[] _shieldEntries; + private static ItemListEntry[] _bladeEntries; + private static ItemListEntry[] _axeEntries; + private static ItemListEntry[] _maceEntries; + private static ItemListEntry[] _polearmEntries; + private static ItemListEntry[] _platemailEntries; + private static ItemListEntry[] _chainmailEntries; + private static ItemListEntry[] _ringmailEntries; + + private readonly Category _category; + private readonly BaseTool _tool; + + // Resource is always selected before any menu is shown (Lost Lands flow) + public BlacksmithMenu(Mobile from, BaseTool tool) : this(from, tool, Category.Main) + { + } + + private static string GetQuestion(Category category) => category switch + { + Category.Main => "What would you like to do?", + Category.Armor => "What kind of armor?", + Category.Shields => "What kind of shield?", + Category.Weapons => "What kind of weapon?", + Category.Blades => "What kind of blade?", + Category.Axes => "What kind of axe?", + Category.Polearms => "What kind of pole arm?", + Category.Maces => "What kind of bludgeoning weapon?", + Category.Ringmail => "What kind of ring armor?", + Category.Chainmail => "What kind of chain armor?", + Category.Platemail => "What kind of plate armor?", + _ => "What would you like to make?" + }; + + private BlacksmithMenu(Mobile from, BaseTool tool, Category category) + : base(GetQuestion(category), BuildFilteredEntries(from, category)) + { + _tool = tool; + _category = category; + } + + private static string FormatItemName(Type type) + { + var name = type.Name; + Span buffer = stackalloc char[name.Length * 2]; + var pos = 0; + for (var i = 0; i < name.Length; i++) + { + if (i > 0 && char.IsUpper(name[i])) + { + buffer[pos++] = ' '; + } + + buffer[pos++] = char.ToLower(name[i]); + } + + return new string(buffer[..pos]); + } + + private static ItemListEntry[] BuildStaticEntries(Type[] types, string resourceName) + { + var entries = new ItemListEntry[types.Length]; + var count = 0; + var craftItems = DefBlacksmithy.CraftSystem.CraftItems; + + for (var i = 0; i < types.Length; i++) + { + var itemDef = craftItems.SearchFor(types[i]); + if (itemDef == null) + { + continue; + } + + var name = FormatItemName(types[i]); + var res = itemDef.Resources[0]; + var itemId = itemDef.ItemId; + + if (itemId == 7033) + { + itemId = 7032; + } + + entries[count++] = new ItemListEntry($"{name} ({res.Amount} {resourceName})", itemId, 0, i); + } + + if (count < entries.Length) + { + Array.Resize(ref entries, count); + } + + return entries; + } + + private static ItemListEntry[] GetStaticEntries(Category category) => category switch + { + Category.Main => Main(), + Category.Weapons => Weapons(), + Category.Armor => Armor(), + Category.Shields => Shields(), + Category.Blades => Blades(), + Category.Axes => Axes(), + Category.Maces => Maces(), + Category.Polearms => Polearms(), + Category.Platemail => Platemail(), + Category.Chainmail => Chainmail(), + Category.Ringmail => Ringmail(), + _ => null + }; + + private static Type[] GetTypes(Category category) => category switch + { + Category.Shields => ShieldTypes, + Category.Blades => BladeTypes, + Category.Axes => AxeTypes, + Category.Maces => MaceTypes, + Category.Polearms => PolearmTypes, + Category.Platemail => PlatemailTypes, + Category.Chainmail => ChainmailTypes, + Category.Ringmail => RingmailTypes, + _ => null + }; + + // Leaf categories under Weapons + private static readonly Category[] WeaponLeafCategories = + [ + Category.Blades, Category.Axes, Category.Maces, Category.Polearms + ]; + + // Leaf categories under Armor + private static readonly Category[] ArmorLeafCategories = + [ + Category.Ringmail, Category.Chainmail, Category.Platemail + ]; + + public static ItemListEntry[] Main() => _mainEntries ??= + [ + new ItemListEntry("Repair", 0x0FAF, 0, 0), + new ItemListEntry("Smelt", 0x0FB1, 0, 1), + new ItemListEntry("Build Armor", 0x13EC, 0, (int)Category.Armor + MainActionCount), + new ItemListEntry("Build Shield", 0x1B74, 0, (int)Category.Shields + MainActionCount), + new ItemListEntry("Build Weapons", 0xF45, 0, (int)Category.Weapons + MainActionCount) + ]; + + public static ItemListEntry[] Weapons() => _weaponEntries ??= + [ + new ItemListEntry("Build Blades", 0xF61, 0, (int)Category.Blades), + new ItemListEntry("Build Axes", 0x13FB, 0, (int)Category.Axes), + new ItemListEntry("Build Pole Arms", 0xF4D, 0, (int)Category.Polearms), + new ItemListEntry("Build Bludgeoning Weapons", 0x1407, 0, (int)Category.Maces) + ]; + + public static ItemListEntry[] Armor() => _armorEntries ??= + [ + new ItemListEntry("Build Ring Armor", 0x13EC, 0, (int)Category.Ringmail), + new ItemListEntry("Build Chain Armor", 0x13BF, 0, (int)Category.Chainmail), + new ItemListEntry("Build Plate Armor", 0x1415, 0, (int)Category.Platemail) + ]; + + public static ItemListEntry[] Shields() => _shieldEntries ??= BuildStaticEntries(ShieldTypes, "ingots"); + public static ItemListEntry[] Blades() => _bladeEntries ??= BuildStaticEntries(BladeTypes, "ingots"); + public static ItemListEntry[] Axes() => _axeEntries ??= BuildStaticEntries(AxeTypes, "ingots"); + public static ItemListEntry[] Maces() => _maceEntries ??= BuildStaticEntries(MaceTypes, "ingots"); + public static ItemListEntry[] Polearms() => _polearmEntries ??= BuildStaticEntries(PolearmTypes, "ingots"); + public static ItemListEntry[] Platemail() => _platemailEntries ??= BuildStaticEntries(PlatemailTypes, "ingots"); + public static ItemListEntry[] Chainmail() => _chainmailEntries ??= BuildStaticEntries(ChainmailTypes, "ingots"); + public static ItemListEntry[] Ringmail() => _ringmailEntries ??= BuildStaticEntries(RingmailTypes, "ingots"); + + private static Type GetSelectedResourceType(Mobile from) + { + var context = DefBlacksmithy.CraftSystem.GetContext(from); + if (context?.LastResourceIndex >= 0) + { + var res = DefBlacksmithy.CraftSystem.CraftSubRes; + if (context.LastResourceIndex < res.Count) + { + return res[context.LastResourceIndex].ItemType; + } + } + + return null; + } + + private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category) + { + // Resource is always selected before any menu is shown + var selectedResType = GetSelectedResourceType(from); + + if (category == Category.Main) + { + return BuildFilteredMainEntries(from, selectedResType); + } + + if (category == Category.Weapons) + { + return BuildFilteredMidEntries(from, Weapons(), WeaponLeafCategories, selectedResType); + } + + if (category == Category.Armor) + { + return BuildFilteredMidEntries(from, Armor(), ArmorLeafCategories, selectedResType); + } + + // Leaf category: filter individual items + var types = GetTypes(category); + var staticEntries = GetStaticEntries(category); + if (types == null || staticEntries == null) + { + return []; + } + + return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefBlacksmithy.CraftSystem, selectedResType); + } + + private static ItemListEntry[] BuildFilteredMainEntries(Mobile from, Type selectedResType) + { + var system = DefBlacksmithy.CraftSystem; + var mainStatic = Main(); + var filtered = new ItemListEntry[mainStatic.Length]; + var count = 0; + + for (var i = 0; i < mainStatic.Length; i++) + { + var entry = mainStatic[i]; + + // Repair and Smelt are always shown + if (entry.CraftIndex < MainActionCount) + { + filtered[count++] = entry; + continue; + } + + var cat = (Category)(entry.CraftIndex - MainActionCount); + + if (cat == Category.Shields) + { + if (T2ACraftSystem.AnyCraftableInCategory(from, ShieldTypes, system, selectedResType)) + { + filtered[count++] = entry; + } + } + else if (cat == Category.Weapons) + { + if (AnyLeafCraftable(from, system, WeaponLeafCategories, selectedResType)) + { + filtered[count++] = entry; + } + } + else if (cat == Category.Armor) + { + if (AnyLeafCraftable(from, system, ArmorLeafCategories, selectedResType)) + { + filtered[count++] = entry; + } + } + } + + if (count == 0) + { + return []; + } + + if (count < filtered.Length) + { + Array.Resize(ref filtered, count); + } + + return filtered; + } + + private static ItemListEntry[] BuildFilteredMidEntries( + Mobile from, ItemListEntry[] staticEntries, Category[] leafCategories, Type selectedResType + ) + { + var system = DefBlacksmithy.CraftSystem; + var filtered = new ItemListEntry[staticEntries.Length]; + var count = 0; + + for (var i = 0; i < staticEntries.Length; i++) + { + var entry = staticEntries[i]; + var leafCat = (Category)entry.CraftIndex; + var types = GetTypes(leafCat); + if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system, selectedResType)) + { + filtered[count++] = entry; + } + } + + if (count == 0) + { + return []; + } + + if (count < filtered.Length) + { + Array.Resize(ref filtered, count); + } + + return filtered; + } + + private static bool AnyLeafCraftable( + Mobile from, CraftSystem system, Category[] leafCategories, Type selectedResType = null + ) + { + for (var i = 0; i < leafCategories.Length; i++) + { + var types = GetTypes(leafCategories[i]); + if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system, selectedResType)) + { + return true; + } + } + + return false; + } + + private void CraftItemFromType(Mobile from, Type itemType) + { + var itemDef = DefBlacksmithy.CraftSystem.CraftItems.SearchFor(itemType); + + if (itemDef == null) + { + return; + } + + var num = DefBlacksmithy.CraftSystem.CanCraft(from, _tool, itemDef.ItemType); + if (num > 0) + { + from.SendLocalizedMessage(num); + return; + } + + var context = DefBlacksmithy.CraftSystem.GetContext(from); + var res = itemDef.UseSubRes2 + ? DefBlacksmithy.CraftSystem.CraftSubRes2 + : DefBlacksmithy.CraftSystem.CraftSubRes; + var resIndex = itemDef.UseSubRes2 ? context.LastResourceIndex2 : context.LastResourceIndex; + var type = resIndex > -1 ? res[resIndex].ItemType : null; + + DefBlacksmithy.CraftSystem.CreateItem(from, itemDef.ItemType, type, _tool, itemDef); + } + + public override void OnResponse(NetState state, int index) + { + var from = state.Mobile; + var craftIndex = Entries[index].CraftIndex; + + if (_category == Category.Main) + { + switch (craftIndex) + { + case 0: + Repair.Do(from, DefBlacksmithy.CraftSystem, _tool); + return; + case 1: + Resmelt.Do(from, DefBlacksmithy.CraftSystem, _tool); + return; + } + + var menu = new BlacksmithMenu(from, _tool, (Category)(craftIndex - MainActionCount)); + if (menu.Entries.Length == 0) + { + from.SendAsciiMessage("You lack the skill and materials to craft anything in that category."); + return; + } + + from.SendMenu(menu); + return; + } + + if (_category is Category.Weapons or Category.Armor) + { + var menu = new BlacksmithMenu(from, _tool, (Category)craftIndex); + if (menu.Entries.Length == 0) + { + from.SendAsciiMessage("You lack the skill and materials to craft anything in that category."); + return; + } + + from.SendMenu(menu); + return; + } + + var types = GetTypes(_category); + if (types != null && craftIndex >= 0 && craftIndex < types.Length) + { + CraftItemFromType(from, types[craftIndex]); + } + } + + public override void OnCancel(NetState state) + { + base.OnCancel(state); + } + + public static void ResourceSelection( + Mobile from, BaseTool tool, Action afterSelect, Item preTarget = null + ) + { + var res = DefBlacksmithy.CraftSystem.CraftSubRes; + + // Validate preTarget first — reject invalid targets before any auto-selection + if (preTarget != null) + { + if (TrySelectResource(from, preTarget, res, afterSelect, tool)) + { + // Valid ingot — resource selected, menu will open + return; + } + + if (preTarget is BaseArmor or BaseWeapon + && DefBlacksmithy.CraftSystem.CraftItems.SearchForSubclass(preTarget.GetType()) != null) + { + // Repairable/smeltable item — auto-select default resource and open menu + SelectDefaultResource(from, res); + afterSelect(from, tool); + return; + } + + // Invalid target — prompt for ingots + from.SendMessage("Target the ingots you wish to use."); + from.Target = new BlacksmithResourceTarget(tool, afterSelect); + return; + } + + // No target (make-last failure path) — auto-select if only one ingot type available + var availableCount = 0; + var lastAvailable = -1; + + for (var i = 0; i < res.Count; ++i) + { + if ((from.Backpack?.GetAmount(res[i].ItemType) ?? 0) > 0) + { + availableCount++; + lastAvailable = i; + } + } + + if (availableCount <= 1) + { + var context = DefBlacksmithy.CraftSystem.GetContext(from); + if (context != null && lastAvailable != -1) + { + context.LastResourceIndex = lastAvailable; + } + + afterSelect(from, tool); + } + else + { + from.SendMessage("Target the ingots you wish to use."); + from.Target = new BlacksmithResourceTarget(tool, afterSelect); + } + } + + private static void SelectDefaultResource(Mobile from, CraftSubResCol res) + { + var context = DefBlacksmithy.CraftSystem.GetContext(from); + if (context == null) + { + return; + } + + var pack = from.Backpack; + var firstAvailable = -1; + + for (var i = 0; i < res.Count; ++i) + { + if ((pack?.GetAmount(res[i].ItemType) ?? 0) > 0) + { + if (res[i].ItemType == typeof(IronIngot)) + { + context.LastResourceIndex = i; + return; + } + + if (firstAvailable == -1) + { + firstAvailable = i; + } + } + } + + if (firstAvailable != -1) + { + context.LastResourceIndex = firstAvailable; + } + } + + private static bool TrySelectResource( + Mobile from, Item item, CraftSubResCol res, Action afterSelect, BaseTool tool + ) + { + for (var i = 0; i < res.Count; ++i) + { + if (item.GetType() == res[i].ItemType) + { + var context = DefBlacksmithy.CraftSystem.GetContext(from); + if (context != null) + { + context.LastResourceIndex = i; + } + + afterSelect(from, tool); + return true; + } + } + + return false; + } +} + +public class BlacksmithResourceTarget : Target +{ + private readonly BaseTool _tool; + private readonly Action _afterSelect; + + public BlacksmithResourceTarget(BaseTool tool, Action afterSelect) + : base(2, false, TargetFlags.None) + { + _tool = tool; + _afterSelect = afterSelect; + } + + protected override void OnTarget(Mobile from, object targeted) + { + if (targeted is Item item) + { + var res = DefBlacksmithy.CraftSystem.CraftSubRes; + for (var i = 0; i < res.Count; ++i) + { + if (item.GetType() == res[i].ItemType) + { + var context = DefBlacksmithy.CraftSystem.GetContext(from); + context?.LastResourceIndex = i; + + _afterSelect(from, _tool); + return; + } + } + } + + from.SendMessage("That is not a valid ingot."); + from.Target = new BlacksmithResourceTarget(_tool, _afterSelect); + } +} diff --git a/Projects/UOContent/Engines/Craft/T2A/BowFletchingMenu.cs b/Projects/UOContent/Engines/Craft/T2A/BowFletchingMenu.cs new file mode 100644 index 000000000..88818c233 --- /dev/null +++ b/Projects/UOContent/Engines/Craft/T2A/BowFletchingMenu.cs @@ -0,0 +1,95 @@ +using System; +using Server.Items; +using Server.Menus.ItemLists; +using Server.Network; + +namespace Server.Engines.Craft.T2A; + +public class BowFletchingMenu : ItemListMenu +{ + private static readonly Type[] ItemTypes = + [ + typeof(Kindling), typeof(Shaft), typeof(Arrow), typeof(Bolt), + typeof(Bow), typeof(Crossbow), typeof(HeavyCrossbow) + ]; + + private static readonly string[] ItemNames = + [ + "Kindling", "Shafts", "Arrows", "Bolts", "Bow", "Crossbow", "Heavy Crossbow" + ]; + + private static readonly int[] ItemGraphics = [0xDE1, 0x1BD4, 0xF3F, 0x1BFB, 0x13B2, 0xF50, 0x13FD]; + + private static ItemListEntry[] _cachedEntries; + + private readonly BaseTool _tool; + + public BowFletchingMenu(Mobile from, BaseTool tool) + : base("What would you like to make?", BuildFilteredEntries(from)) + { + _tool = tool; + } + + public static ItemListEntry[] Main() + { + if (_cachedEntries != null) + { + return _cachedEntries; + } + + var entries = new ItemListEntry[ItemTypes.Length]; + var count = 0; + var craftItems = DefBowFletching.CraftSystem.CraftItems; + + for (var i = 0; i < ItemTypes.Length; i++) + { + if (craftItems.SearchFor(ItemTypes[i]) != null) + { + entries[count++] = new ItemListEntry(ItemNames[i], ItemGraphics[i], 0, i); + } + } + + if (count < entries.Length) + { + Array.Resize(ref entries, count); + } + + _cachedEntries = entries; + return entries; + } + + private static ItemListEntry[] BuildFilteredEntries(Mobile from) + { + return T2ACraftSystem.FilterEntries(from, Main(), ItemTypes, DefBowFletching.CraftSystem); + } + + public override void OnResponse(NetState state, int index) + { + var from = state.Mobile; + var craftIndex = Entries[index].CraftIndex; + + if (craftIndex < 0 || craftIndex >= ItemTypes.Length) + { + return; + } + + var itemDef = DefBowFletching.CraftSystem.CraftItems.SearchFor(ItemTypes[craftIndex]); + if (itemDef == null) + { + return; + } + + var num = DefBowFletching.CraftSystem.CanCraft(from, _tool, itemDef.ItemType); + if (num > 0) + { + from.SendLocalizedMessage(num); + return; + } + + var context = DefBowFletching.CraftSystem.GetContext(from); + var res = itemDef.UseSubRes2 ? DefBowFletching.CraftSystem.CraftSubRes2 : DefBowFletching.CraftSystem.CraftSubRes; + var resIndex = itemDef.UseSubRes2 ? context.LastResourceIndex2 : context.LastResourceIndex; + var type = resIndex > -1 ? res[resIndex].ItemType : null; + DefBowFletching.CraftSystem.CreateItem(from, itemDef.ItemType, type, _tool, itemDef); + } +} diff --git a/Projects/UOContent/Engines/Craft/T2A/CarpentryMenu.cs b/Projects/UOContent/Engines/Craft/T2A/CarpentryMenu.cs new file mode 100644 index 000000000..f3da3f80f --- /dev/null +++ b/Projects/UOContent/Engines/Craft/T2A/CarpentryMenu.cs @@ -0,0 +1,298 @@ +using System; +using Server.Items; +using Server.Menus.ItemLists; +using Server.Network; + +namespace Server.Engines.Craft.T2A; + +public class CarpentryMenu : ItemListMenu +{ + private enum Category + { + Main, + Furniture, + Containers, + Weapons, + Instruments, + Misc, + Addons + } + + private static readonly Type[] FurnitureTypes = + [ + typeof(FootStool), typeof(Stool), typeof(BambooChair), typeof(WoodenChair), + typeof(FancyWoodenChairCushion), typeof(WoodenChairCushion), + typeof(WoodenBench), typeof(WoodenThrone), typeof(Throne), + typeof(Nightstand), typeof(WritingTable), typeof(YewWoodTable), typeof(LargeTable) + ]; + + private static readonly Type[] ContainerTypes = + [ + typeof(WoodenBox), typeof(SmallCrate), typeof(MediumCrate), typeof(LargeCrate), + typeof(WoodenChest), typeof(EmptyBookcase), typeof(FancyArmoire), typeof(Armoire), + typeof(Keg) + ]; + + private static readonly Type[] WeaponTypes = + [ + typeof(ShepherdsCrook), typeof(QuarterStaff), typeof(GnarledStaff), typeof(WoodenShield) + ]; + + private static readonly Type[] InstrumentTypes = + [ + typeof(LapHarp), typeof(Harp), typeof(Drums), typeof(Lute), + typeof(Tambourine), typeof(TambourineTassel) + ]; + + private static readonly Type[] MiscItemTypes = + [ + typeof(FishingPole), typeof(BarrelStaves), typeof(BarrelLid), + typeof(ShortMusicStand), typeof(TallMusicStand), typeof(Easel) + ]; + + private static readonly Type[] AddonTypes = + [ + typeof(SmallBedSouthDeed), typeof(SmallBedEastDeed), + typeof(LargeBedSouthDeed), typeof(LargeBedEastDeed), + typeof(DartBoardSouthDeed), typeof(DartBoardEastDeed), + typeof(BallotBoxDeed), + typeof(PentagramDeed), typeof(AbbatoirDeed), + typeof(SmallForgeDeed), typeof(LargeForgeEastDeed), typeof(LargeForgeSouthDeed), + typeof(AnvilEastDeed), typeof(AnvilSouthDeed), + typeof(TrainingDummyEastDeed), typeof(TrainingDummySouthDeed), + typeof(PickpocketDipEastDeed), typeof(PickpocketDipSouthDeed), + typeof(Dressform), + typeof(SpinningWheelEastDeed), typeof(SpinningWheelSouthDeed), + typeof(LoomEastDeed), typeof(LoomSouthDeed), + typeof(StoneOvenEastDeed), typeof(StoneOvenSouthDeed), + typeof(FlourMillEastDeed), typeof(FlourMillSouthDeed), + typeof(WaterTroughEastDeed), typeof(WaterTroughSouthDeed) + ]; + + private static ItemListEntry[] _mainEntries; + private static ItemListEntry[] _furnitureEntries; + private static ItemListEntry[] _containerEntries; + private static ItemListEntry[] _weaponEntries; + private static ItemListEntry[] _instrumentEntries; + private static ItemListEntry[] _miscEntries; + private static ItemListEntry[] _addonEntries; + + private readonly Category _category; + private readonly BaseTool _tool; + + public CarpentryMenu(Mobile from, BaseTool tool) : this(from, tool, Category.Main) + { + } + + private static string GetQuestion(Category category) => category switch + { + Category.Main => "What would you like to make?", + Category.Furniture => "What kind of furniture?", + Category.Containers => "What kind of container?", + Category.Weapons => "What kind of weapon?", + Category.Instruments => "What kind of instrument?", + Category.Addons => "What kind of add-on?", + _ => "What would you like to make?" + }; + + private CarpentryMenu(Mobile from, BaseTool tool, Category category) + : base(GetQuestion(category), BuildFilteredEntries(from, category)) + { + _tool = tool; + _category = category; + } + + private static string FormatItemName(Type type) + { + var name = type.Name; + Span buffer = stackalloc char[name.Length * 2]; + var pos = 0; + + for (var i = 0; i < name.Length; i++) + { + if (i > 0 && char.IsUpper(name[i])) + { + buffer[pos++] = ' '; + } + + buffer[pos++] = char.ToLower(name[i]); + } + + return new string(buffer[..pos]); + } + + private static ItemListEntry[] BuildStaticEntries(Type[] types, string resourceName) + { + var entries = new ItemListEntry[types.Length]; + var count = 0; + var craftItems = DefCarpentry.CraftSystem.CraftItems; + + for (var i = 0; i < types.Length; i++) + { + var itemDef = craftItems.SearchFor(types[i]); + if (itemDef == null) + { + continue; + } + + var name = FormatItemName(types[i]); + var res = itemDef.Resources; + + string label; + if (res.Count > 1) + { + var secondName = res[1].ItemType == typeof(IronIngot) ? "ingots" : "cloth"; + label = $"{name} ({res[0].Amount} {resourceName}, {res[1].Amount} {secondName})"; + } + else + { + label = $"{name} ({res[0].Amount} {resourceName})"; + } + + entries[count++] = new ItemListEntry(label, itemDef.ItemId, 0, i); + } + + if (count < entries.Length) + { + Array.Resize(ref entries, count); + } + + return entries; + } + + private static ItemListEntry[] GetStaticEntries(Category category) => category switch + { + Category.Main => Main(), + Category.Furniture => Furniture(), + Category.Containers => Containers(), + Category.Weapons => Weapons(), + Category.Instruments => Instruments(), + Category.Misc => Misc(), + Category.Addons => Addons(), + _ => null + }; + + private static Type[] GetTypes(Category category) => category switch + { + Category.Furniture => FurnitureTypes, + Category.Containers => ContainerTypes, + Category.Weapons => WeaponTypes, + Category.Instruments => InstrumentTypes, + Category.Misc => MiscItemTypes, + Category.Addons => AddonTypes, + _ => null + }; + + public static ItemListEntry[] Main() => _mainEntries ??= + [ + new ItemListEntry("Furniture", 0xB57, 0, (int)Category.Furniture), + new ItemListEntry("Containers", 0x9AA, 0, (int)Category.Containers), + new ItemListEntry("Weapons", 0xE89, 0, (int)Category.Weapons), + new ItemListEntry("Instruments", 0xEB3, 0, (int)Category.Instruments), + new ItemListEntry("Miscellaneous", 0xDC0, 0, (int)Category.Misc), + new ItemListEntry("Add-Ons", 0x14F0, 0, (int)Category.Addons) + ]; + + public static ItemListEntry[] Furniture() => _furnitureEntries ??= BuildStaticEntries(FurnitureTypes, "wood"); + public static ItemListEntry[] Containers() => _containerEntries ??= BuildStaticEntries(ContainerTypes, "wood"); + public static ItemListEntry[] Weapons() => _weaponEntries ??= BuildStaticEntries(WeaponTypes, "wood"); + public static ItemListEntry[] Instruments() => _instrumentEntries ??= BuildStaticEntries(InstrumentTypes, "wood"); + public static ItemListEntry[] Misc() => _miscEntries ??= BuildStaticEntries(MiscItemTypes, "wood"); + public static ItemListEntry[] Addons() => _addonEntries ??= BuildStaticEntries(AddonTypes, "wood"); + + private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category) + { + if (category == Category.Main) + { + return BuildFilteredMainEntries(from); + } + + var types = GetTypes(category); + var staticEntries = GetStaticEntries(category); + if (types == null || staticEntries == null) + { + return []; + } + + return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefCarpentry.CraftSystem); + } + + private static ItemListEntry[] BuildFilteredMainEntries(Mobile from) + { + var system = DefCarpentry.CraftSystem; + var mainStatic = Main(); + var filtered = new ItemListEntry[mainStatic.Length]; + var count = 0; + + for (var i = 0; i < mainStatic.Length; i++) + { + var entry = mainStatic[i]; + var types = GetTypes((Category)entry.CraftIndex); + if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system)) + { + filtered[count++] = entry; + } + } + + if (count == 0) + { + return []; + } + + if (count < filtered.Length) + { + Array.Resize(ref filtered, count); + } + + return filtered; + } + + private void CraftSelectedItem(Mobile from, Type itemType) + { + var itemDef = DefCarpentry.CraftSystem.CraftItems.SearchFor(itemType); + + if (itemDef == null) + { + return; + } + + var num = DefCarpentry.CraftSystem.CanCraft(from, _tool, itemDef.ItemType); + + if (num > 0) + { + from.SendLocalizedMessage(num); + return; + } + + var context = DefCarpentry.CraftSystem.GetContext(from); + var res = itemDef.UseSubRes2 ? DefCarpentry.CraftSystem.CraftSubRes2 : DefCarpentry.CraftSystem.CraftSubRes; + var resIndex = itemDef.UseSubRes2 ? context.LastResourceIndex2 : context.LastResourceIndex; + var type = resIndex > -1 ? res[resIndex].ItemType : null; + DefCarpentry.CraftSystem.CreateItem(from, itemDef.ItemType, type, _tool, itemDef); + } + + public override void OnResponse(NetState state, int index) + { + var from = state.Mobile; + var craftIndex = Entries[index].CraftIndex; + + if (_category == Category.Main) + { + var menu = new CarpentryMenu(from, _tool, (Category)craftIndex); + if (menu.Entries.Length == 0) + { + from.SendAsciiMessage("You lack the skill and materials to craft anything in that category."); + return; + } + + from.SendMenu(menu); + return; + } + + var types = GetTypes(_category); + if (types != null && craftIndex >= 0 && craftIndex < types.Length) + { + CraftSelectedItem(from, types[craftIndex]); + } + } +} diff --git a/Projects/UOContent/Engines/Craft/T2A/CartographyMenu.cs b/Projects/UOContent/Engines/Craft/T2A/CartographyMenu.cs new file mode 100644 index 000000000..926b8ad4e --- /dev/null +++ b/Projects/UOContent/Engines/Craft/T2A/CartographyMenu.cs @@ -0,0 +1,110 @@ +using System; +using Server.Items; +using Server.Menus.ItemLists; +using Server.Network; + +namespace Server.Engines.Craft.T2A; + +public class CartographyMenu : ItemListMenu +{ + private static ItemListEntry[] _cachedEntries; + + private readonly BaseTool _tool; + + public CartographyMenu(Mobile from, BaseTool tool) + : base("What kind of map?", BuildFilteredEntries(from)) + { + _tool = tool; + } + + public static ItemListEntry[] Main() + { + if (_cachedEntries != null) + { + return _cachedEntries; + } + + var craftItems = DefCartography.CraftSystem.CraftItems; + var entries = new ItemListEntry[craftItems.Count]; + var count = 0; + + for (var i = 0; i < craftItems.Count; i++) + { + var name = i switch + { + 0 => "A map of the local environs.", + 1 => "A map suitable for cities.", + 2 => "A moderately sized sea chart.", + 3 => "A map of the world.", + _ => craftItems[i].ItemType.Name + }; + + entries[count++] = new ItemListEntry(name, 6511 + i, 0, i); + } + + if (count < entries.Length) + { + Array.Resize(ref entries, count); + } + + _cachedEntries = entries; + return entries; + } + + private static ItemListEntry[] BuildFilteredEntries(Mobile from) + { + var staticEntries = Main(); + var craftItems = DefCartography.CraftSystem.CraftItems; + + var filtered = new ItemListEntry[staticEntries.Length]; + var count = 0; + + for (var i = 0; i < staticEntries.Length; i++) + { + var entry = staticEntries[i]; + var craftIndex = entry.CraftIndex; + if (craftIndex >= 0 && craftIndex < craftItems.Count) + { + var itemDef = craftItems[craftIndex]; + if (T2ACraftSystem.CanCraftItem(from, itemDef, DefCartography.CraftSystem)) + { + filtered[count++] = entry; + } + } + } + + if (count == 0) + { + return []; + } + + if (count < filtered.Length) + { + Array.Resize(ref filtered, count); + } + + return filtered; + } + + public override void OnResponse(NetState state, int index) + { + var from = state.Mobile; + var craftIndex = Entries[index].CraftIndex; + var craftItems = DefCartography.CraftSystem.CraftItems; + + if (craftIndex < 0 || craftIndex >= craftItems.Count) + { + return; + } + + var itemDef = craftItems[craftIndex]; + var num = DefCartography.CraftSystem.CanCraft(from, _tool, itemDef.ItemType); + if (num > 0) + { + from.SendLocalizedMessage(num); + return; + } + + DefCartography.CraftSystem.CreateItem(from, itemDef.ItemType, typeof(BlankMap), _tool, itemDef); + } +} diff --git a/Projects/UOContent/Engines/Craft/T2A/InscriptionMenu.cs b/Projects/UOContent/Engines/Craft/T2A/InscriptionMenu.cs new file mode 100644 index 000000000..e68580577 --- /dev/null +++ b/Projects/UOContent/Engines/Craft/T2A/InscriptionMenu.cs @@ -0,0 +1,334 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Server.Items; +using Server.Menus.ItemLists; +using Server.Network; + +namespace Server.Engines.Craft.T2A; + +public class InscriptionMenu : ItemListMenu +{ + private enum Category + { + Main, + Circle1, + Circle2, + Circle3, + Circle4, + Circle5, + Circle6, + Circle7, + Circle8, + Runebook + } + + private static readonly ItemListEntry[][] _circleEntries = new ItemListEntry[8][]; + + private static ItemListEntry[] _mainEntries; + + private readonly Category _category; + private readonly BaseTool _tool; + + public InscriptionMenu(Mobile from, BaseTool tool) : this(from, tool, Category.Main) + { + } + + private static string GetQuestion(Category category) => category switch + { + Category.Main => "Which circle of spells?", + _ => "Which spell would you like to scribe?" + }; + + private InscriptionMenu(Mobile from, BaseTool tool, Category category) + : base(GetQuestion(category), BuildFilteredEntries(from, category)) + { + _tool = tool; + _category = category; + } + + private static string FormatScrollName(Type type) + { + var name = type.Name; + if (name.EndsWith("Scroll")) + { + name = name[..^6]; + } + + Span buffer = stackalloc char[name.Length * 2]; + var pos = 0; + for (var i = 0; i < name.Length; i++) + { + if (i > 0 && char.IsUpper(name[i])) + { + buffer[pos++] = ' '; + } + + buffer[pos++] = char.ToLower(name[i]); + } + + return new string(buffer[..pos]); + } + + public static ItemListEntry[] Main() => _mainEntries ??= + [ + new ItemListEntry("Runebook", 0xEFA, 0, (int)Category.Runebook), + new ItemListEntry("First Circle", 8384, 0, (int)Category.Circle1), + new ItemListEntry("Second Circle", 8385, 0, (int)Category.Circle2), + new ItemListEntry("Third Circle", 8386, 0, (int)Category.Circle3), + new ItemListEntry("Fourth Circle", 8387, 0, (int)Category.Circle4), + new ItemListEntry("Fifth Circle", 8388, 0, (int)Category.Circle5), + new ItemListEntry("Sixth Circle", 8389, 0, (int)Category.Circle6), + new ItemListEntry("Seventh Circle", 8390, 0, (int)Category.Circle7), + new ItemListEntry("Eighth Circle", 8391, 0, (int)Category.Circle8) + ]; + + private static ItemListEntry[] BuildCircleEntries(int circleIndex) + { + var offset = circleIndex * 8; + var craftItems = DefInscription.CraftSystem.CraftItems; + var entries = new ItemListEntry[8]; + var count = 0; + + for (var i = 0; i < 8; i++) + { + var itemDef = craftItems[offset + i]; + var name = FormatScrollName(itemDef.ItemType); + entries[count++] = new ItemListEntry(name, 8320 + offset + i, 0, i); + } + + if (count < entries.Length) + { + Array.Resize(ref entries, count); + } + + return entries; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static ItemListEntry[] GetCircleEntries(int circleIndex) => + _circleEntries[circleIndex] ??= BuildCircleEntries(circleIndex); + + private static Dictionary _spellIds; + private static object[] _args; + + private static bool HasSpellInBook(Mobile from, Type scrollType) + { + if (scrollType == null) + { + return false; + } + + _spellIds ??= []; + if (!_spellIds.TryGetValue(scrollType, out var spellId)) + { + try + { + _args ??= [1]; + var scroll = scrollType.CreateInstance(_args); + if (scroll != null) + { + spellId = _spellIds[scrollType] = scroll.SpellID; + scroll.Delete(); + } + else + { + return false; + } + } + catch + { + return false; + } + } + + var book = Spellbook.Find(from, spellId); + return book?.HasSpell(spellId) ?? false; + } + + private static bool CanScribeScroll(Mobile from, CraftItem itemDef) + { + // Skill check + if (!T2ACraftSystem.CanCraftItem(from, itemDef, DefInscription.CraftSystem)) + { + return false; + } + + // Must have blank scrolls + if ((from.Backpack?.GetAmount(typeof(BlankScroll)) ?? 0) == 0) + { + return false; + } + + // Must have the spell in spellbook + return HasSpellInBook(from, itemDef.ItemType); + } + + private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category) + { + if (category == Category.Main) + { + return BuildFilteredMainEntries(from); + } + + var circleIndex = (int)category - 1; + var staticEntries = GetCircleEntries(circleIndex); + var craftItems = DefInscription.CraftSystem.CraftItems; + var offset = circleIndex * 8; + + var filtered = new ItemListEntry[staticEntries.Length]; + var count = 0; + + for (var i = 0; i < staticEntries.Length; i++) + { + var entry = staticEntries[i]; + var scrollIndex = entry.CraftIndex; + var itemDef = craftItems[offset + scrollIndex]; + if (CanScribeScroll(from, itemDef)) + { + filtered[count++] = entry; + } + } + + if (count == 0) + { + return []; + } + + if (count < filtered.Length) + { + Array.Resize(ref filtered, count); + } + + return filtered; + } + + private static ItemListEntry[] BuildFilteredMainEntries(Mobile from) + { + var craftItems = DefInscription.CraftSystem.CraftItems; + var system = DefInscription.CraftSystem; + var mainStatic = Main(); + var filtered = new ItemListEntry[mainStatic.Length]; + var count = 0; + + for (var i = 0; i < mainStatic.Length; i++) + { + var entry = mainStatic[i]; + var category = (Category)entry.CraftIndex; + + if (category == Category.Runebook) + { + if (T2ACraftSystem.CanCraftItem(from, typeof(Runebook), system)) + { + filtered[count++] = entry; + } + + continue; + } + + var circleIndex = (int)category - 1; + var offset = circleIndex * 8; + + var hasAny = false; + for (var j = 0; j < 8; j++) + { + var itemDef = craftItems[offset + j]; + if (CanScribeScroll(from, itemDef)) + { + hasAny = true; + break; + } + } + + if (hasAny) + { + filtered[count++] = entry; + } + } + + if (count == 0) + { + return []; + } + + if (count < filtered.Length) + { + Array.Resize(ref filtered, count); + } + + return filtered; + } + + private void CraftScroll(Mobile from, Category circle, int scrollIndex) + { + var itemIndex = ((int)circle - 1) * 8 + scrollIndex; + var craftItems = DefInscription.CraftSystem.CraftItems; + var itemDef = craftItems[itemIndex]; + + if (!HasSpellInBook(from, itemDef.ItemType)) + { + from.SendAsciiMessage("You do not have that spell in your spellbook."); + return; + } + + var context = DefInscription.CraftSystem.GetContext(from); + var res = itemDef.UseSubRes2 ? DefInscription.CraftSystem.CraftSubRes2 : DefInscription.CraftSystem.CraftSubRes; + var resIndex = itemDef.UseSubRes2 ? context.LastResourceIndex2 : context.LastResourceIndex; + var type = resIndex > -1 ? res[resIndex].ItemType : null; + DefInscription.CraftSystem.CreateItem(from, itemDef.ItemType, type, _tool, itemDef); + } + + public override void OnResponse(NetState state, int index) + { + var from = state.Mobile; + + if ((from.Backpack?.GetAmount(typeof(BlankScroll)) ?? 0) == 0) + { + from.SendAsciiMessage("You do not have enough blank scrolls to make that."); + return; + } + + var craftIndex = Entries[index].CraftIndex; + if (_category == Category.Main) + { + var category = (Category)craftIndex; + + if (category == Category.Runebook) + { + CraftRunebook(from); + return; + } + + var menu = new InscriptionMenu(from, _tool, category); + if (menu.Entries.Length == 0) + { + from.SendAsciiMessage("You lack the skill and materials to scribe anything in that circle."); + return; + } + + from.SendMenu(menu); + return; + } + + CraftScroll(from, _category, craftIndex); + } + + private void CraftRunebook(Mobile from) + { + var system = DefInscription.CraftSystem; + var itemDef = system.CraftItems.SearchFor(typeof(Runebook)); + if (itemDef == null) + { + return; + } + + var num = system.CanCraft(from, _tool, itemDef.ItemType); + if (num > 0) + { + from.SendLocalizedMessage(num); + return; + } + + system.CreateItem(from, itemDef.ItemType, null, _tool, itemDef); + } +} diff --git a/Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs b/Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs new file mode 100644 index 000000000..785a319ad --- /dev/null +++ b/Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs @@ -0,0 +1,351 @@ +// T2A crafting system: packet-based, not gump-based + +using System; +using Server.Items; +using Server.Menus.ItemLists; +using Server.Targeting; + +namespace Server.Engines.Craft.T2A; + +public static class T2ACraftSystem +{ + /// + /// Whether T2A packet-based crafting menus are active. Set once at startup from the + /// "t2aCraftMenus" server setting (default: !Core.UOTD). Not flippable at runtime. + /// + public static bool Enabled { get; set; } + + public static void ShowMenu(Mobile from, CraftSystem craftSystem, BaseTool tool, Item preTarget = null) + { + if (!Enabled) + { + return; + } + + if (craftSystem == DefBlacksmithy.CraftSystem) + { + // Lost Lands flow: resource selection first, then menu + BlacksmithMenu.ResourceSelection(from, tool, (mob, t) => + { + var menu = new BlacksmithMenu(mob, t); + if (menu.Entries.Length == 0) + { + mob.SendAsciiMessage("You lack the skill and materials to craft anything."); + return; + } + + mob.SendMenu(menu); + }, preTarget); + } + else if (craftSystem == DefAlchemy.CraftSystem) + { + if (preTarget is BaseReagent or Bottle || preTarget == null) + { + ShowMenuDirect(from, tool); + } + else + { + PromptForResource(from, tool, craftSystem, "Target a reagent or empty bottle.", + item => item is BaseReagent or Bottle); + } + } + else if (craftSystem == DefBowFletching.CraftSystem) + { + if (preTarget is Log or Board or Feather or Shaft || preTarget == null) + { + ShowMenuDirect(from, tool); + } + else + { + PromptForResource(from, tool, craftSystem, "Target the wood or feathers you wish to use.", + item => item is Log or Board or Feather or Shaft); + } + } + else if (craftSystem == DefCarpentry.CraftSystem) + { + if (preTarget is Log or Board || preTarget == null) + { + ShowMenuDirect(from, tool); + } + else + { + PromptForResource(from, tool, craftSystem, "Target the wood you wish to use.", + item => item is Log or Board); + } + } + else if (craftSystem == DefCartography.CraftSystem) + { + if (preTarget is BlankMap || preTarget == null) + { + ShowMenuDirect(from, tool); + } + else + { + PromptForResource(from, tool, craftSystem, "Target a blank map.", + item => item is BlankMap); + } + } + else if (craftSystem == DefInscription.CraftSystem) + { + if (preTarget is BlankScroll or BaseReagent or RecallRune || preTarget == null) + { + ShowMenuDirect(from, tool); + } + else + { + PromptForResource(from, tool, craftSystem, "Target the blank scrolls you wish to use.", + item => item is BlankScroll or BaseReagent or RecallRune); + } + } + else if (craftSystem == DefTailoring.CraftSystem) + { + TailoringMenu.ResourceSelection(from, tool, preTarget); + } + else if (craftSystem == DefTinkering.CraftSystem) + { + TinkeringMenu.ResourceSelection(from, tool, preTarget); + } + } + + private static void ShowMenuDirect(Mobile from, BaseTool tool) where T : ItemListMenu + { + var menu = (T)Activator.CreateInstance(typeof(T), from, tool); + if (menu.Entries.Length == 0) + { + from.SendAsciiMessage("You lack the skill and materials to craft anything."); + return; + } + + from.SendMenu(menu); + } + + private static void PromptForResource( + Mobile from, BaseTool tool, CraftSystem system, string message, Func isValid + ) + { + from.SendAsciiMessage(message); + from.Target = new CraftResourceTarget(tool, system, message, isValid); + } + + private class CraftResourceTarget : Target + { + private readonly BaseTool _tool; + private readonly CraftSystem _system; + private readonly string _message; + private readonly Func _isValid; + + public CraftResourceTarget( + BaseTool tool, CraftSystem system, string message, Func isValid + ) : base(12, false, TargetFlags.None) + { + _tool = tool; + _system = system; + _message = message; + _isValid = isValid; + } + + protected override void OnTarget(Mobile from, object targeted) + { + if (targeted is Item item && _isValid(item)) + { + ShowMenu(from, _system, _tool, item); + return; + } + + from.SendAsciiMessage(_message); + from.Target = new CraftResourceTarget(_tool, _system, _message, _isValid); + } + } + + /// + /// Persists the selected resource type as a LastResourceIndex on the craft context, + /// so that make-last can recall which resource was used. + /// + public static void SetLastResourceIndex(Mobile from, CraftSystem system, Type selectedResourceType) + { + if (selectedResourceType == null) + { + return; + } + + var context = system.GetContext(from); + var resCol = system.CraftSubRes; + + if (context == null || !resCol.Init) + { + return; + } + + for (var i = 0; i < resCol.Count; i++) + { + if (resCol[i].ItemType == selectedResourceType) + { + context.LastResourceIndex = i; + return; + } + } + } + + /// + /// 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. + /// + public static bool CanCraftItem( + Mobile from, CraftItem itemDef, CraftSystem system, Type selectedResourceType = null + ) + { + var pack = from.Backpack; + if (pack == null) + { + return false; + } + + var chance = itemDef.GetSuccessChance(from, selectedResourceType, system, false, out var allRequiredSkills); + if (!allRequiredSkills || chance <= 0.0) + { + return false; + } + + var resCol = system.CraftSubRes; + + for (var i = 0; i < itemDef.Resources.Count; i++) + { + var res = itemDef.Resources[i]; + var resType = res.ItemType; + + // If this resource is the base sub-resource type (e.g. IronIngot for blacksmithing), + // handle sub-resource substitution + if (resCol.Init && resType == resCol.ResType) + { + if (selectedResourceType != null) + { + // Specific resource selected — check skill gate for this sub-resource + var subRes = resCol.SearchFor(selectedResourceType); + if (subRes != null && from.Skills[system.MainSkill].Value < subRes.RequiredSkill) + { + return false; + } + + // Check if player has enough of the selected resource + if (GetResourceAmount(pack, selectedResourceType) < res.Amount) + { + return false; + } + } + else if (!HasAnySufficientSubResource(from, pack, res.Amount, system, resCol)) + { + return false; + } + } + else if (GetResourceAmount(pack, resType) < res.Amount) + { + return false; + } + } + + return true; + } + + /// + /// Convenience overload that resolves a Type to a CraftItem first. + /// + public static bool CanCraftItem(Mobile from, Type itemType, CraftSystem system, Type selectedResourceType = null) + { + var itemDef = system.CraftItems.SearchFor(itemType); + return itemDef != null && CanCraftItem(from, itemDef, system, selectedResourceType); + } + + /// + /// 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 + ) + { + var filtered = new ItemListEntry[staticEntries.Length]; + var count = 0; + + for (var i = 0; i < staticEntries.Length; i++) + { + var entry = staticEntries[i]; + var typeIndex = entry.CraftIndex; + if (typeIndex >= 0 && typeIndex < types.Length && + CanCraftItem(from, types[typeIndex], system, selectedResourceType)) + { + filtered[count++] = entry; + } + } + + if (count == 0) + { + return []; + } + + if (count < filtered.Length) + { + Array.Resize(ref filtered, count); + } + + return filtered; + } + + /// + /// 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 + ) + { + for (var i = 0; i < types.Length; i++) + { + if (CanCraftItem(from, types[i], system, selectedResourceType)) + { + return true; + } + } + + 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)] + ]; + + 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); + } + + private static bool HasAnySufficientSubResource( + Mobile from, Container pack, int amountNeeded, CraftSystem system, CraftSubResCol resCol + ) + { + for (var j = 0; j < resCol.Count; j++) + { + var subRes = resCol[j]; + if (from.Skills[system.MainSkill].Value >= subRes.RequiredSkill && + GetResourceAmount(pack, subRes.ItemType) >= amountNeeded) + { + return true; + } + } + + return false; + } +} diff --git a/Projects/UOContent/Engines/Craft/T2A/T2ACraftToolTarget.cs b/Projects/UOContent/Engines/Craft/T2A/T2ACraftToolTarget.cs new file mode 100644 index 000000000..d7398f000 --- /dev/null +++ b/Projects/UOContent/Engines/Craft/T2A/T2ACraftToolTarget.cs @@ -0,0 +1,71 @@ +using Server.Items; +using Server.Targeting; + +namespace Server.Engines.Craft.T2A; + +public class T2ACraftToolTarget : Target +{ + private readonly BaseTool _tool; + private readonly CraftSystem _system; + + public T2ACraftToolTarget(BaseTool tool, CraftSystem system) : base(2, false, TargetFlags.None) + { + _tool = tool; + _system = system; + } + + protected override void OnTarget(Mobile from, object targeted) + { + if (targeted == _tool) + { + // Make Last: repeat last crafted item + var context = _system.GetContext(from); + var lastMade = context?.LastMade; + + if (lastMade != null) + { + var num = _system.CanCraft(from, _tool, lastMade.ItemType); + + if (num > 0) + { + from.SendLocalizedMessage(num); + return; + } + + var res = lastMade.UseSubRes2 ? _system.CraftSubRes2 : _system.CraftSubRes; + var resIndex = lastMade.UseSubRes2 ? context.LastResourceIndex2 : context.LastResourceIndex; + var type = resIndex > -1 ? res[resIndex].ItemType : null; + + // Jewelry requires gem targeting — re-prompt instead of crafting directly + if (typeof(BaseJewel).IsAssignableFrom(lastMade.ItemType)) + { + context.PendingGemType = GemType.None; + context.PendingGemCount = 0; + from.SendAsciiMessage("Target the gemstone you wish to use."); + from.Target = new TinkeringMenu.GemSelectTarget(from, _tool, lastMade.ItemType, type); + return; + } + + if (context.LastHue >= 0) + { + _system.CreateItem(from, lastMade.ItemType, type, _tool, lastMade, context.LastHue); + } + else + { + _system.CreateItem(from, lastMade.ItemType, type, _tool, lastMade); + } + } + else + { + from.SendAsciiMessage("You have not yet crafted anything."); + T2ACraftSystem.ShowMenu(from, _system, _tool); + } + } + else + { + // Normal flow: pass the targeted object through so resource selection + // can use it directly instead of requiring a second target. + T2ACraftSystem.ShowMenu(from, _system, _tool, targeted as Item); + } + } +} diff --git a/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs b/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs new file mode 100644 index 000000000..80f950ab8 --- /dev/null +++ b/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs @@ -0,0 +1,375 @@ +using System; +using Server.Items; +using Server.Menus.ItemLists; +using Server.Network; +using Server.Targeting; + +namespace Server.Engines.Craft.T2A; + +public class TailoringMenu : ItemListMenu +{ + private enum Category + { + Main, + LeatherMain, + Hats, + Shirts, + Pants, + Misc, + Footwear, + Leather, + Studded, + Female + } + + private static readonly Type[] HatsTypes = + [ + typeof(SkullCap), typeof(Bandana), typeof(FloppyHat), typeof(Cap), typeof(WideBrimHat), + typeof(StrawHat), typeof(TallStrawHat), typeof(WizardsHat), typeof(Bonnet), + typeof(FeatheredHat), typeof(TricorneHat), typeof(JesterHat) + ]; + + private static readonly Type[] ShirtsTypes = + [ + typeof(Doublet), typeof(Shirt), typeof(FancyShirt), typeof(Tunic), typeof(Surcoat), typeof(PlainDress) + ]; + + private static readonly Type[] PantsTypes = [typeof(ShortPants), typeof(LongPants), typeof(Kilt) ]; + + private static readonly Type[] MiscTypes = + [ + typeof(Skirt), typeof(Cloak), typeof(Robe), typeof(JesterSuit), typeof(FancyDress), + typeof(BodySash), typeof(HalfApron), typeof(FullApron) + ]; + + private static readonly Type[] FootwearTypes = [typeof(Sandals), typeof(Shoes), typeof(Boots), typeof(ThighBoots)]; + + private static readonly Type[] LeatherArmorTypes = + [ + typeof(LeatherChest), typeof(LeatherGorget), typeof(LeatherGloves), typeof(LeatherCap), + typeof(LeatherArms), typeof(LeatherLegs) + ]; + + private static readonly Type[] StuddedArmorTypes = + [ + typeof(StuddedChest), typeof(StuddedGorget), typeof(StuddedGloves), typeof(StuddedArms), typeof(StuddedLegs) + ]; + + private static readonly Type[] FemaleArmorTypes = + [ + typeof(FemaleLeatherChest), typeof(FemaleStuddedChest), typeof(LeatherBustierArms), typeof(StuddedBustierArms), + typeof(FemalePlateChest), typeof(LeatherShorts), typeof(LeatherSkirt) + ]; + + private static ItemListEntry[] _mainEntries; + private static ItemListEntry[] _leatherMainEntries; + private static ItemListEntry[] _hatsEntries; + private static ItemListEntry[] _shirtsEntries; + private static ItemListEntry[] _pantsEntries; + private static ItemListEntry[] _miscEntries; + private static ItemListEntry[] _footwearEntries; + private static ItemListEntry[] _leatherEntries; + private static ItemListEntry[] _studdedEntries; + private static ItemListEntry[] _femaleEntries; + + private readonly Category _category; + private readonly BaseTool _tool; + private readonly int _hue; + + private static string GetQuestion(Category category) => category switch + { + Category.Main => "What would you like to make?", + Category.LeatherMain => "What would you like to make?", + Category.Hats => "What kind of hat?", + Category.Shirts => "What kind of shirt?", + Category.Pants => "What kind of pants?", + Category.Misc => "What would you like to make?", + Category.Footwear => "What kind of footwear?", + Category.Leather => "What kind of leather armor?", + Category.Studded => "What kind of studded armor?", + Category.Female => "What kind of female leather?", + _ => "What would you like to make?" + }; + + private TailoringMenu(Mobile from, BaseTool tool, Category category, int hue = -1) + : base(GetQuestion(category), BuildFilteredEntries(from, category)) + { + _tool = tool; + _category = category; + _hue = hue; + } + + private static string FormatItemName(Type type) + { + var name = type.Name; + Span buffer = stackalloc char[name.Length * 2]; + var pos = 0; + + for (var i = 0; i < name.Length; i++) + { + if (i > 0 && char.IsUpper(name[i])) + { + buffer[pos++] = ' '; + } + + buffer[pos++] = char.ToLower(name[i]); + } + + return new string(buffer[..pos]); + } + + private static ItemListEntry[] BuildStaticEntries(Type[] types, string resourceName) + { + var entries = new ItemListEntry[types.Length]; + var count = 0; + var craftItems = DefTailoring.CraftSystem.CraftItems; + + for (var i = 0; i < types.Length; i++) + { + var itemDef = craftItems.SearchFor(types[i]); + if (itemDef == null) + { + continue; + } + + var name = FormatItemName(types[i]); + var res = itemDef.Resources[0]; + entries[count++] = new ItemListEntry($"{name} ({res.Amount} {resourceName})", itemDef.ItemId, 0, i); + } + + if (count < entries.Length) + { + Array.Resize(ref entries, count); + } + + return entries; + } + + private static ItemListEntry[] GetStaticEntries(Category category) => category switch + { + Category.Main => Main(), + Category.LeatherMain => LeatherMain(), + Category.Hats => Hats(), + Category.Shirts => Shirts(), + Category.Pants => Pants(), + Category.Misc => Misc(), + Category.Footwear => Footwear(), + Category.Leather => Leather(), + Category.Studded => Studded(), + Category.Female => Female(), + _ => null + }; + + private static Type[] GetTypes(Category category) => category switch + { + Category.Hats => HatsTypes, + Category.Shirts => ShirtsTypes, + Category.Pants => PantsTypes, + Category.Misc => MiscTypes, + Category.Footwear => FootwearTypes, + Category.Leather => LeatherArmorTypes, + Category.Studded => StuddedArmorTypes, + Category.Female => FemaleArmorTypes, + _ => null + }; + + public static ItemListEntry[] Main() => _mainEntries ??= + [ + new ItemListEntry("Build Hats", 0x1718, 0, (int)Category.Hats), + new ItemListEntry("Build Shirts", 0x1517, 0, (int)Category.Shirts), + new ItemListEntry("Build Pants", 0x1539, 0, (int)Category.Pants), + new ItemListEntry("Build Misc", 0x153D, 0, (int)Category.Misc) + ]; + + public static ItemListEntry[] LeatherMain() => _leatherMainEntries ??= + [ + new ItemListEntry("Build Shoes", 0x170f, 0, (int)Category.Footwear), + new ItemListEntry("Build Leather Armor", 0x13cc, 0, (int)Category.Leather), + new ItemListEntry("Build Studded Armor", 0x13db, 0, (int)Category.Studded), + new ItemListEntry("Build Female Armor", 0x1c06, 0, (int)Category.Female) + ]; + + public static ItemListEntry[] Hats() => _hatsEntries ??= BuildStaticEntries(HatsTypes, "cloth"); + public static ItemListEntry[] Shirts() => _shirtsEntries ??= BuildStaticEntries(ShirtsTypes, "cloth"); + public static ItemListEntry[] Pants() => _pantsEntries ??= BuildStaticEntries(PantsTypes, "cloth"); + public static ItemListEntry[] Misc() => _miscEntries ??= BuildStaticEntries(MiscTypes, "cloth"); + public static ItemListEntry[] Footwear() => _footwearEntries ??= BuildStaticEntries(FootwearTypes, "leather"); + public static ItemListEntry[] Leather() => _leatherEntries ??= BuildStaticEntries(LeatherArmorTypes, "leather"); + public static ItemListEntry[] Studded() => _studdedEntries ??= BuildStaticEntries(StuddedArmorTypes, "leather"); + public static ItemListEntry[] Female() => _femaleEntries ??= BuildStaticEntries(FemaleArmorTypes, "leather"); + + private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category) + { + if (category is Category.Main or Category.LeatherMain) + { + return BuildFilteredMainEntries(from, category); + } + + var types = GetTypes(category); + var staticEntries = GetStaticEntries(category); + if (types == null || staticEntries == null) + { + return []; + } + + return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefTailoring.CraftSystem); + } + + private static ItemListEntry[] BuildFilteredMainEntries(Mobile from, Category mainCategory) + { + var system = DefTailoring.CraftSystem; + var mainStatic = GetStaticEntries(mainCategory); + if (mainStatic == null) + { + return []; + } + + var filtered = new ItemListEntry[mainStatic.Length]; + var count = 0; + + for (var i = 0; i < mainStatic.Length; i++) + { + var entry = mainStatic[i]; + var types = GetTypes((Category)entry.CraftIndex); + if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system)) + { + filtered[count++] = entry; + } + } + + if (count == 0) + { + return []; + } + + if (count < filtered.Length) + { + Array.Resize(ref filtered, count); + } + + return filtered; + } + + private void CraftItem(Mobile from, Type itemType) + { + var itemDef = DefTailoring.CraftSystem.CraftItems.SearchFor(itemType); + if (itemDef == null) + { + return; + } + + var num = DefTailoring.CraftSystem.CanCraft(from, _tool, itemDef.ItemType); + if (num > 0) + { + from.SendLocalizedMessage(num); + return; + } + + var context = DefTailoring.CraftSystem.GetContext(from); + var res = itemDef.UseSubRes2 ? DefTailoring.CraftSystem.CraftSubRes2 : DefTailoring.CraftSystem.CraftSubRes; + var resIndex = itemDef.UseSubRes2 ? context.LastResourceIndex2 : context.LastResourceIndex; + var resourceType = resIndex > -1 ? res[resIndex].ItemType : null; + + if (_hue >= 0) + { + // Pipeline B: hue-aware crafting — only consumes resources matching this hue + context.LastHue = _hue; + DefTailoring.CraftSystem.CreateItem(from, itemDef.ItemType, resourceType, _tool, itemDef, _hue); + } + else + { + DefTailoring.CraftSystem.CreateItem(from, itemDef.ItemType, resourceType, _tool, itemDef); + } + } + + public override void OnResponse(NetState state, int index) + { + var from = state.Mobile; + var craftIndex = Entries[index].CraftIndex; + + if (_category is Category.Main or Category.LeatherMain) + { + // Carry hue through to child menus + var menu = new TailoringMenu(from, _tool, (Category)craftIndex, _hue); + if (menu.Entries.Length == 0) + { + from.SendAsciiMessage("You lack the skill and materials to craft anything in that category."); + return; + } + + from.SendMenu(menu); + return; + } + + var types = GetTypes(_category); + if (types != null && craftIndex >= 0 && craftIndex < types.Length) + { + CraftItem(from, types[craftIndex]); + } + } + + public static void ResourceSelection(Mobile from, BaseTool tool, Item preTarget = null) + { + if (preTarget != null && TrySelectResource(from, tool, preTarget)) + { + return; + } + + from.SendAsciiMessage("Select the resource you wish to use (cloth, leather, or hides)."); + from.Target = new ResourceSelectTarget(from, tool); + } + + private static bool TrySelectResource(Mobile from, BaseTool tool, Item targeted) + { + if (targeted is Cloth or UncutCloth) + { + var menu = new TailoringMenu(from, tool, Category.Main, targeted.Hue); + if (menu.Entries.Length == 0) + { + from.SendAsciiMessage("You lack the skill and materials to craft anything."); + return true; + } + + from.SendMenu(menu); + return true; + } + + if (targeted is Items.Leather or Hides) + { + var menu = new TailoringMenu(from, tool, Category.LeatherMain); + if (menu.Entries.Length == 0) + { + from.SendAsciiMessage("You lack the skill and materials to craft anything."); + return true; + } + + from.SendMenu(menu); + return true; + } + + return false; + } + + private class ResourceSelectTarget : Target + { + private readonly Mobile _from; + private readonly BaseTool _tool; + + public ResourceSelectTarget(Mobile from, BaseTool tool) : base(12, false, TargetFlags.None) + { + _from = from; + _tool = tool; + } + + protected override void OnTarget(Mobile from, object targeted) + { + if (targeted is Item item && TrySelectResource(from, _tool, item)) + { + return; + } + + from.SendAsciiMessage("That is not a valid resource. Please select cloth, leather, or hides."); + from.Target = new ResourceSelectTarget(_from, _tool); + } + } +} diff --git a/Projects/UOContent/Engines/Craft/T2A/TinkeringMenu.cs b/Projects/UOContent/Engines/Craft/T2A/TinkeringMenu.cs new file mode 100644 index 000000000..e823d68ee --- /dev/null +++ b/Projects/UOContent/Engines/Craft/T2A/TinkeringMenu.cs @@ -0,0 +1,529 @@ +using System; +using Server.Items; +using Server.Menus.ItemLists; +using Server.Network; +using Server.Targeting; + +namespace Server.Engines.Craft.T2A; + +public class TinkeringMenu : ItemListMenu +{ + private enum Category + { + Main, + Wood, + Tools, + Parts, + Utensils, + Traps, + Misc, + Jewelry, + Necklaces, + Earrings, + Rings, + Keg + } + + private static readonly Type[] WoodItemTypes = + [ + typeof(JointingPlane), typeof(MouldingPlane), typeof(SmoothingPlane), + typeof(ClockFrame), typeof(Axle), typeof(RollingPin) + ]; + + private static readonly Type[] ToolTypes = + [ + typeof(SewingKit), typeof(TinkerTools), + typeof(DrawKnife), typeof(Froe), typeof(Inshave), typeof(Scorp), + typeof(Scissors), typeof(Tongs), + typeof(DovetailSaw), typeof(Saw), typeof(Hammer), + typeof(SmithHammer), typeof(SledgeHammer), typeof(Shovel), + typeof(MortarPestle), typeof(Hatchet), typeof(Pickaxe), typeof(Lockpick) + ]; + + private static readonly Type[] PartTypes = + [ + typeof(Gears), typeof(Springs), typeof(Hinge), + typeof(ClockParts), typeof(SextantParts), + typeof(BarrelTap), typeof(BarrelHoops), typeof(AxleGears) + ]; + + private static readonly Type[] UtensilTypes = + [ + typeof(ButcherKnife), typeof(Plate), typeof(Cleaver), + typeof(KnifeLeft), typeof(KnifeRight), typeof(SkinningKnife), + typeof(ForkLeft), typeof(ForkRight), + typeof(SpoonLeft), typeof(SpoonRight), + typeof(Goblet), typeof(PewterMug) + ]; + + private static readonly Type[] TrapTypes = + [ + typeof(DartTrapCraft), typeof(ExplosionTrapCraft), typeof(PoisonTrapCraft) + ]; + + private static readonly Type[] MiscTypes = + [ + typeof(KeyRing), typeof(Key), + typeof(Scales), typeof(Spyglass), typeof(Lantern), typeof(HeatingStand), + typeof(Globe), typeof(Candelabra), typeof(Sextant), + typeof(ClockRight), typeof(ClockLeft) + ]; + + private static readonly Type[] NecklaceTypes = [typeof(GoldNecklace), typeof(SilverNecklace)]; + private static readonly Type[] EarringTypes = [typeof(GoldEarrings), typeof(SilverEarrings)]; + private static readonly Type[] RingTypes = [typeof(GoldRing), typeof(SilverRing), typeof(WeddingRing)]; + + // Combined for AnyCraftableInCategory check on the Jewelry parent entry + private static readonly Type[] AllJewelryTypes = + [ + typeof(GoldNecklace), typeof(SilverNecklace), + typeof(GoldEarrings), typeof(SilverEarrings), + typeof(GoldRing), typeof(SilverRing), typeof(WeddingRing) + ]; + + private static readonly Type[] KegItemTypes = [typeof(PotionKeg)]; + + private static ItemListEntry[] _mainEntries; + private static ItemListEntry[] _woodEntries; + private static ItemListEntry[] _toolEntries; + private static ItemListEntry[] _partEntries; + private static ItemListEntry[] _utensilEntries; + private static ItemListEntry[] _trapEntries; + private static ItemListEntry[] _miscEntries; + private static ItemListEntry[] _necklaceEntries; + private static ItemListEntry[] _earringEntries; + private static ItemListEntry[] _ringEntries; + private static ItemListEntry[] _kegEntries; + + private readonly Category _category; + private readonly BaseTool _tool; + private readonly Type _selectedResourceType; + + private static string GetQuestion(Category category) => category switch + { + Category.Main => "What would you like to make?", + Category.Wood => "What kind of wooden item?", + Category.Tools => "What kind of tool?", + Category.Parts => "What kind of part?", + Category.Utensils => "What kind of utensil?", + Category.Traps => "What kind of trap?", + Category.Misc => "What would you like to make?", + Category.Jewelry => "What kind of jewelry?", + Category.Necklaces => "What kind of necklace?", + Category.Earrings => "What kind of earrings?", + Category.Rings => "What kind of ring?", + Category.Keg => "What would you like to make?", + _ => "What would you like to make?" + }; + + private TinkeringMenu(Mobile from, BaseTool tool, Category category, Type selectedResourceType) + : base(GetQuestion(category), BuildFilteredEntries(from, category)) + { + _tool = tool; + _category = category; + _selectedResourceType = selectedResourceType; + } + + private static string FormatItemName(Type type) + { + var name = type.Name; + Span buffer = stackalloc char[name.Length * 2]; + var pos = 0; + + for (var i = 0; i < name.Length; i++) + { + if (i > 0 && char.IsUpper(name[i])) + { + buffer[pos++] = ' '; + } + + buffer[pos++] = char.ToLower(name[i]); + } + + return new string(buffer[..pos]); + } + + private static ItemListEntry[] BuildStaticEntries(Type[] types, string resourceName) + { + var entries = new ItemListEntry[types.Length]; + var count = 0; + var craftItems = DefTinkering.CraftSystem.CraftItems; + + for (var i = 0; i < types.Length; i++) + { + var itemDef = craftItems.SearchFor(types[i]); + if (itemDef == null) + { + continue; + } + + var name = FormatItemName(types[i]); + var res = itemDef.Resources[0]; + entries[count++] = new ItemListEntry($"{name} ({res.Amount} {resourceName})", itemDef.ItemId, 0, i); + } + + if (count < entries.Length) + { + Array.Resize(ref entries, count); + } + + return entries; + } + + private static ItemListEntry[] GetStaticEntries(Category category) => category switch + { + Category.Main => Main(), + Category.Wood => Wood(), + Category.Tools => Tools(), + Category.Parts => Parts(), + Category.Utensils => Utensils(), + Category.Traps => Traps(), + Category.Misc => Misc(), + Category.Necklaces => Necklaces(), + Category.Earrings => Earrings(), + Category.Rings => Rings(), + Category.Keg => KegItems(), + _ => null + }; + + private static Type[] GetTypes(Category category) => category switch + { + Category.Wood => WoodItemTypes, + Category.Tools => ToolTypes, + Category.Parts => PartTypes, + Category.Utensils => UtensilTypes, + Category.Traps => TrapTypes, + Category.Misc => MiscTypes, + Category.Jewelry => AllJewelryTypes, + Category.Necklaces => NecklaceTypes, + Category.Earrings => EarringTypes, + Category.Rings => RingTypes, + Category.Keg => KegItemTypes, + _ => null + }; + + public static ItemListEntry[] Main() => _mainEntries ??= + [ + new ItemListEntry("Wooden Items", 0x1BDD, 0, (int)Category.Wood), + new ItemListEntry("Tools", 0x1EB8, 0, (int)Category.Tools), + new ItemListEntry("Parts", 0x1053, 0, (int)Category.Parts), + new ItemListEntry("Utensils", 0x9D7, 0, (int)Category.Utensils), + new ItemListEntry("Traps", 0x1BFC, 0, (int)Category.Traps), + new ItemListEntry("Miscellaneous", 0xA25, 0, (int)Category.Misc), + new ItemListEntry("Jewelry", 0x1088, 0, (int)Category.Jewelry) + ]; + + public static ItemListEntry[] Wood() => _woodEntries ??= BuildStaticEntries(WoodItemTypes, "logs"); + public static ItemListEntry[] Tools() => _toolEntries ??= BuildStaticEntries(ToolTypes, "ingots"); + public static ItemListEntry[] Parts() => _partEntries ??= BuildStaticEntries(PartTypes, "ingots"); + public static ItemListEntry[] Utensils() => _utensilEntries ??= BuildStaticEntries(UtensilTypes, "ingots"); + public static ItemListEntry[] Traps() => _trapEntries ??= BuildStaticEntries(TrapTypes, "ingots"); + public static ItemListEntry[] Misc() => _miscEntries ??= BuildStaticEntries(MiscTypes, "ingots"); + public static ItemListEntry[] Necklaces() => _necklaceEntries ??= BuildStaticEntries(NecklaceTypes, "ingots"); + public static ItemListEntry[] Earrings() => _earringEntries ??= BuildStaticEntries(EarringTypes, "ingots"); + public static ItemListEntry[] Rings() => _ringEntries ??= BuildStaticEntries(RingTypes, "ingots"); + public static ItemListEntry[] KegItems() => _kegEntries ??= BuildStaticEntries(KegItemTypes, "kegs"); + + private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category) + { + if (category == Category.Main) + { + return BuildFilteredMainEntries(from); + } + + if (category == Category.Jewelry) + { + return BuildFilteredJewelryEntries(from); + } + + var types = GetTypes(category); + var staticEntries = GetStaticEntries(category); + if (types == null || staticEntries == null) + { + return []; + } + + return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefTinkering.CraftSystem); + } + + private static readonly ItemListEntry[] JewelrySubcategoryEntries = + [ + new("Necklaces", 0x1088, 0, (int)Category.Necklaces), + new("Earrings", 0x1087, 0, (int)Category.Earrings), + new("Rings", 0x108a, 0, (int)Category.Rings) + ]; + + private static ItemListEntry[] BuildFilteredJewelryEntries(Mobile from) + { + var system = DefTinkering.CraftSystem; + var filtered = new ItemListEntry[JewelrySubcategoryEntries.Length]; + var count = 0; + + for (var i = 0; i < JewelrySubcategoryEntries.Length; i++) + { + var entry = JewelrySubcategoryEntries[i]; + var types = GetTypes((Category)entry.CraftIndex); + if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system)) + { + filtered[count++] = entry; + } + } + + if (count == 0) + { + return []; + } + + if (count < filtered.Length) + { + Array.Resize(ref filtered, count); + } + + return filtered; + } + + private static ItemListEntry[] BuildFilteredMainEntries(Mobile from) + { + var system = DefTinkering.CraftSystem; + var mainStatic = Main(); + var filtered = new ItemListEntry[mainStatic.Length]; + var count = 0; + + for (var i = 0; i < mainStatic.Length; i++) + { + var entry = mainStatic[i]; + var types = GetTypes((Category)entry.CraftIndex); + if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system)) + { + filtered[count++] = entry; + } + } + + if (count == 0) + { + return []; + } + + if (count < filtered.Length) + { + Array.Resize(ref filtered, count); + } + + return filtered; + } + + public override void OnResponse(NetState state, int index) + { + var from = state.Mobile; + var craftIndex = Entries[index].CraftIndex; + + // Navigation categories: Main → subcategory, Jewelry → subcategory + if (_category is Category.Main or Category.Jewelry) + { + var childCategory = (Category)craftIndex; + var menu = new TinkeringMenu(from, _tool, childCategory, _selectedResourceType); + if (menu.Entries.Length == 0) + { + from.SendAsciiMessage("You lack the skill and materials to craft anything in that category."); + return; + } + + from.SendMenu(menu); + return; + } + + // Jewelry leaf categories: prompt for gem targeting + if (_category is Category.Necklaces or Category.Earrings or Category.Rings) + { + var types = GetTypes(_category); + if (types == null || craftIndex < 0 || craftIndex >= types.Length) + { + return; + } + + var itemType = types[craftIndex]; + if (DefTinkering.CraftSystem.CraftItems.SearchFor(itemType) == null) + { + return; + } + + // Clear stale gem state from any previous craft attempt (e.g. failed skill check) + var ctx = DefTinkering.CraftSystem.GetContext(from); + if (ctx != null) + { + ctx.PendingGemType = GemType.None; + ctx.PendingGemCount = 0; + } + + from.SendAsciiMessage("Target the gemstone you wish to use."); + from.Target = new GemSelectTarget(from, _tool, itemType, _selectedResourceType); + return; + } + + // Leaf categories: craft directly + var leafTypes = GetTypes(_category); + if (leafTypes == null || craftIndex < 0 || craftIndex >= leafTypes.Length) + { + return; + } + + var leafItemType = leafTypes[craftIndex]; + var system = DefTinkering.CraftSystem; + var itemDef = system.CraftItems.SearchFor(leafItemType); + if (itemDef == null || _selectedResourceType == null) + { + return; + } + + // Persist selected resource index so make-last remembers it + T2ACraftSystem.SetLastResourceIndex(from, system, _selectedResourceType); + + itemDef.Craft(from, system, _selectedResourceType, _tool); + } + + public static void ResourceSelection(Mobile from, BaseTool tool, Item preTarget = null) + { + if (preTarget != null && TrySelectResource(from, tool, preTarget)) + { + return; + } + + from.SendAsciiMessage("Select the resource you wish to use (wood or ingots)."); + from.Target = new ResourceSelectTarget(from, tool); + } + + private static bool TrySelectResource(Mobile from, BaseTool tool, Item targeted) + { + if (targeted is Log or Board) + { + var menu = new TinkeringMenu(from, tool, Category.Wood, typeof(Log)); + if (menu.Entries.Length == 0) + { + from.SendAsciiMessage("You lack the skill and materials to craft anything."); + return true; + } + + from.SendMenu(menu); + return true; + } + + if (targeted is BaseIngot) + { + var menu = new TinkeringMenu(from, tool, Category.Main, targeted.GetType()); + if (menu.Entries.Length == 0) + { + from.SendAsciiMessage("You lack the skill and materials to craft anything."); + return true; + } + + from.SendMenu(menu); + return true; + } + + if (targeted is Keg) + { + var menu = new TinkeringMenu(from, tool, Category.Keg, typeof(Keg)); + if (menu.Entries.Length == 0) + { + from.SendAsciiMessage("You lack the skill and materials to craft anything."); + return true; + } + + from.SendMenu(menu); + return true; + } + + return false; + } + + private class ResourceSelectTarget : Target + { + private readonly Mobile _from; + private readonly BaseTool _tool; + + public ResourceSelectTarget(Mobile from, BaseTool tool) : base(12, false, TargetFlags.None) + { + _from = from; + _tool = tool; + } + + protected override void OnTarget(Mobile from, object targeted) + { + if (targeted is Item item && TrySelectResource(from, _tool, item)) + { + return; + } + + from.SendAsciiMessage("That is not a valid resource. Please select wood or ingots."); + from.Target = new ResourceSelectTarget(_from, _tool); + } + } + + internal class GemSelectTarget : Target + { + private readonly Mobile _from; + private readonly BaseTool _tool; + private readonly Type _itemType; + private readonly Type _selectedResourceType; + + public GemSelectTarget(Mobile from, BaseTool tool, Type itemType, Type selectedResourceType) + : base(12, false, TargetFlags.None) + { + _from = from; + _tool = tool; + _itemType = itemType; + _selectedResourceType = selectedResourceType; + } + + protected override void OnTarget(Mobile from, object targeted) + { + if (targeted is not Item gemItem) + { + from.SendAsciiMessage("That is not a gemstone."); + return; + } + + var gemType = BaseJewel.GetGemType(gemItem); + if (gemType == GemType.None) + { + from.SendAsciiMessage("That is not a gemstone."); + return; + } + + var amount = gemItem.Amount; + if (amount < 1) + { + from.SendAsciiMessage("That gemstone stack is empty."); + return; + } + + var system = DefTinkering.CraftSystem; + var itemDef = system.CraftItems.SearchFor(_itemType); + if (itemDef == null) + { + return; + } + + // Store pending gem info in craft context + var ctx = system.GetContext(from); + if (ctx == null) + { + return; + } + + ctx.PendingGemType = gemType; + ctx.PendingGemCount = amount; + + T2ACraftSystem.SetLastResourceIndex(from, system, _selectedResourceType); + itemDef.Craft(from, system, _selectedResourceType, _tool); + } + + protected override void OnTargetCancel(Mobile from, TargetCancelType cancelType) + { + if (cancelType == TargetCancelType.Canceled) + { + CraftItem.ShowCraftMenu(from, DefTinkering.CraftSystem, _tool); + } + } + } +} diff --git a/Projects/UOContent/Engines/Factions/Gumps/FactionImbueGump.cs b/Projects/UOContent/Engines/Factions/Gumps/FactionImbueGump.cs index eec829a08..776b65f26 100644 --- a/Projects/UOContent/Engines/Factions/Gumps/FactionImbueGump.cs +++ b/Projects/UOContent/Engines/Factions/Gumps/FactionImbueGump.cs @@ -96,7 +96,7 @@ public class FactionImbueGump : FactionGump if (m_Tool?.Deleted == false && m_Tool.UsesRemaining > 0) { - m_Mobile.SendGump(new CraftGump(m_Mobile, m_CraftSystem, m_Tool, m_Notice)); + CraftItem.ShowCraftMenu(m_Mobile, m_CraftSystem, m_Tool, m_Notice); } else if (m_Notice != null) { diff --git a/Projects/UOContent/Engines/ML Quests/MLQuest.cs b/Projects/UOContent/Engines/ML Quests/MLQuest.cs index 7ab3ffc31..4dde39f6c 100644 --- a/Projects/UOContent/Engines/ML Quests/MLQuest.cs +++ b/Projects/UOContent/Engines/ML Quests/MLQuest.cs @@ -5,7 +5,6 @@ using Server.Engines.MLQuests.Gumps; using Server.Engines.MLQuests.Objectives; using Server.Engines.MLQuests.Rewards; using Server.Engines.Spawners; -using Server.Gumps; using Server.Mobiles; namespace Server.Engines.MLQuests diff --git a/Projects/UOContent/Engines/ML Quests/MLQuestEntry.cs b/Projects/UOContent/Engines/ML Quests/MLQuestEntry.cs index 524a69dd0..abb108999 100644 --- a/Projects/UOContent/Engines/ML Quests/MLQuestEntry.cs +++ b/Projects/UOContent/Engines/ML Quests/MLQuestEntry.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using Server.Engines.MLQuests.Gumps; using Server.Engines.MLQuests.Objectives; -using Server.Gumps; using Server.Mobiles; namespace Server.Engines.MLQuests diff --git a/Projects/UOContent/Engines/Plants/PlantPourTarget.cs b/Projects/UOContent/Engines/Plants/PlantPourTarget.cs index 454f4cfcc..0e59d79a1 100644 --- a/Projects/UOContent/Engines/Plants/PlantPourTarget.cs +++ b/Projects/UOContent/Engines/Plants/PlantPourTarget.cs @@ -1,4 +1,3 @@ -using Server.Gumps; using Server.Targeting; namespace Server.Engines.Plants diff --git a/Projects/UOContent/Engines/Plants/PollinateTarget.cs b/Projects/UOContent/Engines/Plants/PollinateTarget.cs index b202c79a2..5b846cbcd 100644 --- a/Projects/UOContent/Engines/Plants/PollinateTarget.cs +++ b/Projects/UOContent/Engines/Plants/PollinateTarget.cs @@ -1,4 +1,3 @@ -using Server.Gumps; using Server.Targeting; namespace Server.Engines.Plants diff --git a/Projects/UOContent/Engines/Player Murder System/BountyReportMurdererGump.cs b/Projects/UOContent/Engines/Player Murder System/BountyReportMurdererGump.cs index c099fd2b9..2f68a22e8 100644 --- a/Projects/UOContent/Engines/Player Murder System/BountyReportMurdererGump.cs +++ b/Projects/UOContent/Engines/Player Murder System/BountyReportMurdererGump.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using Server.Gumps; -using Server.Items; using Server.Mobiles; using Server.Network; diff --git a/Projects/UOContent/Engines/Veteran Rewards/RewardSystem.cs b/Projects/UOContent/Engines/Veteran Rewards/RewardSystem.cs index 1484a0195..4121a5520 100644 --- a/Projects/UOContent/Engines/Veteran Rewards/RewardSystem.cs +++ b/Projects/UOContent/Engines/Veteran Rewards/RewardSystem.cs @@ -1,7 +1,6 @@ using System; using ModernUO.CodeGeneratedEvents; using Server.Accounting; -using Server.Gumps; using Server.Items; using Server.Mobiles; diff --git a/Projects/UOContent/Gumps/VendorInventoryGump.cs b/Projects/UOContent/Gumps/VendorInventoryGump.cs index 6b3728d0d..fe2bfaa08 100644 --- a/Projects/UOContent/Gumps/VendorInventoryGump.cs +++ b/Projects/UOContent/Gumps/VendorInventoryGump.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using System.Linq; using Server.Mobiles; using Server.Multis; using Server.Network; diff --git a/Projects/UOContent/Items/Jewels/BaseJewel.cs b/Projects/UOContent/Items/Jewels/BaseJewel.cs index 9e4bf0365..29f7d67d8 100644 --- a/Projects/UOContent/Items/Jewels/BaseJewel.cs +++ b/Projects/UOContent/Items/Jewels/BaseJewel.cs @@ -18,7 +18,7 @@ public enum GemType Diamond } -[SerializationGenerator(4, false)] +[SerializationGenerator(5, false)] public abstract partial class BaseJewel : Item, ICraftable, IAosItem { [EncodedInt] @@ -47,6 +47,11 @@ public abstract partial class BaseJewel : Item, ICraftable, IAosItem [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; + [EncodedInt] + [SerializableField(7)] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _gemCount; + public BaseJewel(int itemID, Layer layer) : base(itemID) { _attributes = new AosAttributes(this); @@ -183,9 +188,129 @@ public abstract partial class BaseJewel : Item, ICraftable, IAosItem } } + // T2A jewelry: read gem info from craft context (set by GemSelectTarget). + // The entire targeted gem stack is consumed and the piece is named by that + // count (e.g. "a 1000 diamond ring"). + if (context is { PendingGemType: not GemType.None, PendingGemCount: > 0 }) + { + var gemItemType = GetGemItemType(context.PendingGemType); + var gemCount = context.PendingGemCount; + + if (gemItemType != null && from.Backpack?.ConsumeTotal(gemItemType, gemCount) == true) + { + GemType = context.PendingGemType; + GemCount = gemCount; + } + else + { + // Gems were no longer available (or unknown type): craft a plain piece + // rather than naming it for gems that were never consumed. + from.SendAsciiMessage("You lack the gemstones to set into this piece."); + } + + context.PendingGemType = GemType.None; + context.PendingGemCount = 0; + } + return 1; } + public override void OnSingleClick(Mobile from) + { + if (!Core.UOTD) + { + OnSingleClickPreUOTD(from); + return; + } + + base.OnSingleClick(from); + } + + public virtual void OnSingleClickPreUOTD(Mobile from) + { + var plural = _gemCount > 1; + string name; + if (this is WeddingRing) + { + name = $"a {Name}"; + } + else + { + name = Name; + + if (name == null) + { + var articleAnName = (TileData.ItemTable[ItemID].Flags & TileFlag.ArticleAn) != 0; + name = $"{(articleAnName ? "an" : "a")} {Localization.GetText(LabelNumber).ToLowerInvariant()}"; + } + } + + if (_gemType != GemType.None && _gemCount > 0) + { + var gemName = GetGemName(_gemType, plural); + LabelTo(from, plural + ? $"{name} with {_gemCount} {gemName}" + : $"{name} with {gemName}"); + } + else + { + LabelTo(from, name); + } + } + + + private static string GetGemName(GemType type, bool plural = false) => type switch + { + GemType.StarSapphire when plural => "star sapphires", + GemType.StarSapphire => "a star sapphire", + GemType.Emerald when plural => "emeralds", + GemType.Emerald => "an emerald", + GemType.Sapphire when plural => "sapphires", + GemType.Sapphire => "a sapphire", + GemType.Ruby when plural => "rubies", + GemType.Ruby => "a ruby", + GemType.Citrine when plural => "citrines", + GemType.Citrine => "a citrine", + GemType.Amethyst when plural => "amethysts", + GemType.Amethyst => "an amethyst", + GemType.Tourmaline when plural => "tourmalines", + GemType.Tourmaline => "a tourmaline", + GemType.Amber when plural => "ambers", + GemType.Amber => "an amber", + GemType.Diamond when plural => "diamonds", + GemType.Diamond => "a diamond", + _ when plural => "gems", + _ => "a gem" + }; + + internal static GemType GetGemType(Item item) => item switch + { + StarSapphire => GemType.StarSapphire, + Emerald => GemType.Emerald, + Sapphire => GemType.Sapphire, + Ruby => GemType.Ruby, + Citrine => GemType.Citrine, + Amethyst => GemType.Amethyst, + Tourmaline => GemType.Tourmaline, + Amber => GemType.Amber, + Diamond => GemType.Diamond, + _ => GemType.None + }; + + internal static Type GetGemItemType(GemType type) => type switch + { + GemType.StarSapphire => typeof(StarSapphire), + GemType.Emerald => typeof(Emerald), + GemType.Sapphire => typeof(Sapphire), + GemType.Ruby => typeof(Ruby), + GemType.Citrine => typeof(Citrine), + GemType.Amethyst => typeof(Amethyst), + GemType.Tourmaline => typeof(Tourmaline), + GemType.Amber => typeof(Amber), + GemType.Diamond => typeof(Diamond), + _ => null + }; + public override void OnAfterDuped(Item newItem) { if (newItem is not BaseJewel jewel) @@ -406,6 +531,18 @@ public abstract partial class BaseJewel : Item, ICraftable, IAosItem _skillBonuses.Deserialize(reader); } + private void MigrateFrom(V4Content content) + { + _maxHitPoints = content.MaxHitPoints; + _hitPoints = content.HitPoints; + _resource = content.Resource; + _gemType = content.GemType; + _attributes = content.Attributes; + _resistances = content.Resistances; + _skillBonuses = content.SkillBonuses; + // _gemCount defaults to 0 + } + [AfterDeserialization] private void AfterDeserialization() { diff --git a/Projects/UOContent/Items/Jewels/Ring.cs b/Projects/UOContent/Items/Jewels/Ring.cs index 856f1ccfe..8a552508a 100644 --- a/Projects/UOContent/Items/Jewels/Ring.cs +++ b/Projects/UOContent/Items/Jewels/Ring.cs @@ -33,3 +33,16 @@ public partial class SilverRing : BaseRing public override double DefaultWeight => 0.1; } + +[SerializationGenerator(0, false)] +public partial class WeddingRing : BaseRing +{ + public override string DefaultName => "wedding ring"; + + [Constructible] + public WeddingRing() : base(0x108a) + { + } + + public override double DefaultWeight => 0.1; +} diff --git a/Projects/UOContent/Items/Skill Items/Tools/BaseTool.cs b/Projects/UOContent/Items/Skill Items/Tools/BaseTool.cs index 74af05e69..73482ccd0 100644 --- a/Projects/UOContent/Items/Skill Items/Tools/BaseTool.cs +++ b/Projects/UOContent/Items/Skill Items/Tools/BaseTool.cs @@ -1,7 +1,7 @@ using System; using ModernUO.Serialization; using Server.Engines.Craft; -using Server.Gumps; +using Server.Engines.Craft.T2A; using Server.Network; namespace Server.Items; @@ -159,9 +159,14 @@ public abstract partial class BaseTool : Item, IUsesRemaining, ICraftable { from.SendLocalizedMessage(num); } + else if (T2ACraftSystem.Enabled) + { + from.Target = new T2ACraftToolTarget(this, system); + from.SendAsciiMessage("Target this tool to make last item, or any other target to begin crafting."); + } else { - from.SendGump(new CraftGump(from, system, this, null)); + CraftItem.ShowCraftMenu(from, system, this, null); } } else diff --git a/Projects/UOContent/Migrations/Server.Items.BaseJewel.v5.json b/Projects/UOContent/Migrations/Server.Items.BaseJewel.v5.json new file mode 100644 index 000000000..f26e43f46 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.BaseJewel.v5.json @@ -0,0 +1,64 @@ +{ + "version": 5, + "type": "Server.Items.BaseJewel", + "properties": [ + { + "name": "MaxHitPoints", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "HitPoints", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "Resource", + "type": "Server.Items.CraftResource", + "rule": "EnumMigrationRule" + }, + { + "name": "GemType", + "type": "Server.Items.GemType", + "rule": "EnumMigrationRule" + }, + { + "name": "Attributes", + "type": "Server.AosAttributes", + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "Resistances", + "type": "Server.AosElementAttributes", + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "SkillBonuses", + "type": "Server.AosSkillBonuses", + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "GemCount", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.WeddingRing.v0.json b/Projects/UOContent/Migrations/Server.Items.WeddingRing.v0.json new file mode 100644 index 000000000..ed31f38f1 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.WeddingRing.v0.json @@ -0,0 +1,4 @@ +{ + "version": 0, + "type": "Server.Items.WeddingRing" +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/Guildmasters/TinkerGuildmaster.cs b/Projects/UOContent/Mobiles/Vendors/NPC/Guildmasters/TinkerGuildmaster.cs index a43f08d78..789ce4824 100644 --- a/Projects/UOContent/Mobiles/Vendors/NPC/Guildmasters/TinkerGuildmaster.cs +++ b/Projects/UOContent/Mobiles/Vendors/NPC/Guildmasters/TinkerGuildmaster.cs @@ -1,7 +1,6 @@ using ModernUO.Serialization; using Server.Collections; using Server.ContextMenus; -using Server.Gumps; using Server.Items; namespace Server.Mobiles diff --git a/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs b/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs index 6da86be91..a6297837d 100644 --- a/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs +++ b/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs @@ -287,30 +287,32 @@ public static class IncomingPlayerPackets public static void MenuResponse(NetState state, SpanReader reader) { var serial = reader.ReadUInt32(); - int menuID = reader.ReadInt16(); // unused in our implementation + int menuID = reader.ReadInt16(); int index = reader.ReadInt16(); int itemID = reader.ReadInt16(); int hue = reader.ReadInt16(); index -= 1; // convert from 1-based to 0-based - foreach (var menu in state.Menus) + for (var i = 0; i < state.Menus.Count; i++) { - if (menu.Serial == serial) + var menu = state.Menus[i]; + if ((uint)menu.Serial != serial) { - state.RemoveMenu(menu); - - if (index >= 0 && index < menu.EntryLength) - { - menu.OnResponse(state, index); - } - else - { - menu.OnCancel(state); - } - - break; + continue; } + + state.RemoveMenu(menu); + + if (index >= 0 && index < menu.EntryLength) + { + menu.OnResponse(state, index); + } + else + { + menu.OnCancel(state); + } + break; } } diff --git a/Projects/UOContent/Skills/Cartography.cs b/Projects/UOContent/Skills/Cartography.cs new file mode 100644 index 000000000..e6911590c --- /dev/null +++ b/Projects/UOContent/Skills/Cartography.cs @@ -0,0 +1,25 @@ +using System; +using Server.Engines.Craft; +using Server.Engines.Craft.T2A; + +namespace Server.SkillHandlers; + +public static class Cartography +{ + public static void Initialize() + { + SkillInfo.Table[(int)SkillName.Cartography].Callback = OnUse; + } + + public static TimeSpan OnUse(Mobile m) + { + if (!T2ACraftSystem.Enabled) + { + m.SendLocalizedMessage(1046444); // Use a mapmaker's pen to draw maps. + return TimeSpan.Zero; + } + + T2ACraftSystem.ShowMenu(m, DefCartography.CraftSystem, null); + return TimeSpan.FromSeconds(1.0); + } +} diff --git a/Projects/UOContent/Skills/Inscribe.cs b/Projects/UOContent/Skills/Inscribe.cs index 84c47de5f..d2d90f156 100644 --- a/Projects/UOContent/Skills/Inscribe.cs +++ b/Projects/UOContent/Skills/Inscribe.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using Server.Engines.Craft; +using Server.Engines.Craft.T2A; using Server.Items; using Server.Targeting; @@ -16,10 +18,19 @@ namespace Server.SkillHandlers public static TimeSpan OnUse(Mobile m) { - Target target = new InternalTargetSrc(); - m.Target = target; + if (T2ACraftSystem.Enabled) + { + var target = new T2AInscribeTarget(); + m.Target = target; + m.SendAsciiMessage("Target the book you wish to copy or scroll you want to use."); + target.BeginTimeout(m, 60000); // 1 minute + return TimeSpan.FromSeconds(1.0); + } + + Target uotdTarget = new InternalTargetSrc(); + m.Target = uotdTarget; m.SendLocalizedMessage(1046295); // Target the book you wish to copy. - target.BeginTimeout(m, 60000); // 1 minute + uotdTarget.BeginTimeout(m, 60000); // 1 minute return TimeSpan.FromSeconds(1.0); } @@ -42,10 +53,12 @@ namespace Server.SkillHandlers public static bool IsEmpty(BaseBook book) { - foreach (var page in book.Pages) + for (var i = 0; i < book.Pages.Length; i++) { - foreach (var line in page.Lines) + var page = book.Pages[i]; + for (var j = 0; j < page.Lines.Length; j++) { + var line = page.Lines[j]; if (!string.IsNullOrEmpty(line)) { return false; @@ -78,6 +91,76 @@ namespace Server.SkillHandlers } } + private class T2AInscribeTarget : Target + { + public T2AInscribeTarget() : base(3, false, TargetFlags.None) + { + } + + protected override void OnTarget(Mobile from, object targeted) + { + if (targeted is BlankScroll scroll) + { + if (!scroll.IsChildOf(from.Backpack)) + { + from.SendAsciiMessage("That must be in your pack for you to use it."); + } + else + { + T2ACraftSystem.ShowMenu(from, DefInscription.CraftSystem, null); + } + } + else if (targeted is RecallRune) + { + var system = DefInscription.CraftSystem; + var itemDef = system.CraftItems.SearchFor(typeof(Runebook)); + if (itemDef != null) + { + var num = system.CanCraft(from, null, itemDef.ItemType); + if (num > 0) + { + from.SendLocalizedMessage(num); + } + else + { + system.CreateItem(from, itemDef.ItemType, null, null, itemDef); + } + } + } + else if (targeted is BaseBook book) + { + if (IsEmpty(book)) + { + from.SendLocalizedMessage(501611); // Can't copy an empty book. + } + else if (GetUser(book) != null) + { + from.SendLocalizedMessage(501621); // Someone else is inscribing that item. + } + else + { + Target target = new InternalTargetDst(book); + from.Target = target; + from.SendLocalizedMessage(501612); // Select a book to copy this to. + target.BeginTimeout(from, 60000); + SetUser(book, from); + } + } + else + { + from.SendLocalizedMessage(1046296); // That is not a book + } + } + + protected override void OnTargetCancel(Mobile from, TargetCancelType cancelType) + { + if (cancelType == TargetCancelType.Timeout) + { + from.SendLocalizedMessage(501619); + } + } + } + private class InternalTargetSrc : Target { public InternalTargetSrc() : base(3, false, TargetFlags.None) @@ -151,19 +234,16 @@ namespace Server.SkillHandlers { from.SendLocalizedMessage(501621); // Someone else is inscribing that item. } + else if (from.CheckTargetSkill(SkillName.Inscribe, bookDst, 0, 50)) + { + Copy(m_BookSrc, bookDst); + + from.SendLocalizedMessage(501618); // You make a copy of the book. + from.PlaySound(0x249); + } else { - if (from.CheckTargetSkill(SkillName.Inscribe, bookDst, 0, 50)) - { - Copy(m_BookSrc, bookDst); - - from.SendLocalizedMessage(501618); // You make a copy of the book. - from.PlaySound(0x249); - } - else - { - from.SendLocalizedMessage(501617); // You fail to make a copy of the book. - } + from.SendLocalizedMessage(501617); // You fail to make a copy of the book. } } diff --git a/dev-docs/t2a-crafting.md b/dev-docs/t2a-crafting.md new file mode 100644 index 000000000..27b619264 --- /dev/null +++ b/dev-docs/t2a-crafting.md @@ -0,0 +1,141 @@ +# T2A Packet-Based Crafting Menus + +This document covers ModernUO's **T2A-era crafting menus** — the pre-UO:Third-Dawn, packet-based item-list crafting UI that replaces the modern gump crafting interface when enabled. It is the developer/AI reference for how the system is wired, how to extend it, and how it deviates from authentic T2A behavior. + +## Overview + +In the T2A era (≈1998–2001, before Publish 14 on 2001-11-30), UO crafting did not use gumps. The server sent the generic `0x7C` "Open Dialog" menu packet and the client replied with the 13-byte `0x7D` response. Double-clicking a crafting tool opened a **skill-and-material-filtered item-list menu**; the player picked a category/item and targeted a resource, and the item was made. + +ModernUO reproduces this behind a single startup-read toggle. When `T2ACraftSystem.Enabled` is `false`, crafting uses the normal `CraftGump`. When `true`, the same `CraftSystem`/`CraftItem` definitions are presented through packet menus instead. The value is read once at startup from the `t2aCraftMenus` server setting (default `!Core.UOTD`), so a pre-UO:TD shard gets the T2A menus automatically and a UO:TD-or-later shard gets gumps — with no runtime/admin toggle. + +The wire-level menu packets (`0x7C`/`0x7D`) already exist in the engine (`Projects/Server/Network/Packets/OutgoingMenuPackets.cs`, `Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs`) and in `Server.Menus.ItemLists.ItemListMenu` / `Server.Menus.Questions.QuestionMenu`. The T2A feature is a *consumer* of that existing infrastructure, not a new protocol. + +## Activation + +Toggle: `T2ACraftSystem.Enabled` (static). It is set once in `ExpansionConfiguration.Configure()` from `ServerConfiguration.GetSetting("t2aCraftMenus", !Core.UOTD)` — a read-only setting (the default is **not** written back to the config file). It is intentionally **not** a runtime feature flag and cannot be flipped in-game by admins; change it via the `t2aCraftMenus` server setting and restart. + +**Intended deployment:** because the default is `!Core.UOTD`, a pre-UO:TD shard gets T2A menus and the matching era mechanics automatically. The toggle controls the *UI system*; the expansion/era (`Core.UOTD`) controls *mechanics* (see [Gating model](#gating-model-toggle-vs-era)). Since the default tracks the era and there is no runtime override, the two cannot drift into an incoherent combination. + +## Architecture + +All T2A-specific code lives under `Projects/UOContent/Engines/Craft/T2A/`. + +| Type | File | Responsibility | +|---|---|---| +| `T2ACraftSystem` (static) | `T2ACraftSystem.cs` | Central router. `ShowMenu(from, craftSystem, tool, preTarget)` dispatches per craft system to the right resource-selection / menu flow. Hosts shared filtering helpers. | +| `T2ACraftToolTarget` (Target) | `T2ACraftToolTarget.cs` | The first target after double-clicking a tool: target the **tool** → make-last; target **anything else** → begin crafting with that item as `preTarget`. | +| `*Menu : ItemListMenu` | `AlchemyMenu.cs`, `BlacksmithMenu.cs`, `BowFletchingMenu.cs`, `CarpentryMenu.cs`, `CartographyMenu.cs`, `InscriptionMenu.cs`, `TailoringMenu.cs`, `TinkeringMenu.cs` | One menu per skill. Builds filtered entries, drives category submenus, and on response either descends a category or crafts. | + +**Separation of concerns:** +- Each `*Menu` owns only its category tree and entry formatting. +- `T2ACraftSystem.CanCraftItem` / `FilterEntries` / `AnyCraftableInCategory` own *craft-eligibility* (skill gate + material count, sub-resource aware, with resource-equivalence: `Log↔Board`, `Cloth↔UncutCloth`, `Leather↔Hides`). +- `CraftItem` / `CraftSystem` own *consumption and item creation*. + +### Control flow + +``` +BaseTool.OnDoubleClick + └─ if T2ACraftSystem.Enabled: + from.Target = new T2ACraftToolTarget(tool, system) + "Target this tool to make last item, or any other target to begin crafting." + ├─ target == tool → make-last (repeat context.LastMade; jewelry re-prompts the gem) + └─ target == item/null → T2ACraftSystem.ShowMenu(from, system, tool, preTarget) + ├─ resource selected/targeted (per skill) + ├─ build filtered menu; empty → "You lack the skill and materials…" + └─ ItemListMenu sent (0x7C) → player picks → 0x7D → OnResponse + ├─ category → open submenu + └─ leaf → CraftItem.Craft(...) +``` + +Tool-less skills (Inscription, Cartography) enter `ShowMenu` from their **skill handler** (`Skills/Inscribe.cs`, `Skills/Cartography.cs`) instead of a tool double-click — see [Tool-less skills](#tool-less-skills). + +### How a selection maps back to a craftable + +`ItemListEntry` carries a `CraftIndex` (a 4th constructor arg added for this feature) — an index into the menu's parallel `Type[]`. When the `0x7D` response arrives, `OnResponse(state, index)` uses the entry's `CraftIndex` to resolve the chosen category or `CraftItem` type. Menus build their entries through `T2ACraftSystem.FilterEntries(from, staticEntries, types, system, selectedResourceType)` so only craftable rows appear. + +## Key mechanics + +### Resource pre-selection +Tool skills select the working resource **before** the menu opens. `T2ACraftToolTarget` passes whatever the player targeted as `preTarget`; `T2ACraftSystem.ShowMenu` validates it per skill (e.g. ingots for smithing, cloth/leather for tailoring, wood for carpentry/fletching, blank map for cartography, blank scroll/reagent/rune for inscription) and otherwise prompts for a valid resource. The selected sub-resource index is stored via `T2ACraftSystem.SetLastResourceIndex` (`context.LastResourceIndex` / `LastResourceIndex2`) for make-last. + +### Make-last (QoL — see deviations) +`T2ACraftToolTarget`: targeting the tool repeats `context.LastMade` with the remembered resource (and hue). Jewelry re-prompts for a gem target (you cannot silently re-consume gems). **Not historically part of T2A packet menus** ("Make Last" was a Publish 14 gump feature) — kept as a quality-of-life convenience. + +### Hue-aware tailoring +Targeting hued cloth/leather makes the craft consume **only matching-hue** material for the primary resource. Implemented by the `CraftItem.Craft(..., resHue)` overload and `CheckHuedRes`/`ConsumeHuedRes`/`GetHuedAmount`/`ConsumeHuedAmount`; the hue rides the `InternalTimer` (`m_ResHue`) into the hue-aware `CompleteCraft` overload, which sets `context.LastHue`. Secondary resources (e.g. ingots in mixed items) are consumed normally. This affects **consumption only**. + +### How crafted items get their color +A crafted item's color comes from its **`CraftResource`**, not from the consumed item's (dyed) hue: `OnCraft` sets `Resource = CraftResources.GetFromType(resourceType)` and armor/clothing then take `Hue = CraftResources.GetHue(Resource)`. So dyeing raw leather/cloth does **not** tint the crafted piece (plain `Leather` maps to `RegularLeather`, hue 0) — **leather, cloth, and wood never produce colored items in T2A**. The only color-bearing resource in T2A is **colored ingots/ore** (→ colored metal armor and shields). Colored/special leather, hides, and scales that convey color via `CraftResource` are an **AOS+** addition and don't exist in the T2A era. + +Era gating differs by item: +- **`BaseArmor`** / **`BaseClothing`**: set `Resource` (and thus color) in **all eras** — colored-ore armor is colored even in T2A (authentic). +- **`BaseWeapon`**: sets `Resource`/color **only when `Core.AOS`** — pre-AOS weapons are uncolored (and unnamed by resource). This is intended; weapons did not retain resource color until AOS/runic. + +### Stacked-gem jewelry +Tinkered jewelry consumes ingots + a targeted gem **stack**. The player targets a stack of N gems; `TinkeringMenu.GemSelectTarget` captures `gemItem.Amount` into `context.PendingGemCount` and the gem type into `context.PendingGemType`. `BaseJewel.OnCraft` consumes the **entire** stack (`ConsumeTotal(gemItemType, PendingGemCount)`) and names the piece by count ("a 1000 diamond ring"). The count persists via `_gemCount` (`[SerializableField(7)]`, jewel serialization **v5**) and is shown in `OnSingleClickPreUOTD`. If the gems are unavailable at craft time the piece is left plain and the player is messaged. + +### Half-resources on failure (era mechanic) +`CraftItem.ConsumeRes` reduces each resource by half on a failed craft when `!Core.UOTD` (`amounts[i] -= amounts[i] / 2`). Note integer division: amount-1 resources (e.g. each inscription reagent + the single blank scroll) are fully consumed, matching the confirmed scroll-scribing rule; multi-unit resources (e.g. runebook's 8 blank scrolls) lose half. + +### Tool-less skills +`DefInscription` and `DefCartography` override `RequiresTool => !T2ACraftSystem.Enabled`, and `CanCraft` wraps tool validation in `if (RequiresTool)`. Inscription is invoked from the skill list (`Inscribe.cs` → `T2AInscribeTarget`: blank scroll opens the menu, recall rune crafts a runebook, a book enters the copy flow); cartography from `Cartography.cs`. `CraftItem` tool-null guards prevent `UsesRemaining` decrement when there is no tool. + +### Maker's mark +Under T2A the system **always prompts** for the maker's mark (no auto/never toggle): `CompleteCraft` gates on `makersMark && (T2ACraftMenus || context.MarkOption == PromptForMark)`, using the shared `QueryMakersMarkGump` (the old `QueryMakersMarkMenu` was removed). Exceptional + mark are tied to GM/near-GM skill, as in the era. + +## Gating model (toggle vs era) + +| Switch | Meaning | Governs | +|---|---|---| +| `T2ACraftSystem.Enabled` (from `t2aCraftMenus` setting, default `!Core.UOTD`) | "Use packet menus instead of gumps." | Menu routing, `ShowCraftMenu` (message vs gump), tool-less inscription/cartography, jewelry gem-targeting flow, always-prompt maker's mark, `BlankMap`/`BlankScroll` equivalence suppression. | +| `Core.UOTD` (expansion/era) | T2A↔UO:TD era boundary (`false` = T2A or earlier). | Era mechanics: half-on-failure, tinkering metal-color suppression, pre-AOS recipe availability. | + +Because the toggle's default **is** `!Core.UOTD` and there is no runtime override, the two move together by construction — a pre-UO:TD shard gets both the menus and the era mechanics, and there is no incoherent "menus on / UO:TD era" combination to guard against. An operator can still force the setting explicitly (e.g. menus on a later era) via `t2aCraftMenus`, but that is a deliberate, restart-time choice. + +## Extending: add a craftable to a T2A menu + +1. Ensure the item has a `CraftItem` in the relevant `Def*.cs` (`AddCraft(...)`), as for gump crafting — the T2A menus read the same `CraftSystem.CraftItems`. +2. Add the item's `Type` to the appropriate category `Type[]` in the skill's `*Menu.cs` and a matching static `ItemListEntry` (name + `ItemID` + `CraftIndex`). Entries are filtered at build time by `T2ACraftSystem`, so you don't repeat skill/material checks. +3. For a new **category**, add a `Category` enum value, a `GetQuestion` arm, a static entries array, and the navigation case in `OnResponse`. `BlacksmithMenu.cs` is the canonical template. +4. Jewelry: gem-bearing pieces flow through `TinkeringMenu.GemSelectTarget` and `BaseJewel.OnCraft`; ensure `BaseJewel.GetGemType`/`GetGemItemType` cover any new gem. + +## Gotchas + +- **Resource equivalence is era-gated.** `CraftItem.InitTypesTable()` only treats `BlankMap`/`BlankScroll` as interchangeable when `!T2ACraftMenus` (the gump clilocs reference both). Under T2A they are distinct, so cartography consumes blank *maps*, not scroll s. +- **Transient context fields are not serialized.** `CraftContext.PendingGemType`, `PendingGemCount`, and `LastHue` are plain properties (no `[SerializableField]`) — they exist only during a craft. +- **`BaseJewel` is at serialization v5.** Bumping it again requires `MigrateFrom(V5Content)` per the serialization rules. +- **Menu entry creation uses reflection in one spot.** `T2ACraftSystem.ShowMenuDirect` uses `Activator.CreateInstance` (once per tool double-click). Fine for now; convert to a compiled factory if it ever shows up hot. + +## Files + +- T2A UI: `Projects/UOContent/Engines/Craft/T2A/*.cs` +- Engine glue: `Projects/UOContent/Engines/Craft/Core/{CraftItem,CraftContext,CraftSystem,Enhance,Repair,Resmelt,CraftGumpItem,QueryMakersMarkGump}.cs` +- Defs: `Projects/UOContent/Engines/Craft/Def{Alchemy,Cartography,Inscription,Tailoring,Tinkering}.cs` +- Skills: `Projects/UOContent/Skills/{Inscribe,Cartography}.cs` +- Items: `Projects/UOContent/Items/Jewels/{BaseJewel,Ring}.cs` (+ `Migrations/Server.Items.BaseJewel.v5.json`) +- Tool entry: `Projects/UOContent/Items/Skill Items/Tools/BaseTool.cs` +- Toggle: `T2ACraftSystem.Enabled` (in `Engines/Craft/T2A/T2ACraftSystem.cs`), set from `Projects/UOContent/Configuration/ExpansionConfiguration.cs` via `ServerConfiguration.GetSetting("t2aCraftMenus", !Core.UOTD)` +- Engine menus (additive): `Projects/Server/Menus/{BaseMenu,ItemListMenu,QuestionMenu}.cs`; response: `Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs` +- Tests: `Projects/UOContent.Tests/Tests/Items/Jewels/T2AJewelGemCraftTests.cs` + +## Testing + +`BaseJewel.OnCraft`'s gem block is unit-testable directly (it keys off `CraftContext.PendingGem*`, not the flag): see `T2AJewelGemCraftTests.cs`. The packet-menu UX (double-click → window → target → craft) requires a running shard + T2A client and is covered by the manual checklist in the design spec (§12.1). + +## Deviations from authentic T2A (summary) + +Decided in the design spec §4; faithful to Jack's research except where shard authority overrode: +- **Make-last** — kept as QoL though it post-dates the T2A packet menus. +- **Half-on-failure** for non-scroll crafts — best-known reconstruction, not OSI-confirmed. +- **Hue-aware tailoring** — matching-hue *consumption* only (does **not** color the product); reconstruction, unverified by primary sources. +- **Stacked-gem jewelry** — the full targeted stack is consumed and named by count (shard-authoritative; overrides both the "single gem" reconstruction and Jack's deliberate "consume 1, name by stack"). +- **Cooking** — out of scope (no T2A crafting menu existed for it). + +## Related docs + +| Topic | File | +|---|---| +| Serialization | `dev-docs/serialization.md` | +| Networking & packets | `dev-docs/networking-packets.md` | +| Era & expansion handling | `dev-docs/era-expansion.md` | +| Gumps (the non-T2A path) | `dev-docs/gump-system.md` |