diff --git a/CLAUDE.md b/CLAUDE.md
index 54f5bb78..bd79abb5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -118,6 +118,11 @@ fallbacks) for inline images/maps.
- **A meter is drawn only once its value has arrived, and from then its fields are reserved** (value and
max five cells each, `StatusMeter.Fit` scales past that). A world with no game data costs the row
nothing; one with it never reflows the row as numbers change.
+- **Maps** (`Core/Mapping`: `WorldMap`, `MapRoom`, `PathFinder`, `MapStore`). Mudlet's room model (id, area,
+ x/y/z, exits by direction and by command, room and exit weights, locks). `MapStore` keeps one file per
+ **endpoint** under `maps/` (`MsspCache`'s reasoning), memory-only with no directory, never overwrites a
+ newer client's file and moves an unreadable one aside. `PathFinder` is Dijkstra, not A*: coordinates are
+ not a lower bound on cost once portals and nudged placements exist, and 10,000 rooms is milliseconds.
- **Tui** — **SharpConsoleUI** app: a `TabControl` of output windows (main + trigger-routed **spawn
windows** + web view, with unread badges), each a `MarkupControl` in a `ScrollablePanelControl`
viewport (PgUp/PgDn, Shift+↑/↓, ⌃Home/⌃End, wheel; unread badges count output arriving below a
diff --git a/src/SharpMUTerm.Core/Mapping/MapDirections.cs b/src/SharpMUTerm.Core/Mapping/MapDirections.cs
new file mode 100644
index 00000000..d74d5cb7
--- /dev/null
+++ b/src/SharpMUTerm.Core/Mapping/MapDirections.cs
@@ -0,0 +1,80 @@
+namespace SharpMUTerm.Core.Mapping;
+
+///
+/// The twelve directions a map knows, by their long names (north, up, in), with the
+/// short command each is walked by, its reverse, and the grid step it takes. Every exit key a
+/// holds is one of ; anything else is a special exit.
+///
+public static class MapDirections
+{
+ /// The directions in the order a room's exits are listed.
+ public static readonly IReadOnlyList All =
+ ["north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest", "up", "down", "in", "out"];
+
+ private static readonly Dictionary Aliases = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["n"] = "north", ["ne"] = "northeast", ["e"] = "east", ["se"] = "southeast",
+ ["s"] = "south", ["sw"] = "southwest", ["w"] = "west", ["nw"] = "northwest",
+ ["u"] = "up", ["d"] = "down",
+ };
+
+ private static readonly Dictionary Short = new(StringComparer.Ordinal)
+ {
+ ["north"] = "n", ["northeast"] = "ne", ["east"] = "e", ["southeast"] = "se",
+ ["south"] = "s", ["southwest"] = "sw", ["west"] = "w", ["northwest"] = "nw",
+ ["up"] = "u", ["down"] = "d", ["in"] = "in", ["out"] = "out",
+ };
+
+ private static readonly Dictionary Reverses = new(StringComparer.Ordinal)
+ {
+ ["north"] = "south", ["northeast"] = "southwest", ["east"] = "west", ["southeast"] = "northwest",
+ ["south"] = "north", ["southwest"] = "northeast", ["west"] = "east", ["northwest"] = "southeast",
+ ["up"] = "down", ["down"] = "up", ["in"] = "out", ["out"] = "in",
+ };
+
+ ///
+ /// The long name for (n, North and north all give
+ /// north), or null when it is not a direction.
+ ///
+ public static string? Normalise(string? direction)
+ {
+ var trimmed = direction?.Trim();
+ if (string.IsNullOrEmpty(trimmed))
+ {
+ return null;
+ }
+
+ if (Aliases.TryGetValue(trimmed, out var full))
+ {
+ return full;
+ }
+
+ var lower = trimmed.ToLowerInvariant();
+ return Short.ContainsKey(lower) ? lower : null;
+ }
+
+ /// The command that walks : n for north.
+ public static string Command(string direction) => Short.GetValueOrDefault(direction, direction);
+
+ /// The opposite direction, or null for one that is not a direction.
+ public static string? Reverse(string direction) => Reverses.GetValueOrDefault(direction);
+
+ ///
+ /// The grid step a direction takes, with north as −y (rows grow downwards on screen) and up as +z.
+ /// in and out take none: they lead somewhere, but not anywhere a grid can say.
+ ///
+ public static (int Dx, int Dy, int Dz) Offset(string direction) => direction switch
+ {
+ "north" => (0, -1, 0),
+ "northeast" => (1, -1, 0),
+ "east" => (1, 0, 0),
+ "southeast" => (1, 1, 0),
+ "south" => (0, 1, 0),
+ "southwest" => (-1, 1, 0),
+ "west" => (-1, 0, 0),
+ "northwest" => (-1, -1, 0),
+ "up" => (0, 0, 1),
+ "down" => (0, 0, -1),
+ _ => (0, 0, 0),
+ };
+}
diff --git a/src/SharpMUTerm.Core/Mapping/MapModel.cs b/src/SharpMUTerm.Core/Mapping/MapModel.cs
new file mode 100644
index 00000000..5c168084
--- /dev/null
+++ b/src/SharpMUTerm.Core/Mapping/MapModel.cs
@@ -0,0 +1,118 @@
+namespace SharpMUTerm.Core.Mapping;
+
+///
+/// One room of a map — Mudlet's model, which players already understand: an id the game gives it, a place
+/// on a grid, exits by direction and by command, and the weights and locks the path finder reads.
+///
+public sealed class MapRoom
+{
+ /// The game's own number for the room (GMCP num/id), or one the client assigned.
+ public long Id { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+
+ /// The the room belongs to, or null.
+ public int? AreaId { get; set; }
+
+ public int X { get; set; }
+
+ public int Y { get; set; }
+
+ public int Z { get; set; }
+
+ /// The game's terrain or environment name, when it sends one.
+ public string? Environment { get; set; }
+
+ /// Exits by direction (a name) → the room they lead to.
+ public Dictionary Exits { get; set; } = new(StringComparer.Ordinal);
+
+ /// Exits by the command that takes them (enter portal) → the room they lead to.
+ public Dictionary SpecialExits { get; set; } = new(StringComparer.Ordinal);
+
+ /// The cost of entering this room, at least 1. A heavier room is walked around when there is a way.
+ public int Weight { get; set; } = 1;
+
+ ///
+ /// The cost of a particular exit, by direction or command, overriding the destination room's
+ /// when set — Mudlet's rule.
+ ///
+ public Dictionary ExitWeights { get; set; } = new(StringComparer.Ordinal);
+
+ /// Exits (directions or commands) the path finder never takes.
+ public HashSet LockedExits { get; set; } = new(StringComparer.Ordinal);
+
+ /// A locked room is never walked through or to.
+ public bool Locked { get; set; }
+
+ /// One or two characters drawn in the room's cell, or null.
+ public string? Symbol { get; set; }
+
+ /// The room's colour as #rrggbb, or null for the default.
+ public string? Color { get; set; }
+
+ /// Anything a script wants to keep on the room.
+ public Dictionary UserData { get; set; } = new(StringComparer.Ordinal);
+
+ /// Every exit, direction and special alike, as the command that takes it and where it leads.
+ public IEnumerable<(string Command, string Key, long To)> AllExits()
+ {
+ foreach (var (direction, to) in Exits)
+ {
+ yield return (MapDirections.Command(direction), direction, to);
+ }
+
+ foreach (var (command, to) in SpecialExits)
+ {
+ yield return (command, command, to);
+ }
+ }
+}
+
+/// A named area rooms belong to.
+public sealed class MapArea
+{
+ public int Id { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+}
+
+///
+/// A world's map: its rooms and areas. Not thread-safe; the client touches it from the UI thread only.
+///
+public sealed class WorldMap
+{
+ public Dictionary Rooms { get; } = new();
+
+ public Dictionary Areas { get; } = new();
+
+ /// The room, or null when the map has none by that id.
+ public MapRoom? Room(long id) => Rooms.GetValueOrDefault(id);
+
+ /// Adds the room, or replaces the one with its id.
+ public MapRoom Add(MapRoom room)
+ {
+ ArgumentNullException.ThrowIfNull(room);
+ Rooms[room.Id] = room;
+ return room;
+ }
+
+ /// The area with this name, created with the next free id when there is none.
+ public MapArea AreaNamed(string name)
+ {
+ foreach (var area in Areas.Values)
+ {
+ if (string.Equals(area.Name, name, StringComparison.OrdinalIgnoreCase))
+ {
+ return area;
+ }
+ }
+
+ var created = new MapArea { Id = Areas.Count == 0 ? 1 : Areas.Keys.Max() + 1, Name = name };
+ Areas[created.Id] = created;
+ return created;
+ }
+
+ /// Whether a room other than already sits at this point of this area.
+ public bool IsOccupied(int? areaId, int x, int y, int z, long except) =>
+ Rooms.Values.Any(r => r.Id != except && r.AreaId == areaId && r.X == x && r.Y == y && r.Z == z);
+}
diff --git a/src/SharpMUTerm.Core/Mapping/MapStore.cs b/src/SharpMUTerm.Core/Mapping/MapStore.cs
new file mode 100644
index 00000000..62e7539c
--- /dev/null
+++ b/src/SharpMUTerm.Core/Mapping/MapStore.cs
@@ -0,0 +1,241 @@
+using System.Globalization;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace SharpMUTerm.Core.Mapping;
+
+///
+/// Where maps live: one JSON file per server under maps/ beside config.json, versioned on its
+/// own and written through a temporary file and a rename.
+///
+/// Keyed by endpoint, not world — MsspCache's reasoning. A map describes the game a server
+/// runs, and a world name is a label the user can rename out from under it; two worlds pointed at one server
+/// share one map.
+///
+///
+/// Memory-only by construction when built without a directory, which is what every test and snapshot
+/// gets: maps are still kept for the life of the process, and nothing is read or written. Only
+/// Program hands one a directory.
+///
+///
+/// A file from a newer client is never overwritten: its map loads empty,
+/// says why, and saves to that endpoint are skipped, so running an older build cannot destroy a map. A file
+/// that is not a map at all is renamed with and mapping starts again. A file with
+/// no version is the first layout.
+///
+///
+public sealed class MapStore
+{
+ /// The folder beside config.json.
+ public const string FolderName = "maps";
+
+ /// What an unreadable map file is renamed with, so a new one can be written.
+ public const string AsideSuffix = ".unreadable";
+
+ /// This file's schema version.
+ public const int CurrentVersion = 1;
+
+ private static readonly JsonSerializerOptions Json = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = true,
+ };
+
+ private readonly string? _directory;
+ private readonly Dictionary _maps = new(StringComparer.Ordinal);
+ private readonly Dictionary _problems = new(StringComparer.Ordinal);
+ private readonly HashSet _readOnly = new(StringComparer.Ordinal);
+
+ /// A store; null — the default — is memory-only.
+ public MapStore(string? directory = null)
+ {
+ _directory = string.IsNullOrWhiteSpace(directory) ? null : Path.GetFullPath(directory);
+ }
+
+ /// The maps folder beside a configuration file.
+ public static string PathFor(string configurationPath)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(configurationPath);
+ var directory = Path.GetDirectoryName(Path.GetFullPath(configurationPath));
+ return Path.Combine(directory ?? string.Empty, FolderName);
+ }
+
+ public ILogger Logger { get; set; } = NullLogger.Instance;
+
+ public bool IsPersistent => _directory is not null;
+
+ /// The identity a map is filed under: the host lower-cased without a trailing dot, and the port.
+ public static string Key(string? host, int port) =>
+ string.Concat(
+ (host ?? string.Empty).Trim().TrimEnd('.').ToLowerInvariant(),
+ ":",
+ port.ToString(CultureInfo.InvariantCulture));
+
+ ///
+ /// The file a key is stored in: every character outside [a-z0-9.-] becomes _, so a host
+ /// can never name a path outside the folder.
+ ///
+ public static string FileNameFor(string key)
+ {
+ var name = new StringBuilder(key.Length + 5);
+ foreach (var ch in key)
+ {
+ name.Append(ch is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '-' or '.' ? ch : '_');
+ }
+
+ // A key of only dots would be "." or ".."; no host is that, but the folder must still hold the file.
+ return (name.ToString().Trim('.') is { Length: > 0 } safe ? safe : "_") + ".json";
+ }
+
+ /// Why this endpoint's file could not be read, or null.
+ public string? ProblemFor(string? host, int port) => _problems.GetValueOrDefault(Key(host, port));
+
+ /// The map for this endpoint — read from disk the first time it is asked for, then held.
+ public WorldMap For(string? host, int port)
+ {
+ var key = Key(host, port);
+ if (_maps.TryGetValue(key, out var held))
+ {
+ return held;
+ }
+
+ var map = _directory is null ? new WorldMap() : Read(key);
+ _maps[key] = map;
+ return map;
+ }
+
+ /// Writes this endpoint's map, when the store is persistent and the file is ours to replace.
+ public void Save(string? host, int port)
+ {
+ var key = Key(host, port);
+ if (_directory is null || _readOnly.Contains(key) || !_maps.TryGetValue(key, out var map))
+ {
+ return;
+ }
+
+ var path = Path.Combine(_directory, FileNameFor(key));
+ try
+ {
+ Directory.CreateDirectory(_directory);
+ var temporary = path + ".tmp";
+ File.WriteAllText(temporary, Serialize(map, key));
+ File.Move(temporary, path, overwrite: true);
+ }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException or NotSupportedException)
+ {
+ Logger.LogWarning(e, "The map for {Endpoint} could not be written to {Path}.", key, path);
+ }
+ }
+
+ /// The file's text for a map.
+ public static string Serialize(WorldMap map, string key)
+ {
+ ArgumentNullException.ThrowIfNull(map);
+ var document = new JsonObject
+ {
+ ["version"] = CurrentVersion,
+ ["endpoint"] = key,
+ ["areas"] = JsonSerializer.SerializeToNode(map.Areas.Values.OrderBy(a => a.Id).ToArray(), Json),
+ ["rooms"] = JsonSerializer.SerializeToNode(map.Rooms.Values.OrderBy(r => r.Id).ToArray(), Json),
+ };
+ return document.ToJsonString(Json);
+ }
+
+ ///
+ /// A map from a file's text. Throws for a version newer than this
+ /// client's, and for text that is not a map.
+ ///
+ public static WorldMap Deserialize(string text)
+ {
+ var document = JsonNode.Parse(text) as JsonObject ?? throw new JsonException("A map file is a JSON object.");
+ var version = document["version"]?.GetValue() ?? 1;
+ if (version > CurrentVersion)
+ {
+ throw new NotSupportedException(
+ $"this map was written by a newer SharpMUTerm (map version {version}; this one reads {CurrentVersion})");
+ }
+
+ var map = new WorldMap();
+ foreach (var area in document["areas"]?.Deserialize(Json) ?? [])
+ {
+ map.Areas[area.Id] = area;
+ }
+
+ foreach (var room in document["rooms"]?.Deserialize(Json) ?? [])
+ {
+ map.Add(Normalised(room));
+ }
+
+ return map;
+ }
+
+ ///
+ /// Puts back what a hand-edited file may have lost: the dictionaries' ordinal comparers (the serializer
+ /// builds default ones), a weight of at least 1, and only real directions among the exits.
+ ///
+ private static MapRoom Normalised(MapRoom room)
+ {
+ var exits = new Dictionary(StringComparer.Ordinal);
+ foreach (var (direction, to) in room.Exits ?? [])
+ {
+ if (MapDirections.Normalise(direction) is { } name)
+ {
+ exits[name] = to;
+ }
+ }
+
+ room.Exits = exits;
+ room.SpecialExits = new Dictionary(room.SpecialExits ?? [], StringComparer.Ordinal);
+ room.ExitWeights = new Dictionary(room.ExitWeights ?? [], StringComparer.Ordinal);
+ room.LockedExits = new HashSet(room.LockedExits ?? [], StringComparer.Ordinal);
+ room.UserData = new Dictionary(room.UserData ?? [], StringComparer.Ordinal);
+ room.Name ??= string.Empty;
+ room.Weight = Math.Max(1, room.Weight);
+ return room;
+ }
+
+ private WorldMap Read(string key)
+ {
+ var path = Path.Combine(_directory!, FileNameFor(key));
+ if (!File.Exists(path))
+ {
+ return new WorldMap();
+ }
+
+ try
+ {
+ return Deserialize(File.ReadAllText(path));
+ }
+ catch (NotSupportedException e)
+ {
+ _readOnly.Add(key);
+ _problems[key] = e.Message;
+ }
+ catch (Exception e) when (e is JsonException or InvalidOperationException or FormatException)
+ {
+ // Unreadable, not newer: moved aside rather than overwritten, so mapping can start again and
+ // whatever the file held is still there for someone who wants it back.
+ _problems[key] = $"the map file could not be read ({e.Message}); it was moved to {Path.GetFileName(path)}{AsideSuffix}";
+ try
+ {
+ File.Move(path, path + AsideSuffix, overwrite: true);
+ }
+ catch (Exception move) when (move is IOException or UnauthorizedAccessException)
+ {
+ _readOnly.Add(key);
+ _problems[key] = $"the map file could not be read ({e.Message}) or moved aside; it is left as it is";
+ }
+ }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException)
+ {
+ _readOnly.Add(key);
+ _problems[key] = $"the map file could not be read ({e.Message}); it is left as it is";
+ }
+
+ Logger.LogWarning("The map for {Endpoint} was not loaded: {Problem}", key, _problems[key]);
+ return new WorldMap();
+ }
+}
diff --git a/src/SharpMUTerm.Core/Mapping/PathFinder.cs b/src/SharpMUTerm.Core/Mapping/PathFinder.cs
new file mode 100644
index 00000000..5c7810f1
--- /dev/null
+++ b/src/SharpMUTerm.Core/Mapping/PathFinder.cs
@@ -0,0 +1,89 @@
+namespace SharpMUTerm.Core.Mapping;
+
+/// One step of a walk: the command to send, and the room it should reach.
+public readonly record struct PathStep(string Command, long To);
+
+///
+/// The cheapest walk between two rooms, as commands. Each step costs its exit's weight when it has one and
+/// otherwise the room it enters; locked rooms and locked exits are never taken, and an exit to a room the map
+/// does not hold is not a way anywhere.
+///
+/// Dijkstra, not A* with a distance heuristic. A heuristic has to be a lower bound to stay correct, and
+/// a map's coordinates are not one: special exits and portals jump across the grid, placement nudges rooms
+/// off their true step, and areas share coordinates. A zero heuristic is always admissible, and a 10,000-room
+/// map is still a few milliseconds.
+///
+///
+public static class PathFinder
+{
+ ///
+ /// The steps from to : empty when they are the same room, null
+ /// when there is no way.
+ ///
+ public static IReadOnlyList? Find(WorldMap map, long from, long to)
+ {
+ ArgumentNullException.ThrowIfNull(map);
+ if (map.Room(from) is null || map.Room(to) is not { Locked: false })
+ {
+ return null;
+ }
+
+ if (from == to)
+ {
+ return [];
+ }
+
+ var cost = new Dictionary { [from] = 0 };
+ var previous = new Dictionary();
+ var queue = new PriorityQueue();
+ queue.Enqueue(from, 0);
+
+ while (queue.TryDequeue(out var current, out var reached))
+ {
+ if (current == to)
+ {
+ return Walk(previous, from, to);
+ }
+
+ if (reached > cost[current] || map.Room(current) is not { } room)
+ {
+ continue; // a stale entry, superseded by a cheaper one
+ }
+
+ foreach (var (command, key, next) in room.AllExits())
+ {
+ if (room.LockedExits.Contains(key) || map.Room(next) is not { Locked: false } target)
+ {
+ continue;
+ }
+
+ var step = room.ExitWeights.TryGetValue(key, out var weight) && weight > 0
+ ? weight
+ : Math.Max(1, target.Weight);
+ var total = reached + step;
+ if (cost.TryGetValue(next, out var known) && known <= total)
+ {
+ continue;
+ }
+
+ cost[next] = total;
+ previous[next] = (current, command);
+ queue.Enqueue(next, total);
+ }
+ }
+
+ return null;
+ }
+
+ private static List Walk(Dictionary previous, long from, long to)
+ {
+ var steps = new List();
+ for (var at = to; at != from; at = previous[at].Room)
+ {
+ steps.Add(new PathStep(previous[at].Command, at));
+ }
+
+ steps.Reverse();
+ return steps;
+ }
+}
diff --git a/tests/SharpMUTerm.Core.Tests/Mapping/MapStoreTests.cs b/tests/SharpMUTerm.Core.Tests/Mapping/MapStoreTests.cs
new file mode 100644
index 00000000..e7e8b39d
--- /dev/null
+++ b/tests/SharpMUTerm.Core.Tests/Mapping/MapStoreTests.cs
@@ -0,0 +1,144 @@
+using SharpMUTerm.Core.Mapping;
+
+namespace SharpMUTerm.Core.Tests.Mapping;
+
+public class MapStoreTests
+{
+ private static string TempDirectory()
+ {
+ var path = Path.Combine(Path.GetTempPath(), "sharpmuterm-maps-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(path);
+ return path;
+ }
+
+ [Test]
+ public async Task AMapRoundTrips()
+ {
+ var directory = TempDirectory();
+ try
+ {
+ var store = new MapStore(directory);
+ var map = store.For("Mud.Example.ORG.", 4000);
+ var area = map.AreaNamed("Town");
+ map.Add(new MapRoom
+ {
+ Id = 7,
+ Name = "Square",
+ AreaId = area.Id,
+ X = 1,
+ Y = -2,
+ Z = 3,
+ Environment = "city",
+ Exits = { ["north"] = 8 },
+ SpecialExits = { ["enter fountain"] = 9 },
+ Weight = 4,
+ ExitWeights = { ["north"] = 2 },
+ LockedExits = { "enter fountain" },
+ Locked = true,
+ Symbol = "$",
+ Color = "#ff8800",
+ UserData = { ["note"] = "bank" },
+ });
+ store.Save("mud.example.org", 4000);
+
+ await Assert.That(File.Exists(Path.Combine(directory, "mud.example.org_4000.json"))).IsTrue();
+
+ var loaded = new MapStore(directory).For("mud.example.org", 4000);
+ var room = loaded.Room(7)!;
+ await Assert.That(loaded.Areas[area.Id].Name).IsEqualTo("Town");
+ await Assert.That((room.Name, room.AreaId, room.X, room.Y, room.Z)).IsEqualTo(("Square", (int?)area.Id, 1, -2, 3));
+ await Assert.That(room.Exits["north"]).IsEqualTo(8L);
+ await Assert.That(room.SpecialExits["enter fountain"]).IsEqualTo(9L);
+ await Assert.That((room.Weight, room.ExitWeights["north"], room.Locked)).IsEqualTo((4, 2, true));
+ await Assert.That(room.LockedExits).Contains("enter fountain");
+ await Assert.That((room.Symbol, room.Color, room.UserData["note"])).IsEqualTo(("$", "#ff8800", "bank"));
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Test]
+ public async Task AStoreWithNoDirectoryWritesNothing()
+ {
+ var store = new MapStore();
+ store.For("h", 1).Add(new MapRoom { Id = 1 });
+ store.Save("h", 1);
+
+ await Assert.That(store.IsPersistent).IsFalse();
+ await Assert.That(store.For("h", 1).Room(1)).IsNotNull();
+ }
+
+ /// An older client never destroys a newer client's map: it loads empty, says why, and does not write.
+ [Test]
+ public async Task ANewerFileIsLeftAlone()
+ {
+ var directory = TempDirectory();
+ try
+ {
+ var path = Path.Combine(directory, MapStore.FileNameFor(MapStore.Key("h", 1)));
+ var newer = """{"version":99,"rooms":[{"id":1,"name":"Future"}]}""";
+ File.WriteAllText(path, newer);
+
+ var store = new MapStore(directory);
+ var map = store.For("h", 1);
+ map.Add(new MapRoom { Id = 2 });
+ store.Save("h", 1);
+
+ await Assert.That(map.Rooms.Count).IsEqualTo(1);
+ await Assert.That(store.ProblemFor("h", 1)).Contains("newer");
+ await Assert.That(File.ReadAllText(path)).IsEqualTo(newer);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Test]
+ public async Task AnUnversionedFileIsTheFirstLayoutAndBadExitsAreDropped()
+ {
+ var map = MapStore.Deserialize("""{"rooms":[{"id":1,"name":"Old","weight":0,"exits":{"N":2,"sideways":3}}]}""");
+
+ var room = map.Room(1)!;
+ await Assert.That(room.Exits.Keys).IsEquivalentTo(new[] { "north" });
+ await Assert.That(room.Weight).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task AnUnreadableFileIsMovedAsideAndMappingStartsAgain()
+ {
+ var directory = TempDirectory();
+ try
+ {
+ var path = Path.Combine(directory, MapStore.FileNameFor(MapStore.Key("h", 1)));
+ File.WriteAllText(path, "not json");
+
+ var store = new MapStore(directory);
+ store.For("h", 1).Add(new MapRoom { Id = 5 });
+ store.Save("h", 1);
+
+ await Assert.That(File.ReadAllText(path + MapStore.AsideSuffix)).IsEqualTo("not json");
+ await Assert.That(new MapStore(directory).For("h", 1).Room(5)).IsNotNull();
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Test]
+ [Arguments("../../etc/passwd", 23)]
+ [Arguments("..", 1)]
+ [Arguments("a/b\\c", 2)]
+ public async Task AHostCannotNameAPathOutsideTheFolder(string host, int port)
+ {
+ var name = MapStore.FileNameFor(MapStore.Key(host, port));
+
+ await Assert.That(name).DoesNotContain("/");
+ await Assert.That(name).DoesNotContain("\\");
+ await Assert.That(name.StartsWith('.')).IsFalse();
+ await Assert.That(Path.GetFileName(name)).IsEqualTo(name);
+ }
+}
diff --git a/tests/SharpMUTerm.Core.Tests/Mapping/PathFinderTests.cs b/tests/SharpMUTerm.Core.Tests/Mapping/PathFinderTests.cs
new file mode 100644
index 00000000..6947366f
--- /dev/null
+++ b/tests/SharpMUTerm.Core.Tests/Mapping/PathFinderTests.cs
@@ -0,0 +1,133 @@
+using System.Diagnostics;
+using SharpMUTerm.Core.Mapping;
+
+namespace SharpMUTerm.Core.Tests.Mapping;
+
+public class PathFinderTests
+{
+ /// A row of rooms 1 → 2 → … → , each east of the last, with exits both ways.
+ private static WorldMap Corridor(int count)
+ {
+ var map = new WorldMap();
+ for (var id = 1; id <= count; id++)
+ {
+ var room = map.Add(new MapRoom { Id = id, Name = $"Room {id}", X = id });
+ if (id > 1)
+ {
+ room.Exits["west"] = id - 1;
+ }
+
+ if (id < count)
+ {
+ room.Exits["east"] = id + 1;
+ }
+ }
+
+ return map;
+ }
+
+ [Test]
+ public async Task TheShortestWalkIsItsCommands()
+ {
+ var map = Corridor(4);
+
+ var path = PathFinder.Find(map, 1, 4);
+
+ await Assert.That(path!.Select(s => s.Command)).IsEquivalentTo(new[] { "e", "e", "e" });
+ await Assert.That(path!.Select(s => s.To)).IsEquivalentTo(new long[] { 2, 3, 4 });
+ await Assert.That(PathFinder.Find(map, 4, 4)).IsEmpty();
+ }
+
+ /// A square: 1 east to 2 south to 4, or 1 south to 3 east to 4. A heavy room 2 sends the walk round by 3.
+ [Test]
+ public async Task AHeavyRoomIsWalkedAround()
+ {
+ var map = new WorldMap();
+ map.Add(new MapRoom { Id = 1, Exits = { ["east"] = 2, ["south"] = 3 } });
+ map.Add(new MapRoom { Id = 2, Exits = { ["south"] = 4 } });
+ map.Add(new MapRoom { Id = 3, Exits = { ["east"] = 4 } });
+ map.Add(new MapRoom { Id = 4 });
+
+ map.Room(3)!.Weight = 5;
+ await Assert.That(PathFinder.Find(map, 1, 4)!.Select(s => s.Command)).IsEquivalentTo(new[] { "e", "s" });
+
+ map.Room(3)!.Weight = 1;
+ map.Room(2)!.Weight = 5;
+ await Assert.That(PathFinder.Find(map, 1, 4)!.Select(s => s.Command)).IsEquivalentTo(new[] { "s", "e" });
+
+ // An exit's own weight wins over the room it enters.
+ map.Room(1)!.ExitWeights["east"] = 1;
+ map.Room(2)!.ExitWeights["south"] = 1;
+ map.Room(1)!.ExitWeights["south"] = 3;
+ await Assert.That(PathFinder.Find(map, 1, 4)!.Select(s => s.Command)).IsEquivalentTo(new[] { "e", "s" });
+ }
+
+ [Test]
+ public async Task LockedRoomsAndExitsAreNeverTaken()
+ {
+ var map = new WorldMap();
+ map.Add(new MapRoom { Id = 1, Exits = { ["east"] = 2, ["south"] = 3 } });
+ map.Add(new MapRoom { Id = 2, Exits = { ["south"] = 4 } });
+ map.Add(new MapRoom { Id = 3, Exits = { ["east"] = 4 } });
+ map.Add(new MapRoom { Id = 4 });
+
+ map.Room(2)!.Locked = true;
+ await Assert.That(PathFinder.Find(map, 1, 4)!.Select(s => s.Command)).IsEquivalentTo(new[] { "s", "e" });
+
+ map.Room(1)!.LockedExits.Add("south");
+ await Assert.That(PathFinder.Find(map, 1, 4)).IsNull();
+
+ map.Room(4)!.Locked = true;
+ await Assert.That(PathFinder.Find(map, 3, 4)).IsNull();
+ }
+
+ [Test]
+ public async Task SpecialExitsAreWalkedByTheirCommand()
+ {
+ var map = new WorldMap();
+ map.Add(new MapRoom { Id = 1, SpecialExits = { ["enter portal"] = 9 } });
+ map.Add(new MapRoom { Id = 9 });
+
+ await Assert.That(PathFinder.Find(map, 1, 9)!.Single().Command).IsEqualTo("enter portal");
+ }
+
+ [Test]
+ public async Task ARoomNoExitReachesIsUnreachable()
+ {
+ var map = Corridor(3);
+ map.Add(new MapRoom { Id = 50 });
+ map.Room(3)!.Exits["north"] = 999; // leads to a room the map does not hold
+
+ await Assert.That(PathFinder.Find(map, 1, 50)).IsNull();
+ await Assert.That(PathFinder.Find(map, 1, 999)).IsNull();
+ await Assert.That(PathFinder.Find(map, 777, 1)).IsNull();
+ }
+
+ /// A 100×100 grid, corner to corner: 198 steps, inside a budget a speedwalk can afford per keystroke.
+ [Test]
+ public async Task TenThousandRoomsIsQuick()
+ {
+ const int side = 100;
+ var map = new WorldMap();
+ long Id(int x, int y) => (y * side) + x + 1;
+ for (var y = 0; y < side; y++)
+ {
+ for (var x = 0; x < side; x++)
+ {
+ var room = map.Add(new MapRoom { Id = Id(x, y), X = x, Y = y });
+ if (x > 0) room.Exits["west"] = Id(x - 1, y);
+ if (x < side - 1) room.Exits["east"] = Id(x + 1, y);
+ if (y > 0) room.Exits["north"] = Id(x, y - 1);
+ if (y < side - 1) room.Exits["south"] = Id(x, y + 1);
+ }
+ }
+
+ PathFinder.Find(map, 1, 2); // warm up the JIT, not the map
+ var clock = Stopwatch.StartNew();
+ var path = PathFinder.Find(map, Id(0, 0), Id(side - 1, side - 1));
+ clock.Stop();
+
+ await Assert.That(path!.Count).IsEqualTo((2 * side) - 2);
+ await Assert.That(clock.ElapsedMilliseconds).IsLessThan(500);
+ }
+}