Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions src/SharpMUTerm.Core/Mapping/MapDirections.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
namespace SharpMUTerm.Core.Mapping;

/// <summary>
/// The twelve directions a map knows, by their long names (<c>north</c>, <c>up</c>, <c>in</c>), with the
/// short command each is walked by, its reverse, and the grid step it takes. Every exit key a
/// <see cref="MapRoom"/> holds is one of <see cref="All"/>; anything else is a special exit.
/// </summary>
public static class MapDirections
{
/// <summary>The directions in the order a room's exits are listed.</summary>
public static readonly IReadOnlyList<string> All =
["north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest", "up", "down", "in", "out"];

private static readonly Dictionary<string, string> 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<string, string> 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<string, string> 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",
};

/// <summary>
/// The long name for <paramref name="direction"/> (<c>n</c>, <c>North</c> and <c>north</c> all give
/// <c>north</c>), or null when it is not a direction.
/// </summary>
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;
}

/// <summary>The command that walks <paramref name="direction"/>: <c>n</c> for north.</summary>
public static string Command(string direction) => Short.GetValueOrDefault(direction, direction);

/// <summary>The opposite direction, or null for one that is not a direction.</summary>
public static string? Reverse(string direction) => Reverses.GetValueOrDefault(direction);

/// <summary>
/// The grid step a direction takes, with north as −y (rows grow downwards on screen) and up as +z.
/// <c>in</c> and <c>out</c> take none: they lead somewhere, but not anywhere a grid can say.
/// </summary>
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),
};
}
118 changes: 118 additions & 0 deletions src/SharpMUTerm.Core/Mapping/MapModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
namespace SharpMUTerm.Core.Mapping;

/// <summary>
/// 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.
/// </summary>
public sealed class MapRoom
{
/// <summary>The game's own number for the room (GMCP <c>num</c>/<c>id</c>), or one the client assigned.</summary>
public long Id { get; set; }

public string Name { get; set; } = string.Empty;

/// <summary>The <see cref="MapArea.Id"/> the room belongs to, or null.</summary>
public int? AreaId { get; set; }

public int X { get; set; }

public int Y { get; set; }

public int Z { get; set; }

/// <summary>The game's terrain or environment name, when it sends one.</summary>
public string? Environment { get; set; }

/// <summary>Exits by direction (a <see cref="MapDirections.All"/> name) → the room they lead to.</summary>
public Dictionary<string, long> Exits { get; set; } = new(StringComparer.Ordinal);

/// <summary>Exits by the command that takes them (<c>enter portal</c>) → the room they lead to.</summary>
public Dictionary<string, long> SpecialExits { get; set; } = new(StringComparer.Ordinal);

/// <summary>The cost of entering this room, at least 1. A heavier room is walked around when there is a way.</summary>
public int Weight { get; set; } = 1;

/// <summary>
/// The cost of a particular exit, by direction or command, overriding the destination room's
/// <see cref="Weight"/> when set — Mudlet's rule.
/// </summary>
public Dictionary<string, int> ExitWeights { get; set; } = new(StringComparer.Ordinal);

/// <summary>Exits (directions or commands) the path finder never takes.</summary>
public HashSet<string> LockedExits { get; set; } = new(StringComparer.Ordinal);

/// <summary>A locked room is never walked through or to.</summary>
public bool Locked { get; set; }

/// <summary>One or two characters drawn in the room's cell, or null.</summary>
public string? Symbol { get; set; }

/// <summary>The room's colour as <c>#rrggbb</c>, or null for the default.</summary>
public string? Color { get; set; }

/// <summary>Anything a script wants to keep on the room.</summary>
public Dictionary<string, string> UserData { get; set; } = new(StringComparer.Ordinal);

/// <summary>Every exit, direction and special alike, as the command that takes it and where it leads.</summary>
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);
}
}
}

/// <summary>A named area rooms belong to.</summary>
public sealed class MapArea
{
public int Id { get; set; }

public string Name { get; set; } = string.Empty;
}

/// <summary>
/// A world's map: its rooms and areas. Not thread-safe; the client touches it from the UI thread only.
/// </summary>
public sealed class WorldMap
{
public Dictionary<long, MapRoom> Rooms { get; } = new();

public Dictionary<int, MapArea> Areas { get; } = new();

/// <summary>The room, or null when the map has none by that id.</summary>
public MapRoom? Room(long id) => Rooms.GetValueOrDefault(id);

/// <summary>Adds the room, or replaces the one with its id.</summary>
public MapRoom Add(MapRoom room)
{
ArgumentNullException.ThrowIfNull(room);
Rooms[room.Id] = room;
return room;
}

/// <summary>The area with this name, created with the next free id when there is none.</summary>
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;
}

/// <summary>Whether a room other than <paramref name="except"/> already sits at this point of this area.</summary>
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);
}
Loading