ModernUO/Projects/UOContent/Engines/Craft/T2A/InscriptionMenu.cs
Kamron Batman 16bf3016fb
feat: Pre-Publish 14 Crafting (supersedes #2181, #2381) (#2476)
## 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<Item>()` → `PooledRefList<Item>` 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
2026-06-07 20:27:22 -07:00

334 lines
9.4 KiB
C#

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<char> 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<Type, int> _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<SpellScroll>(_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);
}
}