fix: Cleans up AI Pathfinding code to make it more portable for custom requirements. (#2474)
This commit is contained in:
parent
412a71dfe0
commit
30fec7da26
16 changed files with 198 additions and 102 deletions
|
|
@ -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;
|
||||
|
|
@ -427,7 +455,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 +532,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;
|
||||
|
||||
|
|
|
|||
|
|
@ -460,7 +460,27 @@ 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
|
||||
);
|
||||
}
|
||||
|
||||
var chunkX = x >> 4;
|
||||
|
|
@ -489,7 +509,27 @@ 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);
|
||||
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
|
||||
|
|
@ -524,7 +564,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 +624,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)
|
||||
|
|
|
|||
|
|
@ -234,9 +234,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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ using System;
|
|||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using Server.Engines.Pathing.Cache;
|
||||
using Server.PathAlgorithms.BitmapAStar;
|
||||
using Server.PathAlgorithms;
|
||||
using Server.Targeting;
|
||||
|
||||
namespace Server.Engines.Pathing;
|
||||
|
|
|
|||
|
|
@ -31,18 +31,15 @@ public static class PathfindRecorder
|
|||
{
|
||||
private static readonly ILogger logger = LogFactory.GetLogger(typeof(PathfindRecorder));
|
||||
|
||||
private static bool _enabled;
|
||||
private static string _outputPath;
|
||||
private static StreamWriter _writer;
|
||||
private static long _recordsWritten;
|
||||
|
||||
public static bool Enabled => _enabled;
|
||||
public static string OutputPath => _outputPath;
|
||||
public static long RecordsWritten => _recordsWritten;
|
||||
public static bool Enabled { get; private set; }
|
||||
public static string OutputPath { get; set; }
|
||||
public static long RecordsWritten { get; private set; }
|
||||
|
||||
public static void Configure()
|
||||
{
|
||||
_outputPath = ServerConfiguration.GetOrUpdateSetting(
|
||||
OutputPath = ServerConfiguration.GetOrUpdateSetting(
|
||||
"pathfinding.recorder.path",
|
||||
Path.Combine(Core.BaseDirectory, "Data", "Pathfinding", "recordings", "pathfinds.jsonl")
|
||||
);
|
||||
|
|
@ -61,7 +58,7 @@ public static class PathfindRecorder
|
|||
/// </summary>
|
||||
public static void SetEnabled(bool enabled)
|
||||
{
|
||||
if (enabled == _enabled)
|
||||
if (enabled == Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -70,22 +67,22 @@ public static class PathfindRecorder
|
|||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_outputPath) ?? ".");
|
||||
var stream = new FileStream(_outputPath, FileMode.Append, FileAccess.Write, FileShare.Read);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(OutputPath) ?? ".");
|
||||
var stream = new FileStream(OutputPath, FileMode.Append, FileAccess.Write, FileShare.Read);
|
||||
_writer = new StreamWriter(stream, new UTF8Encoding(false));
|
||||
_enabled = true;
|
||||
logger.Information("PathfindRecorder enabled, writing to {Path}", _outputPath);
|
||||
Enabled = true;
|
||||
logger.Information("PathfindRecorder enabled, writing to {Path}", OutputPath);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.Warning(ex, "PathfindRecorder: failed to open {Path} for write", _outputPath);
|
||||
logger.Warning(ex, "PathfindRecorder: failed to open {Path} for write", OutputPath);
|
||||
_writer = null;
|
||||
_enabled = false;
|
||||
Enabled = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_enabled = false;
|
||||
Enabled = false;
|
||||
try
|
||||
{
|
||||
_writer?.Flush();
|
||||
|
|
@ -93,10 +90,10 @@ public static class PathfindRecorder
|
|||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.Warning(ex, "PathfindRecorder: error closing {Path}", _outputPath);
|
||||
logger.Warning(ex, "PathfindRecorder: error closing {Path}", OutputPath);
|
||||
}
|
||||
_writer = null;
|
||||
logger.Information("PathfindRecorder disabled ({Count} records this session)", _recordsWritten);
|
||||
logger.Information("PathfindRecorder disabled ({Count} records this session)", RecordsWritten);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +110,7 @@ public static class PathfindRecorder
|
|||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.Warning(ex, "PathfindRecorder: flush failed for {Path}", _outputPath);
|
||||
logger.Warning(ex, "PathfindRecorder: flush failed for {Path}", OutputPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +121,7 @@ public static class PathfindRecorder
|
|||
/// </summary>
|
||||
public static void RecordIfEnabled(Mobile m, Map map, Point3D start, Point3D goal)
|
||||
{
|
||||
if (!_enabled || _writer == null || m == null || map == null)
|
||||
if (!Enabled || _writer == null || m == null || map == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -159,7 +156,7 @@ public static class PathfindRecorder
|
|||
vsb.Append(canMoveOverObstacles ? "true" : "false");
|
||||
vsb.Append("}\n");
|
||||
_writer.Write(vsb.AsSpan());
|
||||
_recordsWritten++;
|
||||
RecordsWritten++;
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue