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()