diff --git a/src/MUI.Crawl/LoginFlow/LoginCommandReading.cs b/src/MUI.Crawl/LoginFlow/LoginCommandReading.cs
index 8822272e..79d016cf 100644
--- a/src/MUI.Crawl/LoginFlow/LoginCommandReading.cs
+++ b/src/MUI.Crawl/LoginFlow/LoginCommandReading.cs
@@ -85,13 +85,16 @@ public static partial class LoginCommandReading
///
/// Counterpart to MsspDefaults.MeaningfulName for codebases (RhostMUSH among them) that
/// answer INFO but offer no MSSP. Same filter, same reason: Name: PennMUSH 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
+ /// is: a game that takes INFO as a character name answers
+ /// with its own Name: prompt, and mud.paroxysmrpg.com's — a colour reset after the colon —
+ /// was once listed as the game's name.
///
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))
@@ -120,39 +123,51 @@ public static partial class LoginCommandReading
/// Uptime: is never read numerically — PennMUSH writes it as a ctime string, not a number.
///
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;
+ }
+
+ ///
+ /// The lines strictly inside the first INFO block, colour codes removed, or null when no
+ /// block both opened and closed.
+ ///
+ private static List? InfoBlock(string? info)
{
if (string.IsNullOrWhiteSpace(info))
{
return null;
}
- var inside = false;
- int? connected = null;
+ List? 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;
@@ -418,6 +433,10 @@ private static IEnumerable FamiliesIn(IEnumerable 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();
diff --git a/tests/MUI.Crawl.Tests/LoginFlow/LoginCommandReadingTests.cs b/tests/MUI.Crawl.Tests/LoginFlow/LoginCommandReadingTests.cs
index 99e2b2b0..c04fb2a3 100644
--- a/tests/MUI.Crawl.Tests/LoginFlow/LoginCommandReadingTests.cs
+++ b/tests/MUI.Crawl.Tests/LoginFlow/LoginCommandReadingTests.cs
@@ -225,6 +225,44 @@ public async Task AnInfoNameThatIsOnlyTheCodebaseOrAPlaceholderIsRefused(string
await Assert.That(LoginCommandReading.MeaningfulName(info, null)).IsNull();
}
+ ///
+ /// A login prompt that answers INFO by asking again is not an INFO block, however
+ /// much its prompt looks like a Name: field.
+ ///
+ ///
+ /// Measured at mud.paroxysmrpg.com:3000 on 2026-09-28: it takes INFO as a character name
+ /// and replies Illegal name, try another. then Name: ESC[40;0;37m — its prompt with
+ /// a trailing colour reset. Read as a field, the escape sequence became the listing's name and
+ /// slug (/g/40-0-37m). The count reader already demanded a closed block for the same
+ /// reason; the name reader now asks the same of it.
+ ///
+ [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();
+ }
+
+ /// A colour code around the value is presentation, not part of the name.
+ [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");
+ }
+
+ /// A value that is nothing but colour codes names nothing.
+ [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();
+ }
+
/// No INFO, or one that never names anything, yields nothing.
[Test]
public async Task AnInfoBlockWithNoNameYieldsNothing()