using System.IO;
using System.Text;
using Server.Logging;
using Server.Mobiles;
using Server.Text;
namespace Server.Engines.Pathing;
///
/// Appends one JSONL record per pathfind, capturing the inputs — start, goal, map, capability
/// flags — needed to replay it later in a benchmark. Toggled at runtime with [PathRecord;
/// only seeds the initial state from server.cfg.
///
/// Meant for short bursts: turn it on, walk the region or trigger the scenario, turn it off. The
/// writes go through a StreamWriter's buffer on the game thread, so a busy shard doing hundreds of
/// pathfinds a second can saturate that buffer and stall the loop on disk I/O. Sustained capture
/// would need an async sink with backpressure.
///
public static class PathfindRecorder
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(PathfindRecorder));
private static StreamWriter _writer;
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(
"pathfinding.recorder.path",
Path.Combine(Core.BaseDirectory, "Data", "Pathfinding", "recordings", "pathfinds.jsonl")
);
var startEnabled = ServerConfiguration.GetOrUpdateSetting("pathfinding.recorder.enable", false);
if (startEnabled)
{
SetEnabled(true);
}
}
///
/// Toggles recording, opening the file on enable and flushing and closing it on disable.
/// Idempotent.
///
public static void SetEnabled(bool enabled)
{
if (enabled == Enabled)
{
return;
}
if (enabled)
{
try
{
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);
}
catch (IOException ex)
{
logger.Warning(ex, "PathfindRecorder: failed to open {Path} for write", OutputPath);
_writer = null;
Enabled = false;
}
}
else
{
Enabled = false;
try
{
_writer?.Flush();
_writer?.Dispose();
}
catch (IOException ex)
{
logger.Warning(ex, "PathfindRecorder: error closing {Path}", OutputPath);
}
_writer = null;
logger.Information("PathfindRecorder disabled ({Count} records this session)", RecordsWritten);
}
}
///
/// Pushes the writer's buffer to disk, so a capture can be inspected without disabling first.
/// No-op when disabled.
///
public static void Flush()
{
try
{
_writer?.Flush();
}
catch (IOException ex)
{
logger.Warning(ex, "PathfindRecorder: flush failed for {Path}", OutputPath);
}
}
///
/// Records one Find. Sits on the pathfinding hot path, so it costs a single bool check when
/// disabled.
///
public static void RecordIfEnabled(Mobile m, Map map, Point3D start, Point3D goal)
{
if (!Enabled || _writer == null || m == null || map == null)
{
return;
}
var canSwim = false;
var canFly = false;
var canOpenDoors = false;
var canMoveOverObstacles = false;
if (m is BaseCreature bc)
{
canSwim = bc.CanSwim;
canFly = bc.CanFly;
canOpenDoors = bc.CanOpenDoors;
canMoveOverObstacles = bc.CanMoveOverObstacles;
}
try
{
// One interpolation covers every numeric field without a per-field ToString. The bools
// are appended as literals because JSON wants lowercase and bool.ToString() capitalizes.
using var vsb = ValueStringBuilder.Create(192);
vsb.Append(
$"{{\"Name\":\"recorded\",\"MapId\":{map.MapID},\"StartX\":{start.X},\"StartY\":{start.Y},\"StartZ\":{start.Z},\"GoalX\":{goal.X},\"GoalY\":{goal.Y},\"GoalZ\":{goal.Z},\"CanSwim\":"
);
vsb.Append(canSwim ? "true" : "false");
vsb.Append(",\"CanFly\":");
vsb.Append(canFly ? "true" : "false");
vsb.Append(",\"CanOpenDoors\":");
vsb.Append(canOpenDoors ? "true" : "false");
vsb.Append(",\"CanMoveOverObstacles\":");
vsb.Append(canMoveOverObstacles ? "true" : "false");
vsb.Append("}\n");
_writer.Write(vsb.AsSpan());
RecordsWritten++;
}
catch (IOException ex)
{
logger.Warning(ex, "PathfindRecorder: write failed, disabling");
SetEnabled(false);
}
}
}