From 5dc5e2a4550dcc2090b398562fc01cd5be4adc70 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 31 Jul 2026 17:59:15 +0000
Subject: [PATCH 1/4] Initial plan
From febdff15a7d14d04a1a921aa534389969606c15b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:03:38 +0000
Subject: [PATCH 2/4] Add INFO and VERSION probing alongside WHO
---
src/MUI.Crawl/ProbeResult.cs | 8 +++-
src/MUI.Crawl/TelnetProbe.cs | 26 +++++++++---
src/MUI.Web/Components/AboutPage.cs | 6 +--
tests/MUI.Crawl.Tests/ProbeRestraintTests.cs | 8 ++--
tests/MUI.Crawl.Tests/ProbeSessionTests.cs | 43 +++++++++++++++++++-
5 files changed, 78 insertions(+), 13 deletions(-)
diff --git a/src/MUI.Crawl/ProbeResult.cs b/src/MUI.Crawl/ProbeResult.cs
index 741d431..f3b170e 100644
--- a/src/MUI.Crawl/ProbeResult.cs
+++ b/src/MUI.Crawl/ProbeResult.cs
@@ -34,13 +34,19 @@ public sealed record ProbeResult
/// Layer 2 — the connect screen, ANSI intact. Display asset and codebase fingerprint both.
public string? Banner { get; init; }
- /// Layer 3 — what WHO or DOING yielded at the login screen.
+ /// Layer 3 — what login-screen commands yielded.
///
/// Defaults to rather than to an unreadable answer, so a probe
/// that failed before it could ask does not claim to have tried.
///
public WhoReading Who { get; init; } = WhoReading.NotAsked;
+ /// The reply to INFO at the login screen, when one arrived.
+ public string? Info { get; init; }
+
+ /// The reply to VERSION at the login screen, when one arrived.
+ public string? Version { get; init; }
+
///
/// Layer 4 — MSSP as the server reported it over telnet option 70. Every variable, every value,
/// in wire order.
diff --git a/src/MUI.Crawl/TelnetProbe.cs b/src/MUI.Crawl/TelnetProbe.cs
index dbd508f..29f0b09 100644
--- a/src/MUI.Crawl/TelnetProbe.cs
+++ b/src/MUI.Crawl/TelnetProbe.cs
@@ -17,7 +17,7 @@ namespace MUI.Crawl;
///
/// This client never authenticates. Everything it reads is what a server hands an anonymous
/// connection: the banner it sends unprompted, the options it negotiates, the MSSP report it
-/// publishes for crawlers, and the pre-login WHO the TinyMUD family answers before login.
+/// publishes for crawlers, and the pre-login commands games may answer before login.
/// is the complete list of what may go on the wire, and
/// connect and create are not on it — enforced by test, not by good intentions.
///
@@ -29,8 +29,10 @@ namespace MUI.Crawl;
///
public sealed class TelnetProbe(ProbeOptions? options = null, ILogger? logger = null) : IProbe
{
- /// The one question this probe exists to ask.
+ /// The login-screen commands this probe is allowed to ask.
public const string WhoCommand = "WHO";
+ public const string InfoCommand = "INFO";
+ public const string VersionCommand = "VERSION";
///
/// Every command this probe is allowed to send. Anything that logs in, creates a character, or
@@ -48,7 +50,7 @@ public sealed class TelnetProbe(ProbeOptions? options = null, ILogger? logger =
/// implement it simply ignores.
///
///
- public static readonly IReadOnlyList PermittedCommands = [WhoCommand];
+ public static readonly IReadOnlyList PermittedCommands = [WhoCommand, InfoCommand, VersionCommand];
private const byte Iac = 255;
private const byte Do = 253;
@@ -126,17 +128,29 @@ int Arrived()
await SettleAsync(telnet, Arrived, bannerLines, _options.QuietPeriod, budget.Token);
var flushLines = Arrived();
- // Phase 3 — the one question we are allowed to ask. SendAsync appends the line ending
+ // Phase 3 — the first question we are allowed to ask. SendAsync appends the line ending
// itself, so the command is handed over bare.
await telnet.SendAsync(Encoding.ASCII.GetBytes(WhoCommand));
await SettleAsync(telnet, Arrived, flushLines, _options.SilenceGrace, budget.Token);
var whoLines = Arrived();
- string banner, whoText;
+ // Phase 4 — INFO at the login screen.
+ await telnet.SendAsync(Encoding.ASCII.GetBytes(InfoCommand));
+ await SettleAsync(telnet, Arrived, whoLines, _options.SilenceGrace, budget.Token);
+ var infoLines = Arrived();
+
+ // Phase 5 — VERSION at the login screen.
+ await telnet.SendAsync(Encoding.ASCII.GetBytes(VersionCommand));
+ await SettleAsync(telnet, Arrived, infoLines, _options.SilenceGrace, budget.Token);
+ var versionLines = Arrived();
+
+ string banner, whoText, infoText, versionText;
lock (lines)
{
banner = string.Join("\n", lines.Take(bannerLines));
whoText = string.Join("\n", lines.Skip(flushLines).Take(whoLines - flushLines));
+ infoText = string.Join("\n", lines.Skip(whoLines).Take(infoLines - whoLines));
+ versionText = string.Join("\n", lines.Skip(infoLines).Take(versionLines - infoLines));
}
if (telnet.CurrentEncoding is not null)
@@ -156,6 +170,8 @@ int Arrived()
Negotiation = seen.ToNegotiation(),
Banner = banner,
Who = new WhoParser().Parse(whoText),
+ Info = infoText.Length == 0 ? null : infoText,
+ Version = versionText.Length == 0 ? null : versionText,
BannerPlayerCount = BannerCount.Find(banner),
Mssp = viaOption ? MsspReport.From(seen.Mssp) : MsspReport.Empty,
MsspOutcome = seen.MsspOutcome,
diff --git a/src/MUI.Web/Components/AboutPage.cs b/src/MUI.Web/Components/AboutPage.cs
index 3ca2b85..0ca9558 100644
--- a/src/MUI.Web/Components/AboutPage.cs
+++ b/src/MUI.Web/Components/AboutPage.cs
@@ -146,8 +146,8 @@ public sealed record AboutPage(string Lede, IReadOnlyList Sections
[
new("A probe is one connection that never logs in.",
"It opens a socket, negotiates telnet options, reads whatever connect screen the "
- + "server paints, asks for MSSP by negotiating option 70, sends a single "
- + $"{string.Join(" or ", TelnetProbe.PermittedCommands)} at the connect screen, and "
+ + "server paints, asks for MSSP by negotiating option 70, sends "
+ + $"{string.Join(", ", TelnetProbe.PermittedCommands)} at the connect screen, and "
+ "disconnects. It creates no character, sends no login, and changes nothing on the "
+ "far side. The whole session is bounded by a timeout so a wedged probe cannot sit "
+ "on a server's connection slot."),
@@ -328,7 +328,7 @@ public sealed record AboutIdentity(string Name, string InfoUrl, bool Announced,
+ "what reaches your logs is that library's own default, and a NEW-ENVIRON request is "
+ "answered from the crawler host's environment rather than with anything about us. Both "
+ "are gaps in the library and both are ours to fix there. Until they are fixed, the way to "
- + "recognise a probe is its shape: one connection, no login, one WHO, gone.";
+ + "recognise a probe is its shape: one connection, no login, a short read-only command set, gone.";
}
/// Whether a directory was actually read, which is not the same as whether we can read it.
diff --git a/tests/MUI.Crawl.Tests/ProbeRestraintTests.cs b/tests/MUI.Crawl.Tests/ProbeRestraintTests.cs
index 8561e5b..3168dcd 100644
--- a/tests/MUI.Crawl.Tests/ProbeRestraintTests.cs
+++ b/tests/MUI.Crawl.Tests/ProbeRestraintTests.cs
@@ -29,12 +29,14 @@ public async Task TheProbeNeverLogsInAndNeverCreatesACharacter()
}
[Test]
- public async Task TheOnlyThingItAsksForIsWho()
+ public async Task TheOnlyThingsItAsksForAreThePreLoginReadOnlyCommands()
{
// A short list is the point. Every addition here is a new way to affect a stranger's server,
// so it should be hard to grow and obvious when it does.
- await Assert.That(TelnetProbe.PermittedCommands).Count().IsEqualTo(1);
- await Assert.That(TelnetProbe.PermittedCommands[0]).IsEqualTo("WHO");
+ await Assert.That(TelnetProbe.PermittedCommands).Count().IsEqualTo(3);
+ await Assert.That(TelnetProbe.PermittedCommands).Contains("WHO");
+ await Assert.That(TelnetProbe.PermittedCommands).Contains("INFO");
+ await Assert.That(TelnetProbe.PermittedCommands).Contains("VERSION");
}
[Test]
diff --git a/tests/MUI.Crawl.Tests/ProbeSessionTests.cs b/tests/MUI.Crawl.Tests/ProbeSessionTests.cs
index 67211bc..bf3d02e 100644
--- a/tests/MUI.Crawl.Tests/ProbeSessionTests.cs
+++ b/tests/MUI.Crawl.Tests/ProbeSessionTests.cs
@@ -115,6 +115,25 @@ public async Task WhatTheServerSaysBackToOurOwnFlushIsCountedAsNeither()
await Assert.That(result.Who.Count).IsEqualTo(5);
}
+ [Test]
+ public async Task InfoAndVersionRepliesAreCapturedSeparately()
+ {
+ await using var game = new FakeGame
+ {
+ Banner = "Welcome to Nowhere\r\n",
+ WhoReply = "There are 5 players connected.\r\n",
+ InfoReply = "Codebase: CorvidMUSH\r\n",
+ VersionReply = "Version 1.2.3\r\n",
+ };
+
+ var result = await new TelnetProbe(Fast()).ProbeAsync(game.Target);
+
+ await Assert.That(result.Who.Count).IsEqualTo(5);
+ await Assert.That(result.Info).IsEqualTo("Codebase: CorvidMUSH");
+ await Assert.That(result.Version).IsEqualTo("Version 1.2.3");
+ await Assert.That(result.Banner).DoesNotContain("Codebase: CorvidMUSH");
+ }
+
[Test]
public async Task AServerThatBuffersOurNegotiationAsTextStillAnswersWho()
{
@@ -136,7 +155,7 @@ public async Task AServerThatBuffersOurNegotiationAsTextStillAnswersWho()
}
[Test]
- public async Task TheProbeNeverSendsAnythingButItsOnePermittedCommand()
+ public async Task TheProbeNeverSendsAnythingButItsPermittedCommands()
{
// The restraint test with a wire under it. Everything the probe types must be the permitted
// command or an empty line; nothing that logs in, creates or changes anything — and, since
@@ -265,6 +284,8 @@ public FakeGame()
public string? BannerTail { get; init; }
public string WhoReply { get; init; } = string.Empty;
+ public string? InfoReply { get; init; }
+ public string? VersionReply { get; init; }
/// The tail of the WHO reply, unterminated.
public string? WhoTail { get; init; }
@@ -394,6 +415,26 @@ private async Task HandleAsync(NetworkStream stream, string line)
{
await SendAsync(stream, WhoTail);
}
+
+ return;
+ }
+
+ if (command.Equals("INFO", StringComparison.OrdinalIgnoreCase))
+ {
+ if (InfoReply is not null)
+ {
+ await SendAsync(stream, InfoReply);
+ }
+
+ return;
+ }
+
+ if (command.Equals("VERSION", StringComparison.OrdinalIgnoreCase))
+ {
+ if (VersionReply is not null)
+ {
+ await SendAsync(stream, VersionReply);
+ }
}
}
From 57d5eca5cc0fc67ed74f811d040278330972ee97 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:12:01 +0000
Subject: [PATCH 3/4] Parse INFO/VERSION codebase hints for identity fallback
---
src/MUI.Crawl/LoginCommandReading.cs | 133 ++++++++++++++++++
src/MUI.Discovery/IdentityMatcher.cs | 3 +-
.../LoginCommandReadingTests.cs | 61 ++++++++
.../IdentityMatcherTests.cs | 23 +++
.../Support/ProbeResults.cs | 4 +
5 files changed, 223 insertions(+), 1 deletion(-)
create mode 100644 src/MUI.Crawl/LoginCommandReading.cs
create mode 100644 tests/MUI.Crawl.Tests/LoginCommandReadingTests.cs
diff --git a/src/MUI.Crawl/LoginCommandReading.cs b/src/MUI.Crawl/LoginCommandReading.cs
new file mode 100644
index 0000000..cfc78a3
--- /dev/null
+++ b/src/MUI.Crawl/LoginCommandReading.cs
@@ -0,0 +1,133 @@
+namespace MUI.Crawl;
+
+///
+/// Reads pre-login command replies (INFO, VERSION) conservatively.
+///
+///
+/// These commands are intentionally free-form and vary by codebase and by game configuration, so this
+/// reader only returns a value when the text explicitly labels one (for example Version: or
+/// Codebase:) or when a VERSION line clearly names a known family.
+///
+public static class LoginCommandReading
+{
+ private static readonly string[] CodebaseLabels = ["codebase", "server", "engine", "family"];
+ private static readonly string[] VersionLabels = ["version", "release"];
+ private static readonly string[] FamilyMarkers =
+ [
+ "pennmush", "rhostmush", "tinymush", "tinymux", "aresmush", "cobramush",
+ "muck", "tinymuck", "mudos", "fluffos", "lpmud", "moo", "evennia",
+ "coffeemud", "smaug", "dikumud", "circlemud", "tbamud", "rom"
+ ];
+
+ ///
+ /// The best codebase/version hint from login-screen command replies, or null.
+ ///
+ public static string? MeaningfulCodebase(string? info, string? version)
+ {
+ return FromLabelledValue(info)
+ ?? FromLabelledValue(version)
+ ?? FromUnlabelledVersion(version);
+ }
+
+ private static string? FromLabelledValue(string? text)
+ {
+ foreach (var line in Lines(text))
+ {
+ if (!TrySplitLabelled(line, out var label, out var value))
+ {
+ continue;
+ }
+
+ if (CodebaseLabels.Contains(label, StringComparer.OrdinalIgnoreCase))
+ {
+ var named = Clean(value);
+ if (named is not null)
+ {
+ return named;
+ }
+ }
+
+ if (VersionLabels.Contains(label, StringComparer.OrdinalIgnoreCase))
+ {
+ var release = Clean(value);
+ if (release is not null && (MentionsKnownFamily(release) || ContainsDigit(release)))
+ {
+ return release;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private static string? FromUnlabelledVersion(string? version)
+ {
+ foreach (var line in Lines(version))
+ {
+ var value = Clean(line);
+ if (value is null)
+ {
+ continue;
+ }
+
+ if (MentionsKnownFamily(value) && ContainsDigit(value))
+ {
+ return value;
+ }
+ }
+
+ return null;
+ }
+
+ private static IEnumerable Lines(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ yield break;
+ }
+
+ foreach (var raw in text.Split('\n'))
+ {
+ var line = raw.Trim();
+ if (line.Length == 0)
+ {
+ continue;
+ }
+
+ // Rhost-style wrappers.
+ if (line.StartsWith("### Begin ", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith("### End ", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ yield return line;
+ }
+ }
+
+ private static bool TrySplitLabelled(string line, out string label, out string value)
+ {
+ var at = line.IndexOf(':');
+ if (at <= 0 || at == line.Length - 1)
+ {
+ label = string.Empty;
+ value = string.Empty;
+ return false;
+ }
+
+ label = line[..at].Trim();
+ value = line[(at + 1)..].Trim();
+ return label.Length > 0 && value.Length > 0;
+ }
+
+ private static string? Clean(string value)
+ {
+ var trimmed = value.Trim();
+ return MsspDefaults.IsPlaceholder(trimmed) ? null : trimmed;
+ }
+
+ private static bool ContainsDigit(string value) => value.Any(char.IsDigit);
+
+ private static bool MentionsKnownFamily(string value) =>
+ FamilyMarkers.Any(marker => value.Contains(marker, StringComparison.OrdinalIgnoreCase));
+}
diff --git a/src/MUI.Discovery/IdentityMatcher.cs b/src/MUI.Discovery/IdentityMatcher.cs
index 201523c..8584302 100644
--- a/src/MUI.Discovery/IdentityMatcher.cs
+++ b/src/MUI.Discovery/IdentityMatcher.cs
@@ -184,7 +184,8 @@ private sealed record Observation(
MsspReading.Meaningful(result.Mssp, IdentityMsspVariables.Created),
MsspReading.Meaningful(result.Mssp, IdentityMsspVariables.Website),
MsspReading.Meaningful(result.Mssp, IdentityMsspVariables.Contact),
- MsspReading.Meaningful(result.Mssp, IdentityMsspVariables.Codebase),
+ MsspReading.Meaningful(result.Mssp, IdentityMsspVariables.Codebase)
+ ?? LoginCommandReading.MeaningfulCodebase(result.Info, result.Version),
FingerprintOf(result.Banner),
ClaimTokenBeacon.Read(result));
diff --git a/tests/MUI.Crawl.Tests/LoginCommandReadingTests.cs b/tests/MUI.Crawl.Tests/LoginCommandReadingTests.cs
new file mode 100644
index 0000000..3019d58
--- /dev/null
+++ b/tests/MUI.Crawl.Tests/LoginCommandReadingTests.cs
@@ -0,0 +1,61 @@
+using MUI.Crawl;
+
+namespace MUI.Crawl.Tests;
+
+public class LoginCommandReadingTests
+{
+ [Test]
+ public async Task ALabelledInfoVersionValueIsRead()
+ {
+ var info = """
+ ### Begin INFO 1
+ Name: Convergence MUSH
+ Uptime: Tue Sep 16 23:39:43 2025
+ Connected: 60
+ Size: 1929
+ Version: RhostMUSH 4.27.3
+ ### End INFO
+ """;
+
+ var read = LoginCommandReading.MeaningfulCodebase(info, null);
+
+ await Assert.That(read).IsEqualTo("RhostMUSH 4.27.3");
+ }
+
+ [Test]
+ public async Task ALabelledCodebaseFieldWinsWhenPresent()
+ {
+ var info = "Codebase: TinyMUX 2.13";
+
+ var read = LoginCommandReading.MeaningfulCodebase(info, "Version: ignored");
+
+ await Assert.That(read).IsEqualTo("TinyMUX 2.13");
+ }
+
+ [Test]
+ public async Task AnUnlabelledVersionLineWithKnownFamilyIsRead()
+ {
+ var version = """
+ TinyMUX 2.14.0.4 #22
+ Copyright 1995-2026 TinyMUX Team
+ """;
+
+ var read = LoginCommandReading.MeaningfulCodebase(null, version);
+
+ await Assert.That(read).IsEqualTo("TinyMUX 2.14.0.4 #22");
+ }
+
+ [Test]
+ public async Task GenericInfoWithoutCodebaseHintsReturnsNull()
+ {
+ var info = """
+ Name: Convergence MUSH
+ Connected: 60
+ Size: 1929
+ """;
+
+ var read = LoginCommandReading.MeaningfulCodebase(info, null);
+
+ await Assert.That(read).IsNull();
+ }
+}
diff --git a/tests/MUI.Discovery.Tests/IdentityMatcherTests.cs b/tests/MUI.Discovery.Tests/IdentityMatcherTests.cs
index e5ba504..308f6cd 100644
--- a/tests/MUI.Discovery.Tests/IdentityMatcherTests.cs
+++ b/tests/MUI.Discovery.Tests/IdentityMatcherTests.cs
@@ -127,6 +127,29 @@ await world.GameAsync(
await Assert.That(verdict).IsTypeOf();
}
+ [Test]
+ public async Task CodebaseCanComeFromInfoWhenMsspCodebaseIsAbsent()
+ {
+ var world = new IdentityWorld();
+ await world.GameAsync(
+ (IdentityFields.Codebase, "RhostMUSH 4.27.3"),
+ (IdentityFields.Website, "https://convergence.example"));
+
+ var verdict = await world.Matcher.ResolveAsync(ProbeResults.Answered(
+ host: "new.example.org",
+ mssp: ProbeResults.Mssp(("WEBSITE", "https://convergence.example")),
+ info: """
+ ### Begin INFO 1
+ Name: Convergence MUSH
+ Version: RhostMUSH 4.27.3
+ ### End INFO
+ """), None);
+
+ await Assert.That(verdict).IsTypeOf();
+ await Assert.That(((IdentityVerdict.Review)verdict).Score.Score)
+ .IsEqualTo(IdentityWeights.WebsiteOrContact + IdentityWeights.CodebaseAndVersion);
+ }
+
[Test]
public async Task TheThresholdsAreConfigurableBecauseTheyNeedCalibrating()
{
diff --git a/tests/MUI.Discovery.Tests/Support/ProbeResults.cs b/tests/MUI.Discovery.Tests/Support/ProbeResults.cs
index 29c3518..7917e51 100644
--- a/tests/MUI.Discovery.Tests/Support/ProbeResults.cs
+++ b/tests/MUI.Discovery.Tests/Support/ProbeResults.cs
@@ -44,6 +44,8 @@ public static ProbeResult Answered(
IReadOnlyDictionary>? mssp = null,
string? banner = null,
WhoReading? who = null,
+ string? info = null,
+ string? version = null,
DateTimeOffset? at = null) => new()
{
Host = host,
@@ -56,6 +58,8 @@ public static ProbeResult Answered(
MsspOutcome = mssp is null ? MsspOutcome.NotOffered : MsspOutcome.Received,
MsspTransport = mssp is null ? MsspTransport.None : MsspTransport.TelnetOption70,
Banner = banner,
+ Info = info,
+ Version = version,
// NotAsked rather than an unreadable answer, for the same reason: a fixture that says nothing
// about WHO must not claim we asked and could not read the reply.
Who = who ?? WhoReading.NotAsked,
From 134a7e486957750a7b4e98b8e21141d75a62e628 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:16:33 +0000
Subject: [PATCH 4/4] Broaden INFO/VERSION parsing for MUD family formats
---
src/MUI.Crawl/LoginCommandReading.cs | 70 ++++++++++++++++---
.../LoginCommandReadingTests.cs | 14 ++++
2 files changed, 75 insertions(+), 9 deletions(-)
diff --git a/src/MUI.Crawl/LoginCommandReading.cs b/src/MUI.Crawl/LoginCommandReading.cs
index cfc78a3..4b9eb99 100644
--- a/src/MUI.Crawl/LoginCommandReading.cs
+++ b/src/MUI.Crawl/LoginCommandReading.cs
@@ -12,12 +12,30 @@ public static class LoginCommandReading
{
private static readonly string[] CodebaseLabels = ["codebase", "server", "engine", "family"];
private static readonly string[] VersionLabels = ["version", "release"];
- private static readonly string[] FamilyMarkers =
- [
- "pennmush", "rhostmush", "tinymush", "tinymux", "aresmush", "cobramush",
- "muck", "tinymuck", "mudos", "fluffos", "lpmud", "moo", "evennia",
- "coffeemud", "smaug", "dikumud", "circlemud", "tbamud", "rom"
- ];
+ private static readonly IReadOnlyDictionary FamilyNames = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["pennmush"] = "PennMUSH",
+ ["rhostmush"] = "RhostMUSH",
+ ["tinymush"] = "TinyMUSH",
+ ["tinymux"] = "TinyMUX",
+ ["aresmush"] = "AresMUSH",
+ ["cobramush"] = "CobraMUSH",
+ ["muck"] = "MUCK",
+ ["tinymuck"] = "TinyMUCK",
+ ["mudos"] = "MudOS",
+ ["fluffos"] = "FluffOS",
+ ["lpmud"] = "LPMud",
+ ["moo"] = "MOO",
+ ["evennia"] = "Evennia",
+ ["coffeemud"] = "CoffeeMUD",
+ ["smaug"] = "SMAUG",
+ ["dikumud"] = "DikuMUD",
+ ["diku"] = "DikuMUD",
+ ["circlemud"] = "CircleMUD",
+ ["tbamud"] = "tbaMUD",
+ ["rom"] = "ROM",
+ ["merc"] = "Merc",
+ };
///
/// The best codebase/version hint from login-screen command replies, or null.
@@ -31,7 +49,10 @@ public static class LoginCommandReading
private static string? FromLabelledValue(string? text)
{
- foreach (var line in Lines(text))
+ var lines = Lines(text).ToArray();
+ var family = FamilyFrom(lines);
+
+ foreach (var line in lines)
{
if (!TrySplitLabelled(line, out var label, out var value))
{
@@ -50,7 +71,22 @@ public static class LoginCommandReading
if (VersionLabels.Contains(label, StringComparer.OrdinalIgnoreCase))
{
var release = Clean(value);
- if (release is not null && (MentionsKnownFamily(release) || ContainsDigit(release)))
+ if (release is null)
+ {
+ continue;
+ }
+
+ if (MentionsKnownFamily(release))
+ {
+ return release;
+ }
+
+ if (family is not null && ContainsDigit(release))
+ {
+ return $"{family} {release}";
+ }
+
+ if (ContainsDigit(release))
{
return release;
}
@@ -129,5 +165,21 @@ private static bool TrySplitLabelled(string line, out string label, out string v
private static bool ContainsDigit(string value) => value.Any(char.IsDigit);
private static bool MentionsKnownFamily(string value) =>
- FamilyMarkers.Any(marker => value.Contains(marker, StringComparison.OrdinalIgnoreCase));
+ FamilyNames.Keys.Any(marker => value.Contains(marker, StringComparison.OrdinalIgnoreCase));
+
+ private static string? FamilyFrom(IEnumerable lines)
+ {
+ foreach (var line in lines)
+ {
+ foreach (var (marker, canonical) in FamilyNames)
+ {
+ if (line.Contains(marker, StringComparison.OrdinalIgnoreCase))
+ {
+ return canonical;
+ }
+ }
+ }
+
+ return null;
+ }
}
diff --git a/tests/MUI.Crawl.Tests/LoginCommandReadingTests.cs b/tests/MUI.Crawl.Tests/LoginCommandReadingTests.cs
index 3019d58..0dfa7db 100644
--- a/tests/MUI.Crawl.Tests/LoginCommandReadingTests.cs
+++ b/tests/MUI.Crawl.Tests/LoginCommandReadingTests.cs
@@ -45,6 +45,20 @@ Copyright 1995-2026 TinyMUX Team
await Assert.That(read).IsEqualTo("TinyMUX 2.14.0.4 #22");
}
+ [Test]
+ public async Task AFamilyHeadingCanPrefixANumericVersionField()
+ {
+ var version = """
+ TinyMUSH Engine
+ ---------------
+ Version : 4.0 stable
+ """;
+
+ var read = LoginCommandReading.MeaningfulCodebase(null, version);
+
+ await Assert.That(read).IsEqualTo("TinyMUSH 4.0 stable");
+ }
+
[Test]
public async Task GenericInfoWithoutCodebaseHintsReturnsNull()
{