Merge branch 'main' into feat/discord-integration

This commit is contained in:
Kamron Batman 2026-06-14 09:51:56 -07:00 committed by GitHub
commit b512f2f5bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
106 changed files with 7612 additions and 694 deletions

View file

@ -9,3 +9,7 @@ updates:
directory: "/" # Location of package manifests
schedule:
interval: "daily"
- package-ecosystem: "github-actions"
directory: "/" # Watches .github/workflows/**
schedule:
interval: "weekly"

View file

@ -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)

View file

@ -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 }}

View file

@ -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"

View file

@ -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` |

View file

@ -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);
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View 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)
{
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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)

View file

@ -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();

View file

@ -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();
}

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

View file

@ -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();
}

View file

@ -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})");
}

View file

@ -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;
}
}

View file

@ -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();
}
}
}
}

View file

@ -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 &amp;&amp; !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;
}
}

View file

@ -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;
}
}
}

View file

@ -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();
}
}
}

View file

@ -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();
}
}
}

View file

@ -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();
}
}
}

View file

@ -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();
}
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}

View file

@ -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();
}
}
}

View file

@ -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;
}
}

View file

@ -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));

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}
}
}

View file

@ -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

View file

@ -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})");

View file

@ -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);

View file

@ -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();
}
}
}

View file

@ -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)
{
}
}
}

View file

@ -1,4 +1,3 @@
using Server.Commands;
using Server.Network;
using Server.Targeting;

View file

@ -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);

View file

@ -1,7 +1,5 @@
using System;
using System.Collections.Generic;
using ModernUO.Serialization;
using Server.Collections;
using Server.Gumps;
using Server.Network;

View file

@ -1,5 +1,4 @@
using ModernUO.Serialization;
using Server.Gumps;
namespace Server.Engines.ConPVP;

View file

@ -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

View file

@ -673,7 +673,6 @@ public class CraftGump : DynamicGump
}
context.DoNotColor = !context.DoNotColor;
_from.SendGump(new CraftGump(_from, _craftSystem, _tool, null, _page));
break;

View file

@ -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
{

View file

@ -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));
}
}
}

View file

@ -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)

View file

@ -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);
}
}
}

View file

@ -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);
}
}
}

View file

@ -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
{

View file

@ -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);
}
}
}

View file

@ -9,6 +9,11 @@ public class DefAlchemy : CraftSystem
public static void Initialize()
{
if (CraftSystem != null)
{
return; // Already initialized
}
CraftSystem = new DefAlchemy();
}

View file

@ -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;

View file

@ -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>();

View file

@ -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;

View file

@ -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)
{

View 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]);
}
}
}

View 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);
}
}

View 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);
}
}

View 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]);
}
}
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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);
}
}
}
}

View file

@ -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)
{

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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
}

View file

@ -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;
}

View 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 &amp; 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 &lt; 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];
}

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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/&lt;mapId&gt;.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}");
}

View file

@ -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;

View file

@ -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)
{

View file

@ -1,4 +1,3 @@
using Server.Gumps;
using Server.Targeting;
namespace Server.Engines.Plants

View file

@ -1,4 +1,3 @@
using Server.Gumps;
using Server.Targeting;
namespace Server.Engines.Plants

View file

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using Server.Gumps;
using Server.Items;
using Server.Mobiles;
using Server.Network;

View file

@ -1,7 +1,6 @@
using System;
using ModernUO.CodeGeneratedEvents;
using Server.Accounting;
using Server.Gumps;
using Server.Items;
using Server.Mobiles;

View file

@ -1,5 +1,3 @@
using System.Collections.Generic;
using System.Linq;
using Server.Mobiles;
using Server.Multis;
using Server.Network;

View file

@ -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()
{

View file

@ -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;
}

View file

@ -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

View 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"
]
}
]
}

View file

@ -0,0 +1,4 @@
{
"version": 0,
"type": "Server.Items.WeddingRing"
}

View file

@ -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! *

View file

@ -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);
}
}

View file

@ -1,7 +1,6 @@
using ModernUO.Serialization;
using Server.Collections;
using Server.ContextMenus;
using Server.Gumps;
using Server.Items;
namespace Server.Mobiles

View file

@ -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;

View file

@ -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;

View file

@ -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