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
53 changes: 36 additions & 17 deletions src/MUI.Crawl/LoginFlow/LoginCommandReading.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,16 @@ public static partial class LoginCommandReading
/// <remarks>
/// Counterpart to <c>MsspDefaults.MeaningfulName</c> for codebases (RhostMUSH among them) that
/// answer <c>INFO</c> but offer no MSSP. Same filter, same reason: <c>Name: PennMUSH</c> merely
/// restates the codebase and is not an identification.
/// restates the codebase and is not an identification. Read only from inside a closed block, as
/// <see cref="ConnectedPlayers"/> is: a game that takes <c>INFO</c> as a character name answers
/// with its own <c>Name:</c> prompt, and mud.paroxysmrpg.com's — a colour reset after the colon —
/// was once listed as the game's name.
/// </remarks>
public static string? MeaningfulName(string? info, string? version)
{
var codebase = MeaningfulCodebase(info, version);

foreach (var line in Lines(info))
foreach (var line in InfoBlock(info) ?? [])
{
if (!TrySplitLabelled(line, out var label, out var value)
|| !NameLabels.Contains(label, StringComparer.OrdinalIgnoreCase))
Expand Down Expand Up @@ -120,39 +123,51 @@ public static partial class LoginCommandReading
/// <c>Uptime:</c> is never read numerically — PennMUSH writes it as a ctime string, not a number.
/// </remarks>
public static int? ConnectedPlayers(string? info)
{
foreach (var line in InfoBlock(info) ?? [])
{
if (TrySplitLabelled(line, out var label, out var value)
&& label.Equals(ConnectedLabel, StringComparison.OrdinalIgnoreCase)
// NumberStyles.None refuses a sign — a negative count means we misread the value, not
// a small number.
&& int.TryParse(value.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var players))
{
return players;
}
}

return null;
}

/// <summary>
/// The lines strictly inside the first <c>INFO</c> block, colour codes removed, or null when no
/// block both opened and closed.
/// </summary>
private static List<string>? InfoBlock(string? info)
{
if (string.IsNullOrWhiteSpace(info))
{
return null;
}

var inside = false;
int? connected = null;
List<string>? inside = null;

foreach (var raw in info.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'))
foreach (var raw in AnsiEscape().Replace(info, string.Empty).Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'))
{
var line = raw.Trim();

if (!inside)
if (inside is null)
{
inside = InfoBlockStart().IsMatch(line);
inside = InfoBlockStart().IsMatch(line) ? [] : null;
continue;
}

if (InfoBlockEnd().IsMatch(line))
{
return connected;
return inside;
}

if (connected is null
&& TrySplitLabelled(line, out var label, out var value)
&& label.Equals(ConnectedLabel, StringComparison.OrdinalIgnoreCase)
// NumberStyles.None refuses a sign — a negative count means we misread the value, not
// a small number.
&& int.TryParse(value.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var players))
{
connected = players;
}
inside.Add(line);
}

return null;
Expand Down Expand Up @@ -418,6 +433,10 @@ private static IEnumerable<string> FamiliesIn(IEnumerable<string> lines) =>
private const string ConnectedLabel = "Connected";

// `### Begin INFO 1.1` (PennMUSH, RhostMUSH, TinyMUX) and `## BEGIN INFO 1.1` (Evennia).
// The same CSI shape WhoParser strips: SGR colour and the cursor/erase codes some prompts carry.
[GeneratedRegex(@"\x1B\[[0-9;?]*[ -/]*[@-~]")]
private static partial Regex AnsiEscape();

[GeneratedRegex(@"^#{2,}\s*begin\s+info\b", RegexOptions.IgnoreCase)]
private static partial Regex InfoBlockStart();

Expand Down
38 changes: 38 additions & 0 deletions tests/MUI.Crawl.Tests/LoginFlow/LoginCommandReadingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,44 @@ public async Task AnInfoNameThatIsOnlyTheCodebaseOrAPlaceholderIsRefused(string
await Assert.That(LoginCommandReading.MeaningfulName(info, null)).IsNull();
}

/// <summary>
/// A login prompt that answers <c>INFO</c> by asking again is not an <c>INFO</c> block, however
/// much its prompt looks like a <c>Name:</c> field.
/// </summary>
/// <remarks>
/// Measured at mud.paroxysmrpg.com:3000 on 2026-09-28: it takes <c>INFO</c> as a character name
/// and replies <c>Illegal name, try another.</c> then <c>Name: ESC[40;0;37m</c> — its prompt with
/// a trailing colour reset. Read as a field, the escape sequence became the listing's name and
/// slug (<c>/g/40-0-37m</c>). The count reader already demanded a closed block for the same
/// reason; the name reader now asks the same of it.
/// </remarks>
[Test]
[Arguments("Illegal name, try another.\r\nName: \u001B[40;0;37m")]
[Arguments("Illegal name, try another.\nName: INFO")]
[Arguments("### Begin INFO 1\nName: Convergence MUSH\nConnected: 64")]
public async Task ANameOutsideAClosedInfoBlockIsNotRead(string info)
{
await Assert.That(LoginCommandReading.MeaningfulName(info, null)).IsNull();
}

/// <summary>A colour code around the value is presentation, not part of the name.</summary>
[Test]
public async Task AnInfoNameIsReadWithoutItsColourCodes()
{
const string Info = "### Begin INFO 1\nName: \u001B[1;36mConvergence MUSH\u001B[0m\n### End INFO";

await Assert.That(LoginCommandReading.MeaningfulName(Info, null)).IsEqualTo("Convergence MUSH");
}

/// <summary>A value that is nothing but colour codes names nothing.</summary>
[Test]
public async Task AnInfoNameThatIsOnlyAColourCodeIsRefused()
{
const string Info = "### Begin INFO 1\nName: \u001B[40;0;37m\n### End INFO";

await Assert.That(LoginCommandReading.MeaningfulName(Info, null)).IsNull();
}

/// <summary>No <c>INFO</c>, or one that never names anything, yields nothing.</summary>
[Test]
public async Task AnInfoBlockWithNoNameYieldsNothing()
Expand Down
Loading