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