diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e4ca311d0..393946bf7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,3 +9,7 @@ updates: directory: "/" # Location of package manifests schedule: interval: "daily" + - package-ecosystem: "github-actions" + directory: "/" # Watches .github/workflows/** + schedule: + interval: "weekly" diff --git a/.github/workflows/build-tool-release.yml b/.github/workflows/build-tool-release.yml index 69ee42ceb..489bbfa12 100644 --- a/.github/workflows/build-tool-release.yml +++ b/.github/workflows/build-tool-release.yml @@ -149,7 +149,7 @@ jobs: sha256sum build-tool-* > checksums-sha256.txt - name: Create or update release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: build-tool-latest name: Build Tool (Latest) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index a04ed63ab..4a67c6287 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -19,7 +19,7 @@ jobs: with: global-json-file: global.json - name: Compute version - uses: dotnet/nbgv@v0.5.1 + uses: dotnet/nbgv@v0.5.2 id: nbgv - name: Push version tag run: | @@ -28,7 +28,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - name: Create Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.nbgv.outputs.Version }} name: ${{ steps.nbgv.outputs.Version }} diff --git a/.github/workflows/post-release-discord.yml b/.github/workflows/post-release-discord.yml index 34f90e0f2..2bcf8b2a9 100644 --- a/.github/workflows/post-release-discord.yml +++ b/.github/workflows/post-release-discord.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Post Release on Discord - uses: SethCohen/github-releases-to-discord@v1.19.0 + uses: SethCohen/github-releases-to-discord@v1.20.0 with: webhook_url: ${{ secrets.WEBHOOK_URL }} username: "Release Changelog" diff --git a/CLAUDE.md b/CLAUDE.md index d9ac0246f..5335c036d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. | Commands & targeting | `dev-docs/commands-targeting.md` | | Event system | `dev-docs/events.md` | | Threading model | `dev-docs/threading-model.md` | +| Server lifecycle & bootstrap phases (Configure/ConfigurePrompts/Initialize) | `dev-docs/server-lifecycle.md` | | Configuration system | `dev-docs/configuration.md` | | Networking & packets | `dev-docs/networking-packets.md` | | Region system | `dev-docs/regions.md` | diff --git a/Projects/Server.Tests/Fixtures/ServerFixture.cs b/Projects/Server.Tests/Fixtures/ServerFixture.cs index 1059436f0..e2795848f 100644 --- a/Projects/Server.Tests/Fixtures/ServerFixture.cs +++ b/Projects/Server.Tests/Fixtures/ServerFixture.cs @@ -1,4 +1,3 @@ -using System; using Xunit; namespace Server.Tests; @@ -8,7 +7,7 @@ namespace Server.Tests; /// Configure client path via MODERNUO_CLIENT_PATH environment variable or place files at C:\Ultima Online Classic. /// [CollectionDefinition("Sequential Server Tests", DisableParallelization = true)] -public class ServerFixture : ICollectionFixture, IDisposable +public class ServerFixture : ICollectionFixture { /// /// True if TileData was successfully loaded from client files. @@ -35,13 +34,8 @@ public class ServerFixture : ICollectionFixture, IDisposable /// public static ushort SurfaceImpassableTileId => TestServerInitializer.SurfaceImpassableTileId; - public ServerFixture() - { - TestServerInitializer.Initialize(loadTileData: true); - } - - public void Dispose() - { - Timer.Init(0); - } + // Global init runs exactly once via the shared, guarded TestServerInitializer. The single + // bootstrap owns global state for the lifetime of the test host, so there is no + // per-collection teardown — matching UOContent.Tests' TestServerInitializer pattern. + public ServerFixture() => TestServerInitializer.Initialize(loadTileData: true); } diff --git a/Projects/Server.Tests/Fixtures/TestServerInitializer.cs b/Projects/Server.Tests/Fixtures/TestServerInitializer.cs index 4620ec0ef..e84c16509 100644 --- a/Projects/Server.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/Server.Tests/Fixtures/TestServerInitializer.cs @@ -10,7 +10,7 @@ namespace Server.Tests; /// Shared server initialization logic for test fixtures. /// Ensures the server is only initialized once across all test collections. /// -public static class TestServerInitializer +internal static class TestServerInitializer { private const string DefaultDataDirectory = @"C:\Ultima Online Classic"; private static bool _initialized; diff --git a/Projects/Server/Configuration/ServerConfiguration.cs b/Projects/Server/Configuration/ServerConfiguration.cs index 84473a8ae..c70b060e4 100644 --- a/Projects/Server/Configuration/ServerConfiguration.cs +++ b/Projects/Server/Configuration/ServerConfiguration.cs @@ -218,11 +218,13 @@ public static class ServerConfiguration Save(); } - // If mock is enabled we skip the console readline. + // Reads (or creates) the configuration file. The interactive first-boot prompts live in + // ConfigurePrompts (run later via AssemblyHandler.Invoke("ConfigurePrompts")) so all + // first-boot prompting — engine and content — shares one phase/wiring. mocked skips prompts + // entirely (ConfigurePrompts is gated on m_Mocked and isn't invoked by the test fixtures). public static void Load(bool mocked = false) { m_Mocked = mocked; - var updated = false; if (File.Exists(m_FilePath)) { @@ -239,15 +241,27 @@ public static class ServerConfiguration } else { - updated = true; _settings = new ServerSettings(); } + } - if (mocked) + // First-boot interactive configuration, discovered + run by AssemblyHandler.Invoke( + // "ConfigurePrompts") after assemblies load but before Serilog's first log line, so the + // console prompts are not interleaved with the async console sink (see + // dev-docs/server-lifecycle.md). CallPriority(0) so the engine's own prompts (data dirs, + // listeners, server name, expansion + map selection) run before any content ConfigurePrompts + // that build on them (e.g. map selection before a pathfinding pre-bake prompt). Also resolves + // Core.Expansion on every non-mocked boot. + [CallPriority(0)] + public static void ConfigurePrompts() + { + if (m_Mocked) { return; } + var updated = false; + if (_settings.DataDirectories.Count == 0) { updated = true; diff --git a/Projects/Server/Items/BaseMulti.cs b/Projects/Server/Items/BaseMulti.cs index e723d3ae3..0ebcac7c5 100644 --- a/Projects/Server/Items/BaseMulti.cs +++ b/Projects/Server/Items/BaseMulti.cs @@ -19,6 +19,24 @@ using ModernUO.Serialization; namespace Server.Items; +/// +/// Whether a multi instance's footprint is eligible for the pathfinding interior-mask cache +/// (Server.Engines.Pathing.Cache.MultiMaskCache). Stored on +/// ; recomputed when it is +/// (e.g. after a move resets it). +/// +public enum MultiInteriorCacheState : byte +{ + /// Not yet determined; recompute on next use. + Unknown = 0, + + /// Whole footprint terrain is below the floor → interior cells may serve from the cache. + Clean = 1, + + /// Terrain intrudes into the footprint → fall back to live synthesis. + Dirty = 2 +} + [SerializationGenerator(0, false)] public abstract partial class BaseMulti : Item { @@ -35,6 +53,10 @@ public abstract partial class BaseMulti : Item Map?.OnLeave(this); base.ItemID = value; Map?.OnEnter(this); + + // The footprint shape changes with ItemID (e.g. a boat's heading swaps the MCL), so + // the pathfinding interior-cache clean/dirty status must be recomputed. + PathInteriorCacheState = MultiInteriorCacheState.Unknown; } } } @@ -65,6 +87,27 @@ public abstract partial class BaseMulti : Item public virtual MultiComponentList Components => MultiData.GetComponents(ItemID); + /// + /// Pathfinding interior-mask cache gate (Server.Engines.Pathing.Cache.MultiMaskCache). + /// Reset to whenever the footprint's world-terrain + /// relationship can change — a location change, a map change, or an ItemID change (e.g. a boat's + /// heading swaps the MCL). Subclasses that override / + /// MUST call base for the reset to fire. + /// + public MultiInteriorCacheState PathInteriorCacheState { get; set; } + + public override void OnLocationChange(Point3D oldLocation) + { + base.OnLocationChange(oldLocation); + PathInteriorCacheState = MultiInteriorCacheState.Unknown; + } + + public override void OnMapChange() + { + base.OnMapChange(); + PathInteriorCacheState = MultiInteriorCacheState.Unknown; + } + public override int GetMaxUpdateRange() => 22; public override int GetUpdateRange(Mobile m) => 22; diff --git a/Projects/Server/Main.cs b/Projects/Server/Main.cs index 1eabf25fc..9f6553904 100644 --- a/Projects/Server/Main.cs +++ b/Projects/Server/Main.cs @@ -413,8 +413,6 @@ public static class Core ServerConfiguration.Load(); - logger.Information("Running on {Framework}", RuntimeInformation.FrameworkDescription); - var assemblyPath = Path.Join(BaseDirectory, AssembliesConfiguration); // Load UOContent.dll @@ -431,6 +429,14 @@ public static class Core AssemblyHandler.LoadAssemblies(assemblyFiles); + // First-boot interactive setup. Runs after assemblies are loaded (so content can + // register prompts) but before any Serilog output, so console prompts are not + // interleaved with the async console sink. Handlers self-gate on first-boot state + // (e.g. "is my setting already present?"). + AssemblyHandler.Invoke("ConfigurePrompts"); + + logger.Information("Running on {Framework}", RuntimeInformation.FrameworkDescription); + VerifySerialization(); _now = DateTime.UtcNow; diff --git a/Projects/Server/Maps/Map.cs b/Projects/Server/Maps/Map.cs index 655e13788..e51a848d2 100644 --- a/Projects/Server/Maps/Map.cs +++ b/Projects/Server/Maps/Map.cs @@ -1875,6 +1875,12 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa internal List Multis => _multis ?? m_DefaultMultiList; + // Cheap public "does this sector currently contain any multi" check. MultisVersion can't + // answer this (it counts enter AND leave, so a place-then-remove leaves it non-zero with + // zero multis). Used by the pathfinding step cache to route multi-covered cells to the + // live movement path instead of the static-only chunk cache. + public bool HasMultis => _multis is { Count: > 0 }; + public int MultisVersion => _multisVersion; internal ref readonly ValueLinkList Mobiles => ref _mobiles; 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/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index 632dbcda9..f20d972b6 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -8132,6 +8132,9 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public void SayTo(Mobile to, int number, string args = "") => to.NetState.SendMessageLocalized(Serial, Body, MessageType.Regular, SpeechHue, 3, number, Name, args); + public void SayTo(Mobile to, int number, int hue, string args = "") => + to.NetState.SendMessageLocalized(Serial, Body, MessageType.Regular, hue, 3, number, Name, args); + public bool SendHuePicker(HuePicker p) { if (m_NetState != null) diff --git a/Projects/Server/Serialization/SerializationThreadWorker.cs b/Projects/Server/Serialization/SerializationThreadWorker.cs index 3089895df..bb0e67783 100644 --- a/Projects/Server/Serialization/SerializationThreadWorker.cs +++ b/Projects/Server/Serialization/SerializationThreadWorker.cs @@ -29,6 +29,7 @@ public class SerializationThreadWorker private readonly AutoResetEvent _stopEvent; // Main thread waits for the worker finish draining private bool _pause; private bool _exit; + private bool _exited; private byte[] _heap; private readonly ConcurrentQueue _entities; @@ -56,6 +57,12 @@ public class SerializationThreadWorker public void Exit() { + if (_exited) + { + return; + } + + _exited = true; _exit = true; Wake(); Sleep(); diff --git a/Projects/UOContent.Tests/Fixtures/PathfindingTestFixture.cs b/Projects/UOContent.Tests/Fixtures/PathfindingTestFixture.cs index ee761d1c0..02dd2bbb1 100644 --- a/Projects/UOContent.Tests/Fixtures/PathfindingTestFixture.cs +++ b/Projects/UOContent.Tests/Fixtures/PathfindingTestFixture.cs @@ -1,92 +1,12 @@ -using System; -using System.IO; -using System.Reflection; -using Server.Items; -using Server.Misc; -using Server.Movement; -using Server.Tests.Maps; using Xunit; namespace Server.Tests.Pathfinding; [CollectionDefinition("Sequential Pathfinding Tests", DisableParallelization = true)] -public class PathfindingTestFixture : ICollectionFixture, IDisposable +public class PathfindingTestFixture : ICollectionFixture { - public PathfindingTestFixture() - { - Core.ApplicationAssembly = Assembly.GetExecutingAssembly(); - Core.LoopContext = new EventLoopContext(); - Core.Expansion = Expansion.EJ; - - ServerConfiguration.Load(true); - ServerConfiguration.AssemblyDirectories.Add(Core.BaseDirectory); - - var clientFiles = Environment.GetEnvironmentVariable("MODERNUO_TEST_DATA_DIR") - ?? @"C:\Ultima Online Classic"; - ServerConfiguration.DataDirectories.Add(clientFiles); - - AssemblyHandler.LoadAssemblies(["Server.dll", "UOContent.dll"]); - - SkillsInfo.Configure(); - Server.Network.NetState.Configure(); - TestMapDefinitions.ConfigureTestMapDefinitions(); - - World.Configure(); - Timer.Init(0); - RaceDefinitions.Configure(); - MovementImpl.Configure(); - PathFollower.Configure(); - World.Load(); - World.ExitSerializationThreads(); - DecayScheduler.Configure(); - - // TileData's static cctor short-circuits when running under xUnit - // (see Server/TileData.cs:295). Force-load via reflection so LandTable/ItemTable - // flags are populated; without this, every tile reads as flag=None and - // MovementImpl.CheckMovement treats everything as walkable. - ForceLoadTileData(); - - VerifyTrammelTileDataLoaded(); - } - - private static void ForceLoadTileData() - { - var loadMethod = typeof(TileData).GetMethod( - "Load", - BindingFlags.Static | BindingFlags.NonPublic - ); - if (loadMethod == null) - { - throw new InvalidOperationException( - "TileData.Load not found via reflection — engine may have refactored." - ); - } - loadMethod.Invoke(null, null); - } - - private static void VerifyTrammelTileDataLoaded() - { - var trammel = Map.Maps[1]; - if (trammel == null) - { - throw new InvalidOperationException( - "Trammel (mapId=1) was not registered. Check TestMapDefinitions." - ); - } - - var tile = trammel.Tiles.GetLandTile(1500, 1600); - if (tile.ID == 0) - { - throw new InvalidOperationException( - $"Trammel tile data did not load — GetLandTile(1500,1600) returned ID 0. " + - $"Verify Distribution/Data/map1*.mul (or map1LegacyMUL.uop) is present at " + - $"{Path.Combine(Core.BaseDirectory, "Data")}." - ); - } - } - - public void Dispose() - { - Timer.Init(0); - } + // Shares the single process-wide bootstrap (TestServerInitializer, in the parent + // Server.Tests namespace). The superset bootstrap already loads the UO client tile data + // and configures movement/pathfinding, so this collection needs no extra setup. + public PathfindingTestFixture() => TestServerInitializer.Initialize(); } diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs new file mode 100644 index 000000000..d84ad7309 --- /dev/null +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -0,0 +1,129 @@ +using System; +using System.IO; +using System.Reflection; +using System.Threading; +using Server.Items; +using Server.Misc; +using Server.Movement; +using Server.PathAlgorithms; +using Server.Tests.Maps; + +namespace Server.Tests; + +/// +/// Single, process-wide ModernUO bootstrap for the UOContent test host. Mirrors Server.Tests' +/// TestServerInitializer in name and shape; kept as a separate (non-shared) copy because this +/// one loads the UOContent assembly and configures the UOContent-specific systems. Both types +/// are internal so the shared name stays scoped to each assembly. +/// +/// ModernUO bootstraps its global singletons (Core, ServerConfiguration, AssemblyHandler, +/// NetState/io-ring, World, Timer, the serialization workers, and TileData) exactly once per +/// process. is guarded to run once, and +/// must run once against the live workers. Each +/// xUnit collection gets its own fixture instance, so this guard makes the bootstrap run a +/// single time regardless of how many collection fixtures are constructed. The two stateful +/// collections use [CollectionDefinition(DisableParallelization = true)] so they never +/// overlap; pure tests still run in parallel. +/// +internal static class TestServerInitializer +{ + private static bool _initialized; + private static readonly Lock _lock = new(); + + public static void Initialize() + { + lock (_lock) + { + if (_initialized) + { + return; + } + + Core.ApplicationAssembly = Assembly.GetExecutingAssembly(); + Core.LoopContext = new EventLoopContext(); + Core.Expansion = Expansion.EJ; + + ServerConfiguration.Load(true); + ServerConfiguration.AssemblyDirectories.Add(Core.BaseDirectory); + + // Required for the pathfinding tests (real .mul tile data). Harmless for the rest. + var clientFiles = Environment.GetEnvironmentVariable("MODERNUO_TEST_DATA_DIR") + ?? @"C:\Ultima Online Classic"; + ServerConfiguration.DataDirectories.Add(clientFiles); + + AssemblyHandler.LoadAssemblies(["Server.dll", "UOContent.dll"]); + + SkillsInfo.Configure(); + Server.Network.NetState.Configure(); + TestMapDefinitions.ConfigureTestMapDefinitions(); + + // TileData's static cctor short-circuits when running under xUnit + // (see Server/TileData.cs:295). Force-load via reflection so LandTable/ItemTable + // flags are populated before anything that reads TileData (MultiData, MovementImpl, + // CheckMovement). Without this, TileData.MaxItemValue is 0 at MultiData.Configure() + // time, causing every MCL tile ID to be masked to 0 and stored as ID=0 in Tiles[x][y]. + ForceLoadTileData(); + + // Production runs every static Configure() via AssemblyHandler.Invoke("Configure"); + // the fixture calls a curated subset, so configure the pathfinding singleton here so + // BitmapAStarAlgorithm.Instance carries its configured MaxSearchNodes before any test + // calls Find. ServerConfiguration is already loaded above, so the setting resolves. + BitmapAStarAlgorithm.Configure(); + + // Multi component lists (multi.mul / MultiCollection.uop). Production invokes this via + // AssemblyHandler.Invoke("Configure"); the curated fixture subset must call it so that + // BaseMulti.Components (MultiData.GetComponents) returns real footprints instead of + // MultiComponentList.Empty. Required by the Multi pathfinding tests. + MultiData.Configure(); + + World.Configure(); + Timer.Init(0); + RaceDefinitions.Configure(); + MovementImpl.Configure(); + PathFollower.Configure(); + World.Load(); + World.ExitSerializationThreads(); + DecayScheduler.Configure(); + + VerifyTrammelTileDataLoaded(); + + _initialized = true; + } + } + + private static void ForceLoadTileData() + { + var loadMethod = typeof(TileData).GetMethod( + "Load", + BindingFlags.Static | BindingFlags.NonPublic + ); + if (loadMethod == null) + { + throw new InvalidOperationException( + "TileData.Load not found via reflection — engine may have refactored." + ); + } + loadMethod.Invoke(null, null); + } + + private static void VerifyTrammelTileDataLoaded() + { + var trammel = Map.Maps[1]; + if (trammel == null) + { + throw new InvalidOperationException( + "Trammel (mapId=1) was not registered. Check TestMapDefinitions." + ); + } + + var tile = trammel.Tiles.GetLandTile(1500, 1600); + if (tile.ID == 0) + { + throw new InvalidOperationException( + $"Trammel tile data did not load — GetLandTile(1500,1600) returned ID 0. " + + $"Verify Distribution/Data/map1*.mul (or map1LegacyMUL.uop) is present at " + + $"{Path.Combine(Core.BaseDirectory, "Data")}." + ); + } + } +} diff --git a/Projects/UOContent.Tests/Fixtures/UOContentFixture.cs b/Projects/UOContent.Tests/Fixtures/UOContentFixture.cs index 4a1905922..d934fda57 100644 --- a/Projects/UOContent.Tests/Fixtures/UOContentFixture.cs +++ b/Projects/UOContent.Tests/Fixtures/UOContentFixture.cs @@ -1,63 +1,13 @@ -using System; -using System.Reflection; -using Server.Items; -using Server.Misc; -using Server.Tests.Maps; using Xunit; namespace Server.Tests; [CollectionDefinition("Sequential UOContent Tests", DisableParallelization = true)] -public class UOContentFixture : ICollectionFixture, IDisposable +public class UOContentFixture : ICollectionFixture { - public UOContentFixture() - { - Core.ApplicationAssembly = Assembly.GetExecutingAssembly(); - Core.LoopContext = new EventLoopContext(); - Core.Expansion = Expansion.EJ; - - // Load Configurations - ServerConfiguration.Load(true); - - // Load UOContent.dll into the type resolver - ServerConfiguration.AssemblyDirectories.Add(Core.BaseDirectory); - AssemblyHandler.LoadAssemblies(["Server.dll", "UOContent.dll"]); - - // Load Skills - SkillsInfo.Configure(); - - // Configure networking (initializes RingSocketManager for tests) - Server.Network.NetState.Configure(); - - // Configure / Initialize - TestMapDefinitions.ConfigureTestMapDefinitions(); - - // Configure the world - World.Configure(); - - Timer.Init(0); - - // Configure Races - RaceDefinitions.Configure(); - - // Load the world - World.Load(); - - World.ExitSerializationThreads(); - - DecayScheduler.Configure(); - } - - private static int _counter; - - public void Dispose() - { - _counter++; - - if (_counter > 1) - { - throw new Exception("NO!"); - } - Timer.Init(0); - } + // All process-global initialization lives in TestServerInitializer and runs exactly once, + // shared across every collection fixture. Tearing down global state here is intentionally + // omitted: the world/serialization workers are initialized once and reused for the whole + // test host, so there is nothing per-collection to dispose. + public UOContentFixture() => TestServerInitializer.Initialize(); } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs index f92fab0c9..ab9497729 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs @@ -1,6 +1,6 @@ using Server.Engines.Pathing.Cache; using Server.Mobiles; -using Server.PathAlgorithms.BitmapAStar; +using Server.PathAlgorithms; using Server.Systems.FeatureFlags; using Xunit; using Xunit.Abstractions; @@ -121,7 +121,7 @@ public class BitmapAStarAlgorithmTests var y = sy; foreach (var dir in result) { - Server.Movement.Movement.Offset(dir, ref x, ref y); + Movement.Movement.Offset(dir, ref x, ref y); Assert.False(x == blockX && y == blockY, $"path traversed blocker cell ({blockX},{blockY})"); } @@ -180,7 +180,7 @@ public class BitmapAStarAlgorithmTests var y = sy; foreach (var dir in result) { - Server.Movement.Movement.Offset(dir, ref x, ref y); + Movement.Movement.Offset(dir, ref x, ref y); Assert.False(x == blockX && y == blockY, $"path traversed item-blocker cell ({blockX},{blockY})"); } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs new file mode 100644 index 000000000..a9796de7f --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs @@ -0,0 +1,87 @@ +using Server.Engines.Pathing.Cache; +using Server.Items; +using Xunit; +using Xunit.Abstractions; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class BoatPathTests +{ + private readonly ITestOutputHelper _output; + + public BoatPathTests(ITestOutputHelper output) => _output = output; + + // Open water in the south-Britain bay (Trammel), used by the existing swim-bake test. + private const int MapId = 1; + private const int WaterX = 1450; + private const int WaterY = 1770; + + [Fact] + public void BoatDeck_HasWalkableSurfaceCells() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + + // SmallBoat North heading = multiID 0x0. A deck is made of surface tiles. + var boat = new TestMulti(0x0); + map.GetAverageZ(WaterX, WaterY, out _, out var z, out _); + boat.MoveToWorld(new Point3D(WaterX, WaterY, (sbyte)z), map); + + try + { + var floor = MultiArt.FindFloorCell(boat); + Assert.True(floor.HasValue, "non-vacuity: boat deck must have surface (floor) tiles"); + + // The deck cell falls through to the live multi-aware path. + var mask = StepCache.Instance.TryGetMask(map, floor.Value.X, floor.Value.Y, (sbyte)z); + Assert.Equal(CacheHitKind.Fallthrough_Multi, mask.HitKind); + _output.WriteLine($"boat deck floor cell at ({floor.Value.X},{floor.Value.Y})"); + } + finally + { + boat.Delete(); + } + } + + [Fact] + public void BoatDeck_FootprintShape_IsPositionInvariant() + { + // The property Phase 2's local-frame, movement-invariant boat cache must preserve: + // the deck's covered-cell shape (in local coords) is identical at two world positions. + var map = Map.Maps[MapId]; + + var a = new TestMulti(0x0); + map.GetAverageZ(WaterX, WaterY, out _, out var za, out _); + a.MoveToWorld(new Point3D(WaterX, WaterY, (sbyte)za), map); + var shapeA = LocalShape(a); + a.Delete(); + + var b = new TestMulti(0x0); + map.GetAverageZ(WaterX + 5, WaterY + 3, out _, out var zb, out _); + b.MoveToWorld(new Point3D(WaterX + 5, WaterY + 3, (sbyte)zb), map); + var shapeB = LocalShape(b); + b.Delete(); + + Assert.Equal(shapeA, shapeB); + Assert.NotEmpty(shapeA); + _output.WriteLine($"boat local deck shape stable across positions: {shapeA.Count} cells"); + } + + private static System.Collections.Generic.HashSet<(int lx, int ly)> LocalShape(BaseMulti multi) + { + var mcl = multi.Components; + var set = new System.Collections.Generic.HashSet<(int, int)>(); + for (var lx = 0; lx < mcl.Width; lx++) + { + for (var ly = 0; ly < mcl.Height; ly++) + { + if (mcl.Tiles[lx][ly].Length > 0) + { + set.Add((lx, ly)); + } + } + } + return set; + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs new file mode 100644 index 000000000..3fdb4fa3e --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs @@ -0,0 +1,87 @@ +using Server; +using Server.Engines.Pathing.Cache; +using Server.Items; +using Xunit; +using Xunit.Abstractions; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class FoundationRedesignTests +{ + private readonly ITestOutputHelper _output; + + public FoundationRedesignTests(ITestOutputHelper output) => _output = output; + + private const int MapId = 1; + private const int PlaceX = 1500; + private const int PlaceY = 1600; + + [Fact] + public void SwappingComponents_ChangesFootprint() + { + var foundation = new SwappableFoundation(0x74); // GuildHouse footprint + try + { + var beforeCount = MultiArt.FootprintCells(foundation).Count; + Assert.True(beforeCount > 0, "non-vacuity: initial footprint must cover cells"); + + foundation.Redesign(MultiData.GetComponents(0x7A)); // Tower footprint (different shape) + var afterCount = MultiArt.FootprintCells(foundation).Count; + + Assert.NotEqual(beforeCount, afterCount); + _output.WriteLine($"redesign footprint {beforeCount} -> {afterCount} cells"); + } + finally + { + foundation.Delete(); + } + } + + [Fact] + public void RedesignReRegistered_RoutesNewFootprintToLivePath() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + Assert.NotNull(map); + + var foundation = new SwappableFoundation(0x74); + map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); + var loc = new Point3D(PlaceX, PlaceY, (sbyte)z); + foundation.MoveToWorld(loc, map); + + try + { + // Current design's covered cells route to the live (multi-aware) path. + var before = MultiArt.FindFloorCell(foundation); + Assert.True(before.HasValue, "non-vacuity: initial design must have a floor cell"); + var maskBefore = StepCache.Instance.TryGetMask(map, before.Value.X, before.Value.Y, (sbyte)z); + Assert.Equal(CacheHitKind.Fallthrough_Multi, maskBefore.HitKind); + + // Redesign, RE-REGISTERED so sectors track the new footprint (model a real commit). + // Internalize() moves the multi to Map.Internal, which fires Map.OnLeave and removes + // the OLD footprint's sector registration. We then swap the MCL and MoveToWorld back, + // which fires Map.OnEnter -> AddMulti against the fresh Components (new footprint). + foundation.Internalize(); + foundation.Redesign(MultiData.GetComponents(0x7A)); + foundation.MoveToWorld(loc, map); + + var after = MultiArt.FindFloorCell(foundation); + Assert.True(after.HasValue, "non-vacuity: redesigned design must have a floor cell"); + // The new footprint cell must be registered (HasMultis) and route to the live path. + var sector = map.GetRealSector(after.Value.X >> 4, after.Value.Y >> 4); + Assert.True(sector.HasMultis, "redesigned footprint must re-register its sector"); + var maskAfter = StepCache.Instance.TryGetMask(map, after.Value.X, after.Value.Y, (sbyte)z); + Assert.Equal(CacheHitKind.Fallthrough_Multi, maskAfter.HitKind); + + _output.WriteLine($"redesign re-registered: before {before.Value.X},{before.Value.Y} after {after.Value.X},{after.Value.Y}"); + } + finally + { + if (!foundation.Deleted) + { + foundation.Delete(); + } + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs new file mode 100644 index 000000000..41caa6255 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs @@ -0,0 +1,225 @@ +using Server.Engines.Pathing.Cache; +using Server.Items; +using Server.Mobiles; +using Server.PathAlgorithms; +using Xunit; +using Xunit.Abstractions; +using CalcMoves = Server.Movement.Movement; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class HousePathRoutingTests +{ + private readonly ITestOutputHelper _output; + + public HousePathRoutingTests(ITestOutputHelper output) => _output = output; + + private const int MapId = 1; + + private sealed class WalkerStub : Mobile + { + public WalkerStub() => Body = 0xC9; + } + + [Fact] + public void PathAround_NeverTraversesAWallCell() + { + StepCache.Instance.Clear(); + var prevThreshold = StepCache.Instance.MissPromotionThreshold; + StepCache.Instance.MissPromotionThreshold = 1; + var map = Map.Maps[MapId]; + Assert.NotNull(map); + + // Open area with lateral room to flank a 7x7 house (verified reachable all 8 dirs). + const int hx = 1480, hy = 1620; + const int sx = 1480, sy = 1630; + const int gx = 1480, gy = 1610; + + map.GetAverageZ(hx, hy, out _, out var hz, out _); + var multi = new TestMulti(0x74); + multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map); + + var wall = MultiArt.FindWallCell(multi); + Assert.True(wall.HasValue, "non-vacuity: house must have wall cells"); + + var walker = new WalkerStub(); + map.GetAverageZ(sx, sy, out _, out var sz, out _); + var start = new Point3D(sx, sy, (sbyte)sz); + var goal = new Point3D(gx, gy, (sbyte)sz); + walker.MoveToWorld(start, map); + + try + { + var path = BitmapAStarAlgorithm.Instance.Find(walker, map, start, goal); + Assert.NotNull(path); // non-vacuity: a route around must exist + Assert.NotEmpty(path); + + // Walk the path; assert it never lands on the known wall cell. + var x = sx; + var y = sy; + foreach (var dir in path) + { + CalcMoves.Offset(dir, ref x, ref y); + Assert.False(x == wall.Value.X && y == wall.Value.Y, + $"path traversed wall cell ({wall.Value.X},{wall.Value.Y})"); + } + _output.WriteLine($"around house: {path.Length} steps, avoided wall"); + } + finally + { + StepCache.Instance.MissPromotionThreshold = prevThreshold; + walker.Delete(); + multi.Delete(); + } + } + + [Fact] + public void Demolish_ReopensCoveredCells() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + Assert.NotNull(map); + + const int hx = 1480, hy = 1620; + map.GetAverageZ(hx, hy, out _, out var hz, out _); + var multi = new TestMulti(0x74); + multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map); + + // Find a wall cell that is blocked purely by the multi — not by underlying static/land + // terrain. FindWallCell returns the first MCL-impassable cell, which may land over + // terrain that is itself impassable. Instead scan until we find one where the base + // terrain is passable so that after demolish the cell provably reopens. + var w = FindPureMultiWallCell(multi, map); + Assert.True(w.HasValue, "non-vacuity: house must have a wall cell over passable terrain"); + var walker = new WalkerStub(); + + var cell = w.Value; + + try + { + // Before demolish: every neighbour fails to step onto the wall cell (it is blocked). + var blockedBefore = NeighbourBlockedInto(map, walker, cell); + Assert.True(blockedBefore, "wall cell should block entry while the house stands"); + + multi.Delete(); + + // After demolish: the cell reverts to open ground; entry from a neighbour should now + // succeed in at least one direction. + StepCache.Instance.Clear(); + var openAfter = !NeighbourBlockedInto(map, walker, cell); + Assert.True(openAfter, $"cell ({cell.X},{cell.Y}) should reopen after demolish"); + _output.WriteLine($"demolish reopened ({cell.X},{cell.Y})"); + } + finally + { + if (!multi.Deleted) + { + multi.Delete(); + } + walker.Delete(); + } + } + + /// + /// Find the first MCL wall cell (Impassable && !Surface) over terrain that is not + /// independently blocked by land or statics. Using this instead of + /// ensures the cell reopens after demolish. + /// + private static MultiArt.Cell? FindPureMultiWallCell(BaseMulti multi, Map map) + { + var mcl = multi.Components; + for (var lx = 0; lx < mcl.Width; lx++) + { + for (var ly = 0; ly < mcl.Height; ly++) + { + var hasWall = false; + var hasSurface = false; + foreach (var t in mcl.Tiles[lx][ly]) + { + var data = TileData.ItemTable[t.ID & TileData.MaxItemValue]; + if (data.Impassable && !data.Surface) + { + hasWall = true; + } + if (data.Surface) + { + hasSurface = true; + } + } + + if (!hasWall || hasSurface) + { + continue; + } + + var wx = multi.X + mcl.Min.X + lx; + var wy = multi.Y + mcl.Min.Y + ly; + + // Check the underlying terrain is not independently impassable. + if (IsTerrainBlocked(map, wx, wy)) + { + continue; + } + + return new MultiArt.Cell(wx, wy); + } + } + return null; + } + + /// + /// Returns true if land tile or any static tile (non-multi) at (x,y) is impassable. + /// + private static bool IsTerrainBlocked(Map map, int x, int y) + { + var lt = map.Tiles.GetLandTile(x, y); + if (!lt.Ignored) + { + var landData = TileData.LandTable[lt.ID & TileData.MaxLandValue]; + if ((landData.Flags & TileFlag.Impassable) != 0) + { + return true; + } + } + + foreach (var tile in map.Tiles.GetStaticTiles(x, y)) + { + var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + if (data.Impassable) + { + return true; + } + } + + return false; + } + + // True if EVERY in-range neighbour fails to step into target (target is blocked). + private static bool NeighbourBlockedInto(Map map, Mobile walker, MultiArt.Cell target) + { + for (var d = 0; d < 8; d++) + { + var nx = target.X; + var ny = target.Y; + CalcMoves.Offset((Direction)((d + 4) & 7), ref nx, ref ny); + if (nx < 0 || ny < 0 || nx >= map.Width || ny >= map.Height) + { + continue; + } + map.GetAverageZ(nx, ny, out _, out var nz, out _); + walker.MoveToWorld(new Point3D(nx, ny, (sbyte)nz), map); + if (CalcMoves.CheckMovement(walker, map, walker.Location, (Direction)d, out _)) + { + var tx = nx; + var ty = ny; + CalcMoves.Offset((Direction)d, ref tx, ref ty); + if (tx == target.X && ty == target.Y) + { + return false; // an entry succeeded → not blocked + } + } + } + return true; + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs new file mode 100644 index 000000000..fc9e58898 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs @@ -0,0 +1,50 @@ +using Server.Engines.Pathing.Cache; +using Server.PathAlgorithms; +using Server.Systems.FeatureFlags; +using Xunit; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class MultiCacheUsedTests +{ + private const int MapId = 1; // Trammel + private const int GuildHouseId = 0x74; + + [Fact] + public void PathNearMulti_IncrementsMultiLocalHits() + { + var map = Map.Maps[MapId]; + map.GetAverageZ(1480, 1620, out _, out var z, out _); + var houseLoc = new Point3D(1480, 1620, (sbyte)z); + var multi = new TestMulti(GuildHouseId); + + var cacheWas = ContentFeatureFlags.BitmapPathfindingCache; + ContentFeatureFlags.BitmapPathfindingCache = true; + + var mover = MultiTestSupport.GetWalkerOracle(map, new Point3D(1480, 1630, (sbyte)z)); + try + { + multi.MoveToWorld(houseLoc, map); + StepCache.Instance.Clear(); + + var before = StepCache.Instance.GetStats().MultiLocalHits; + + // Start outside, goal on the far side, forcing expansion through the multi's halo. + var start = new Point3D(1480, 1630, (sbyte)z); + var goal = new Point3D(1480, 1610, (sbyte)z); + var path = BitmapAStarAlgorithm.Instance.Find(mover, map, start, goal); + + var after = StepCache.Instance.GetStats().MultiLocalHits; + + Assert.NotNull(path); + Assert.True(after > before, $"expected MultiLocalHits to increase, before={before} after={after}"); + } + finally + { + mover.Delete(); + multi.Delete(); + ContentFeatureFlags.BitmapPathfindingCache = cacheWas; + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs new file mode 100644 index 000000000..eb12df7d6 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs @@ -0,0 +1,158 @@ +using Server.Engines.Pathing.Cache; +using Server.Items; +using Xunit; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class MultiEdgeCaseTests +{ + private const int MapId = 1; // Trammel + private const int GuildHouseId = 0x74; + + // GuildHouse placement (mirrors MultiMaskSynthesisTests) — open Trammel ground. + private const int HouseX = 1480; + private const int HouseY = 1620; + + // Open water in the south-Britain bay (mirrors BoatPathTests). + private const int BoatMultiId = 0x0; // SmallBoat North heading + private const int WaterX = 1450; + private const int WaterY = 1770; + private const sbyte DeckZ = 0; // boat deck floor tiles stand at world Z 0 (not the water avgZ) + + /// + /// Two overlapping GuildHouse multis whose footprints intersect. At the stacked cells + /// GetStaticAndMultiTiles yields tiles from BOTH multis; the synthesizer must still + /// agree with CheckMovement everywhere over the union footprint + halo. + /// + [Fact] + public void OverlappingMultis_SynthesizerMatchesCheckMovement() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + + map.GetAverageZ(HouseX, HouseY, out _, out var z, out _); + var locA = new Point3D(HouseX, HouseY, (sbyte)z); + // Origins 3 tiles apart on X so the GuildHouse footprints overlap. + var locB = new Point3D(HouseX + 3, HouseY, (sbyte)z); + + var multiA = new TestMulti(GuildHouseId); + var multiB = new TestMulti(GuildHouseId); + try + { + multiA.MoveToWorld(locA, map); + multiB.MoveToWorld(locB, map); + + // Non-vacuity: prove the two footprints actually intersect at the chosen 3-tile + // separation. The per-sweep touchedMulti guard only proves each multi touched its OWN + // footprint; without this, "overlapping" would be an unverified comment. + var overlap = MultiArt.FootprintCells(multiA); + var setB = new System.Collections.Generic.HashSet(MultiArt.FootprintCells(multiB)); + overlap.RemoveAll(c => !setB.Contains(c)); + Assert.NotEmpty(overlap); // the two footprints must actually intersect, else the test is meaningless + + // The synthesizer must match the oracle over BOTH footprints (each sweep crosses + // the shared, doubly-covered cells). + MultiTestSupport.AssertSynthesizerMatchesCheckMovement(multiA, map); + MultiTestSupport.AssertSynthesizerMatchesCheckMovement(multiB, map); + } + finally + { + multiA.Delete(); + multiB.Delete(); + } + } + + /// + /// A boat placed over open water: deck surface tiles are walkable, surrounding water blocks + /// the (non-swimming) walker. Exercises the synthesizer over water-adjacent deck-edge geometry. + /// + /// + /// The boat is placed at Z=0 (the deck's world Z), NOT at the water average Z (-15 here). + /// The deck floor tiles stand at world Z 0, so a non-swimming walker only finds walkable + /// transitions when the sweep origin Z equals the deck Z. Placing at the water avgZ makes the + /// sweep vacuous ("no walkable transitions") because the deck is 15 tiles overhead and water + /// blocks the rest — that vacuity is a fixture concern, not a synthesizer divergence (the + /// synthesizer agrees with the oracle at every direction either way). + /// + [Fact] + public void BoatOverWater_SynthesizerMatchesCheckMovement() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + + var boat = new TestMulti(BoatMultiId); + boat.MoveToWorld(new Point3D(WaterX, WaterY, DeckZ), map); + try + { + MultiTestSupport.AssertSynthesizerMatchesCheckMovement(boat, map); + } + finally + { + boat.Delete(); + } + } + + /// + /// Redesign a foundation in place (Internalize -> swap MCL -> MoveToWorld back, the same + /// re-registration pattern HouseFoundation uses on commit) and assert the synthesizer reads + /// the LIVE, post-redesign Components — i.e. it matches CheckMovement on the NEW shape. + /// + [Fact] + public void RedesignedFoundation_SynthesizerMatchesNewFootprint() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + + var foundation = new SwappableFoundation(GuildHouseId); + map.GetAverageZ(HouseX, HouseY, out _, out var z, out _); + var loc = new Point3D(HouseX, HouseY, (sbyte)z); + foundation.MoveToWorld(loc, map); + try + { + // Redesign, RE-REGISTERED so sectors track the new footprint (model a real commit). + // Internalize() fires Map.OnLeave (removes the OLD footprint's registration); we swap + // the MCL and MoveToWorld back, firing Map.OnEnter -> AddMulti against the new shape. + foundation.Internalize(); + foundation.Redesign(MultiData.GetComponents(0x7A)); // Tower footprint (different shape) + foundation.MoveToWorld(loc, map); + + MultiTestSupport.AssertSynthesizerMatchesCheckMovement(foundation, map); + } + finally + { + if (!foundation.Deleted) + { + foundation.Delete(); + } + } + } + + /// + /// Extensible slot for repo-owner-supplied gnarly placements. The assertion already covers + /// any (map,x,y) by construction — only the coordinates need filling in. + /// + /// TODO(coords): repo owner to supply (map,x,y) for a static tree inside a footprint and a + /// dungeon cave-wall corner; add InlineData rows here — the assertion already covers them by + /// construction. (Left intentionally unhunted: do not invent tree/dungeon coords.) + /// + [Theory] + [InlineData(MapId, HouseX, HouseY)] // known-good open Trammel placement (passes today) + public void UserSuppliedScenarios_SynthesizerMatchesCheckMovement(int mapId, int x, int y) + { + StepCache.Instance.Clear(); + var map = Map.Maps[mapId]; + + var multi = new TestMulti(GuildHouseId); + map.GetAverageZ(x, y, out _, out var z, out _); + multi.MoveToWorld(new Point3D(x, y, (sbyte)z), map); + try + { + MultiTestSupport.AssertSynthesizerMatchesCheckMovement(multi, map); + } + finally + { + multi.Delete(); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs new file mode 100644 index 000000000..8a544897c --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs @@ -0,0 +1,273 @@ +using Server.Engines.Pathing.Cache; +using Server.Items; +using Xunit; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class MultiMaskCacheTests +{ + private const int MapId = 1; + private const int GuildHouseId = 0x74; + private const int PlaceX = 1480; + private const int PlaceY = 1620; + + [Fact] + public void TryResolveCoveringMulti_FindsPlacedMulti_AndLocalIndices() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); + var loc = new Point3D(PlaceX, PlaceY, (sbyte)z); + var multi = new TestMulti(GuildHouseId); + try + { + multi.MoveToWorld(loc, map); + + Assert.True(MultiMaskCache.TryResolveCoveringMulti(map, PlaceX, PlaceY, out var found, out var lx, out var ly)); + Assert.Same(multi, found); + var mcl = multi.Components; + Assert.Equal(PlaceX - multi.X - mcl.Min.X, lx); + Assert.Equal(PlaceY - multi.Y - mcl.Min.Y, ly); + + Assert.False(MultiMaskCache.TryResolveCoveringMulti(map, PlaceX + 200, PlaceY + 200, out _, out _, out _)); + } + finally + { + multi.Delete(); + } + } + + [Fact] + public void IsInteriorLocalCell_TrueDeepInside_FalseAtEdge() + { + var multi = new TestMulti(GuildHouseId); + try + { + var mcl = multi.Components; + + var foundInterior = false; + var foundEdge = false; + for (var ly = 0; ly < mcl.Height && (!foundInterior || !foundEdge); ly++) + { + for (var lx = 0; lx < mcl.Width; lx++) + { + if (mcl.Tiles[lx][ly].Length == 0) + { + continue; + } + if (MultiMaskCache.IsInteriorLocalCell(mcl, lx, ly)) + { + foundInterior = true; + } + else + { + foundEdge = true; + } + } + } + + Assert.True(foundInterior, "a guild house must have at least one interior cell"); + Assert.True(foundEdge, "a guild house must have at least one edge/perimeter cell"); + } + finally + { + multi.Delete(); + } + } + + [Fact] + public void LocalWorldZ_RoundTrips_AndPreservesMasks() + { + var world = new StepMask( + walkMask: 0b1010_1010, wetMask: 0b0101_0101, + walkZN: 10, walkZNE: 11, walkZE: 12, walkZSE: 13, walkZS: 14, walkZSW: 15, walkZW: 16, walkZNW: 17, + swimZN: -1, swimZNE: -2, swimZE: -3, swimZSE: -4, swimZS: -5, swimZSW: -6, swimZW: -7, swimZNW: -8 + ); + const int multiZ = 7; + + Assert.True(MultiMaskCache.TryToLocalZ(world, multiZ, out var local)); + var back = MultiMaskCache.ToWorldZ(local, multiZ); + + Assert.Equal(world.WalkMask, back.WalkMask); + Assert.Equal(world.WetMask, back.WetMask); + for (var d = 0; d < 8; d++) + { + Assert.Equal(world.GetWalkZ((Direction)d), back.GetWalkZ((Direction)d)); + Assert.Equal(world.GetSwimZ((Direction)d), back.GetSwimZ((Direction)d)); + } + } + + [Fact] + public void TryToLocalZ_RejectsOverflow() + { + var world = new StepMask( + walkMask: 0xFF, wetMask: 0, + walkZN: 100, walkZNE: 0, walkZE: 0, walkZSE: 0, walkZS: 0, walkZSW: 0, walkZW: 0, walkZNW: 0, + swimZN: 0, swimZNE: 0, swimZE: 0, swimZSE: 0, swimZS: 0, swimZSW: 0, swimZW: 0, swimZNW: 0 + ); + Assert.False(MultiMaskCache.TryToLocalZ(world, -100, out _)); + } + + [Fact] + public void TerrainTopBelow_TrueWhenFloorWellAboveGround() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + map.GetAverageZ(PlaceX, PlaceY, out _, out var ground, out _); + + // Floor far above ground → terrain is below → guard passes. + Assert.True(MultiMaskCache.TerrainTopBelow(map, PlaceX, PlaceY, (sbyte)(ground + 50))); + // Floor at/below ground → terrain reaches the envelope → guard fails. + Assert.False(MultiMaskCache.TerrainTopBelow(map, PlaceX, PlaceY, (sbyte)(ground - 50))); + } + + [Fact] + public void PathThroughHouseInterior_IncrementsMultiMaskCacheHits() + { + // (PlaceX,PlaceY)=(1480,1620) is cluttered (footprint overlaps tall map statics → dirty), so it + // would never serve the interior cache under the footprint-clean gate. Use a known flat/clear + // spot so a house there is footprint-clean and its interior cells serve from the cache. + const int CleanX = 1560; + const int CleanY = 1616; + + var map = Map.Maps[MapId]; + map.GetAverageZ(CleanX, CleanY, out _, out var z, out _); + var houseLoc = new Point3D(CleanX, CleanY, (sbyte)z); + var multi = new TestMulti(GuildHouseId); + var cacheWas = Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache; + Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = true; + var mover = MultiTestSupport.GetWalkerOracle(map, new Point3D(CleanX, CleanY + 10, (sbyte)z)); + try + { + multi.MoveToWorld(houseLoc, map); + Assert.True(MultiMaskCache.ComputeFootprintClean(map, multi), + "precondition: (1560,1616) must be footprint-clean for the cache to serve"); + StepCache.Instance.Clear(); + + var start = new Point3D(CleanX, CleanY + 10, (sbyte)z); + var goal = new Point3D(CleanX, CleanY - 10, (sbyte)z); + // First pass builds the interior cache (live synth); second pass should hit it. + Server.PathAlgorithms.BitmapAStarAlgorithm.Instance.Find(mover, map, start, goal); + + var before = StepCache.Instance.GetStats().MultiMaskCacheHits; + Server.PathAlgorithms.BitmapAStarAlgorithm.Instance.Find(mover, map, start, goal); + var after = StepCache.Instance.GetStats().MultiMaskCacheHits; + + Assert.True(after > before, $"expected MultiMaskCacheHits to increase, before={before} after={after}"); + } + finally + { + mover.Delete(); + multi.Delete(); + Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = cacheWas; + } + } + + [Fact] + public void ClutteredHouse_DoesNotServeCache_ButStillPaths() + { + var map = Map.Maps[MapId]; + map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); + var multi = new TestMulti(GuildHouseId); + var cacheWas = Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache; + Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = true; + var mover = MultiTestSupport.GetWalkerOracle(map, new Point3D(PlaceX, PlaceY + 10, (sbyte)z)); + try + { + // (1480,1620) overlaps tall map statics → footprint dirty → cache must NOT serve. + multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map); + Assert.False(MultiMaskCache.ComputeFootprintClean(map, multi), + "precondition: (1480,1620) must be footprint-dirty for this test to be meaningful"); + StepCache.Instance.Clear(); + + var start = new Point3D(PlaceX, PlaceY + 10, (sbyte)z); + var goal = new Point3D(PlaceX, PlaceY - 10, (sbyte)z); + Server.PathAlgorithms.BitmapAStarAlgorithm.Instance.Find(mover, map, start, goal); // warm + var before = StepCache.Instance.GetStats().MultiMaskCacheHits; + var path = Server.PathAlgorithms.BitmapAStarAlgorithm.Instance.Find(mover, map, start, goal); + var after = StepCache.Instance.GetStats().MultiMaskCacheHits; + + Assert.NotNull(path); // pathfinding still works (degraded to live-synth) + Assert.Equal(before, after); // dirty footprint → zero cache serves + } + finally + { + mover.Delete(); + multi.Delete(); + Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = cacheWas; + } + } + + [Fact] + public void PathInteriorCacheState_ResetsOnMove() + { + var map = Map.Maps[MapId]; + var multi = new TestMulti(GuildHouseId); + try + { + multi.MoveToWorld(new Point3D(PlaceX, PlaceY, 0), map); + multi.PathInteriorCacheState = MultiInteriorCacheState.Clean; + + // A move changes the footprint's world terrain, so the gate must reset to Unknown and + // recompute on next use. (BaseMulti.OnLocationChange does this; subclasses like BaseHouse + // and BaseBoat must call base for it to fire — this pins that contract.) + multi.MoveToWorld(new Point3D(PlaceX + 8, PlaceY + 8, 0), map); + Assert.Equal(MultiInteriorCacheState.Unknown, multi.PathInteriorCacheState); + } + finally + { + multi.Delete(); + } + } + + [Fact] + public void PathInteriorCacheState_ResetsOnItemIdChange() + { + var map = Map.Maps[MapId]; + var multi = new TestMulti(GuildHouseId); + try + { + multi.MoveToWorld(new Point3D(PlaceX, PlaceY, 0), map); + multi.PathInteriorCacheState = MultiInteriorCacheState.Clean; + + // Changing ItemID swaps the footprint (e.g. a boat changing heading), so the gate must + // reset to recompute cleanliness for the new shape. + multi.ItemID = 0x7A; // Tower + Assert.Equal(MultiInteriorCacheState.Unknown, multi.PathInteriorCacheState); + } + finally + { + multi.Delete(); + } + } + + [Fact] + public void ComputeFootprintClean_TrueAtNormalPlacement_FalseWhenSunk() + { + // (PlaceX,PlaceY) is a cluttered spot whose footprint overlaps tall map statics, so a guild + // house there is never footprint-clean. Use a known flat/clear spot for the clean assertion. + const int CleanX = 1560; + const int CleanY = 1616; + + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + map.GetAverageZ(CleanX, CleanY, out _, out var ground, out _); + + var normal = new TestMulti(GuildHouseId); + var sunk = new TestMulti(GuildHouseId); + try + { + normal.MoveToWorld(new Point3D(CleanX, CleanY, (sbyte)ground), map); + Assert.True(MultiMaskCache.ComputeFootprintClean(map, normal)); + + sunk.MoveToWorld(new Point3D(CleanX + 60, CleanY, (sbyte)(ground - 40)), map); + Assert.False(MultiMaskCache.ComputeFootprintClean(map, sunk)); + } + finally + { + normal.Delete(); + sunk.Delete(); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskSynthesisTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskSynthesisTests.cs new file mode 100644 index 000000000..923784f4f --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskSynthesisTests.cs @@ -0,0 +1,33 @@ +using Server.Engines.Pathing.Cache; +using Xunit; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class MultiMaskSynthesisTests +{ + private const int MapId = 1; // Trammel + private const int GuildHouseId = 0x74; // static house: walls, door aperture, floor + private const int PlaceX = 1480; + private const int PlaceY = 1620; + + [Fact] + public void ComputeMultiMaskAt_MatchesCheckMovement_OverFootprintAndHalo() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); + var loc = new Point3D(PlaceX, PlaceY, (sbyte)z); + + var multi = new TestMulti(GuildHouseId); + try + { + multi.MoveToWorld(loc, map); + MultiTestSupport.AssertSynthesizerMatchesCheckMovement(multi, map); + } + finally + { + multi.Delete(); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs new file mode 100644 index 000000000..9a920282f --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs @@ -0,0 +1,158 @@ +using Server.Engines.Pathing.Cache; +using Server.Items; +using Server.Mobiles; +using Server.PathAlgorithms; +using Server.Systems.FeatureFlags; +using Xunit; +using Xunit.Abstractions; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class MultiPathInvariantTests +{ + private readonly ITestOutputHelper _output; + + public MultiPathInvariantTests(ITestOutputHelper output) => _output = output; + + private const int MapId = 1; + + private sealed class WalkerStub : Mobile + { + public WalkerStub() => Body = 0xC9; + } + + private static Direction[] FindWithFlag(Mobile m, Map map, Point3D start, Point3D goal, bool cacheOn) + { + var prev = ContentFeatureFlags.BitmapPathfindingCache; + try + { + ContentFeatureFlags.BitmapPathfindingCache = cacheOn; + StepCache.Instance.Clear(); + StepCache.Instance.MissPromotionThreshold = 1; + return BitmapAStarAlgorithm.Instance.Find(m, map, start, goal); + } + finally + { + ContentFeatureFlags.BitmapPathfindingCache = prev; + } + } + + [Theory] + // start, goal: straddle a house placed between them (house at ~ midpoint). + [InlineData(1500, 1600, 1500, 1612)] // N-S across the footprint + [InlineData(1494, 1606, 1512, 1606)] // E-W across the footprint + public void Find_CacheOn_EqualsCacheOff_WithHousePresent(int sx, int sy, int gx, int gy) + { + var map = Map.Maps[MapId]; + Assert.NotNull(map); + + // Place the house at the midpoint so it sits between start and goal. + var hx = (sx + gx) / 2; + var hy = (sy + gy) / 2; + map.GetAverageZ(hx, hy, out _, out var hz, out _); + var multi = new TestMulti(0x74); + multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map); + + var walker = new WalkerStub(); + map.GetAverageZ(sx, sy, out _, out var sz, out _); + var start = new Point3D(sx, sy, (sbyte)sz); + var goal = new Point3D(gx, gy, (sbyte)sz); + walker.MoveToWorld(start, map); + + try + { + var on = FindWithFlag(walker, map, start, goal, cacheOn: true); + var off = FindWithFlag(walker, map, start, goal, cacheOn: false); + + // Both-null or both-equal arrays. Equality of the direction sequence is the invariant. + Assert.Equal(off == null, on == null); + if (on != null) + { + Assert.Equal(off, on); + _output.WriteLine($"({sx},{sy})->({gx},{gy}) house: {on.Length} steps, cache==slow"); + } + else + { + _output.WriteLine($"({sx},{sy})->({gx},{gy}) house: no path (both)"); + } + } + finally + { + walker.Delete(); + multi.Delete(); + } + } + + [Theory] + [InlineData(1500, 1600, 1498, 1598)] + [InlineData(1500, 1600, 1497, 1599)] + public void Find_CacheOn_EqualsCacheOff_NoMultiControl(int sx, int sy, int gx, int gy) + { + var map = Map.Maps[MapId]; + var walker = new WalkerStub(); + map.GetAverageZ(sx, sy, out _, out var sz, out _); + var start = new Point3D(sx, sy, (sbyte)sz); + var goal = new Point3D(gx, gy, (sbyte)sz); + walker.MoveToWorld(start, map); + + try + { + var on = FindWithFlag(walker, map, start, goal, cacheOn: true); + var off = FindWithFlag(walker, map, start, goal, cacheOn: false); + Assert.Equal(off == null, on == null); + if (on != null) + { + Assert.Equal(off, on); + } + } + finally + { + walker.Delete(); + } + } + + [Fact] + public void Find_CacheOn_EqualsCacheOff_RoutesAroundHouse() + { + var map = Map.Maps[MapId]; + Assert.NotNull(map); + + // Open area at (1480,1620,z=20): paths exist in all 8 directions (probe confirmed). + // N-S route: start=(1480,1630) goal=(1480,1610). House at (1480,1620) on direct line. + // Detour exists E (~1488+) and W (~1472-). Both cache-on and cache-off must agree. + const int hx = 1480, hy = 1620; + const int sx = 1480, sy = 1630; + const int gx = 1480, gy = 1610; + + map.GetAverageZ(hx, hy, out _, out var hz, out _); + var multi = new TestMulti(0x74); + multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map); + + var walker = new WalkerStub(); + map.GetAverageZ(sx, sy, out _, out var sz, out _); + map.GetAverageZ(gx, gy, out _, out var gz, out _); + var start = new Point3D(sx, sy, (sbyte)sz); + var goal = new Point3D(gx, gy, (sbyte)gz); + walker.MoveToWorld(start, map); + + try + { + var on = FindWithFlag(walker, map, start, goal, cacheOn: true); + var off = FindWithFlag(walker, map, start, goal, cacheOn: false); + + // Non-vacuity: a real around-the-house route must exist on BOTH sides. + Assert.NotNull(off); + Assert.NotNull(on); + // The invariant: cache and slow path agree on that route. + Assert.Equal(off, on); + + _output.WriteLine($"around-house: {on.Length} steps, cache==slow"); + } + finally + { + walker.Delete(); + multi.Delete(); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs new file mode 100644 index 000000000..4775b8fac --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs @@ -0,0 +1,90 @@ +using Server.Engines.Pathing.Cache; +using Server.Items; +using Xunit; +using Xunit.Abstractions; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class MultiSplitRoutingTests +{ + private readonly ITestOutputHelper _output; + + public MultiSplitRoutingTests(ITestOutputHelper output) => _output = output; + + // Open plain on Trammel used by existing pathfinding tests; room for a 7x7 house. + private const int MapId = 1; + private const int PlaceX = 1500; + private const int PlaceY = 1600; + + [Fact] + public void PlacedMulti_RoutesFootprintAndHalo_ToFallthroughMulti() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + Assert.NotNull(map); + + map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); + var multi = new TestMulti(0x74); // GuildHouse footprint + multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map); + + try + { + var sector = map.GetRealSector(PlaceX >> 4, PlaceY >> 4); + Assert.True(sector.HasMultis, "placing a multi must set Sector.HasMultis"); + + var cells = MultiArt.FootprintWithHalo(multi); + var checkedCovered = 0; + foreach (var c in cells) + { + if (c.X < 0 || c.Y < 0 || c.X >= map.Width || c.Y >= map.Height) + { + continue; + } + var mask = StepCache.Instance.TryGetMask(map, c.X, c.Y, (sbyte)z); + Assert.Equal(CacheHitKind.Fallthrough_Multi, mask.HitKind); + checkedCovered++; + } + + Assert.True(checkedCovered > 0, "non-vacuity: expected at least one covered cell"); + _output.WriteLine($"covered/halo cells routed to fallthrough: {checkedCovered}"); + } + finally + { + multi.Delete(); + } + } + + [Fact] + public void CleanMap_AfterMultiRemoved_ServesStaticHitAgain() + { + StepCache.Instance.Clear(); + var prevThreshold = StepCache.Instance.MissPromotionThreshold; + StepCache.Instance.MissPromotionThreshold = 1; // build on first touch + var map = Map.Maps[MapId]; + Assert.NotNull(map); + + map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); + + var multi = new TestMulti(0x74); + multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map); + var sector = map.GetRealSector(PlaceX >> 4, PlaceY >> 4); + Assert.True(sector.HasMultis, "placing a multi must set Sector.HasMultis"); + + try + { + multi.Delete(); + Assert.False(sector.HasMultis, "removing the only multi must clear HasMultis"); + + // A cell that is NOT a static-fallthrough kind (e.g. off-map / multi) should now be + // eligible for a real static answer. Use the placement center, which is open plain. + var mask = StepCache.Instance.TryGetMask(map, PlaceX, PlaceY, (sbyte)z); + Assert.True(mask.IsHit, $"expected a real static answer after removal, got {mask.HitKind}"); + _output.WriteLine($"post-removal hitKind at center: {mask.HitKind}"); + } + finally + { + StepCache.Instance.MissPromotionThreshold = prevThreshold; + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiTestSupport.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiTestSupport.cs new file mode 100644 index 000000000..092b28711 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiTestSupport.cs @@ -0,0 +1,242 @@ +using System.Collections.Generic; +using Server; +using Server.Engines.Pathing.Cache; +using Server.Items; +using Xunit; +using CalcMoves = Server.Movement.Movement; + +namespace Server.Tests.Pathfinding; + +/// +/// Minimal concrete for tests. Walkability depends only on +/// Components (the shared MCL for the multiID) + Location, so this is a faithful stand-in +/// for any fixed-design multi (classic house, camp, boat heading) without the owning +/// house/boat machinery. Never serialized in tests. +/// +public sealed class TestMulti : BaseMulti +{ + public TestMulti(int itemID) : base(itemID) + { + } +} + +/// Default walker body, shared across pathfinding test fixtures. +public sealed class WalkerStub : Mobile +{ + public WalkerStub() => Body = 0xC9; +} + +/// +/// Stand-in for a customizable foundation: Components is a swappable MCL, exactly the +/// runtime-mutation shape HouseFoundation uses (it replaces its MCL wholesale on redesign +/// commit). Lets the test change the footprint and assert the engine reflects it without +/// driving full house placement/customization. +/// +public sealed class SwappableFoundation : BaseMulti +{ + private MultiComponentList _mcl; + + public SwappableFoundation(int baseMultiID) : base(baseMultiID) => + _mcl = MultiData.GetComponents(baseMultiID); + + public override MultiComponentList Components => _mcl; + + public void Redesign(MultiComponentList replacement) => _mcl = replacement; +} + +/// +/// Shared helpers for placing/probing multis in pathfinding tests. +/// +public static class MultiTestSupport +{ + // A default-walker oracle mobile (CanSwim=false, CantWalk=false) placed in-world so MovementImpl + // state reads are valid. Caller MUST Delete() it (do it in a finally). + public static Mobile GetWalkerOracle(Map map, Point3D loc) + { + var w = new WalkerStub(); + w.MoveToWorld(loc, map); + return w; + } + + public static bool HasMultiTileAt(BaseMulti multi, int wx, int wy) + { + var mcl = multi.Components; + var lx = wx - multi.X + mcl.Center.X; + var ly = wy - multi.Y + mcl.Center.Y; + if (lx < 0 || ly < 0 || lx >= mcl.Width || ly >= mcl.Height) + { + return false; + } + return mcl.Tiles[lx][ly].Length > 0; + } + + /// + /// Sweeps the multi's footprint + 1-cell halo and asserts the multi mask synthesizer + /// () agrees with the CheckMovement oracle + /// for all 8 directions at every cell, including the exact forward walk-Z on allowed moves. + /// Creates its own walker oracle internally and Delete()s it; the caller owns the multi. + /// + public static void AssertSynthesizerMatchesCheckMovement(BaseMulti multi, Map map) + { + var loc = new Point3D(multi.X, multi.Y, multi.Z); + var mover = GetWalkerOracle(map, loc); + try + { + var cells = MultiArt.FootprintWithHalo(multi); + Assert.NotEmpty(cells); + + var touchedMulti = 0; + var sawWalkable = 0; + var sawBlocked = 0; + + foreach (var c in cells) + { + var sourceZ = (sbyte)loc.Z; + var p = new Point3D(c.X, c.Y, sourceZ); + + if (HasMultiTileAt(multi, c.X, c.Y)) + { + touchedMulti++; + } + + var mask = StepProbe.ComputeMultiMaskAt(map, c.X, c.Y, sourceZ); + + for (var d = 0; d < 8; d++) + { + var dir = (Direction)d; + var expectWalk = CalcMoves.CheckMovement(mover, map, p, dir, out var expectZ); + + // The synthesizer reports the raw forward-cell step per direction and does NOT + // apply diagonal corner-cutting — by design, the caller ANDs the partner cells. + // CheckMovement (the oracle) DOES corner-cut. Replicate the caller's corner-cut + // on the mask so we compare like-for-like. The walker is not a player, so the + // diagonal is blocked only when BOTH orthogonal partner cells are blocked. + var forwardWalk = (mask.WalkMask & (1 << d)) != 0; + var gotWalk = forwardWalk; + var isDiagonal = (d & 0x1) == 0x1; + if (forwardWalk && isDiagonal) + { + var leftBit = (d - 1) & 0x7; + var rightBit = (d + 1) & 0x7; + var leftWalk = (mask.WalkMask & (1 << leftBit)) != 0; + var rightWalk = (mask.WalkMask & (1 << rightBit)) != 0; + if (!leftWalk && !rightWalk) + { + gotWalk = false; + } + } + + Assert.Equal(expectWalk, gotWalk); + + if (expectWalk) + { + // Z is taken from the forward cell only; corner-cut never alters newZ when + // the move is allowed. + Assert.Equal((sbyte)expectZ, mask.GetWalkZ(dir)); + sawWalkable++; + } + else + { + sawBlocked++; + } + } + } + + Assert.True(touchedMulti > 0, "sweep touched no multi-covered cells"); + Assert.True(sawWalkable > 0, "sweep observed no walkable transitions"); + Assert.True(sawBlocked > 0, "sweep observed no blocked transitions"); + } + finally + { + mover.Delete(); + } + } +} + +/// +/// Helpers that derive expected geometry from a multi's MCL art at runtime, so tests +/// encode no hardcoded cell coordinates and survive art-data changes. +/// +public static class MultiArt +{ + public readonly record struct Cell(int X, int Y); + + /// Every world cell the multi's footprint covers (Tiles stack non-empty). + public static List FootprintCells(BaseMulti multi) + { + var mcl = multi.Components; + var result = new List(); + for (var lx = 0; lx < mcl.Width; lx++) + { + for (var ly = 0; ly < mcl.Height; ly++) + { + if (mcl.Tiles[lx][ly].Length == 0) + { + continue; + } + result.Add(new Cell(multi.X + mcl.Min.X + lx, multi.Y + mcl.Min.Y + ly)); + } + } + return result; + } + + /// Footprint cells plus a 1-cell halo ring (the cells the split also routes to slow path). + public static HashSet FootprintWithHalo(BaseMulti multi) + { + var foot = FootprintCells(multi); + var set = new HashSet(); + foreach (var c in foot) + { + for (var dx = -1; dx <= 1; dx++) + { + for (var dy = -1; dy <= 1; dy++) + { + set.Add(new Cell(c.X + dx, c.Y + dy)); + } + } + } + return set; + } + + /// First world cell whose MCL stack contains an impassable, non-surface (wall) tile, or null. + public static Cell? FindWallCell(BaseMulti multi) + { + var mcl = multi.Components; + for (var lx = 0; lx < mcl.Width; lx++) + { + for (var ly = 0; ly < mcl.Height; ly++) + { + foreach (var t in mcl.Tiles[lx][ly]) + { + var data = TileData.ItemTable[t.ID & TileData.MaxItemValue]; + if (data.Impassable && !data.Surface) + { + return new Cell(multi.X + mcl.Min.X + lx, multi.Y + mcl.Min.Y + ly); + } + } + } + } + return null; + } + + /// First world cell whose MCL stack contains a walkable surface (floor) tile, or null. + public static Cell? FindFloorCell(BaseMulti multi) + { + var mcl = multi.Components; + for (var lx = 0; lx < mcl.Width; lx++) + { + for (var ly = 0; ly < mcl.Height; ly++) + { + foreach (var t in mcl.Tiles[lx][ly]) + { + var data = TileData.ItemTable[t.ID & TileData.MaxItemValue]; + if (data.Surface && !data.Impassable) + { + return new Cell(multi.X + mcl.Min.X + lx, multi.Y + mcl.Min.Y + ly); + } + } + } + } + return null; + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs new file mode 100644 index 000000000..59e583ac6 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs @@ -0,0 +1,137 @@ +using Server.Engines.Pathing.Cache; +using Server.Items; +using Server.Mobiles; +using Xunit; +using Xunit.Abstractions; +using CalcMoves = Server.Movement.Movement; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class MultiWalkabilityTests +{ + private readonly ITestOutputHelper _output; + + public MultiWalkabilityTests(ITestOutputHelper output) => _output = output; + + private const int MapId = 1; + private const int PlaceX = 1500; + private const int PlaceY = 1600; + + private sealed class WalkerStub : Mobile + { + public WalkerStub() => Body = 0xC9; + } + + [Fact] + public void WallCell_CannotBeEnteredFromAnyAdjacentCell() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); + + var multi = new TestMulti(0x74); + multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map); + + var walker = new WalkerStub(); + walker.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map); + + try + { + var wall = MultiArt.FindWallCell(multi); + Assert.True(wall.HasValue, "non-vacuity: house MCL must contain a wall tile"); + var w = wall.Value; + + // From each of the 8 cells surrounding the wall, try stepping in all 8 directions. + // No step may land ON the wall cell. We also require that SOME step succeeds, so a + // "zero wall entries" result reflects the wall blocking — not the walker being + // unable to move here at all (e.g. a Z mismatch blocking everything: a vacuous pass). + var entriesIntoWall = 0; + var successfulSteps = 0; + for (var around = 0; around < 8; around++) + { + var cx = w.X; + var cy = w.Y; + CalcMoves.Offset((Direction)around, ref cx, ref cy); + map.GetAverageZ(cx, cy, out _, out var cz, out _); + var from = new Point3D(cx, cy, (sbyte)cz); + + for (var d = 0; d < 8; d++) + { + if (!CalcMoves.CheckMovement(walker, map, from, (Direction)d, out _)) + { + continue; + } + + successfulSteps++; + + var tx = cx; + var ty = cy; + CalcMoves.Offset((Direction)d, ref tx, ref ty); + if (tx == w.X && ty == w.Y) + { + entriesIntoWall++; + _output.WriteLine($"UNEXPECTED entry into wall ({w.X},{w.Y}) from ({cx},{cy}) dir {d}"); + } + } + } + + Assert.True(successfulSteps > 0, "non-vacuity: walker must be able to move near the wall"); + Assert.Equal(0, entriesIntoWall); + } + finally + { + walker.Delete(); + multi.Delete(); + } + } + + [Fact] + public void FloorCell_IsStandable() + { + StepCache.Instance.Clear(); + var map = Map.Maps[MapId]; + map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); + + var multi = new TestMulti(0x74); + multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map); + + var walker = new WalkerStub(); + + try + { + var floor = MultiArt.FindFloorCell(multi); + Assert.True(floor.HasValue, "non-vacuity: house MCL must contain a floor tile"); + var f = floor.Value; + + // Stand the walker on a cardinal neighbour of the floor cell and require at least + // one direction that successfully steps onto the floor cell. + var enteredFloor = false; + for (var d = 0; d < 8 && !enteredFloor; d++) + { + var nx = f.X; + var ny = f.Y; + CalcMoves.Offset((Direction)((d + 4) & 7), ref nx, ref ny); + map.GetAverageZ(nx, ny, out _, out var nz, out _); + walker.MoveToWorld(new Point3D(nx, ny, (sbyte)nz), map); + if (CalcMoves.CheckMovement(walker, map, walker.Location, (Direction)d, out _)) + { + var tx = nx; + var ty = ny; + CalcMoves.Offset((Direction)d, ref tx, ref ty); + if (tx == f.X && ty == f.Y) + { + enteredFloor = true; + } + } + } + + Assert.True(enteredFloor, $"expected the floor cell ({f.X},{f.Y}) to be reachable from a neighbour"); + } + finally + { + walker.Delete(); + multi.Delete(); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/PathfindRecorderTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/PathfindRecorderTests.cs index da44a4f19..0930bad7a 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/PathfindRecorderTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/PathfindRecorderTests.cs @@ -10,22 +10,11 @@ public class PathfindRecorderTests private static string NewTempPath() => Path.Combine(Path.GetTempPath(), $"pathfind-recorder-{System.Guid.NewGuid():N}.jsonl"); - /// - /// Reflection-set the static _outputPath without going through Configure (which - /// reads from server.cfg) so tests don't poison the project's server.cfg. - /// - private static void OverrideOutputPath(string path) - { - typeof(PathfindRecorder).GetField("_outputPath", - System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)! - .SetValue(null, path); - } - [Fact] public void Disabled_RecordIfEnabled_DoesNothing() { var path = NewTempPath(); - OverrideOutputPath(path); + PathfindRecorder.OutputPath = path; PathfindRecorder.SetEnabled(false); try @@ -52,7 +41,7 @@ public class PathfindRecorderTests public void Enabled_RecordIfEnabled_WritesValidJsonlLine() { var path = NewTempPath(); - OverrideOutputPath(path); + PathfindRecorder.OutputPath = path; PathfindRecorder.SetEnabled(true); try @@ -95,7 +84,7 @@ public class PathfindRecorderTests public void Enabled_RecordsCapabilityFlagsFromBaseCreature() { var path = NewTempPath(); - OverrideOutputPath(path); + PathfindRecorder.OutputPath = path; PathfindRecorder.SetEnabled(true); try @@ -130,7 +119,7 @@ public class PathfindRecorderTests public void SetEnabled_TogglingTwice_IsIdempotent() { var path = NewTempPath(); - OverrideOutputPath(path); + PathfindRecorder.OutputPath = path; try { @@ -155,9 +144,6 @@ public class PathfindRecorderTests private sealed class RecorderStub : Server.Mobiles.BaseCreature { - public RecorderStub(Serial serial) : base(serial) - { - Body = 0xC9; - } + public RecorderStub(Serial serial) : base(serial) => Body = 0xC9; } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs index d8e9aed01..0d865d08d 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs @@ -55,7 +55,7 @@ public class StepCacheFileTests expected[i] = cache.TryGetMask(map, x, y, standZ[i]); } - var path = Path.Combine(Path.GetTempPath(), $"step-cache-roundtrip-{System.Guid.NewGuid():N}.swb"); + var path = Path.Combine(Path.GetTempPath(), $"step-cache-roundtrip-{Guid.NewGuid():N}.swb"); try { var written = cache.SaveToFile(path, map.MapID); @@ -109,18 +109,61 @@ public class StepCacheFileTests var cache = StepCache.Instance; cache.Clear(); - var path = Path.Combine(Path.GetTempPath(), $"step-cache-missing-{System.Guid.NewGuid():N}.swb"); + var path = Path.Combine(Path.GetTempPath(), $"step-cache-missing-{Guid.NewGuid():N}.swb"); Assert.False(cache.TryOpenLazyReader(path, mapId: 1)); Assert.Equal(0, cache.OpenLazyReaderCount); } + /// + /// HasLazyReader is the boot prebake's skip predicate (PathCacheCommands.Initialize): a map + /// with an open, fingerprint-valid reader needs no bake. Lock the open/clear contract. + /// + [Fact] + public void HasLazyReader_TracksOpenAndClear() + { + var cache = StepCache.Instance; + cache.Clear(); + + var map = Map.Maps[1]; + Assert.NotNull(map); + Assert.False(cache.HasLazyReader(map.MapID)); + + // Build + save a chunk so there's a valid .swb to open. + cache.MissPromotionThreshold = 1; + Span surfZ = stackalloc sbyte[16]; + Assert.True(StepProbe.ComputeStandableSurfaceZs(map, 1500, 1600, surfZ) > 0); + cache.TryGetMask(map, 1500, 1600, surfZ[0]); + + var path = Path.Combine(Path.GetTempPath(), $"step-cache-haslazy-{Guid.NewGuid():N}.swb"); + try + { + Assert.True(cache.SaveToFile(path, map.MapID) > 0); + cache.Clear(); + Assert.False(cache.HasLazyReader(map.MapID)); + + Assert.True(cache.TryOpenLazyReader(path, map.MapID)); + Assert.True(cache.HasLazyReader(map.MapID)); // open → true + + cache.Clear(); + Assert.False(cache.HasLazyReader(map.MapID)); // clear closes the reader → false + } + finally + { + cache.Clear(); + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + [Fact] public void TryOpenLazyReader_BadMagic_ReturnsFalse() { var cache = StepCache.Instance; cache.Clear(); - var path = Path.Combine(Path.GetTempPath(), $"step-cache-badmagic-{System.Guid.NewGuid():N}.swb"); + var path = Path.Combine(Path.GetTempPath(), $"step-cache-badmagic-{Guid.NewGuid():N}.swb"); try { File.WriteAllBytes(path, new byte[] @@ -150,7 +193,7 @@ public class StepCacheFileTests var map = Map.Maps[1]; cache.TryGetMask(map, 1500, 1600, sourceZ: 10); - var path = Path.Combine(Path.GetTempPath(), $"step-cache-stalehash-{System.Guid.NewGuid():N}.swb"); + var path = Path.Combine(Path.GetTempPath(), $"step-cache-stalehash-{Guid.NewGuid():N}.swb"); try { cache.SaveToFile(path, map.MapID); @@ -193,7 +236,7 @@ public class StepCacheFileTests Assert.NotNull(map); // Populate a handful of chunks. - var coords = new (int, int)[] + var coords = new[] { (1500, 1600), (1516, 1600), (1500, 1616), (1516, 1616), (1532, 1600) }; @@ -202,7 +245,7 @@ public class StepCacheFileTests cache.TryGetMask(map, x, y, sourceZ: 10); } - var path = Path.Combine(Path.GetTempPath(), $"step-cache-lazy-{System.Guid.NewGuid():N}.swb"); + var path = Path.Combine(Path.GetTempPath(), $"step-cache-lazy-{Guid.NewGuid():N}.swb"); try { Assert.Equal(coords.Length, cache.SaveToFile(path, map.MapID)); @@ -269,7 +312,7 @@ public class StepCacheFileTests chunk.SwimZE_Layer[cellIndex] = -7; chunk.SwimZSE_Layer[cellIndex] = -7; - var path = Path.Combine(Path.GetTempPath(), $"step-cache-swim-{System.Guid.NewGuid():N}.swb"); + var path = Path.Combine(Path.GetTempPath(), $"step-cache-swim-{Guid.NewGuid():N}.swb"); try { Assert.Equal(1, cache.SaveToFile(path, map.MapID)); @@ -314,7 +357,7 @@ public class StepCacheFileTests var map = Map.Maps[1]; Assert.NotNull(map); - var coords = new (int, int)[] + var coords = new[] { (1500, 1600), (1516, 1600), (1500, 1616), (1516, 1616), (1532, 1600) }; @@ -323,7 +366,7 @@ public class StepCacheFileTests cache.TryGetMask(map, x, y, sourceZ: 10); } - var path = Path.Combine(Path.GetTempPath(), $"step-cache-preload-{System.Guid.NewGuid():N}.swb"); + var path = Path.Combine(Path.GetTempPath(), $"step-cache-preload-{Guid.NewGuid():N}.swb"); try { Assert.Equal(coords.Length, cache.SaveToFile(path, map.MapID)); @@ -369,7 +412,7 @@ public class StepCacheFileTests // Build + save one chunk. cache.TryGetMask(map, 1500, 1600, sourceZ: 10); - var path = Path.Combine(Path.GetTempPath(), $"step-cache-bypass-{System.Guid.NewGuid():N}.swb"); + var path = Path.Combine(Path.GetTempPath(), $"step-cache-bypass-{Guid.NewGuid():N}.swb"); try { Assert.Equal(1, cache.SaveToFile(path, map.MapID)); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV6Tests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV6Tests.cs index 30d8630c1..24764c272 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV6Tests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV6Tests.cs @@ -48,8 +48,8 @@ public class StepCacheFileV6Tests for (var i = 0; i < StepChunk.CellsPerChunk; i++) { c.WalkMask[i] = (byte)(i & 0xFF); - c.WetMask[i] = (byte)((~i) & 0xFF); - c.SourceZ[i] = (sbyte)(baseZ + (i % 7) - 3); // varies, mostly != 0 + c.WetMask[i] = (byte)(~i & 0xFF); + c.SourceZ[i] = (sbyte)(baseZ + i % 7 - 3); // varies, mostly != 0 } SetFlatDirectional(c); return c; @@ -78,9 +78,9 @@ public class StepCacheFileV6Tests { c.WalkMask[i] = (byte)(i & 0xFF); c.WetMask[i] = (byte)((i * 7) & 0xFF); - c.SourceZ[i] = (sbyte)((i % 40) - 20); - c.WalkZN[i] = (sbyte)(c.SourceZ[i] + (i % 3)); - c.SwimZS[i] = (sbyte)(c.SourceZ[i] - (i % 2)); + c.SourceZ[i] = (sbyte)(i % 40 - 20); + c.WalkZN[i] = (sbyte)(c.SourceZ[i] + i % 3); + c.SwimZS[i] = (sbyte)(c.SourceZ[i] - i % 2); } return c; } @@ -91,10 +91,10 @@ public class StepCacheFileV6Tests c.AllocateSwimLayer(); for (var i = 0; i < StepChunk.CellsPerChunk; i++) { - c.SwimSourceZ[i] = (sbyte)((i % 30) - 15); + c.SwimSourceZ[i] = (sbyte)(i % 30 - 15); c.SwimMask[i] = (byte)((i * 5) & 0xFF); c.SwimZN_Layer[i] = (sbyte)(i % 7); - c.SwimZNW_Layer[i] = (sbyte)(-(i % 4)); + c.SwimZNW_Layer[i] = (sbyte)-(i % 4); } return c; } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV7Tests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV7Tests.cs index 83a44d46a..f5e517972 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV7Tests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV7Tests.cs @@ -18,9 +18,9 @@ public class StepCacheFileV7Tests { c.WalkMask[i] = (byte)(i & 0xFF); c.WetMask[i] = (byte)((i * 7) & 0xFF); - c.SourceZ[i] = (sbyte)((i % 40) - 20); - c.WalkZN[i] = (sbyte)(c.SourceZ[i] + (i % 3)); - c.SwimZS[i] = (sbyte)(c.SourceZ[i] - (i % 2)); + c.SourceZ[i] = (sbyte)(i % 40 - 20); + c.WalkZN[i] = (sbyte)(c.SourceZ[i] + i % 3); + c.SwimZS[i] = (sbyte)(c.SourceZ[i] - i % 2); } return c; } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV8Tests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV8Tests.cs index 0a391fb39..8d3cd495b 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV8Tests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileV8Tests.cs @@ -18,9 +18,9 @@ public class StepCacheFileV8Tests { c.WalkMask[i] = (byte)((i + seed) & 0xFF); c.WetMask[i] = (byte)((i * 7 + seed) & 0xFF); - c.SourceZ[i] = (sbyte)(((i + seed) % 40) - 20); - c.WalkZN[i] = (sbyte)(c.SourceZ[i] + (i % 3)); - c.SwimZS[i] = (sbyte)(c.SourceZ[i] - (i % 2)); + c.SourceZ[i] = (sbyte)((i + seed) % 40 - 20); + c.WalkZN[i] = (sbyte)(c.SourceZ[i] + i % 3); + c.SwimZS[i] = (sbyte)(c.SourceZ[i] - i % 2); } return c; } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs new file mode 100644 index 000000000..dee698e59 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs @@ -0,0 +1,43 @@ +using Server.Engines.Pathing.Cache; +using Xunit; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class StepCacheFingerprintTests +{ + /// + /// Regression: the cache fingerprint must hash the on-disk tiledata.mul, NOT the mutable + /// in-memory tables. The server patches item flags/heights at runtime + /// (ItemFixes, LOSBlocker, PotionKeg, CTF, ...) at nondeterministic lifecycle points, so a + /// fingerprint taken over the live tables depended on WHEN it was computed: a runtime + /// [PathBake stamped one value into the .swb and the next startup's Initialize() recomputed a + /// different one, marking the bake stale and re-baking on every boot. Hashing the file makes + /// the fingerprint a pure function of the client's tile data, immune to those mutations. + /// + [Fact] + public void Fingerprint_IgnoresRuntimeTileDataMutation() + { + const int mapId = 1; // Trammel — loaded by the test bootstrap. + + var before = StepCacheFile.ComputeFingerprint(mapId); + + const int probeId = 0x2A0; + var original = TileData.ItemTable[probeId].Flags; + try + { + // Mutate an in-memory item flag the way ItemFixes/CTF/etc. do at runtime. XOR + // guarantees the value actually changes regardless of the current flag state. + TileData.ItemTable[probeId].Flags ^= TileFlag.NoShoot; + Assert.NotEqual(original, TileData.ItemTable[probeId].Flags); // sanity: mutation took + + var after = StepCacheFile.ComputeFingerprint(mapId); + + Assert.Equal(before, after); + } + finally + { + TileData.ItemTable[probeId].Flags = original; + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs index de4b2fa51..244c51534 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs @@ -1,4 +1,7 @@ +using System.Collections.Generic; +using System.Reflection; using Server.Engines.Pathing.Cache; +using Server.Items; using Xunit; namespace Server.Tests.Pathfinding; @@ -205,42 +208,49 @@ public class StepCacheLifecycleTests } [Fact] - public void MultisVersion_Bump_TriggersDirtyRebuild() + public void MultiCoveredCell_AndHalo_RouteToFallthrough() { var cache = StepCache.Instance; cache.Clear(); - cache.MissPromotionThreshold = 2; + cache.MissPromotionThreshold = 1; // eager build so a multi-free cell serves immediately var map = Map.Maps[1]; - var sector = map.GetRealSector(1500 >> 4, 1600 >> 4); - // First touch defers (Fallthrough_NotBuilt); second touch promotes and builds. - Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, cache.TryGetMask(map, 1500, 1600, 10).HitKind); - Assert.Equal(CacheHitKind.Miss_NotBuilt, cache.TryGetMask(map, 1500, 1600, 10).HitKind); + // A cell far from any multi serves from the static cache. + Assert.True(cache.TryGetMask(map, 1500, 1600, 10).IsHit); - // Bump _multisVersion via reflection. - var versionField = typeof(Map.Sector).GetField( - "_multisVersion", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance - ); - Assert.NotNull(versionField); - var current = (int)versionField.GetValue(sector); - versionField.SetValue(sector, current + 1); + // Inject a multi into an isolated sector. Sector.HasMultis only checks Count > 0, so a + // single-entry list is enough to mark the sector as multi-bearing — the fallthrough + // decision never dereferences the multi, so no real BaseMulti instance is needed. + const int mx = 2000; + const int my = 2000; + var sx = mx >> 4; + var sy = my >> 4; + var sector = map.GetRealSector(sx, sy); + var multisField = typeof(Map.Sector).GetField("_multis", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(multisField); + var original = multisField.GetValue(sector); + try + { + multisField.SetValue(sector, new List { null }); - // Third query: detects version mismatch, rebuilds. - Assert.Equal(CacheHitKind.Miss_DirtyRebuild, cache.TryGetMask(map, 1500, 1600, 10).HitKind); + // Cell inside the multi sector → routed to the live path. + Assert.Equal(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, mx, my, 0).HitKind); - var stats = cache.GetStats(); - Assert.Equal(1L, stats.MissesDirtyRebuild); - Assert.Equal(2L, stats.BuildsTotal); + // Cell in the adjacent sector but on the shared boundary → caught by the 1-cell halo + // (its mask would otherwise propose an edge into the multi sector). + var boundaryX = sx * 16 - 1; // last tile of sector sx-1; halo (x+1) reaches into sx + Assert.Equal(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, boundaryX, my, 0).HitKind); - // Mutual-exclusivity invariant: hits + miss-builds + dirty-rebuilds = served-result count. - // Three calls returned an answer; two were "served from a build" (Miss_NotBuilt + Miss_DirtyRebuild), - // and the first was a Fallthrough_NotBuilt (no build, slow-path signal). - Assert.Equal(2L, stats.MissesNotBuilt + stats.MissesDirtyRebuild + stats.Hits); - Assert.Equal(1L, stats.FallthroughNotBuilt); - Assert.Equal(0L, stats.FallthroughMultiZ); - Assert.Equal(0L, stats.FallthroughOffMap); + // Two tiles out → interior of the multi-free sector, unaffected. + Assert.NotEqual(CacheHitKind.Fallthrough_Multi, cache.TryGetMask(map, sx * 16 - 2, my, 0).HitKind); + + Assert.True(cache.GetStats().FallthroughMulti >= 2); + } + finally + { + multisField.SetValue(sector, original); + } } [Fact] @@ -499,7 +509,7 @@ public class StepCacheLifecycleTests // Build 5 distinct chunks by querying different sectors. for (var i = 0; i < 5; i++) { - var x = 1500 + (i * 16); + var x = 1500 + i * 16; var y = 1600; cache.TryGetMask(map, x, y, 10); System.Threading.Thread.Sleep(2); // ensure LastTouchedTicks differs diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs index b00d30f8f..149a9fe13 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs @@ -83,10 +83,11 @@ public class StepCacheParityTests wetCells++; } - if (lookup.WalkZ_N != baker.WalkZ_N || lookup.WalkZ_NE != baker.WalkZ_NE - || lookup.WalkZ_E != baker.WalkZ_E || lookup.WalkZ_SE != baker.WalkZ_SE - || lookup.WalkZ_S != baker.WalkZ_S || lookup.WalkZ_SW != baker.WalkZ_SW - || lookup.WalkZ_W != baker.WalkZ_W || lookup.WalkZ_NW != baker.WalkZ_NW) + if (lookup.WalkZ_N != baker.WalkZ_N + || lookup.WalkZ_NE != baker.WalkZ_NE || lookup.WalkZ_E != baker.WalkZ_E + || lookup.WalkZ_SE != baker.WalkZ_SE || lookup.WalkZ_S != baker.WalkZ_S + || lookup.WalkZ_SW != baker.WalkZ_SW || lookup.WalkZ_W != baker.WalkZ_W + || lookup.WalkZ_NW != baker.WalkZ_NW) { disagreements++; _output.WriteLine($"Z DIFF @ ({x},{y}) cache=({lookup.WalkZ_N},{lookup.WalkZ_NE},{lookup.WalkZ_E},{lookup.WalkZ_SE},{lookup.WalkZ_S},{lookup.WalkZ_SW},{lookup.WalkZ_W},{lookup.WalkZ_NW}) baker=({baker.WalkZ_N},{baker.WalkZ_NE},{baker.WalkZ_E},{baker.WalkZ_SE},{baker.WalkZ_S},{baker.WalkZ_SW},{baker.WalkZ_W},{baker.WalkZ_NW})"); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeParityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeParityTests.cs index 25b80773d..df8ff5da5 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeParityTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeParityTests.cs @@ -52,7 +52,7 @@ public class StaticWalkabilityParityTests // diagonal walkable iff raw-diagonal AND (left-partner OR right-partner). // (Raw masks are correct per spec; baker omits diagonal logic per design.) var newOk = bakerResult.IsWalkable(dir); - if (newOk && ((d & 1) == 1)) + if (newOk && (d & 1) == 1) { var leftPartner = (Direction)((d - 1) & 7); var rightPartner = (Direction)((d + 1) & 7); 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.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs b/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs new file mode 100644 index 000000000..0264a282e --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs @@ -0,0 +1,140 @@ +using System; +using Server.Mobiles; +using Server.Spells.Necromancy; +using Xunit; + +namespace Server.Tests.Spells.Necromancy; + +[Collection("Sequential UOContent Tests")] +public class BloodOathSpellTests +{ + private static Mobile NewMobile() + { + var m = new Mobile(World.NewMobile); + m.DefaultMobileInit(); + return m; + } + + // Issue #1690: the real OSI duration is ((SpiritSpeak - Resist) / 8) + 8 seconds. + // ModernUO previously used /80 (matching the bugged in-game tooltip), which made + // Spirit Speak almost irrelevant to duration. RunUO/ServUO and the code's own + // fixed-point comment both use /8. + [Theory] + [InlineData(120.0, 0.0, 23.0)] // GM Spirit Speak, no resist + [InlineData(120.0, 120.0, 8.0)] // equal skills -> baseline 8s + [InlineData(100.0, 20.0, 18.0)] // (100-20)/8 + 8 + [InlineData(20.0, 0.0, 10.5)] // minimum skill + public void GetDurationSeconds_UsesDivideByEight(double ss, double resist, double expected) + { + Assert.Equal(expected, BloodOathSpell.GetDurationSeconds(ss, resist), 3); + } + + // Player caster: Publish 48 resist mitigation does NOT apply; the attacker takes the + // full reflected (original, un-bonused) damage. + [Fact] + public void ComputeReflectedDamage_NoMitigation_ReturnsOriginal() + { + Assert.Equal(40, BloodOathSpell.ComputeReflectedDamage(40, 120.0, applyResistMitigation: false)); + } + + // Creature caster (Publish 48 / SA+): ((Resist * 10) / 20) + 10 = % of reflected damage resisted. + [Theory] + [InlineData(40, 0.0, 36)] // 10% resisted -> 40 * 0.90 + [InlineData(40, 100.0, 16)] // 60% resisted -> 40 * 0.40 + [InlineData(40, 120.0, 12)] // 70% resisted -> 40 * 0.30 + public void ComputeReflectedDamage_WithMitigation_ReducesByResist(int dmg, double resist, int expected) + { + Assert.Equal(expected, BloodOathSpell.ComputeReflectedDamage(dmg, resist, applyResistMitigation: true)); + } + + // The cursed target reflects to the caster; the caster attacking other mobiles must not reflect. + [Fact] + public void RegisterOath_BindsTargetToCaster_NotCasterToSelf() + { + var caster = NewMobile(); + var target = NewMobile(); + + BloodOathSpell.RegisterOath(caster, target, TimeSpan.FromMinutes(5)); + + Assert.Equal(caster, BloodOathSpell.GetBloodOath(target)); + Assert.Null(BloodOathSpell.GetBloodOath(caster)); + + BloodOathSpell.RemoveCurse(target); + caster.Delete(); + target.Delete(); + } + + // Death/delete of either party removes the oath, so RemoveCurse must resolve from either + // the caster or the target key (the death hooks call it with `this`). + [Fact] + public void RemoveCurse_ByCaster_ClearsBothEntries() + { + var caster = NewMobile(); + var target = NewMobile(); + BloodOathSpell.RegisterOath(caster, target, TimeSpan.FromMinutes(5)); + + Assert.True(BloodOathSpell.RemoveCurse(caster)); + + Assert.Null(BloodOathSpell.GetBloodOath(target)); + Assert.False(BloodOathSpell.RemoveCurse(target)); // already removed + + caster.Delete(); + target.Delete(); + } + + [Fact] + public void RemoveCurse_NotCursed_ReturnsFalse() + { + var m = NewMobile(); + Assert.False(BloodOathSpell.RemoveCurse(m)); + m.Delete(); + } + + // Issue #1690: death/delete of either party must break the oath immediately. The oath is wired + // to the central PlayerMobile/BaseCreature death+delete events instead of a polling timer. + [Fact] + public void PlayerDeletedEvent_BreaksOath() + { + var caster = new PlayerMobile(World.NewMobile); + caster.DefaultMobileInit(); + var target = new PlayerMobile(World.NewMobile); + target.DefaultMobileInit(); + + BloodOathSpell.RegisterOath(caster, target, TimeSpan.FromMinutes(5)); + + PlayerMobile.PlayerDeletedEvent(caster); // central handler breaks the oath from the caster side + + Assert.Null(BloodOathSpell.GetBloodOath(target)); + Assert.False(BloodOathSpell.RemoveCurse(target)); + + caster.Delete(); + target.Delete(); + } + + [Fact] + public void CreatureDeletedEvent_BreaksOath() + { + var caster = new PlayerMobile(World.NewMobile); + caster.DefaultMobileInit(); + var target = new TestCreature(World.NewMobile); + target.DefaultMobileInit(); + + BloodOathSpell.RegisterOath(caster, target, TimeSpan.FromMinutes(5)); + + BaseCreature.CreatureDeletedEvent(target); // central handler breaks the oath from the target side + + Assert.Null(BloodOathSpell.GetBloodOath(target)); + Assert.False(BloodOathSpell.RemoveCurse(caster)); + + caster.Delete(); + target.Delete(); + } + + private class TestCreature : BaseCreature + { + // Serial ctor skips AI/speed-table setup, which the test fixture does not configure. + public TestCreature(Serial serial) : base(serial) + { + } + } +} 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/Pathing/BitmapAStarAlgorithm.cs b/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs index 564791ed5..9008c18bd 100644 --- a/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs +++ b/Projects/UOContent/Engines/Pathing/BitmapAStarAlgorithm.cs @@ -23,7 +23,7 @@ using Server.Systems.FeatureFlags; using CalcMoves = Server.Movement.Movement; using MoveImpl = Server.Movement.MovementImpl; -namespace Server.PathAlgorithms.BitmapAStar; +namespace Server.PathAlgorithms; /// /// A* pathfinder with a single bitmap-cache lookup per cell expansion. Default walkers @@ -43,7 +43,6 @@ public class BitmapAStarAlgorithm : PathAlgorithm public int z; } - private const int MaxDepth = 300; private const int AreaSize = 38; private const int NodeCount = AreaSize * AreaSize * PlaneCount; @@ -51,38 +50,66 @@ public class BitmapAStarAlgorithm : PathAlgorithm private const int PlaneOffset = 128; private const int PlaneCount = 13; private const int PlaneHeight = 20; - public static readonly PathAlgorithm Instance = new BitmapAStarAlgorithm(); + // Default shared singleton (MaxSearchNodes = 1000, set from config in Configure). Typed + // as the concrete class so Configure can set its instance config; assignable anywhere a + // PathAlgorithm is expected. Specialized variants are just additional instances. + public static readonly BitmapAStarAlgorithm Instance = new(); - private static readonly Direction[] _path = new Direction[AreaSize * AreaSize]; - private static readonly PathNode[] _nodes = new PathNode[NodeCount]; - private static readonly byte[] _nodeStates = new byte[NodeCount]; - private static readonly int[] _successors = new int[8]; - private static readonly PriorityQueue _openQueue = new(); + // Scratch buffers — reused across every Find on THIS instance. Per-instance (not static) + // so independently-configured algorithms don't share state. ~320 KB per instance; create + // specialized instances once (static readonly), never per-call. Safe to reuse per Find + // because the game loop is single-threaded and Find is never re-entered. + private readonly Direction[] _path = new Direction[AreaSize * AreaSize]; + private readonly PathNode[] _nodes = new PathNode[NodeCount]; + private readonly byte[] _nodeStates = new byte[NodeCount]; + private readonly int[] _successors = new int[8]; + private readonly PriorityQueue _openQueue = new(); - private static int _xOffset; - private static int _yOffset; + // A* node-expansion budget: the search bails (returning null) after this many node + // expansions. Benchmarked as near-optimal: above the ~500 needed to solve walled-off + // indoor routes, below the ~1500 window-exhaustion cost ceiling where a failed + // (unreachable) search's worst-case cost spikes for no solving benefit. Successful + // searches terminate on goal-found, so this never touches the common open-terrain case. + // Per-instance so specialized algorithms (e.g. a wider-budget variant for special NPCs) + // can coexist; the shared default lives on Instance and is set from config in Configure. + public int MaxSearchNodes { get; set; } = 1000; + + private int _xOffset; + private int _yOffset; // When set, GetSuccessors delegates to the per-cell slow path on every expansion // (creature has CanFly — Z-jumping is beyond the cache's static-only scope). - private static bool _currentMobileNeedsSlowPath; + private bool _currentMobileNeedsSlowPath; // When set, diagonal corner-cut uses the strict AND-rule (BOTH cardinal partners // must be walkable) instead of the lenient creature OR-rule. Cache still applies — // partner bits live in the same source-cell mask byte. Non-GM players only. - private static bool _currentMobilePlayerStrict; + private bool _currentMobilePlayerStrict; // Capability overlay applied to cache results. Layered each cell: // effective = (walkMask & !cantWalk) | (wetMask & canSwim) // Reset at end of Find. - private static bool _currentMobileCanSwim; - private static bool _currentMobileCantWalk; + private bool _currentMobileCanSwim; + private bool _currentMobileCantWalk; // Dynamic-obstacle pass capability flags (per-mobile, captured in Find). // Mirrors MovementImpl.Check's per-mobile derivations so per-cell items/mobiles // checks can be evaluated without re-deriving. - private static bool _currentMobileIgnoreDoors; - private static bool _currentMobileIgnoreSpellFields; - private static bool _currentMobileIgnoreMovableImpassables; + private bool _currentMobileIgnoreDoors; + private bool _currentMobileIgnoreSpellFields; + private bool _currentMobileIgnoreMovableImpassables; + + public static void Configure() + { + // A* node-expansion budget. Default 1000 is benchmarked near-optimal (see + // MaxSearchNodes). Applied to the shared singleton; specialized instances pass their + // own value. Written back to server.cfg on first boot. Auto-invoked at startup via + // AssemblyHandler.Invoke("Configure"). + Instance.MaxSearchNodes = ServerConfiguration.GetOrUpdateSetting( + "pathfinding.maxSearchNodes", + 1000 + ); + } private Point3D _goal; @@ -133,6 +160,7 @@ public class BitmapAStarAlgorithm : PathAlgorithm _currentMobileIgnoreDoors = false; _currentMobileIgnoreMovableImpassables = false; } + // Mirrors MovementImpl: dead/spectral mobiles also ignore doors. _currentMobileIgnoreDoors |= !m.Alive || m.Body.BodyID == 0x3DB || m.IsDeadBondedPet; _currentMobileIgnoreSpellFields = m is PlayerMobile && map != Map.Felucca; @@ -163,7 +191,7 @@ public class BitmapAStarAlgorithm : PathAlgorithm while (_openQueue.Count > 0) { - if (++depth > MaxDepth) + if (++depth > MaxSearchNodes) { break; } @@ -287,7 +315,7 @@ public class BitmapAStarAlgorithm : PathAlgorithm return null; } - private static int GetIndex(int x, int y, int z) + private int GetIndex(int x, int y, int z) { x -= _xOffset; y -= _yOffset; @@ -304,7 +332,7 @@ public class BitmapAStarAlgorithm : PathAlgorithm /// On cache fallthrough or for non-default walkers, defers to /// for THIS cell only. /// - private static int GetSuccessors(int p, Mobile m, Map map) + private int GetSuccessors(int p, Mobile m, Map map) { var px = p % AreaSize; var py = p / AreaSize % AreaSize; @@ -325,7 +353,22 @@ public class BitmapAStarAlgorithm : PathAlgorithm if (!lookup.IsHit) { - return GetSuccessorsSlowPath(m, map, px, py, p3D, vals); + // Multi-covered cells: synthesize a multi-aware mask in ONE pass (over land + statics + + // house/boat component tiles) instead of the slow path's 8x per-cell CheckMovement. + // Fliers and cache-off already returned at the top of GetSuccessors, so this only runs + // for cacheable walkers/swimmers. The synthesized mask flows through the SAME + // capability-overlay + diagonal corner-cut + dynamic-obstacle loop below as a static hit. + if (lookup.HitKind == CacheHitKind.Fallthrough_Multi) + { + // Multi-covered cell: the per-multiID interior cache serves a ~20 ns lookup for + // interior cells (and records the right counter); it falls back internally to the + // Phase-2 live synthesizer for perimeter / terrain-dirty / foundation cells. + lookup = MultiMaskCache.Instance.GetMask(map, p3D.X, p3D.Y, (sbyte)p3D.Z); + } + else + { + return GetSuccessorsSlowPath(m, map, px, py, p3D, vals); + } } // Capability overlay: walking allowed unless cantWalk; swimming allowed if canSwim. @@ -427,7 +470,7 @@ public class BitmapAStarAlgorithm : PathAlgorithm /// non-Felucca players → ignore spell fields). Mobiles: any other mobile whose Z range /// overlaps and which we can't move over. /// - private static bool IsBlockedByDynamic(Mobile m, Map map, int x, int y, int z) + private bool IsBlockedByDynamic(Mobile m, Map map, int x, int y, int z) { var ourTop = z + PersonHeightConst; @@ -504,7 +547,7 @@ public class BitmapAStarAlgorithm : PathAlgorithm /// CheckMovement validates land/statics/items via MovementImpl; dynamic mobile blocking /// is layered on top because MovementImpl doesn't iterate same-cell mobiles. /// - private static int GetSuccessorsSlowPath(Mobile m, Map map, int px, int py, Point3D p3D, int[] vals) + private int GetSuccessorsSlowPath(Mobile m, Map map, int px, int py, Point3D p3D, int[] vals) { var count = 0; diff --git a/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs b/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs index 6418ca1c1..ad4d8b3e7 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/CacheHitKind.cs @@ -3,7 +3,7 @@ namespace Server.Engines.Pathing.Cache; /// /// Outcome categories for StepCache.TryGetMask. Used for telemetry and to drive /// the slow-path fallthrough decision in callers. Ordering is load-bearing: -/// values 0-2 are hits, values 3-6 are fallthroughs (see StepMask.IsHit). +/// values 0-2 are hits, values 3+ are fallthroughs (see StepMask.IsHit). /// public enum CacheHitKind : byte { @@ -14,4 +14,5 @@ public enum CacheHitKind : byte Fallthrough_OffMap = 4, // out of bounds Fallthrough_SourceZMismatch = 5, // |loc.Z - BakedSourceZ| > StepHeight; cache answer would diverge Fallthrough_NotBuilt = 6, // first-touch miss without lazy file hit; build deferred until second touch + Fallthrough_Multi = 7, // a multi (house/boat) covers this cell or its halo; use the live path } diff --git a/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs b/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs index 10c78014d..72c225423 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/CacheStats.cs @@ -13,6 +13,9 @@ public readonly struct CacheStats( long fallthroughOffMap, long fallthroughSourceZMismatch, long fallthroughNotBuilt, + long fallthroughMulti, + long multiLocalHits, + long multiMaskCacheHits, long evictionsByLruCap, long buildsTotal ) @@ -25,6 +28,9 @@ public readonly struct CacheStats( public readonly long FallthroughOffMap = fallthroughOffMap; public readonly long FallthroughSourceZMismatch = fallthroughSourceZMismatch; public readonly long FallthroughNotBuilt = fallthroughNotBuilt; + public readonly long FallthroughMulti = fallthroughMulti; + public readonly long MultiLocalHits = multiLocalHits; + public readonly long MultiMaskCacheHits = multiMaskCacheHits; public readonly long EvictionsByLruCap = evictionsByLruCap; public readonly long BuildsTotal = buildsTotal; } diff --git a/Projects/UOContent/Engines/Pathing/Cache/MultiMaskCache.cs b/Projects/UOContent/Engines/Pathing/Cache/MultiMaskCache.cs new file mode 100644 index 000000000..efb047d23 --- /dev/null +++ b/Projects/UOContent/Engines/Pathing/Cache/MultiMaskCache.cs @@ -0,0 +1,346 @@ +using System; +using System.Collections.Generic; +using Server.Items; +using Server.Multis; + +namespace Server.Engines.Pathing.Cache; + +/// +/// Warm, in-memory cache of per-multiID local-frame walkability masks for INTERIOR multi cells +/// (cell + all 8 neighbours covered by the multi → terrain-neighbour-free → position-invariant). +/// Wraps the Phase-2 synthesizer (StepProbe.ComputeMultiMaskAt). Cleanliness is decided ONCE per +/// instance (BaseMulti.PathInteriorCacheState, via ComputeFootprintClean): a clean instance — whole +/// footprint terrain below the floor — serves interior cells from the shared per-multiID cache; +/// dirty instances, boats (movers), and HouseFoundation (runtime-mutable) fall back to live-synth. +/// Keyed by multiID & 0x3FFF. +/// +public sealed class MultiMaskCache +{ + public static MultiMaskCache Instance { get; } = new(); + + private const int StepHeight = 2; + + private readonly Dictionary _byMultiId = []; + + public void Clear() => _byMultiId.Clear(); + + /// + /// Returns the multi-aware StepMask for a covered cell (x,y,sourceZ). Serves a cached interior + /// mask when available and the guards pass (counted as a MultiMaskCacheHit); otherwise falls + /// back to the Phase-2 live synthesizer ComputeMultiMaskAt (counted as a MultiLocalHit), caching + /// the result if the cell is interior and clean. Always returns a usable mask (HitKind == Hit). + /// + public StepMask GetMask(Map map, int x, int y, sbyte sourceZ) + { + if (!TryResolveCoveringMulti(map, x, y, out var multi, out var lx, out var ly) + || multi is HouseFoundation) // runtime-mutable per-instance DesignState MCL + { + return LiveSynth(map, x, y, sourceZ); + } + + // Boats are cached too: their per-multiID deck masks are movement-invariant (built once per + // heading), and the per-instance clean gate below + the ItemID/location/map resets keep a + // moving/turning boat correct. Narrow boats have little interior; wide galleons gain a lot. + + // Per-instance footprint cleanliness (computed once, stored on the multi; reset on move). + // Clean ⇒ no terrain intrusion anywhere in the footprint ⇒ interior cells are exact from the + // shared per-multiID cache. Dirty ⇒ degrade to the live synthesizer (never serve a wrong mask). + if (multi.PathInteriorCacheState == MultiInteriorCacheState.Unknown) + { + multi.PathInteriorCacheState = + ComputeFootprintClean(map, multi) ? MultiInteriorCacheState.Clean : MultiInteriorCacheState.Dirty; + } + + if (multi.PathInteriorCacheState != MultiInteriorCacheState.Clean) + { + return LiveSynth(map, x, y, sourceZ); + } + + var mcl = multi.Components; + var local = GetOrCreate(multi.ItemID & 0x3FFF, mcl.Width, mcl.Height); + var state = local.GetState(lx, ly); + + if (state == MultiLocalMask.CellState.Cached) + { + // Footprint is clean, so only the source-Z match matters (terrain can't intrude). + var worldFloorZ = local.FloorZAt(lx, ly) + multi.Z; + if (Math.Abs(sourceZ - worldFloorZ) <= StepHeight) + { + StepCache.Instance.RecordMultiMaskCacheHit(); + return ToWorldZ(local.MaskAt(lx, ly), multi.Z); + } + + return LiveSynth(map, x, y, sourceZ); + } + + if (state == MultiLocalMask.CellState.NonInterior) + { + return LiveSynth(map, x, y, sourceZ); + } + + // Unknown → classify + (if interior) build & cache. No per-cell terrain guard needed: the + // instance is clean, so every interior cell's 3x3 terrain is below the floor. + var mask = LiveSynth(map, x, y, sourceZ); + if (IsInteriorLocalCell(mcl, lx, ly) + && TryToLocalZ(mask, multi.Z, out var localMask) + && sourceZ - multi.Z is >= sbyte.MinValue and <= sbyte.MaxValue) + { + local.SetCached(lx, ly, localMask, (sbyte)(sourceZ - multi.Z)); + } + else + { + local.SetNonInterior(lx, ly); + } + + return mask; + } + + private static StepMask LiveSynth(Map map, int x, int y, sbyte sourceZ) + { + StepCache.Instance.RecordMultiLocalHit(); + return StepProbe.ComputeMultiMaskAt(map, x, y, sourceZ); + } + + private MultiLocalMask GetOrCreate(int key, int width, int height) + { + if (!_byMultiId.TryGetValue(key, out var m)) + { + m = new MultiLocalMask(width, height); + _byMultiId[key] = m; + } + + return m; + } + + /// + /// Finds the multi covering (x,y) and the local cell indices into its MCL. Mirrors + /// Map.StaticTileEnumerator / BaseMulti.Contains. Returns false if no multi covers the cell. + /// + public static bool TryResolveCoveringMulti(Map map, int x, int y, out BaseMulti multi, out int lx, out int ly) + { + foreach (var candidate in map.GetMultisInSector(x, y)) + { + var mcl = candidate.Components; + var cx = x - candidate.X - mcl.Min.X; + var cy = y - candidate.Y - mcl.Min.Y; + if (cx >= 0 && cy >= 0 && cx < mcl.Width && cy < mcl.Height && mcl.Tiles[cx][cy].Length > 0) + { + multi = candidate; + lx = cx; + ly = cy; + return true; + } + } + + multi = null; + lx = ly = 0; + return false; + } + + /// + /// True iff local cell (lx,ly) and all 8 neighbours are covered by the multi (have MCL tiles). + /// Such a cell's 8-direction transition is fully determined by the multi (no terrain neighbour), + /// so its mask is position-invariant. A pure function of the MCL. + /// + public static bool IsInteriorLocalCell(MultiComponentList mcl, int lx, int ly) + { + for (var dy = -1; dy <= 1; dy++) + { + for (var dx = -1; dx <= 1; dx++) + { + var nx = lx + dx; + var ny = ly + dy; + if (nx < 0 || ny < 0 || nx >= mcl.Width || ny >= mcl.Height || mcl.Tiles[nx][ny].Length == 0) + { + return false; + } + } + } + + return true; + } + + /// + /// Converts a world-frame mask's per-direction Zs to local Z (subtract multiZ). Returns false + /// if any local Z doesn't fit sbyte (caller must then NOT cache the cell — rare; only when + /// |multiZ| is large enough to push a world Z out of range). Mask (walk/wet) bits are copied. + /// + public static bool TryToLocalZ(StepMask world, int multiZ, out StepMask local) + { + local = default; + Span w = stackalloc sbyte[8]; + Span s = stackalloc sbyte[8]; + for (var d = 0; d < 8; d++) + { + var lw = world.GetWalkZ((Direction)d) - multiZ; + var ls = world.GetSwimZ((Direction)d) - multiZ; + if (lw < sbyte.MinValue || lw > sbyte.MaxValue || ls < sbyte.MinValue || ls > sbyte.MaxValue) + { + return false; + } + w[d] = (sbyte)lw; + s[d] = (sbyte)ls; + } + + local = new StepMask( + world.WalkMask, world.WetMask, + w[0], w[1], w[2], w[3], w[4], w[5], w[6], w[7], + s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7] + ); + return true; + } + + /// + /// True iff all terrain (land + statics) at (x,y) sits strictly below , + /// so a creature standing on the multi floor never sees terrain in its envelope and the cached + /// (terrain-free) mask is exact. Cheap: one land-top read + the cell's static-tile array scan. + /// + public static bool TerrainTopBelow(Map map, int x, int y, sbyte floorZ) + { + map.GetAverageZ(x, y, out _, out _, out var landTop); + if (landTop >= floorZ) + { + return false; + } + + foreach (var tile in map.Tiles.GetStaticTiles(x, y)) + { + var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + var top = tile.Z + data.CalcHeight; + if (top >= floorZ) + { + return false; + } + } + + return true; + } + + /// Highest terrain (land + statics) top at (x,y). Building block for the cleanliness check. + public static int TerrainTop(Map map, int x, int y) + { + map.GetAverageZ(x, y, out _, out _, out var top); + foreach (var tile in map.Tiles.GetStaticTiles(x, y)) + { + var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + var t = tile.Z + data.CalcHeight; + if (t > top) + { + top = t; + } + } + + return top; + } + + /// + /// True iff the multi's WHOLE footprint terrain sits below its lowest standable floor — i.e. + /// maxTerrain < minFloor over all covered cells. When true, no covered cell's terrain (nor any + /// neighbour's) can intrude into a creature's floor envelope, so interior cells of this design are + /// safe to serve from the shared per-multiID cache for THIS instance. One-time per instance. + /// + public static bool ComputeFootprintClean(Map map, BaseMulti multi) + { + var mcl = multi.Components; + var minFloorLocal = int.MaxValue; + var maxTerrain = int.MinValue; + + for (var lx = 0; lx < mcl.Width; lx++) + { + for (var ly = 0; ly < mcl.Height; ly++) + { + var col = mcl.Tiles[lx][ly]; + if (col.Length == 0) + { + continue; // uncovered local cell + } + + foreach (var tile in col) + { + var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + if (data.Surface && !data.Impassable) + { + var top = tile.Z + data.CalcHeight; + if (top < minFloorLocal) + { + minFloorLocal = top; + } + } + } + + var terrain = TerrainTop(map, multi.X + mcl.Min.X + lx, multi.Y + mcl.Min.Y + ly); + if (terrain > maxTerrain) + { + maxTerrain = terrain; + } + } + } + + if (minFloorLocal == int.MaxValue) + { + return false; // no standable floor anywhere → don't cache (defensive) + } + + return maxTerrain < minFloorLocal + multi.Z; + } + + /// Inverse of : add multiZ back to recover world Zs. + public static StepMask ToWorldZ(StepMask local, int multiZ) + { + Span w = stackalloc sbyte[8]; + Span s = stackalloc sbyte[8]; + for (var d = 0; d < 8; d++) + { + w[d] = (sbyte)(local.GetWalkZ((Direction)d) + multiZ); + s[d] = (sbyte)(local.GetSwimZ((Direction)d) + multiZ); + } + + return new StepMask( + local.WalkMask, local.WetMask, + w[0], w[1], w[2], w[3], w[4], w[5], w[6], w[7], + s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7] + ); + } +} + +/// +/// Per-multiID lazily-filled grid of interior-cell masks. Cell state: Unknown (not yet classified), +/// Cached (interior + clean → mask valid), NonInterior (perimeter/edge/terrain-dirty → live-synth). +/// +internal sealed class MultiLocalMask +{ + public enum CellState : byte { Unknown = 0, Cached = 1, NonInterior = 2 } + + private readonly int _width; + private readonly int _height; + private readonly CellState[] _state; + private readonly StepMask[] _mask; // local-Z mask, valid when state == Cached + private readonly sbyte[] _floorZ; // local floor Z, valid when state == Cached + + public MultiLocalMask(int width, int height) + { + _width = width; + _height = height; + _state = new CellState[width * height]; + _mask = new StepMask[width * height]; + _floorZ = new sbyte[width * height]; + } + + public int Width => _width; + public int Height => _height; + + public CellState GetState(int lx, int ly) => _state[ly * _width + lx]; + + public void SetCached(int lx, int ly, StepMask localMask, sbyte localFloorZ) + { + var i = ly * _width + lx; + _mask[i] = localMask; + _floorZ[i] = localFloorZ; + _state[i] = CellState.Cached; + } + + public void SetNonInterior(int lx, int ly) => _state[ly * _width + lx] = CellState.NonInterior; + + public StepMask MaskAt(int lx, int ly) => _mask[ly * _width + lx]; + public sbyte FloorZAt(int lx, int ly) => _floorZ[ly * _width + lx]; +} diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs index 2be027477..5955ddd35 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs @@ -54,11 +54,18 @@ public sealed class StepCache private long _fallthroughOffMap; private long _fallthroughSourceZMismatch; private long _fallthroughNotBuilt; + private long _fallthroughMulti; + private long _multiLocalHits; + private long _multiMaskCacheHits; private long _evictionsByLruCap; private long _buildsTotal; private StepCache() { } + public void RecordMultiLocalHit() => _multiLocalHits++; + + public void RecordMultiMaskCacheHit() => _multiMaskCacheHits++; + /// Hard cap on resident chunk count. Default 8192. Override for tests / ops. public int MaxResidentChunks { get; set; } = 8192; @@ -119,6 +126,9 @@ public sealed class StepCache fallthroughOffMap: _fallthroughOffMap, fallthroughSourceZMismatch: _fallthroughSourceZMismatch, fallthroughNotBuilt: _fallthroughNotBuilt, + fallthroughMulti: _fallthroughMulti, + multiLocalHits: _multiLocalHits, + multiMaskCacheHits: _multiMaskCacheHits, evictionsByLruCap: _evictionsByLruCap, buildsTotal: _buildsTotal ); @@ -132,6 +142,7 @@ public sealed class StepCache { ClearResidentChunks(); CloseLazyReaders(); + MultiMaskCache.Instance.Clear(); } /// @@ -153,6 +164,9 @@ public sealed class StepCache _fallthroughOffMap = 0; _fallthroughSourceZMismatch = 0; _fallthroughNotBuilt = 0; + _fallthroughMulti = 0; + _multiLocalHits = 0; + _multiMaskCacheHits = 0; _evictionsByLruCap = 0; _buildsTotal = 0; } @@ -162,21 +176,6 @@ public sealed class StepCache // stays bounded by MaxResidentChunks regardless of file size. private readonly Dictionary _lazyReaders = new(); - /// - /// Combined XxHash3 fingerprint of the running server's TileData flag tables AND - /// the per-map .mul / .uop file contents (mapX.mul, staidxX.mul, staticsX.mul). - /// Public surface for tooling (benchmark fixtures, bake utilities) that wants to - /// detect a stale .swb file without round-tripping through the lazy-open path. - /// - public static ulong ComputeLiveFingerprint(int mapId) => StepCacheFile.ComputeFingerprint(mapId); - - /// - /// Peek at a .swb file's stored fingerprint field without parsing the rest of the - /// header. Returns false on missing file, bad magic, or wrong version. - /// - public static bool TryReadFingerprintFromFile(string path, out ulong fingerprint) => - StepCacheFile.TryReadFingerprint(path, out fingerprint); - /// /// Walk every chunk in , populate the resident set, then /// save to . Returns the number of chunks written. @@ -367,6 +366,15 @@ public sealed class StepCache /// public int OpenLazyReaderCount => _lazyReaders.Count; + /// + /// True if a valid .swb reader is open for . A reader only opens via + /// after validates the + /// file's fingerprint against the live tile data, so "has reader" already means "present and + /// up-to-date" — the boot prebake uses this to skip baking maps that don't need it, instead of + /// recomputing the fingerprint a second time. + /// + public bool HasLazyReader(int mapId) => _lazyReaders.ContainsKey(mapId); + /// Test-only diagnostic: does the lazy reader for hold an offset for (chunkX, chunkY)? internal bool LazyReaderHasChunk(int mapId, int chunkX, int chunkY) => _lazyReaders.TryGetValue(mapId, out var r) && r.Has(chunkX, chunkY); @@ -450,6 +458,41 @@ public sealed class StepCache private const int ChunkSize = 16; + /// + /// True if a multi (house / boat) covers (x, y) or any of its 8 neighbours. Multi-covered + /// cells — plus the 1-cell halo, because a cell's mask encodes the edges TO its neighbours, so + /// a neighbouring wall must block those edges — are served by the live movement path, not the + /// static chunk cache. Cheap: an interior cell checks only its own sector (chunk == sector); + /// only edge/corner cells additionally check the adjacent sector(s) the halo reaches. + /// + private static bool MultiInfluence(Map map, int x, int y) + { + var sx = x >> 4; + var sy = y >> 4; + if (map.GetRealSector(sx, sy).HasMultis) + { + return true; + } + + var west = (x & 15) == 0; + var east = (x & 15) == 15; + var north = (y & 15) == 0; + var south = (y & 15) == 15; + if (!(west || east || north || south)) + { + return false; // interior cell — its whole halo is inside the (multi-free) own sector + } + + return west && map.GetRealSector(sx - 1, sy).HasMultis + || east && map.GetRealSector(sx + 1, sy).HasMultis + || north && map.GetRealSector(sx, sy - 1).HasMultis + || south && map.GetRealSector(sx, sy + 1).HasMultis + || west && north && map.GetRealSector(sx - 1, sy - 1).HasMultis + || east && north && map.GetRealSector(sx + 1, sy - 1).HasMultis + || west && south && map.GetRealSector(sx - 1, sy + 1).HasMultis + || east && south && map.GetRealSector(sx + 1, sy + 1).HasMultis; + } + /// /// Hot-path query. Returns the cached mask + 8 destination Z values + hit kind. /// Inspect to decide whether to use the result or fall @@ -460,7 +503,40 @@ public sealed class StepCache if (map == null || map == Map.Internal || x < 0 || y < 0 || x >= map.Width || y >= map.Height) { _fallthroughOffMap++; - return new StepMask(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, CacheHitKind.Fallthrough_OffMap); + return new StepMask( + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + CacheHitKind.Fallthrough_OffMap + ); + } + + // Multis (houses, boats) are not baked into the static chunk cache (they're dynamic + // content). If a multi covers this cell or its 1-cell halo, route to the live movement + // path, which is fully multi-aware. Gated on Sector.HasMultis, so the multi-free majority + // of the map pays a single (interior) sector lookup. + if (MultiInfluence(map, x, y)) + { + _fallthroughMulti++; + return new StepMask( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + CacheHitKind.Fallthrough_Multi + ); } var chunkX = x >> 4; @@ -489,22 +565,32 @@ public sealed class StepCache else { _fallthroughNotBuilt++; - return new StepMask(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, CacheHitKind.Fallthrough_NotBuilt); - } - } - else - { - var sector = map.GetRealSector(chunkX, chunkY); - if (chunk.BuiltMultisVersion != sector.MultisVersion) - { - chunk = BuildChunk(map, chunkX, chunkY); - _chunks[key] = chunk; - hitKindResult = CacheHitKind.Miss_DirtyRebuild; - // _missesDirtyRebuild++ deferred to the outcome switch below so a - // multi-Z fallthrough on a freshly dirty-rebuilt chunk doesn't double-count. + return new StepMask( + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + CacheHitKind.Fallthrough_NotBuilt + ); } } + // A resident chunk is static-only — it never goes stale from multis (multi-covered cells + // fall through to the live path above). chunk.LastTouchedTicks = Core.TickCount; var cellIndex = ((y - (chunkY << 4)) << 4) | (x - (chunkX << 4)); @@ -524,7 +610,27 @@ public sealed class StepCache return stratumResult; } _fallthroughMultiZ++; - return new StepMask(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, CacheHitKind.Fallthrough_MultiZ); + return new StepMask( + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + CacheHitKind.Fallthrough_MultiZ + ); } // Source-Z guard: the cache stores one answer per cell baked at SourceZ. @@ -564,7 +670,27 @@ public sealed class StepCache } _fallthroughSourceZMismatch++; - return new StepMask(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, CacheHitKind.Fallthrough_SourceZMismatch); + return new StepMask( + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + CacheHitKind.Fallthrough_SourceZMismatch + ); } switch (hitKindResult) @@ -610,13 +736,9 @@ public sealed class StepCache { return null; } - var loaded = reader.TryReadChunk(chunkX, chunkY); - if (loaded == null) - { - return null; - } - var sector = map.GetRealSector(chunkX, chunkY); - return loaded.BuiltMultisVersion == sector.MultisVersion ? loaded : null; + // Static-only chunks are valid once the file fingerprint matched at open time; multi-covered + // cells fall through before reaching here. Returns null when the file lacks this chunk. + return reader.TryReadChunk(chunkX, chunkY); } /// @@ -725,8 +847,6 @@ public sealed class StepCache private StepChunk BuildChunk(Map map, int chunkX, int chunkY) { var chunk = new StepChunk(); - var sector = map.GetRealSector(chunkX, chunkY); - chunk.BuiltMultisVersion = sector.MultisVersion; var baseX = chunkX << 4; var baseY = chunkY << 4; diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs index d737e6b52..8cbc5c51f 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs @@ -20,7 +20,7 @@ namespace Server.Engines.Pathing.Cache; /// /// Header (40 bytes): /// u32 Magic = 0x42575300 ('SWB\0') -/// u32 Version = current FormatVersion (8) +/// u32 Version = current FormatVersion (9) /// u32 MapId /// u64 Fingerprint XxHash3 over (1) LandTable + ItemTable flags AND (2) the /// on-disk bytes of mapX.mul / .uop, staidxX.mul, staticsX.mul. @@ -42,7 +42,7 @@ namespace Server.Engines.Pathing.Cache; /// Record body (after inflate — the v6 layout): /// u16 ChunkX /// u16 ChunkY -/// u32 BuiltMultisVersion +/// u32 BuiltMultisVersion (reserved since v9 — always 0; chunks are static-only) /// u8 Kind 0 = Full; 2 = Uniform /// // Uniform (Kind == 2): ~28-byte record — all 256 cells share these single values: /// byte walkMask, wetMask; sbyte sourceZ; sbyte walkZ_N..NW (8); sbyte swimZ_N..NW (8) @@ -90,14 +90,20 @@ namespace Server.Engines.Pathing.Cache; internal static class StepCacheFile { public const uint Magic = 0x42575300; // 'SWB\0' - public const uint FormatVersion = 8; + + // v9: chunks are STATIC-ONLY (land + statics.mul, no multis). v8 and earlier baked multis + // (houses/boats) into chunks, which is unsafe to persist — multis are dynamic, and the + // BuiltMultisVersion they were tagged with is a non-persisted session counter. Bumping the + // version rejects those old files so they re-bake static-only. The BuiltMultisVersion record + // field is retained as a reserved (always-0) u32 to avoid a layout change. + public const uint FormatVersion = 9; /// /// Lowest format version this binary can load. Files below it are treated as missing /// (silently rejected) and overwritten by the next SaveToFile / BakeMap. The cache is /// fully regenerable, so a format bump just forces a one-time re-bake of stale files. /// - public const uint MinSupportedVersion = 8; + public const uint MinSupportedVersion = 9; // Per-chunk record discriminator (first byte after BuiltMultisVersion). 1 is reserved. private const byte KindFull = 0; @@ -177,38 +183,30 @@ internal static class StepCacheFile } /// - /// Combined XxHash3 fingerprint over (1) the loaded TileData flag tables and (2) the + /// Combined XxHash3 fingerprint over (1) the on-disk tiledata.mul file and (2) the /// per-map .mul / .uop file contents (via ). - /// Bake files carry this hash so a load can refuse to populate the cache when EITHER - /// tile flags shifted (client patch) OR the map data was rewritten (CentredSharp / - /// UOFiddler edit). The .mul format has no built-in CRC; this is the only way to - /// detect those mutations. + /// Bake files carry this hash so a load can refuse to populate the cache when EITHER the + /// tile data shifted (client patch) OR the map data was rewritten (CentredSharp / UOFiddler + /// edit). The .mul format has no built-in CRC; this is the only way to detect those mutations. + /// + /// IMPORTANT: hash the FILES, never the in-memory / + /// . The server patches those tables at runtime (ItemFixes, + /// LOSBlocker, PotionKeg, CTF, ...) at nondeterministic lifecycle points, so a fingerprint over + /// the live tables varies with WHEN it is taken; the file hash is the only lifecycle-stable + /// "did the client's tile data change?" signal. Server-side tile patches are applied identically + /// every boot and intentionally do NOT invalidate the cache — change one and you must + /// [PathCacheClear or bump the format. /// public static ulong ComputeFingerprint(int mapId) { var hasher = HashUtility.CreateXxHash3(); - // TileData flag tables — same projection trick as before: just the Flags ulong - // from each entry, written little-endian into a contiguous byte buffer. The - // struct itself has a string Name (reference) whose object identity isn't - // stable across runs, so MemoryMarshal.Cast over the whole struct would drift. - var landTable = TileData.LandTable; - var itemTable = TileData.ItemTable; - var bytes = new byte[(landTable.Length + itemTable.Length) * sizeof(ulong)]; - var span = bytes.AsSpan(); + // (1) tiledata.mul — hashed once, cached. The authoritative source for tile flags/heights. + Span tileDataBytes = stackalloc byte[sizeof(ulong)]; + BinaryPrimitives.WriteUInt64LittleEndian(tileDataBytes, TileDataFileFingerprint()); + hasher.Append(tileDataBytes); - for (var i = 0; i < landTable.Length; i++) - { - BinaryPrimitives.WriteUInt64LittleEndian(span[(i * 8)..], (ulong)landTable[i].Flags); - } - var itemOffset = landTable.Length * 8; - for (var i = 0; i < itemTable.Length; i++) - { - BinaryPrimitives.WriteUInt64LittleEndian(span[(itemOffset + i * 8)..], (ulong)itemTable[i].Flags); - } - hasher.Append(bytes); - - // Map files (mapX.mul / .uop, staidxX.mul, staticsX.mul). TileMatrix already + // (2) Map files (mapX.mul / .uop, staidxX.mul, staticsX.mul). TileMatrix already // streamed them through XxHash3 once at construction; mix the result in. var map = Map.Maps[mapId]; if (map != null && map != Map.Internal && map.Tiles != null) @@ -221,6 +219,35 @@ internal static class StepCacheFile return hasher.GetCurrentHashAsUInt64(); } + private static ulong _tileDataFileFingerprint; + private static bool _tileDataFileFingerprintComputed; + + /// + /// XxHash3 over the raw tiledata.mul bytes, computed once and cached — the file never + /// changes during a run. Mirrors for the map + /// files. Returns 0 if the file can't be found (the server can't run without it anyway, so + /// this only matters in stripped test hosts, where 0 is a fine deterministic constant). + /// + private static ulong TileDataFileFingerprint() + { + if (_tileDataFileFingerprintComputed) + { + return _tileDataFileFingerprint; + } + + var path = Core.FindDataFile("tiledata.mul", false); + if (path != null) + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + var hasher = HashUtility.CreateXxHash3(); + hasher.Append(fs); + _tileDataFileFingerprint = hasher.GetCurrentHashAsUInt64(); + } + + _tileDataFileFingerprintComputed = true; + return _tileDataFileFingerprint; + } + /// /// Writes the file: header (with placeholder IndexOffset) → chunks (offsets recorded) /// → index trailer → patches the header IndexOffset. must @@ -234,9 +261,7 @@ internal static class StepCacheFile // chunks add another ~2.5 KB (swim layer) but they're a small fraction of any // map; the writer grows on overflow so under-estimating just causes a few // realloc/copy cycles during the bake — not a correctness issue. - var capacity = HeaderSize - + (BytesPerChunkBase + 256) * (int)chunkCount - + IndexEntryBytes * (int)chunkCount; + var capacity = HeaderSize + (BytesPerChunkBase + 256) * (int)chunkCount + IndexEntryBytes * (int)chunkCount; var buffer = new byte[capacity]; var w = new BufferWriter(buffer, prefixStr: false); diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs index 8b6bd5b20..a8c89991b 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepProbe.cs @@ -5,8 +5,11 @@ namespace Server.Engines.Pathing.Cache; /// /// Computes static-only walkability for a single cell — the per-cell, per-direction -/// "can step" mask and destination Z, based purely on land + statics + multis. Mirrors -/// .Check minus the item and mobile collision phases. +/// "can step" mask and destination Z, based purely on land + statics.mul tiles (NOT +/// multis). Mirrors .Check minus the item and mobile collision +/// phases. Multis (houses, boats) are intentionally excluded: they're dynamic content, so +/// cells they cover route to the live movement path via 's +/// multi-halo fallthrough rather than being baked into the static chunk cache. /// /// /// Bakes two rule sets per cell: walker (canSwim=false, cantWalk=false) and swim-only @@ -56,7 +59,7 @@ public static class StepProbe zs[count++] = landCenter; } - foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) + foreach (var tile in map.Tiles.GetStaticTiles(x, y)) { if (count >= zs.Length) { @@ -141,7 +144,7 @@ public static class StepProbe cand[count++] = landCenter; } - foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) + foreach (var tile in map.Tiles.GetStaticTiles(x, y)) { if (count >= cand.Length) { @@ -183,16 +186,38 @@ public static class StepProbe return n; } - public static StepMask ComputeMaskAt(Map map, int x, int y, sbyte sourceZ) + public static StepMask ComputeMaskAt(Map map, int x, int y, sbyte sourceZ) => + ComputeMaskCore(map, x, y, sourceZ, includeMultis: false); + + /// + /// Multi-aware counterpart to : synthesizes the full 8-direction + /// walkability mask for a cell covered by (or adjacent to) a multi, folding house/boat component + /// tiles into the surface/step logic via GetStaticAndMultiTiles. Replaces the slow path's 8x + /// per-cell CheckMovement for Fallthrough_Multi cells. Item/mobile collision is still handled by + /// the caller's dynamic-obstacle pass. + /// + public static StepMask ComputeMultiMaskAt(Map map, int x, int y, sbyte sourceZ) => + ComputeMaskCore(map, x, y, sourceZ, includeMultis: true); + + /// + /// Shared per-cell 8-direction mask builder. With includeMultis=false this reproduces the + /// static-only bake (land + statics.mul). With includeMultis=true it also folds in multi + /// (house/boat) component tiles via GetStaticAndMultiTiles — the multi-aware synthesizer used + /// for Fallthrough_Multi cells. Item/mobile collision phases are still omitted (the dynamic pass + /// owns them). + /// + private static StepMask ComputeMaskCore(Map map, int x, int y, sbyte sourceZ, bool includeMultis) { if (map == null || map == Map.Internal) { return default; } - GetStaticStartZ(map, x, y, sourceZ, canSwim: false, cantWalk: false, + var srcTiles = includeMultis ? map.Tiles.GetStaticAndMultiTiles(x, y) : map.Tiles.GetStaticTiles(x, y); + + GetStaticStartZ(map, x, y, sourceZ, srcTiles, canSwim: false, cantWalk: false, out var walkStartZ, out var walkStartTop, out _); - GetStaticStartZ(map, x, y, sourceZ, canSwim: true, cantWalk: true, + GetStaticStartZ(map, x, y, sourceZ, srcTiles, canSwim: true, cantWalk: true, out var swimStartZ, out var swimStartTop, out _); byte walkMask = 0; @@ -210,14 +235,16 @@ public static class StepProbe var dy = y; CalcMoves.Offset((Direction)d, ref dx, ref dy); - if (CheckStaticStep(map, dx, dy, walkStartZ, walkStartTop, + var dTiles = includeMultis ? map.Tiles.GetStaticAndMultiTiles(dx, dy) : map.Tiles.GetStaticTiles(dx, dy); + + if (CheckStaticStep(map, dx, dy, dTiles, walkStartZ, walkStartTop, canSwim: false, cantWalk: false, out var walkZ)) { walkMask |= (byte)(1 << d); walkZs[d] = (sbyte)walkZ; } - if (CheckStaticStep(map, dx, dy, swimStartZ, swimStartTop, + if (CheckStaticStep(map, dx, dy, dTiles, swimStartZ, swimStartTop, canSwim: true, cantWalk: true, out var swimZ)) { wetMask |= (byte)(1 << d); @@ -242,7 +269,7 @@ public static class StepProbe /// public static int ComputeStandingZ(Map map, int x, int y, int locZ) { - GetStaticStartZ(map, x, y, locZ, canSwim: false, cantWalk: false, out _, out _, out var zCenter); + GetStaticStartZ(map, x, y, locZ, map.Tiles.GetStaticTiles(x, y), canSwim: false, cantWalk: false, out _, out _, out var zCenter); return zCenter; } @@ -270,7 +297,7 @@ public static class StepProbe } // Otherwise scan statics for a wet surface. - foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) + foreach (var tile in map.Tiles.GetStaticTiles(x, y)) { var data = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; if (data.Wet) @@ -286,7 +313,8 @@ public static class StepProbe /// Mirrors GetStartZ from MovementImpl, parameterized by canSwim / cantWalk. /// private static void GetStaticStartZ( - Map map, int x, int y, int locZ, bool canSwim, bool cantWalk, out int zLow, out int zTop, out int zCenter + Map map, int x, int y, int locZ, Map.StaticTileEnumerable tiles, + bool canSwim, bool cantWalk, out int zLow, out int zTop, out int zCenter ) { var landTile = map.Tiles.GetLandTile(x, y); @@ -312,7 +340,7 @@ public static class StepProbe isSet = true; } - foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) + foreach (var tile in tiles) { var id = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; var calcTop = tile.Z + id.CalcHeight; @@ -350,7 +378,8 @@ public static class StepProbe /// Items and mobile collision phases are omitted. /// private static bool CheckStaticStep( - Map map, int x, int y, int startZ, int startTop, bool canSwim, bool cantWalk, out int newZ + Map map, int x, int y, Map.StaticTileEnumerable tiles, int startZ, int startTop, + bool canSwim, bool cantWalk, out int newZ ) { newZ = 0; @@ -377,7 +406,7 @@ public static class StepProbe int testTop; - foreach (var tile in map.Tiles.GetStaticAndMultiTiles(x, y)) + foreach (var tile in tiles) { var itemData = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; var notWater = !itemData.Wet; diff --git a/Projects/UOContent/Engines/Pathing/MovementPath.cs b/Projects/UOContent/Engines/Pathing/MovementPath.cs index eb5d2f9d4..b5307ad3d 100644 --- a/Projects/UOContent/Engines/Pathing/MovementPath.cs +++ b/Projects/UOContent/Engines/Pathing/MovementPath.cs @@ -4,7 +4,6 @@ using Server.Engines.Pathing; using Server.Engines.Pathing.Cache; using Server.Items; using Server.PathAlgorithms; -using Server.PathAlgorithms.BitmapAStar; using Server.Spells; using Server.Targeting; diff --git a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs index e53746ebd..14a41b6c0 100644 --- a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs +++ b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs @@ -1,6 +1,8 @@ +using System; using System.Diagnostics; using System.IO; using Server.Engines.Pathing.Cache; +using Server.Logging; namespace Server.Engines.Pathing; @@ -19,6 +21,12 @@ namespace Server.Engines.Pathing; /// public static class PathCacheCommands { + private static readonly ILogger logger = LogFactory.GetLogger(typeof(PathCacheCommands)); + + // modernuo.json flag: when true, Initialize() bakes any missing/stale .swb at startup. + // The first-boot ConfigurePrompts() prompt writes it. + private const string PrebakeSetting = "pathfinding.prebakeMaps"; + private static string PathFor(int mapId) => Path.Combine(Core.BaseDirectory, "Data", "Pathfinding", $"{mapId}.swb"); @@ -43,6 +51,86 @@ public static class PathCacheCommands AutoLoadAtStartup(); } + /// + /// First-boot prompt, auto-invoked by AssemblyHandler.Invoke("ConfigurePrompts") in + /// the startup sequence — after assemblies load (so content can prompt) but before Serilog + /// starts, so the console prompt isn't interleaved with async log output. Offers to pre-bake + /// the pathfinding .swb cache for the selected maps; the answer persists in + /// modernuo.json (), so it's asked exactly once. Skipped when the + /// setting already exists or when input is redirected (headless/CI) — operators can set the + /// flag directly. The bake itself happens later in . + /// + public static void ConfigurePrompts() + { + if (ServerConfiguration.GetSetting(PrebakeSetting, (string)null) != null || Console.IsInputRedirected) + { + return; + } + + Console.WriteLine(); + Console.WriteLine("Pre-bake the pathfinding cache for your selected maps now?"); + Console.WriteLine(" Bakes each map's .swb so there is zero first-pathfind-after-boot latency."); + Console.WriteLine(" Takes several minutes and ~tens of MB of disk per facet. You can also do"); + Console.WriteLine(" this later at runtime with [PathBake."); + Console.Write("Pre-bake now? [y/N] "); + + var answer = Console.ReadLine()?.Trim(); + var prebake = answer?.StartsWith("y", StringComparison.OrdinalIgnoreCase) == true; + + ServerConfiguration.SetSetting(PrebakeSetting, prebake); + } + + /// + /// Auto-invoked by AssemblyHandler.Invoke("Initialize") after the tile matrix and + /// world are loaded. When is set, bakes any map whose + /// .swb is missing or stale, so the first pathfind on each region is already warm. A + /// fresh cache makes this a no-op, so only first boot — or a client/map update that changes + /// the fingerprint — pays the cost. + /// + /// Validity is decided by : runs + /// in the earlier Configure phase, opening (and fingerprint- + /// validating) a reader for every up-to-date .swb. So a map with an open reader is + /// already good and we skip it — no need to recompute the fingerprint a second time here. + /// + public static void Initialize() + { + if (!ServerConfiguration.GetSetting(PrebakeSetting, false)) + { + return; + } + + var baked = 0; + for (var i = 0; i < Map.Maps.Length; i++) + { + var map = Map.Maps[i]; + if (map == null || map == Map.Internal) + { + continue; + } + + if (StepCache.Instance.HasLazyReader(map.MapID)) + { + continue; // AutoLoadAtStartup already opened a fingerprint-valid .swb for this map + } + + var path = PathFor(map.MapID); + + logger.Information( + "PathBake: pre-baking map {MapId} (pathfinding.prebakeMaps) — this can take several minutes...", + map.MapID + ); + StepCache.Instance.BakeMap(map.MapID, path); + StepCache.Instance.ClearResidentChunks(); + baked++; + } + + if (baked > 0) + { + logger.Information("PathBake: pre-bake complete ({Count} map(s) written).", baked); + AutoLoadAtStartup(); // (re)open the freshly written files as lazy backing stores + } + } + /// /// Open Data/Pathfinding/<mapId>.swb as a lazy backing store for every map. /// Reads only the header + chunk-offset index up front (~16 bytes per chunk); @@ -73,6 +161,8 @@ public static class PathCacheCommands from.SendMessage($" builds={stats.BuildsTotal} hits={stats.Hits}"); from.SendMessage($" miss(notBuilt)={stats.MissesNotBuilt} miss(dirty)={stats.MissesDirtyRebuild}"); from.SendMessage($" fallthru(multiZ)={stats.FallthroughMultiZ} fallthru(offMap)={stats.FallthroughOffMap} fallthru(srcZ)={stats.FallthroughSourceZMismatch}"); + from.SendMessage($" fallthru(multi)={stats.FallthroughMulti} fallthru(notBuilt)={stats.FallthroughNotBuilt}"); + from.SendMessage($" multiLocalHits={stats.MultiLocalHits}"); from.SendMessage($" evictions(lruCap)={stats.EvictionsByLruCap}"); } diff --git a/Projects/UOContent/Engines/Pathing/PathDiag.cs b/Projects/UOContent/Engines/Pathing/PathDiag.cs index 51b1372b8..d115c3c6c 100644 --- a/Projects/UOContent/Engines/Pathing/PathDiag.cs +++ b/Projects/UOContent/Engines/Pathing/PathDiag.cs @@ -2,7 +2,7 @@ using System; using System.Diagnostics; using System.IO; using Server.Engines.Pathing.Cache; -using Server.PathAlgorithms.BitmapAStar; +using Server.PathAlgorithms; using Server.Targeting; namespace Server.Engines.Pathing; diff --git a/Projects/UOContent/Engines/Pathing/PathfindRecorder.cs b/Projects/UOContent/Engines/Pathing/PathfindRecorder.cs index 795719911..f21637dc4 100644 --- a/Projects/UOContent/Engines/Pathing/PathfindRecorder.cs +++ b/Projects/UOContent/Engines/Pathing/PathfindRecorder.cs @@ -31,18 +31,15 @@ public static class PathfindRecorder { private static readonly ILogger logger = LogFactory.GetLogger(typeof(PathfindRecorder)); - private static bool _enabled; - private static string _outputPath; private static StreamWriter _writer; - private static long _recordsWritten; - public static bool Enabled => _enabled; - public static string OutputPath => _outputPath; - public static long RecordsWritten => _recordsWritten; + public static bool Enabled { get; private set; } + public static string OutputPath { get; set; } + public static long RecordsWritten { get; private set; } public static void Configure() { - _outputPath = ServerConfiguration.GetOrUpdateSetting( + OutputPath = ServerConfiguration.GetOrUpdateSetting( "pathfinding.recorder.path", Path.Combine(Core.BaseDirectory, "Data", "Pathfinding", "recordings", "pathfinds.jsonl") ); @@ -61,7 +58,7 @@ public static class PathfindRecorder /// public static void SetEnabled(bool enabled) { - if (enabled == _enabled) + if (enabled == Enabled) { return; } @@ -70,22 +67,22 @@ public static class PathfindRecorder { try { - Directory.CreateDirectory(Path.GetDirectoryName(_outputPath) ?? "."); - var stream = new FileStream(_outputPath, FileMode.Append, FileAccess.Write, FileShare.Read); + Directory.CreateDirectory(Path.GetDirectoryName(OutputPath) ?? "."); + var stream = new FileStream(OutputPath, FileMode.Append, FileAccess.Write, FileShare.Read); _writer = new StreamWriter(stream, new UTF8Encoding(false)); - _enabled = true; - logger.Information("PathfindRecorder enabled, writing to {Path}", _outputPath); + Enabled = true; + logger.Information("PathfindRecorder enabled, writing to {Path}", OutputPath); } catch (IOException ex) { - logger.Warning(ex, "PathfindRecorder: failed to open {Path} for write", _outputPath); + logger.Warning(ex, "PathfindRecorder: failed to open {Path} for write", OutputPath); _writer = null; - _enabled = false; + Enabled = false; } } else { - _enabled = false; + Enabled = false; try { _writer?.Flush(); @@ -93,10 +90,10 @@ public static class PathfindRecorder } catch (IOException ex) { - logger.Warning(ex, "PathfindRecorder: error closing {Path}", _outputPath); + logger.Warning(ex, "PathfindRecorder: error closing {Path}", OutputPath); } _writer = null; - logger.Information("PathfindRecorder disabled ({Count} records this session)", _recordsWritten); + logger.Information("PathfindRecorder disabled ({Count} records this session)", RecordsWritten); } } @@ -113,7 +110,7 @@ public static class PathfindRecorder } catch (IOException ex) { - logger.Warning(ex, "PathfindRecorder: flush failed for {Path}", _outputPath); + logger.Warning(ex, "PathfindRecorder: flush failed for {Path}", OutputPath); } } @@ -124,7 +121,7 @@ public static class PathfindRecorder /// public static void RecordIfEnabled(Mobile m, Map map, Point3D start, Point3D goal) { - if (!_enabled || _writer == null || m == null || map == null) + if (!Enabled || _writer == null || m == null || map == null) { return; } @@ -159,7 +156,7 @@ public static class PathfindRecorder vsb.Append(canMoveOverObstacles ? "true" : "false"); vsb.Append("}\n"); _writer.Write(vsb.AsSpan()); - _recordsWritten++; + RecordsWritten++; } catch (IOException ex) { 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/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 75f516afc..86f8e4848 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -1479,6 +1479,10 @@ namespace Server.Mobiles { var oldHits = Hits; + // Blood oath reflects the original damage the attacker dealt, before other modifiers. + var hasBloodOath = from != null && BloodOathSpell.GetBloodOath(from) == this; + var reflectedDamage = hasBloodOath ? amount : 0; + if (Core.AOS && !Summoned && Controlled && Utility.RandomDouble() < 0.2) { amount = (int)(amount * BonusPetDamageScalar); @@ -1489,14 +1493,24 @@ namespace Server.Mobiles amount = (int)(amount * 1.25); } - if (from != null && BloodOathSpell.GetBloodOath(from) == this) + if (hasBloodOath) { - amount = (int)(amount * 1.1); - from.Damage(amount, from); + amount = (int)(amount * 1.2); } base.Damage(amount, from, informMount); + // If the blood oath caster will die then damage is not reflected back to the attacker. + if (hasBloodOath && Alive && !Deleted && !IsDeadBondedPet) + { + // Reflect the original damage back to the attacker, attributed to the caster. + // The caster is a creature, so the Publish 48 (SA+) resist mitigation applies. + from.Damage( + BloodOathSpell.ComputeReflectedDamage(reflectedDamage, from.Skills.MagicResist.Value, Core.SA), + this + ); + } + if (SubdueBeforeTame && !Controlled && oldHits > HitsMax / 10 && Hits <= HitsMax / 10) { // * The creature has been beaten into subjugation! * diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs index d122b7acc..1eb9f45c5 100644 --- a/Projects/UOContent/Mobiles/PlayerMobile.cs +++ b/Projects/UOContent/Mobiles/PlayerMobile.cs @@ -2824,13 +2824,10 @@ namespace Server.Mobiles // If the blood oath caster will die then damage is not reflected back to the attacker if (hasBloodOath && Alive && !Deleted && !IsDeadBondedPet) { - // In some expansions resisting spells reduces reflect dmg from monster blood oath - var resistReflectedDamage = !from.Player && Core.ML && !Core.HS - ? (from.Skills.MagicResist.Value * 0.5 + 10) / 100 - : 0; - - // Reflect damage to the attacker - from.Damage((int)(amount * (1.0 - resistReflectedDamage)), this); + // Reflect the attacker's original damage back to them, attributed to the caster. + // The caster is a player, so the Publish 48 resist mitigation does not apply + // (it only reduces reflected damage from creature casters). + from.Damage(BloodOathSpell.ComputeReflectedDamage(amount, 0, applyResistMitigation: false), this); } } 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/Multis/Boats/BaseBoat.cs b/Projects/UOContent/Multis/Boats/BaseBoat.cs index 167ae2202..2082056da 100644 --- a/Projects/UOContent/Multis/Boats/BaseBoat.cs +++ b/Projects/UOContent/Multis/Boats/BaseBoat.cs @@ -371,52 +371,27 @@ namespace Server.Multis public override void OnLocationChange(Point3D old) { - if (TillerMan != null) - { - TillerMan.Location = new Point3D( - X + (TillerMan.X - old.X), - Y + (TillerMan.Y - old.Y), - Z + (TillerMan.Z - old.Z) - ); - } + base.OnLocationChange(old); - if (Hold != null) - { - Hold.Location = new Point3D(X + (Hold.X - old.X), Y + (Hold.Y - old.Y), Z + (Hold.Z - old.Z)); - } + TillerMan?.Location = new Point3D( + X + (TillerMan.X - old.X), + Y + (TillerMan.Y - old.Y), + Z + (TillerMan.Z - old.Z) + ); - if (PPlank != null) - { - PPlank.Location = new Point3D(X + (PPlank.X - old.X), Y + (PPlank.Y - old.Y), Z + (PPlank.Z - old.Z)); - } - - if (SPlank != null) - { - SPlank.Location = new Point3D(X + (SPlank.X - old.X), Y + (SPlank.Y - old.Y), Z + (SPlank.Z - old.Z)); - } + Hold?.Location = new Point3D(X + (Hold.X - old.X), Y + (Hold.Y - old.Y), Z + (Hold.Z - old.Z)); + PPlank?.Location = new Point3D(X + (PPlank.X - old.X), Y + (PPlank.Y - old.Y), Z + (PPlank.Z - old.Z)); + SPlank?.Location = new Point3D(X + (SPlank.X - old.X), Y + (SPlank.Y - old.Y), Z + (SPlank.Z - old.Z)); } public override void OnMapChange() { - if (TillerMan != null) - { - TillerMan.Map = Map; - } + base.OnMapChange(); - if (Hold != null) - { - Hold.Map = Map; - } - - if (PPlank != null) - { - PPlank.Map = Map; - } - - if (SPlank != null) - { - SPlank.Map = Map; - } + TillerMan?.Map = Map; + Hold?.Map = Map; + PPlank?.Map = Map; + SPlank?.Map = Map; } public bool CanCommand(Mobile m) => true; diff --git a/Projects/UOContent/Multis/Houses/BaseHouse.cs b/Projects/UOContent/Multis/Houses/BaseHouse.cs index 325ec479d..aea0cb8bc 100644 --- a/Projects/UOContent/Multis/Houses/BaseHouse.cs +++ b/Projects/UOContent/Multis/Houses/BaseHouse.cs @@ -1556,6 +1556,8 @@ namespace Server.Multis public override void OnMapChange() { + base.OnMapChange(); + if (LockDowns == null) { return; @@ -1627,6 +1629,8 @@ namespace Server.Multis public override void OnLocationChange(Point3D oldLocation) { + base.OnLocationChange(oldLocation); + if (LockDowns == null) { return; 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/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs b/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs index 847b3dbd7..c0f3c3ed0 100644 --- a/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs +++ b/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using ModernUO.CodeGeneratedEvents; using Server.Engines.BuffIcons; using Server.Mobiles; using Server.Targeting; @@ -16,7 +17,8 @@ public class BloodOathSpell : NecromancerSpell, ITargetingSpell Reagent.DaemonBlood ); - private static readonly Dictionary _oathTable = new(); + // Keyed by BOTH participants (caster and target) -> shared timer, so the oath resolves and + // removes from either side. Required so the death/delete events can break it from either mobile. private static readonly Dictionary _table = new(); public BloodOathSpell(Mobile caster, Item scroll = null) : base(caster, scroll, _info) @@ -39,11 +41,11 @@ public class BloodOathSpell : NecromancerSpell, ITargetingSpell { Caster.SendLocalizedMessage(1060508); // You can't curse that. } - else if (_oathTable.ContainsKey(Caster)) + else if (_table.ContainsKey(Caster)) { Caster.SendLocalizedMessage(1061607); // You are already bonded in a Blood Oath. } - else if (_oathTable.ContainsKey(m)) + else if (_table.ContainsKey(m)) { if (m.Player) { @@ -60,17 +62,12 @@ public class BloodOathSpell : NecromancerSpell, ITargetingSpell /* Temporarily creates a dark pact between the caster and the target. * Any damage dealt by the target to the caster is increased, but the target receives the same amount of damage. - * The effect lasts for ((Spirit Speak skill level - target's Resist Magic skill level) / 80 ) + 8 seconds. + * The effect lasts for ((Spirit Speak skill level - target's Resist Magic skill level) / 8) + 8 seconds. * - * NOTE: The above algorithm must be fixed point, it should be: - * ((ss-rm)/8)+8 + * NOTE: The in-game tooltip (and UOGuide) display /80 due to a fixed-point bug. + * The actual OSI formula is /8, matching RunUO/ServUO. */ - RemoveCurse(m); - - _oathTable[Caster] = Caster; - _oathTable[m] = Caster; - m.Spell?.OnCasterHurt(); Caster.PlaySound(0x175); @@ -81,16 +78,11 @@ public class BloodOathSpell : NecromancerSpell, ITargetingSpell m.FixedParticles(0x375A, 1, 17, 9919, 33, 7, EffectLayer.Waist); m.FixedParticles(0x3728, 1, 13, 9502, 33, 7, (EffectLayer)255); - var duration = TimeSpan.FromSeconds((GetDamageSkill(Caster) - GetResistSkill(m)) / 80 + 8); + var duration = TimeSpan.FromSeconds(GetDurationSeconds(GetDamageSkill(Caster), GetResistSkill(m))); m.CheckSkill(SkillName.MagicResist, 0.0, 120.0); // Skill check for gain - var timer = new ExpireTimer(Caster, m, duration); - timer.Start(); + RegisterOath(Caster, m, duration); - (Caster as PlayerMobile)?.AddBuff(new BuffInfo(BuffIcon.BloodOathCaster, 1075659, duration, m.Name)); - (m as PlayerMobile)?.AddBuff(new BuffInfo(BuffIcon.BloodOathCurse, 1075661, duration, Caster.Name)); - - _table[m] = timer; HarmfulSpell(m); } } @@ -100,25 +92,51 @@ public class BloodOathSpell : NecromancerSpell, ITargetingSpell Caster.Target = new SpellTarget(this, TargetFlags.Harmful); } - public static bool RemoveCurse(Mobile target) + // ((Spirit Speak - Resisting Spells) / 8) + 8 seconds. + internal static double GetDurationSeconds(double damageSkill, double resistSkill) => + (damageSkill - resistSkill) / 8 + 8; + + // The attacker takes the original (un-bonused) damage reflected back. Publish 48 lets the + // attacker's Resisting Spells reduce the reflected damage, but only against creature casters. + internal static int ComputeReflectedDamage(int originalDamage, double attackerMagicResist, bool applyResistMitigation) { - if (!_table.Remove(target, out var timer)) + if (!applyResistMitigation) + { + return originalDamage; + } + + // ((Resisting Spells * 10) / 20) + 10 = percentage of damage resisted + var resisted = (attackerMagicResist * 0.5 + 10) / 100; + return (int)(originalDamage * (1.0 - resisted)); + } + + internal static void RegisterOath(Mobile caster, Mobile target, TimeSpan duration) + { + var timer = new ExpireTimer(caster, target, duration); + _table[caster] = timer; + _table[target] = timer; + timer.Start(); + + (caster as PlayerMobile)?.AddBuff(new BuffInfo(BuffIcon.BloodOathCaster, 1075659, duration, target.Name)); + (target as PlayerMobile)?.AddBuff(new BuffInfo(BuffIcon.BloodOathCurse, 1075661, duration, caster.Name)); + } + + public static bool RemoveCurse(Mobile m) + { + if (m == null || !_table.TryGetValue(m, out var timer)) { return false; } var caster = timer.Caster; - if (_oathTable.Remove(caster)) - { - caster.SendLocalizedMessage(1061620); // Your Blood Oath has been broken. - } - - if (_oathTable.Remove(target)) - { - target.SendLocalizedMessage(1061620); // Your Blood Oath has been broken. - } + var target = timer.Target; timer.Stop(); + _table.Remove(caster); + _table.Remove(target); + + caster.SendLocalizedMessage(1061620); // Your Blood Oath has been broken. + target.SendLocalizedMessage(1061620); // Your Blood Oath has been broken. (caster as PlayerMobile)?.RemoveBuff(BuffIcon.BloodOathCaster); (target as PlayerMobile)?.RemoveBuff(BuffIcon.BloodOathCurse); @@ -127,31 +145,29 @@ public class BloodOathSpell : NecromancerSpell, ITargetingSpell } public static Mobile GetBloodOath(Mobile m) => - m == null || _oathTable.TryGetValue(m, out var oath) && oath == m ? null : oath; + m != null && _table.TryGetValue(m, out var timer) && timer.Target == m ? timer.Caster : null; + + // Death or deletion of either participant breaks the oath immediately. RemoveCurse resolves the + // shared timer from either the caster or the target key, so a single call per mobile is enough. + [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] + [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] + [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] + [OnEvent(nameof(BaseCreature.CreatureDeletedEvent))] + public static void OnCurseEnds(Mobile m) => RemoveCurse(m); private class ExpireTimer : Timer { - private readonly Mobile _target; - private readonly DateTime _end; - public Mobile Caster { get; } + public Mobile Target { get; } - public ExpireTimer(Mobile caster, Mobile target, TimeSpan delay) : base( - TimeSpan.FromSeconds(1.0), - TimeSpan.FromSeconds(1.0) - ) + // Single-shot: fire once when the oath expires. Death or deletion of either party is + // handled separately by the OnCurseEnds event handler. + public ExpireTimer(Mobile caster, Mobile target, TimeSpan delay) : base(delay) { Caster = caster; - _target = target; - _end = Core.Now + delay; + Target = target; } - protected override void OnTick() - { - if (Caster.Deleted || _target.Deleted || !Caster.Alive || !_target.Alive || Core.Now >= _end) - { - RemoveCurse(_target); - } - } + protected override void OnTick() => RemoveCurse(Target); } } diff --git a/dev-docs/pathfinding.md b/dev-docs/pathfinding.md index 40ae35917..dfe3d8e09 100644 --- a/dev-docs/pathfinding.md +++ b/dev-docs/pathfinding.md @@ -116,12 +116,36 @@ up front (~16 B/chunk); individual chunks are fetched on demand and remain LRU-c file buys is **zero first-pathfind-after-boot latency** for a region (chunks reload from file instead of being rebuilt by the runtime baker). -**Disk cost is large — measured, not the stale ~25 MB some older notes claim:** Trammel -(`1.swb`) is **~565 MB**. Felucca is comparable; all six facets together are on the order of -**~1.5–2 GB**. Baking is therefore a heavy, opt-in operation for serious shards with disk to -spare — **do not ship `.swb` files, and do not bake by default.** (If that footprint seems -wrong for what it stores, the file format is worth auditing separately — it is far above what -the format's design notes projected.) +**Disk cost (current, format v8):** the size-reduction roadmap (§ `.swb` size reduction) took +Trammel from ~565 MB to **17.9 MB**; the other facets are smaller, so all six together are on the +order of **tens of MB** — not the ~1.5–2 GB the uncompressed v2 format cost. Baking is now cheap +enough to **offer by prompt at first boot** (below) rather than being a heavy opt-in-only step. +Still don't *ship* prebaked `.swb` files (they're tile-data-version-specific and regenerate from +the client files anyway). + +### First-boot pre-bake prompt + +On the **first boot** (right after map selection) an interactive prompt offers to pre-bake the +`.swb` cache for the selected maps. The answer is stored in `modernuo.json` as +**`pathfinding.prebakeMaps`** (default **false**), so it is asked exactly once; headless/CI boots +(redirected input) skip the prompt and default to off — operators can set the flag directly. + +When the flag is set, `PathCacheCommands.Initialize()` bakes, at startup, any map whose `.swb` is +missing or **stale** (its tile-data fingerprint no longer matches — e.g. after a client/map +update). A fresh cache is a no-op, so only the first boot (or a post-update boot) pays the +several-minutes cost. Wiring: + +- The prompt runs in a dedicated `AssemblyHandler.Invoke("ConfigurePrompts")` startup phase — + after assemblies load (so content can register prompts) but **before Serilog starts**, so the + console prompt is not interleaved with the async console sink. Any class can participate by + defining `public static void ConfigurePrompts()` and self-gating on first-boot state. +- The bake runs in the later `Invoke("Initialize")` phase (after the tile matrix + world load, + which the bake walks). +- Staleness is decided by the `.swb` fingerprint, which `StepCacheFile.OpenForLazy` validates at + open time (hash of `tiledata.mul` + the per-map `.mul`/`.uop` files — never the in-memory + `TileData` tables, which the server patches at runtime). `Configure` opens a reader for every + up-to-date file; the bake in `Initialize` then skips any map where `StepCache.HasLazyReader` is + already true, so the fingerprint is computed once per boot, not twice. ## Configuration levers @@ -131,6 +155,7 @@ the format's design notes projected.) | `bitmap_pathfinding_cache` feature flag (`ContentFeatureFlags.BitmapPathfindingCache`, `Server.Systems.FeatureFlags`) | `FeatureFlagManager` | `true` | Off → `BitmapAStar` routes straight to the slow path with **no cache probe and no warming memory**. ≈ old FastAStar at ~1×. | | `pathfinding.maxResidentChunks` | `PathCacheCommands.Configure` | 8192 (~40 MB) | LRU cap on resident chunks = the warming-memory ceiling. Lower it (e.g. 512–1024 ≈ 2.5–5 MB) on small shards. | | `pathfinding.maxSearchNodes` | `PathCacheCommands.Configure` → `BitmapAStarAlgorithm.MaxSearchNodes` | 1000 | A* per-Find node-expansion budget. See limits above; ~1000 is the sweet spot. | +| `pathfinding.prebakeMaps` | `PathCacheCommands` (first-boot prompt + `Initialize`) | `false` | When set, bakes any missing/stale `.swb` for the selected maps at startup (fingerprint-gated, so a fresh cache is a no-op). Set interactively by the first-boot prompt. | | `PathFollower` `RepathDelay` | `PathFollower.cs` (const) | 2 s | Throttle: a moving goal re-`Find`s at most ~once per 2 s; a stationary reachable goal is pathed once and reused until arrival. Not a setting (compile-time). | ### Default configuration (recommended) @@ -146,7 +171,7 @@ that care about first-pathfind-after-boot latency and can spend ~1.5–2 GB of d | `bitmap_pathfinding_cache = false` | ≈1× (old FastAStar) | **0** cache RAM | 0 | | Cache on, `maxResidentChunks` low (~512) | ~2–5× on hot regions | ~few MB | 0 | | Cache on, default cap (8192) | 2–5× warm | ~40 MB plateau | 0 | -| Cache on + baked `.swb` | + zero first-pathfind-after-boot latency | ~40 MB + index | **~1.5–2 GB** | +| Cache on + baked `.swb` | + zero first-pathfind-after-boot latency | ~40 MB + index | **~tens of MB** (v8) | The key point for RAM-starved boxes: **disabling the cache is not a regression** — it's the old FastAStar behavior at ~1× with zero warming memory (the slow path does the same per-cell work, diff --git a/dev-docs/server-lifecycle.md b/dev-docs/server-lifecycle.md new file mode 100644 index 000000000..69056427c --- /dev/null +++ b/dev-docs/server-lifecycle.md @@ -0,0 +1,127 @@ +# Server Lifecycle & Bootstrap Phases + +How a ModernUO server starts, the reflection-discovered lifecycle hooks (`ConfigurePrompts`, +`Configure`, `Initialize`), the runtime `EventSink` events, and **which hook to use for what**. + +The startup orchestration lives in `Projects/Server/Main.cs` (`Core` entry point). The named +phases are dispatched by `AssemblyHandler.Invoke("")`, which finds every +`public static void ()` (parameterless) across `Core.Assembly` **and** all loaded +content assemblies and calls them — no registration required. + +## Startup sequence (in order) + +> Don't hardcode line numbers when reasoning about this — refer to the phase/method names; the +> ordering is what's stable. + +1. **Console banner + setup** — direct synchronous `Console.*` writes (no logging yet). +2. **`ServerConfiguration.Load()`** — reads/creates `modernuo.json`. On **first boot** (file + absent) it runs the engine's own interactive console prompts: data directories, listeners, + server name, expansion + map selection. **Pre-Serilog** — nothing has logged yet, so the + console is clean for prompts. (`Load(mocked: true)` skips all prompts; that's what tests use.) +3. **`AssemblyHandler.LoadAssemblies(...)`** — loads `UOContent.dll` (and friends) from + `AssemblyDirectories` (default `./Assemblies`). Note: this depends on `AssemblyDirectories`, + **not** `DataDirectories`, so it does not need the data-dir prompt to have run. +4. **`AssemblyHandler.Invoke("ConfigurePrompts")`** — first-boot interactive prompts contributed + by *any* assembly (engine or content). Runs **after** assemblies load (so content can + participate) but **before the first Serilog line** (so prompts aren't interleaved with the + async console sink). Each handler self-gates on first-boot state. +5. **First `logger.Information(...)`** — Serilog goes live. From here on, log via the logger; + the console sink is async, so anything you write with `Console.*` after this can interleave + with log output. +6. **`VerifySerialization()`** → **`Timer.Init(...)`**. +7. **`AssemblyHandler.Invoke("Configure")`** — the main configuration phase. World is **not** + loaded yet (no entities), but maps are registered. +8. **`TileMatrixLoader.LoadTileMatrix()`** → **`RegionJsonSerializer.LoadRegions()`**. +9. **`World.Load()`** — deserializes all items/mobiles; fires `EventSink.WorldLoad`. +10. **`AssemblyHandler.Invoke("Initialize")`** — post-world phase. World entities **and** the + tile matrix are available. +11. **`NetState.Start()`** / **`PingServer.Start()`** → **`EventSink.InvokeServerStarted()`** → + **`RunEventLoop()`** (the single-threaded game loop begins). + +## The three reflection phases — which to use + +| Phase | Runs | Use it for | Don't | +|---|---|---|---| +| **`ConfigurePrompts()`** | after assemblies load, **before logging** | one-time **first-boot interactive prompts**; persist the answer to `modernuo.json`; self-gate so it asks once; skip when input is redirected | log (Serilog isn't live — use `Console`); touch World/maps/tile data (not ready) | +| **`Configure()`** | post-logging, **pre-World** | command registration, reading settings (`GetOrUpdateSetting`), `EventSink` subscriptions, wiring systems | anything needing loaded **World entities** or the tile matrix | +| **`Initialize()`** | **post-World**, post-tile-matrix | work needing a loaded world / tile data: decoration/generation, validation, pre-baking caches | first-boot prompts (too late, and it would clobber logs) | + +All three are `public static void ()`, parameterless, discovered across every loaded +assembly. Within a phase, order is controlled by **`[CallPriority(n)]`** (lower runs first; +default `50`). **Same-priority order is unspecified**, so never rely on one class's `Configure` +running before another's at the same priority — use `EventSink`/explicit calls for ordering. + +## Pre-Serilog vs post-Serilog — why `ConfigurePrompts` exists + +Logging uses an **async** Serilog console sink (`Serilog.Sinks.Async` → `LogFactory`). Once the +first `logger.*` call fires (right after the `ConfigurePrompts` phase), log lines are pumped to +the console from a background thread and will **interleave** with anything written via +`Console.*`. Interactive prompts therefore have to run *before* that point. `ConfigurePrompts` +is the **only** reflection phase that runs pre-logging — that is its entire reason to exist. +Inside it: use `Console`, never the logger; and guard with `Console.IsInputRedirected` so +headless/CI boots don't block on `Console.ReadLine`. + +## Runtime lifecycle events (`EventSink`) + +Subscribe to these from `Configure`/`Initialize` (`EventSink. += handler`): + +- **`ServerStarted`** — after world load and listeners are up, at loop start. +- **`WorldLoad`** / **`WorldSave`** — around persistence (see `WorldEvents`). +- **`Shutdown`** — during shutdown. + +## Recipe: add a first-boot prompt + +```csharp +public static void ConfigurePrompts() +{ + // Ask once, and only when a human is at the console. The answer persists in modernuo.json. + if (ServerConfiguration.GetSetting("my.feature", (string)null) != null || Console.IsInputRedirected) + { + return; + } + + Console.Write("Enable my feature? [y/N] "); + var yes = Console.ReadLine()?.Trim().StartsWith("y", StringComparison.OrdinalIgnoreCase) == true; + ServerConfiguration.SetSetting("my.feature", yes); +} +``` + +If acting on the answer needs a loaded world / tile data, do that in `Initialize()` (read the +setting there), not in `ConfigurePrompts`. + +### Canonical example — pathfinding pre-bake + +`Projects/UOContent/Engines/Pathing/PathCacheCommands.cs` is the reference pairing: + +- `ConfigurePrompts()` — first-boot `[y/N]`, stores `pathfinding.prebakeMaps`. +- `Initialize()` — when set, bakes any missing/stale `.swb` (needs the tile matrix, so it must + be `Initialize`, not `Configure`). + +## Testing note + +Tests do **not** go through `Main`. The test fixtures (`Server.Tests`/`UOContent.Tests` +`TestServerInitializer`) call a curated subset of phase methods directly with +`ServerConfiguration.Load(mocked: true)`, so console prompts are skipped. Consequence: changes +to the **startup ordering in `Main.cs`** (including the prompt phases) are **not** covered by the +test suite and need first-boot runtime verification. + +## Unified: the engine's first-boot prompts run through `ConfigurePrompts` + +The engine's own first-boot prompts (data directories, listeners, server name, expansion + map +selection) live in **`ServerConfiguration.ConfigurePrompts()`** (`[CallPriority(0)]`) and are +discovered by the same `Invoke("ConfigurePrompts")` phase as content prompts — one sequence and +one wiring for all first-boot prompting. `ServerConfiguration.Load` now only reads/creates the +config file. What made this safe: + +- Assembly loading uses `AssemblyDirectories` (default `./Assemblies`), **not** `DataDirectories`, + so assemblies load fine before the (now-later) data-dir prompt. +- `UOClient.Load()` (client-file discovery via `Core.FindDataFile`) needs `DataDirectories`, so it + moved *with* the data-dir prompt into `ConfigurePrompts`. +- `Core.Expansion` is now assigned in `ConfigurePrompts` (every non-mocked boot). Nothing between + assembly-load and that phase reads it — type initializers run lazily on first use, not during + `LoadAssemblies`. +- `[CallPriority(0)]` keeps the engine prompts (including map selection) ahead of content prompts + such as the pathfinding pre-bake (default priority 50), preserving "after map selection". + +Since `Main.cs` startup ordering isn't covered by the fixture-based suite (see Testing note), this +path is validated by a first-boot runtime check rather than tests. 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` |