Merge branch 'main' into feat/discord-integration
This commit is contained in:
commit
b512f2f5bd
106 changed files with 7612 additions and 694 deletions
4
.github/dependabot.yml
vendored
4
.github/dependabot.yml
vendored
|
|
@ -9,3 +9,7 @@ updates:
|
|||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "daily"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/" # Watches .github/workflows/**
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
|
|
|||
2
.github/workflows/build-tool-release.yml
vendored
2
.github/workflows/build-tool-release.yml
vendored
|
|
@ -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)
|
||||
|
|
|
|||
4
.github/workflows/create-release.yml
vendored
4
.github/workflows/create-release.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
2
.github/workflows/post-release-discord.yml
vendored
2
.github/workflows/post-release-discord.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
/// </summary>
|
||||
[CollectionDefinition("Sequential Server Tests", DisableParallelization = true)]
|
||||
public class ServerFixture : ICollectionFixture<ServerFixture>, IDisposable
|
||||
public class ServerFixture : ICollectionFixture<ServerFixture>
|
||||
{
|
||||
/// <summary>
|
||||
/// True if TileData was successfully loaded from client files.
|
||||
|
|
@ -35,13 +34,8 @@ public class ServerFixture : ICollectionFixture<ServerFixture>, IDisposable
|
|||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
/// </summary>
|
||||
public static class TestServerInitializer
|
||||
internal static class TestServerInitializer
|
||||
{
|
||||
private const string DefaultDataDirectory = @"C:\Ultima Online Classic";
|
||||
private static bool _initialized;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,24 @@ using ModernUO.Serialization;
|
|||
|
||||
namespace Server.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Whether a multi instance's footprint is eligible for the pathfinding interior-mask cache
|
||||
/// (Server.Engines.Pathing.Cache.MultiMaskCache). Stored on
|
||||
/// <see cref="BaseMulti.PathInteriorCacheState"/>; recomputed when it is <see cref="Unknown"/>
|
||||
/// (e.g. after a move resets it).
|
||||
/// </summary>
|
||||
public enum MultiInteriorCacheState : byte
|
||||
{
|
||||
/// <summary>Not yet determined; recompute on next use.</summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>Whole footprint terrain is below the floor → interior cells may serve from the cache.</summary>
|
||||
Clean = 1,
|
||||
|
||||
/// <summary>Terrain intrudes into the footprint → fall back to live synthesis.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Pathfinding interior-mask cache gate (Server.Engines.Pathing.Cache.MultiMaskCache).
|
||||
/// Reset to <see cref="MultiInteriorCacheState.Unknown"/> 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 <see cref="OnLocationChange"/> /
|
||||
/// <see cref="OnMapChange"/> MUST call base for the reset to fire.
|
||||
/// </summary>
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1875,6 +1875,12 @@ public sealed partial class Map : IComparable<Map>, ISpanFormattable, ISpanParsa
|
|||
|
||||
internal List<BaseMulti> 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<Mobile> Mobiles => ref _mobiles;
|
||||
|
|
|
|||
34
Projects/Server/Menus/BaseMenu.cs
Normal file
34
Projects/Server/Menus/BaseMenu.cs
Normal file
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -8132,6 +8132,9 @@ public partial class Mobile : IHued, IComparable<Mobile>, 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)
|
||||
|
|
|
|||
|
|
@ -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<IGenericSerializable> _entities;
|
||||
|
|
@ -56,6 +57,12 @@ public class SerializationThreadWorker
|
|||
|
||||
public void Exit()
|
||||
{
|
||||
if (_exited)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_exited = true;
|
||||
_exit = true;
|
||||
Wake();
|
||||
Sleep();
|
||||
|
|
|
|||
|
|
@ -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<PathfindingTestFixture>, IDisposable
|
||||
public class PathfindingTestFixture : ICollectionFixture<PathfindingTestFixture>
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
129
Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs
Normal file
129
Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>internal</c> 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. <see cref="World.Load"/> is guarded to run once, and
|
||||
/// <see cref="World.ExitSerializationThreads"/> 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 <c>[CollectionDefinition(DisableParallelization = true)]</c> so they never
|
||||
/// overlap; pure tests still run in parallel.
|
||||
/// </summary>
|
||||
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")}."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<UOContentFixture>, IDisposable
|
||||
public class UOContentFixture : ICollectionFixture<UOContentFixture>
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find the first MCL wall cell (Impassable && !Surface) over terrain that is not
|
||||
/// independently blocked by land or statics. Using this instead of
|
||||
/// <see cref="MultiArt.FindWallCell"/> ensures the cell reopens after demolish.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if land tile or any static tile (non-multi) at (x,y) is impassable.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
/// <summary>
|
||||
/// Two overlapping GuildHouse multis whose footprints intersect. At the stacked cells
|
||||
/// <c>GetStaticAndMultiTiles</c> yields tiles from BOTH multis; the synthesizer must still
|
||||
/// agree with CheckMovement everywhere over the union footprint + halo.
|
||||
/// </summary>
|
||||
[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.Cell>(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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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).
|
||||
/// </remarks>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>Components</c> — i.e. it matches CheckMovement on the NEW shape.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.)
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal concrete <see cref="BaseMulti"/> 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.
|
||||
/// </summary>
|
||||
public sealed class TestMulti : BaseMulti
|
||||
{
|
||||
public TestMulti(int itemID) : base(itemID)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Default walker body, shared across pathfinding test fixtures.</summary>
|
||||
public sealed class WalkerStub : Mobile
|
||||
{
|
||||
public WalkerStub() => Body = 0xC9;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for placing/probing multis in pathfinding tests.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sweeps the multi's footprint + 1-cell halo and asserts the multi mask synthesizer
|
||||
/// (<see cref="StepProbe.ComputeMultiMaskAt"/>) agrees with the <c>CheckMovement</c> 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class MultiArt
|
||||
{
|
||||
public readonly record struct Cell(int X, int Y);
|
||||
|
||||
/// <summary>Every world cell the multi's footprint covers (Tiles stack non-empty).</summary>
|
||||
public static List<Cell> FootprintCells(BaseMulti multi)
|
||||
{
|
||||
var mcl = multi.Components;
|
||||
var result = new List<Cell>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Footprint cells plus a 1-cell halo ring (the cells the split also routes to slow path).</summary>
|
||||
public static HashSet<Cell> FootprintWithHalo(BaseMulti multi)
|
||||
{
|
||||
var foot = FootprintCells(multi);
|
||||
var set = new HashSet<Cell>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>First world cell whose MCL stack contains an impassable, non-surface (wall) tile, or null.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>First world cell whose MCL stack contains a walkable surface (floor) tile, or null.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,22 +10,11 @@ public class PathfindRecorderTests
|
|||
private static string NewTempPath() =>
|
||||
Path.Combine(Path.GetTempPath(), $"pathfind-recorder-{System.Guid.NewGuid():N}.jsonl");
|
||||
|
||||
/// <summary>
|
||||
/// Reflection-set the static _outputPath without going through Configure (which
|
||||
/// reads from server.cfg) so tests don't poison the project's server.cfg.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<sbyte> 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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
using Server.Engines.Pathing.Cache;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests.Pathfinding;
|
||||
|
||||
[Collection("Sequential Pathfinding Tests")]
|
||||
public class StepCacheFingerprintTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Regression: the cache fingerprint must hash the on-disk tiledata.mul, NOT the mutable
|
||||
/// in-memory <see cref="TileData"/> 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.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<BaseMulti> { 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
|
||||
|
|
|
|||
|
|
@ -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})");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
using Server.Commands;
|
||||
using Server.Network;
|
||||
using Server.Targeting;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ModernUO.Serialization;
|
||||
using Server.Collections;
|
||||
using Server.Gumps;
|
||||
using Server.Network;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using ModernUO.Serialization;
|
||||
using Server.Gumps;
|
||||
|
||||
namespace Server.Engines.ConPVP;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -673,7 +673,6 @@ public class CraftGump : DynamicGump
|
|||
}
|
||||
|
||||
context.DoNotColor = !context.DoNotColor;
|
||||
|
||||
_from.SendGump(new CraftGump(_from, _craftSystem, _tool, null, _page));
|
||||
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<Type[]> 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<CraftSystem>();
|
||||
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<CraftSystem>();
|
||||
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<CraftSystem>();
|
||||
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<CraftSystem>();
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void Craft(Mobile from, CraftSystem craftSystem, Type typeRes, BaseTool tool, int resHue)
|
||||
{
|
||||
if (!from.BeginAction<CraftSystem>())
|
||||
{
|
||||
from.SendLocalizedMessage(500119); // You must wait to perform another action
|
||||
return;
|
||||
}
|
||||
|
||||
if (RequiredExpansion != Expansion.None && from.NetState?.SupportsExpansion(RequiredExpansion) != true)
|
||||
{
|
||||
from.EndAction<CraftSystem>();
|
||||
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<CraftSystem>();
|
||||
from.SendAsciiMessage("You lack the required skill to craft this item.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Recipe != null && (from as PlayerMobile)?.HasRecipe(Recipe) == false)
|
||||
{
|
||||
from.EndAction<CraftSystem>();
|
||||
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<CraftSystem>();
|
||||
ShowCraftMenu(from, craftSystem, tool, badCraft);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dry run: check hued resources are available
|
||||
if (!CheckHuedRes(from, typeRes, craftSystem, resHue))
|
||||
{
|
||||
from.EndAction<CraftSystem>();
|
||||
// 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<CraftSystem>();
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<Item>.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<AncientSmithyHammer>(Layer.OneHanded);
|
||||
if (hammer != null && hammer != tool)
|
||||
tool.UsesRemaining--;
|
||||
|
||||
if (craftSystem is DefBlacksmithy)
|
||||
{
|
||||
hammer.UsesRemaining--;
|
||||
if (hammer.UsesRemaining < 1)
|
||||
var hammer = from.FindItemOnLayer<AncientSmithyHammer>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hue-aware CompleteCraft. Uses ConsumeHuedRes to consume only resources matching
|
||||
/// the target hue, and applies that hue to the crafted item.
|
||||
/// </summary>
|
||||
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<AncientSmithyHammer>(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<Item>();
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,17 +12,20 @@ public class QueryMakersMarkGump : StaticGump<QueryMakersMarkGump>
|
|||
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<QueryMakersMarkGump>
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ public class DefAlchemy : CraftSystem
|
|||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (CraftSystem != null)
|
||||
{
|
||||
return; // Already initialized
|
||||
}
|
||||
|
||||
CraftSystem = new DefAlchemy();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<SpellScroll>();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
262
Projects/UOContent/Engines/Craft/T2A/AlchemyMenu.cs
Normal file
262
Projects/UOContent/Engines/Craft/T2A/AlchemyMenu.cs
Normal file
|
|
@ -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<char> buffer = stackalloc char[name.Length * 2];
|
||||
var pos = 0;
|
||||
|
||||
for (var i = 0; i < name.Length; i++)
|
||||
{
|
||||
if (i > 0 && char.IsUpper(name[i]))
|
||||
{
|
||||
buffer[pos++] = ' ';
|
||||
}
|
||||
|
||||
buffer[pos++] = char.ToLower(name[i]);
|
||||
}
|
||||
|
||||
return new string(buffer[..pos]);
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
624
Projects/UOContent/Engines/Craft/T2A/BlacksmithMenu.cs
Normal file
624
Projects/UOContent/Engines/Craft/T2A/BlacksmithMenu.cs
Normal file
|
|
@ -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<char> buffer = stackalloc char[name.Length * 2];
|
||||
var pos = 0;
|
||||
for (var i = 0; i < name.Length; i++)
|
||||
{
|
||||
if (i > 0 && char.IsUpper(name[i]))
|
||||
{
|
||||
buffer[pos++] = ' ';
|
||||
}
|
||||
|
||||
buffer[pos++] = char.ToLower(name[i]);
|
||||
}
|
||||
|
||||
return new string(buffer[..pos]);
|
||||
}
|
||||
|
||||
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<Mobile, BaseTool> 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<Mobile, BaseTool> 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<Mobile, BaseTool> _afterSelect;
|
||||
|
||||
public BlacksmithResourceTarget(BaseTool tool, Action<Mobile, BaseTool> 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);
|
||||
}
|
||||
}
|
||||
95
Projects/UOContent/Engines/Craft/T2A/BowFletchingMenu.cs
Normal file
95
Projects/UOContent/Engines/Craft/T2A/BowFletchingMenu.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
298
Projects/UOContent/Engines/Craft/T2A/CarpentryMenu.cs
Normal file
298
Projects/UOContent/Engines/Craft/T2A/CarpentryMenu.cs
Normal file
|
|
@ -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<char> buffer = stackalloc char[name.Length * 2];
|
||||
var pos = 0;
|
||||
|
||||
for (var i = 0; i < name.Length; i++)
|
||||
{
|
||||
if (i > 0 && char.IsUpper(name[i]))
|
||||
{
|
||||
buffer[pos++] = ' ';
|
||||
}
|
||||
|
||||
buffer[pos++] = char.ToLower(name[i]);
|
||||
}
|
||||
|
||||
return new string(buffer[..pos]);
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
110
Projects/UOContent/Engines/Craft/T2A/CartographyMenu.cs
Normal file
110
Projects/UOContent/Engines/Craft/T2A/CartographyMenu.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
334
Projects/UOContent/Engines/Craft/T2A/InscriptionMenu.cs
Normal file
334
Projects/UOContent/Engines/Craft/T2A/InscriptionMenu.cs
Normal file
|
|
@ -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<char> buffer = stackalloc char[name.Length * 2];
|
||||
var pos = 0;
|
||||
for (var i = 0; i < name.Length; i++)
|
||||
{
|
||||
if (i > 0 && char.IsUpper(name[i]))
|
||||
{
|
||||
buffer[pos++] = ' ';
|
||||
}
|
||||
|
||||
buffer[pos++] = char.ToLower(name[i]);
|
||||
}
|
||||
|
||||
return new string(buffer[..pos]);
|
||||
}
|
||||
|
||||
public static ItemListEntry[] Main() => _mainEntries ??=
|
||||
[
|
||||
new ItemListEntry("Runebook", 0xEFA, 0, (int)Category.Runebook),
|
||||
new ItemListEntry("First Circle", 8384, 0, (int)Category.Circle1),
|
||||
new ItemListEntry("Second Circle", 8385, 0, (int)Category.Circle2),
|
||||
new ItemListEntry("Third Circle", 8386, 0, (int)Category.Circle3),
|
||||
new ItemListEntry("Fourth Circle", 8387, 0, (int)Category.Circle4),
|
||||
new ItemListEntry("Fifth Circle", 8388, 0, (int)Category.Circle5),
|
||||
new ItemListEntry("Sixth Circle", 8389, 0, (int)Category.Circle6),
|
||||
new ItemListEntry("Seventh Circle", 8390, 0, (int)Category.Circle7),
|
||||
new ItemListEntry("Eighth Circle", 8391, 0, (int)Category.Circle8)
|
||||
];
|
||||
|
||||
private static ItemListEntry[] BuildCircleEntries(int circleIndex)
|
||||
{
|
||||
var offset = circleIndex * 8;
|
||||
var craftItems = DefInscription.CraftSystem.CraftItems;
|
||||
var entries = new ItemListEntry[8];
|
||||
var count = 0;
|
||||
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
var itemDef = craftItems[offset + i];
|
||||
var name = FormatScrollName(itemDef.ItemType);
|
||||
entries[count++] = new ItemListEntry(name, 8320 + offset + i, 0, i);
|
||||
}
|
||||
|
||||
if (count < entries.Length)
|
||||
{
|
||||
Array.Resize(ref entries, count);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static ItemListEntry[] GetCircleEntries(int circleIndex) =>
|
||||
_circleEntries[circleIndex] ??= BuildCircleEntries(circleIndex);
|
||||
|
||||
private static Dictionary<Type, int> _spellIds;
|
||||
private static object[] _args;
|
||||
|
||||
private static bool HasSpellInBook(Mobile from, Type scrollType)
|
||||
{
|
||||
if (scrollType == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_spellIds ??= [];
|
||||
if (!_spellIds.TryGetValue(scrollType, out var spellId))
|
||||
{
|
||||
try
|
||||
{
|
||||
_args ??= [1];
|
||||
var scroll = scrollType.CreateInstance<SpellScroll>(_args);
|
||||
if (scroll != null)
|
||||
{
|
||||
spellId = _spellIds[scrollType] = scroll.SpellID;
|
||||
scroll.Delete();
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var book = Spellbook.Find(from, spellId);
|
||||
return book?.HasSpell(spellId) ?? false;
|
||||
}
|
||||
|
||||
private static bool CanScribeScroll(Mobile from, CraftItem itemDef)
|
||||
{
|
||||
// Skill check
|
||||
if (!T2ACraftSystem.CanCraftItem(from, itemDef, DefInscription.CraftSystem))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must have blank scrolls
|
||||
if ((from.Backpack?.GetAmount(typeof(BlankScroll)) ?? 0) == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must have the spell in spellbook
|
||||
return HasSpellInBook(from, itemDef.ItemType);
|
||||
}
|
||||
|
||||
private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category)
|
||||
{
|
||||
if (category == Category.Main)
|
||||
{
|
||||
return BuildFilteredMainEntries(from);
|
||||
}
|
||||
|
||||
var circleIndex = (int)category - 1;
|
||||
var staticEntries = GetCircleEntries(circleIndex);
|
||||
var craftItems = DefInscription.CraftSystem.CraftItems;
|
||||
var offset = circleIndex * 8;
|
||||
|
||||
var filtered = new ItemListEntry[staticEntries.Length];
|
||||
var count = 0;
|
||||
|
||||
for (var i = 0; i < staticEntries.Length; i++)
|
||||
{
|
||||
var entry = staticEntries[i];
|
||||
var scrollIndex = entry.CraftIndex;
|
||||
var itemDef = craftItems[offset + scrollIndex];
|
||||
if (CanScribeScroll(from, itemDef))
|
||||
{
|
||||
filtered[count++] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (count < filtered.Length)
|
||||
{
|
||||
Array.Resize(ref filtered, count);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private static ItemListEntry[] BuildFilteredMainEntries(Mobile from)
|
||||
{
|
||||
var craftItems = DefInscription.CraftSystem.CraftItems;
|
||||
var system = DefInscription.CraftSystem;
|
||||
var mainStatic = Main();
|
||||
var filtered = new ItemListEntry[mainStatic.Length];
|
||||
var count = 0;
|
||||
|
||||
for (var i = 0; i < mainStatic.Length; i++)
|
||||
{
|
||||
var entry = mainStatic[i];
|
||||
var category = (Category)entry.CraftIndex;
|
||||
|
||||
if (category == Category.Runebook)
|
||||
{
|
||||
if (T2ACraftSystem.CanCraftItem(from, typeof(Runebook), system))
|
||||
{
|
||||
filtered[count++] = entry;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var circleIndex = (int)category - 1;
|
||||
var offset = circleIndex * 8;
|
||||
|
||||
var hasAny = false;
|
||||
for (var j = 0; j < 8; j++)
|
||||
{
|
||||
var itemDef = craftItems[offset + j];
|
||||
if (CanScribeScroll(from, itemDef))
|
||||
{
|
||||
hasAny = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAny)
|
||||
{
|
||||
filtered[count++] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (count < filtered.Length)
|
||||
{
|
||||
Array.Resize(ref filtered, count);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private void CraftScroll(Mobile from, Category circle, int scrollIndex)
|
||||
{
|
||||
var itemIndex = ((int)circle - 1) * 8 + scrollIndex;
|
||||
var craftItems = DefInscription.CraftSystem.CraftItems;
|
||||
var itemDef = craftItems[itemIndex];
|
||||
|
||||
if (!HasSpellInBook(from, itemDef.ItemType))
|
||||
{
|
||||
from.SendAsciiMessage("You do not have that spell in your spellbook.");
|
||||
return;
|
||||
}
|
||||
|
||||
var context = DefInscription.CraftSystem.GetContext(from);
|
||||
var res = itemDef.UseSubRes2 ? DefInscription.CraftSystem.CraftSubRes2 : DefInscription.CraftSystem.CraftSubRes;
|
||||
var resIndex = itemDef.UseSubRes2 ? context.LastResourceIndex2 : context.LastResourceIndex;
|
||||
var type = resIndex > -1 ? res[resIndex].ItemType : null;
|
||||
DefInscription.CraftSystem.CreateItem(from, itemDef.ItemType, type, _tool, itemDef);
|
||||
}
|
||||
|
||||
public override void OnResponse(NetState state, int index)
|
||||
{
|
||||
var from = state.Mobile;
|
||||
|
||||
if ((from.Backpack?.GetAmount(typeof(BlankScroll)) ?? 0) == 0)
|
||||
{
|
||||
from.SendAsciiMessage("You do not have enough blank scrolls to make that.");
|
||||
return;
|
||||
}
|
||||
|
||||
var craftIndex = Entries[index].CraftIndex;
|
||||
if (_category == Category.Main)
|
||||
{
|
||||
var category = (Category)craftIndex;
|
||||
|
||||
if (category == Category.Runebook)
|
||||
{
|
||||
CraftRunebook(from);
|
||||
return;
|
||||
}
|
||||
|
||||
var menu = new InscriptionMenu(from, _tool, category);
|
||||
if (menu.Entries.Length == 0)
|
||||
{
|
||||
from.SendAsciiMessage("You lack the skill and materials to scribe anything in that circle.");
|
||||
return;
|
||||
}
|
||||
|
||||
from.SendMenu(menu);
|
||||
return;
|
||||
}
|
||||
|
||||
CraftScroll(from, _category, craftIndex);
|
||||
}
|
||||
|
||||
private void CraftRunebook(Mobile from)
|
||||
{
|
||||
var system = DefInscription.CraftSystem;
|
||||
var itemDef = system.CraftItems.SearchFor(typeof(Runebook));
|
||||
if (itemDef == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var num = system.CanCraft(from, _tool, itemDef.ItemType);
|
||||
if (num > 0)
|
||||
{
|
||||
from.SendLocalizedMessage(num);
|
||||
return;
|
||||
}
|
||||
|
||||
system.CreateItem(from, itemDef.ItemType, null, _tool, itemDef);
|
||||
}
|
||||
}
|
||||
351
Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs
Normal file
351
Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs
Normal file
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether T2A packet-based crafting menus are active. Set once at startup from the
|
||||
/// "t2aCraftMenus" server setting (default: !Core.UOTD). Not flippable at runtime.
|
||||
/// </summary>
|
||||
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<AlchemyMenu>(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<BowFletchingMenu>(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<CarpentryMenu>(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<CartographyMenu>(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<InscriptionMenu>(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<T>(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<Item, bool> 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<Item, bool> _isValid;
|
||||
|
||||
public CraftResourceTarget(
|
||||
BaseTool tool, CraftSystem system, string message, Func<Item, bool> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the selected resource type as a LastResourceIndex on the craft context,
|
||||
/// so that make-last can recall which resource was used.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a player can craft a specific item, accounting for sub-resource types.
|
||||
/// When <paramref name="selectedResourceType"/> 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience overload that resolves a Type to a CraftItem first.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters static template entries to only those the player can craft.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if at least one item in the type array is craftable.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Equivalent type pairs mirroring CraftItem.m_TypesTable — used so that menu filtering
|
||||
/// counts boards when checking for logs, hides when checking for leather, etc.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
71
Projects/UOContent/Engines/Craft/T2A/T2ACraftToolTarget.cs
Normal file
71
Projects/UOContent/Engines/Craft/T2A/T2ACraftToolTarget.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
375
Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs
Normal file
375
Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs
Normal file
|
|
@ -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<char> buffer = stackalloc char[name.Length * 2];
|
||||
var pos = 0;
|
||||
|
||||
for (var i = 0; i < name.Length; i++)
|
||||
{
|
||||
if (i > 0 && char.IsUpper(name[i]))
|
||||
{
|
||||
buffer[pos++] = ' ';
|
||||
}
|
||||
|
||||
buffer[pos++] = char.ToLower(name[i]);
|
||||
}
|
||||
|
||||
return new string(buffer[..pos]);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
529
Projects/UOContent/Engines/Craft/T2A/TinkeringMenu.cs
Normal file
529
Projects/UOContent/Engines/Craft/T2A/TinkeringMenu.cs
Normal file
|
|
@ -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<char> buffer = stackalloc char[name.Length * 2];
|
||||
var pos = 0;
|
||||
|
||||
for (var i = 0; i < name.Length; i++)
|
||||
{
|
||||
if (i > 0 && char.IsUpper(name[i]))
|
||||
{
|
||||
buffer[pos++] = ' ';
|
||||
}
|
||||
|
||||
buffer[pos++] = char.ToLower(name[i]);
|
||||
}
|
||||
|
||||
return new string(buffer[..pos]);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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<int, int> _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<int, int> _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
|
||||
/// <see cref="GetSuccessorsSlowPath"/> for THIS cell only.
|
||||
/// </summary>
|
||||
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.
|
||||
/// </summary>
|
||||
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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ namespace Server.Engines.Pathing.Cache;
|
|||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
346
Projects/UOContent/Engines/Pathing/Cache/MultiMaskCache.cs
Normal file
346
Projects/UOContent/Engines/Pathing/Cache/MultiMaskCache.cs
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server.Items;
|
||||
using Server.Multis;
|
||||
|
||||
namespace Server.Engines.Pathing.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class MultiMaskCache
|
||||
{
|
||||
public static MultiMaskCache Instance { get; } = new();
|
||||
|
||||
private const int StepHeight = 2;
|
||||
|
||||
private readonly Dictionary<int, MultiLocalMask> _byMultiId = [];
|
||||
|
||||
public void Clear() => _byMultiId.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static bool TryToLocalZ(StepMask world, int multiZ, out StepMask local)
|
||||
{
|
||||
local = default;
|
||||
Span<sbyte> w = stackalloc sbyte[8];
|
||||
Span<sbyte> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True iff all terrain (land + statics) at (x,y) sits strictly below <paramref name="floorZ"/>,
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Highest terrain (land + statics) top at (x,y). Building block for the cleanliness check.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Inverse of <see cref="TryToLocalZ"/>: add multiZ back to recover world Zs.</summary>
|
||||
public static StepMask ToWorldZ(StepMask local, int multiZ)
|
||||
{
|
||||
Span<sbyte> w = stackalloc sbyte[8];
|
||||
Span<sbyte> 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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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];
|
||||
}
|
||||
|
|
@ -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++;
|
||||
|
||||
/// <summary>Hard cap on resident chunk count. Default 8192. Override for tests / ops.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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<int, StepCacheFile.LazyReader> _lazyReaders = new();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static ulong ComputeLiveFingerprint(int mapId) => StepCacheFile.ComputeFingerprint(mapId);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static bool TryReadFingerprintFromFile(string path, out ulong fingerprint) =>
|
||||
StepCacheFile.TryReadFingerprint(path, out fingerprint);
|
||||
|
||||
/// <summary>
|
||||
/// Walk every chunk in <paramref name="mapId"/>, populate the resident set, then
|
||||
/// save to <paramref name="path"/>. Returns the number of chunks written.
|
||||
|
|
@ -367,6 +366,15 @@ public sealed class StepCache
|
|||
/// </summary>
|
||||
public int OpenLazyReaderCount => _lazyReaders.Count;
|
||||
|
||||
/// <summary>
|
||||
/// True if a valid .swb reader is open for <paramref name="mapId"/>. A reader only opens via
|
||||
/// <see cref="TryOpenLazyReader"/> after <see cref="StepCacheFile.OpenForLazy"/> 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.
|
||||
/// </summary>
|
||||
public bool HasLazyReader(int mapId) => _lazyReaders.ContainsKey(mapId);
|
||||
|
||||
/// <summary>Test-only diagnostic: does the lazy reader for <paramref name="mapId"/> hold an offset for (chunkX, chunkY)?</summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hot-path query. Returns the cached mask + 8 destination Z values + hit kind.
|
||||
/// Inspect <see cref="StepMask.IsHit"/> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combined XxHash3 fingerprint over (1) the loaded TileData flag tables and (2) the
|
||||
/// Combined XxHash3 fingerprint over (1) the on-disk <c>tiledata.mul</c> file and (2) the
|
||||
/// per-map .mul / .uop file contents (via <see cref="TileMatrix.MapFilesFingerprint"/>).
|
||||
/// 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 <see cref="TileData.LandTable"/> /
|
||||
/// <see cref="TileData.ItemTable"/>. 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.
|
||||
/// </summary>
|
||||
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<byte> 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;
|
||||
|
||||
/// <summary>
|
||||
/// XxHash3 over the raw <c>tiledata.mul</c> bytes, computed once and cached — the file never
|
||||
/// changes during a run. Mirrors <see cref="TileMatrix.MapFilesFingerprint"/> 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).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the file: header (with placeholder IndexOffset) → chunks (offsets recorded)
|
||||
/// → index trailer → patches the header IndexOffset. <paramref name="chunkCount"/> 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ namespace Server.Engines.Pathing.Cache;
|
|||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="MovementImpl"/>.Check minus the item and mobile collision phases.
|
||||
/// "can step" mask and destination Z, based purely on land + statics.mul tiles (NOT
|
||||
/// multis). Mirrors <see cref="MovementImpl"/>.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 <see cref="StepCache"/>'s
|
||||
/// multi-halo fallthrough rather than being baked into the static chunk cache.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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);
|
||||
|
||||
/// <summary>
|
||||
/// Multi-aware counterpart to <see cref="ComputeMaskAt"/>: 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.
|
||||
/// </summary>
|
||||
public static StepMask ComputeMultiMaskAt(Map map, int x, int y, sbyte sourceZ) =>
|
||||
ComputeMaskCore(map, x, y, sourceZ, includeMultis: true);
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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
|
|||
/// </summary>
|
||||
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.
|
||||
/// </summary>
|
||||
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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
|||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// First-boot prompt, auto-invoked by <c>AssemblyHandler.Invoke("ConfigurePrompts")</c> 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 <c>.swb</c> cache for the selected maps; the answer persists in
|
||||
/// modernuo.json (<see cref="PrebakeSetting"/>), 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 <see cref="Initialize"/>.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-invoked by <c>AssemblyHandler.Invoke("Initialize")</c> after the tile matrix and
|
||||
/// world are loaded. When <see cref="PrebakeSetting"/> is set, bakes any map whose
|
||||
/// <c>.swb</c> 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 <see cref="StepCache.HasLazyReader"/>: <see cref="Configure"/> runs
|
||||
/// <see cref="AutoLoadAtStartup"/> in the earlier Configure phase, opening (and fingerprint-
|
||||
/// validating) a reader for every up-to-date <c>.swb</c>. So a map with an open reader is
|
||||
/// already good and we skip it — no need to recompute the fingerprint a second time here.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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}");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|||
/// </summary>
|
||||
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
|
|||
/// </summary>
|
||||
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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using Server.Gumps;
|
||||
using Server.Targeting;
|
||||
|
||||
namespace Server.Engines.Plants
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using Server.Gumps;
|
||||
using Server.Targeting;
|
||||
|
||||
namespace Server.Engines.Plants
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server.Gumps;
|
||||
using Server.Items;
|
||||
using Server.Mobiles;
|
||||
using Server.Network;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
using System;
|
||||
using ModernUO.CodeGeneratedEvents;
|
||||
using Server.Accounting;
|
||||
using Server.Gumps;
|
||||
using Server.Items;
|
||||
using Server.Mobiles;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Server.Mobiles;
|
||||
using Server.Multis;
|
||||
using Server.Network;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
64
Projects/UOContent/Migrations/Server.Items.BaseJewel.v5.json
generated
Normal file
64
Projects/UOContent/Migrations/Server.Items.BaseJewel.v5.json
generated
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
4
Projects/UOContent/Migrations/Server.Items.WeddingRing.v0.json
generated
Normal file
4
Projects/UOContent/Migrations/Server.Items.WeddingRing.v0.json
generated
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"version": 0,
|
||||
"type": "Server.Items.WeddingRing"
|
||||
}
|
||||
|
|
@ -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! *
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
using ModernUO.Serialization;
|
||||
using Server.Collections;
|
||||
using Server.ContextMenus;
|
||||
using Server.Gumps;
|
||||
using Server.Items;
|
||||
|
||||
namespace Server.Mobiles
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue