diff --git a/src/MUI.Catalog/Games/AddressName.cs b/src/MUI.Catalog/Games/AddressName.cs
new file mode 100644
index 00000000..23acb108
--- /dev/null
+++ b/src/MUI.Catalog/Games/AddressName.cs
@@ -0,0 +1,55 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace MUI.Catalog;
+
+///
+/// Whether a game's name is only the address we found it at — mud.example.org:4000,
+/// 203.0.113.7:23 — because the game told us no name of its own.
+///
+///
+///
+/// CatalogueBinder.NameOf falls back to {host}:{port} when neither MSSP nor a login
+/// command gave a meaningful name, and that name stays until a probe, an owner or staff supplies a
+/// better one. This reads the name rather than a flag, so a rename takes the game out of the set with
+/// nothing else to update.
+///
+///
+/// A statement about the name we list the game under, never that the game has no name —
+/// it may well have one it did not publish (rule 5).
+///
+///
+/// The host has to look like an address: a dotted name, an IPv4 literal, or an IPv6 literal (bare,
+/// which is how the fallback writes it, or bracketed). A name such as Babylon:5 carries no dot
+/// and stays a name.
+///
+///
+public static partial class AddressName
+{
+ public static bool Is(string? name)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ return false;
+ }
+
+ var match = Shape().Match(name.Trim());
+
+ if (!match.Success
+ || !int.TryParse(
+ match.Groups["port"].ValueSpan, NumberStyles.None, CultureInfo.InvariantCulture, out var port)
+ || port is < 1 or > 65535)
+ {
+ return false;
+ }
+
+ var host = match.Groups["host"].Value;
+
+ return host.Contains('.', StringComparison.Ordinal) || host.Contains(':', StringComparison.Ordinal);
+ }
+
+ [GeneratedRegex(
+ @"^(?\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9.-]+|[0-9A-Fa-f:.]+):(?[0-9]{1,5})$",
+ RegexOptions.CultureInvariant)]
+ private static partial Regex Shape();
+}
diff --git a/src/MUI.Catalog/Games/Views.cs b/src/MUI.Catalog/Games/Views.cs
index f3056f20..f4459083 100644
--- a/src/MUI.Catalog/Games/Views.cs
+++ b/src/MUI.Catalog/Games/Views.cs
@@ -396,6 +396,14 @@ public sealed record GameFilter
public FacetChoice? Language { get; init; }
+ ///
+ /// The games listed under their address rather than a name ().
+ ///
+ ///
+ /// !yes drops them. Unset by default, so every existing caller still gets them.
+ ///
+ public FacetChoice? Unnamed { get; init; }
+
///
/// What order the answer comes back in.
///
diff --git a/src/MUI.Catalog/Search/Facets.FacetedSearch.Choices.cs b/src/MUI.Catalog/Search/Facets.FacetedSearch.Choices.cs
index 5ae594a0..83c65964 100644
--- a/src/MUI.Catalog/Search/Facets.FacetedSearch.Choices.cs
+++ b/src/MUI.Catalog/Search/Facets.FacetedSearch.Choices.cs
@@ -84,5 +84,14 @@ private sealed record ChoiceFacet(
FacetTokens.GrowthDirections),
new(FacetKeys.Genre, FacetEvidence.Declared, r => [r.Genre], f => f.Genre),
new(FacetKeys.Language, FacetEvidence.Declared, r => [r.Language], f => f.Language),
+
+ // Last: it is not a question about the game's character but about the row's label, and a
+ // reader browsing for a game meets it after everything that is.
+ new(
+ FacetKeys.Unnamed,
+ FacetEvidence.Derived,
+ r => [AddressName.Is(r.Summary.Name) ? FacetTokens.Yes : null],
+ f => f.Unnamed,
+ FacetTokens.YesOnly),
];
}
diff --git a/src/MUI.Catalog/Search/Facets.cs b/src/MUI.Catalog/Search/Facets.cs
index 9dd5739a..1bed4980 100644
--- a/src/MUI.Catalog/Search/Facets.cs
+++ b/src/MUI.Catalog/Search/Facets.cs
@@ -99,6 +99,17 @@ public static class FacetKeys
///
public const string Unreachable = "unreachable";
+ ///
+ /// Games listed under their address because they told us no name — see .
+ ///
+ ///
+ /// What crawlers and API clients reach for to skip rows like mud.example.org:4000:
+ /// ?unnamed=!yes. Ours, labelled : the name is the one
+ /// we list the game under, and a game listed by address may have a name it never
+ /// published, so this never says a game is nameless (rule 5).
+ ///
+ public const string Unnamed = "unnamed";
+
///
/// What order the listing comes back in. A filter parameter by spelling and by plumbing, so the
/// panel, the page and the read API cannot grow two words for one question.
diff --git a/src/MUI.Web/Api/ApiModels.cs b/src/MUI.Web/Api/ApiModels.cs
index 967b9759..bc57da78 100644
--- a/src/MUI.Web/Api/ApiModels.cs
+++ b/src/MUI.Web/Api/ApiModels.cs
@@ -278,7 +278,13 @@ public sealed record FilterView(
GameSort Sort,
/// The old name for , at the same value. Prefer the new one.
- string? CodebaseFamily = null)
+ string? CodebaseFamily = null,
+
+ ///
+ /// Whether the games listed under their address rather than a name were asked for, or dropped.
+ ///
+ /// Last and defaulted, so the positional shape every earlier field has is unchanged.
+ string? Unnamed = null)
{
public static FilterView Of(GameFilter filter)
{
@@ -303,7 +309,8 @@ public static FilterView Of(GameFilter filter)
filter.Genre?.Token,
filter.Language?.Token,
filter.Sort,
- filter.Codebase?.Token);
+ filter.Codebase?.Token,
+ filter.Unnamed?.Token);
}
}
diff --git a/src/MUI.Web/Api/GameFilterBinding.cs b/src/MUI.Web/Api/GameFilterBinding.cs
index f3e96ba2..7d86ae95 100644
--- a/src/MUI.Web/Api/GameFilterBinding.cs
+++ b/src/MUI.Web/Api/GameFilterBinding.cs
@@ -59,6 +59,7 @@ private static bool TryRead(
|| !TryLastSeen(read, out var seen, out error)
|| !TryToggle(read, FacetKeys.Uncounted, out var uncounted, out error)
|| !TryToggle(read, FacetKeys.Unreachable, out var unreachable, out error)
+ || !TryToggle(read, FacetKeys.Unnamed, out var unnamed, out error)
|| !TryTrending(read, out var trending, out error)
|| !TrySort(read, out var sort, out error))
{
@@ -98,6 +99,7 @@ private static bool TryRead(
Trending = trending,
Genre = Choice(read, FacetKeys.Genre),
Language = Choice(read, FacetKeys.Language),
+ Unnamed = unnamed,
Sort = sort,
};
@@ -135,7 +137,7 @@ .. read(FacetKeys.Protocol)
}
///
- /// One of the two measurement switches: yes, !yes, or not asked.
+ /// One of the yes-or-nothing switches (could not count, could not reach, listed by address): yes, !yes, or not asked.
///
///
/// Refused rather than ignored: its vocabulary is one word, so anything else is a typo, and a typo
diff --git a/src/MUI.Web/Api/OpenApiDocument.cs b/src/MUI.Web/Api/OpenApiDocument.cs
index dfb3064a..facd0fbf 100644
--- a/src/MUI.Web/Api/OpenApiDocument.cs
+++ b/src/MUI.Web/Api/OpenApiDocument.cs
@@ -178,6 +178,12 @@ private static JsonArray ListingParameters()
{
parameters.Add(Parameter(key, "yes selects, !yes excludes; ~unknown is an alias for !yes.", values: ["yes", "!yes", "~unknown"]));
}
+ parameters.Add(Parameter(
+ FacetKeys.Unnamed,
+ "Games listed under their address (host:port) because they published no name of their own. "
+ + "!yes excludes them, yes selects only them; ~unknown is an alias for !yes. "
+ + "Unset, they are included.",
+ values: ["yes", "!yes", "~unknown"]));
parameters.Add(Parameter(FacetKeys.Trending, "Weekly count trend; prefix ! to exclude, or ~unknown for no trend."));
return parameters;
}
diff --git a/src/MUI.Web/Components/Text/FacetWords.cs b/src/MUI.Web/Components/Text/FacetWords.cs
index bb30e704..e3a42037 100644
--- a/src/MUI.Web/Components/Text/FacetWords.cs
+++ b/src/MUI.Web/Components/Text/FacetWords.cs
@@ -38,6 +38,7 @@ public static class FacetWords
FacetKeys.Trending => "facet.group.trending",
FacetKeys.Genre => "facet.group.genre",
FacetKeys.Language => "facet.group.language",
+ FacetKeys.Unnamed => "facet.group.unnamed",
// Named here, not in the chip builder, so a facet is put into words in one place.
FacetKeys.Text => "facet.group.search",
@@ -176,6 +177,7 @@ public static string Value(string tag, string key, FacetValue value)
// the same claim.
FacetKeys.Uncounted => Messages.For(tag, "state.uncounted"),
FacetKeys.Unreachable => Messages.For(tag, "state.unreachable"),
+ FacetKeys.Unnamed => Messages.For(tag, "facet.unnamed.yes"),
// Everything else IS the value — a codebase name, version string, protocol acronym — and
// translating it would destroy the evidence rather than localize anything.
@@ -205,6 +207,7 @@ public static string Excluded(string tag, string key, FacetValue value)
// say what the reader actually did — hid some rows.
FacetKeys.Uncounted => Messages.For(tag, "facet.excluded.uncounted"),
FacetKeys.Unreachable => Messages.For(tag, "facet.excluded.unreachable"),
+ FacetKeys.Unnamed => Messages.For(tag, "facet.excluded.unnamed"),
// A message rather than string concatenation: negation word order varies by language, and
// concatenating here would fix English's order for every translator.
diff --git a/src/MUI.Web/IndexableFacet.cs b/src/MUI.Web/IndexableFacet.cs
index 77763bf1..72737d2b 100644
--- a/src/MUI.Web/IndexableFacet.cs
+++ b/src/MUI.Web/IndexableFacet.cs
@@ -88,6 +88,7 @@ .. Dimensions.Where(key => key != FacetKeys.Band),
|| filter.LastSeen is not null
|| filter.Uncounted is not null
|| filter.Unreachable is not null
+ || filter.Unnamed is not null
|| filter.CodebaseVersion is not null
|| filter.Family is not null
|| filter.Trending is not null
diff --git a/src/MUI.Web/Localization/Messages.Chrome.cs b/src/MUI.Web/Localization/Messages.Chrome.cs
index e56c86c5..3f78e9a7 100644
--- a/src/MUI.Web/Localization/Messages.Chrome.cs
+++ b/src/MUI.Web/Localization/Messages.Chrome.cs
@@ -243,6 +243,12 @@ public static partial class Messages
["facet.group.genre"] = "genre",
["facet.group.language"] = "language",
+ // Games listed under host:port because they published no name. The name is ours, so the
+ // words describe our label, never that the game has none (rule 5).
+ ["facet.group.unnamed"] = "name",
+ ["facet.unnamed.yes"] = "listed by address",
+ ["facet.excluded.unnamed"] = "hidden from this listing",
+
// ── facet values ──────────────────────────────────────────────────────────────────────
["facet.band.playersNow"] = "connected now",
["facet.band.activeThisWeek"] = "active this week",
diff --git a/src/MUI.Web/Resources/Messages.de.resx b/src/MUI.Web/Resources/Messages.de.resx
index aea8b79a..8a8f2eb1 100644
--- a/src/MUI.Web/Resources/Messages.de.resx
+++ b/src/MUI.Web/Resources/Messages.de.resx
@@ -637,6 +637,15 @@
Sprache
+
+ Name
+
+
+ unter der Adresse gelistet
+
+
+ aus dieser Auflistung ausgeblendet
+
jetzt verbunden
diff --git a/src/MUI.Web/Resources/Messages.ja.resx b/src/MUI.Web/Resources/Messages.ja.resx
index c561701d..ead1406e 100644
--- a/src/MUI.Web/Resources/Messages.ja.resx
+++ b/src/MUI.Web/Resources/Messages.ja.resx
@@ -637,6 +637,15 @@
言語
+
+ 名前
+
+
+ アドレスで掲載
+
+
+ この一覧では非表示
+
現在接続あり
diff --git a/src/MUI.Web/Resources/Messages.nl.resx b/src/MUI.Web/Resources/Messages.nl.resx
index 8c88c00d..2cb50bf5 100644
--- a/src/MUI.Web/Resources/Messages.nl.resx
+++ b/src/MUI.Web/Resources/Messages.nl.resx
@@ -637,6 +637,15 @@
taal
+
+ naam
+
+
+ vermeld onder adres
+
+
+ niet getoond in deze lijst
+
nu verbonden
diff --git a/src/MUI.Web/Resources/Messages.resx b/src/MUI.Web/Resources/Messages.resx
index d1513cec..d1c523af 100644
--- a/src/MUI.Web/Resources/Messages.resx
+++ b/src/MUI.Web/Resources/Messages.resx
@@ -808,6 +808,18 @@
language
facet groups
+
+ name
+ facet over games listed under their host:port because they published no name
+
+
+ listed by address
+ facet over games listed under their host:port because they published no name
+
+
+ hidden from this listing
+ facet over games listed under their host:port because they published no name
+
connected now
facet values
diff --git a/src/MUI.Web/Resources/Messages.zh-Hans.resx b/src/MUI.Web/Resources/Messages.zh-Hans.resx
index 992ecafa..944039e9 100644
--- a/src/MUI.Web/Resources/Messages.zh-Hans.resx
+++ b/src/MUI.Web/Resources/Messages.zh-Hans.resx
@@ -637,6 +637,15 @@
语言
+
+ 名称
+
+
+ 以地址列出
+
+
+ 已从此列表中隐藏
+
当前有连接
diff --git a/tests/MUI.Catalog.Tests/Persistence/Queries/FacetQueriesPostgresTests.cs b/tests/MUI.Catalog.Tests/Persistence/Queries/FacetQueriesPostgresTests.cs
index 6591ba2e..99c8584a 100644
--- a/tests/MUI.Catalog.Tests/Persistence/Queries/FacetQueriesPostgresTests.cs
+++ b/tests/MUI.Catalog.Tests/Persistence/Queries/FacetQueriesPostgresTests.cs
@@ -353,6 +353,7 @@ await Assert.That((await queries.ListAsync(Choose(key, value.Token))).Count)
FacetKeys.LastSeen => new GameFilter { LastSeen = Seen(token) },
FacetKeys.Uncounted => new GameFilter { Uncounted = FacetChoice.Parse(token) },
FacetKeys.Unreachable => new GameFilter { Unreachable = FacetChoice.Parse(token) },
+ FacetKeys.Unnamed => new GameFilter { Unnamed = FacetChoice.Parse(token) },
FacetKeys.Protocol => new GameFilter { MeasuredProtocols = [token] },
FacetKeys.Tls => new GameFilter { Tls = true },
FacetKeys.Charset => new GameFilter { Charset = FacetChoice.Parse(token) },
diff --git a/tests/MUI.Catalog.Tests/Search/AddressNameTests.cs b/tests/MUI.Catalog.Tests/Search/AddressNameTests.cs
new file mode 100644
index 00000000..84917960
--- /dev/null
+++ b/tests/MUI.Catalog.Tests/Search/AddressNameTests.cs
@@ -0,0 +1,29 @@
+namespace MUI.Catalog.Tests;
+
+///
+/// Which names are only the address a game was found at — the fallback CatalogueBinder.NameOf
+/// writes when a game published no name.
+///
+public class AddressNameTests
+{
+ [Test]
+ [Arguments("mud.example.org:4000")]
+ [Arguments("203.0.113.7:23")]
+ [Arguments("2001:db8::1:4000")]
+ [Arguments("[2001:db8::1]:4000")]
+ [Arguments(" fs.twkang.net:5555 ")]
+ public async Task AnAddressIsAnAddress(string name) =>
+ await Assert.That(AddressName.Is(name)).IsTrue();
+
+ [Test]
+ [Arguments("Convergence MUSH")]
+ [Arguments("Babylon:5")]
+ [Arguments("Star Wars: 1977")]
+ [Arguments("mud.example.org")]
+ [Arguments("mud.example.org:0")]
+ [Arguments("mud.example.org:70000")]
+ [Arguments("")]
+ [Arguments(null)]
+ public async Task ANameIsNot(string? name) =>
+ await Assert.That(AddressName.Is(name)).IsFalse();
+}
diff --git a/tests/MUI.Catalog.Tests/Search/FacetedSearchTests.cs b/tests/MUI.Catalog.Tests/Search/FacetedSearchTests.cs
index 3bd84dd2..4cd2c6dd 100644
--- a/tests/MUI.Catalog.Tests/Search/FacetedSearchTests.cs
+++ b/tests/MUI.Catalog.Tests/Search/FacetedSearchTests.cs
@@ -467,12 +467,47 @@ public async Task ASwitchNothingMatchesIsNotDrawnAtAll()
await Assert.That(listing.Facets.Any(f => f.Key == FacetKeys.Unreachable)).IsFalse();
}
+ [Test]
+ public async Task AGameListedByItsAddressCanBeDroppedFromTheListing()
+ {
+ // What crawlers asked for: rows whose name is only host:port, because the game published
+ // none, are one parameter away from gone — and only on request.
+ GameFacetRow[] rows =
+ [
+ Row("Convergence MUSH"),
+ Row("mud.example.org:4000"),
+ Row("203.0.113.7:23"),
+ ];
+
+ var all = FacetedSearch.Search(rows, new GameFilter());
+ var named = FacetedSearch.Search(rows, new GameFilter { Unnamed = FacetChoice.Not(FacetTokens.Yes) });
+ var unnamed = FacetedSearch.Search(rows, new GameFilter { Unnamed = FacetChoice.Of(FacetTokens.Yes) });
+
+ await Assert.That(all.Games.Count).IsEqualTo(3);
+ await Assert.That(named.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "Convergence MUSH" });
+ await Assert.That(unnamed.Games.Count).IsEqualTo(2);
+
+ var value = Value(all, FacetKeys.Unnamed, FacetTokens.Yes);
+
+ await Assert.That(value.Count).IsEqualTo(2);
+ await Assert.That(Group(all, FacetKeys.Unnamed).Evidence).IsEqualTo(FacetEvidence.Derived);
+ }
+
+ [Test]
+ public async Task AFullyNamedCatalogueOffersNoUnnamedSwitch()
+ {
+ var listing = FacetedSearch.Search([Row("busy"), Row("also")], new GameFilter());
+
+ await Assert.That(listing.Facets.Any(f => f.Key == FacetKeys.Unnamed)).IsFalse();
+ }
+
private static GameFilter Choose(string key, string token) => key switch
{
FacetKeys.Band => new GameFilter { Band = Band(token) },
FacetKeys.LastSeen => new GameFilter { LastSeen = Seen(token) },
FacetKeys.Uncounted => new GameFilter { Uncounted = FacetChoice.Parse(token) },
FacetKeys.Unreachable => new GameFilter { Unreachable = FacetChoice.Parse(token) },
+ FacetKeys.Unnamed => new GameFilter { Unnamed = FacetChoice.Parse(token) },
FacetKeys.Protocol => new GameFilter { MeasuredProtocols = [token] },
FacetKeys.Tls => new GameFilter { Tls = true },
FacetKeys.Charset => new GameFilter { Charset = FacetChoice.Parse(token) },
diff --git a/tests/MUI.Web.Tests/FacetSurfaceTests.cs b/tests/MUI.Web.Tests/FacetSurfaceTests.cs
index 48618e86..1ae9d3b0 100644
--- a/tests/MUI.Web.Tests/FacetSurfaceTests.cs
+++ b/tests/MUI.Web.Tests/FacetSurfaceTests.cs
@@ -43,6 +43,7 @@ .. typeof(FacetKeys)
// narrowing a listing to nothing on a typo.
FacetKeys.Uncounted => "yes",
FacetKeys.Unreachable => "yes",
+ FacetKeys.Unnamed => "yes",
FacetKeys.Protocol => "GMCP",
FacetKeys.Trending => "up",
// Not the default. The point of the check below is that the parameter changed something,
diff --git a/tests/MUI.Web.Tests/UnnamedFacetTests.cs b/tests/MUI.Web.Tests/UnnamedFacetTests.cs
new file mode 100644
index 00000000..576e711b
--- /dev/null
+++ b/tests/MUI.Web.Tests/UnnamedFacetTests.cs
@@ -0,0 +1,100 @@
+using MUI.Catalog;
+using MUI.Web.Api;
+using MUI.Web.Components;
+using MUI.Web.Localization;
+
+namespace MUI.Web.Tests;
+
+///
+/// Games listed under their address because they published no name: one parameter drops them from
+/// the API, and the browse panel offers the same switch.
+///
+public class UnnamedFacetTests
+{
+ private static readonly DateTimeOffset Now = new(2026, 7, 30, 12, 0, 0, TimeSpan.Zero);
+
+ private static GameFacetRow Row(string name) =>
+ new(
+ new GameSummary(
+ Guid.NewGuid(), name.Replace(':', '-'), name, Tagline: null, LifecycleState.Active,
+ IsClaimed: false, PlayersNow: 1, Codebase: null, MeasuredProtocols: [],
+ LastReachableAt: Now),
+ ActivityBand.PlayersNow,
+ FacetedSearch.LastSeenOf(Now, Now),
+ TlsMeasured: false,
+ Charset: null,
+ Language: null,
+ Codebase: null,
+ Family: null,
+ Genre: null,
+ IsAdult: false,
+ Uncounted: false,
+ Unreachable: false);
+
+ private static readonly GameFacetRow[] Rows =
+ [Row("Convergence MUSH"), Row("mud.example.org:4000"), Row("203.0.113.7:23")];
+
+ [Test]
+ public async Task TheApiReadsTheExclusionAndEchoesIt()
+ {
+ await Assert.That(GameFilterBinding.TryRead("?unnamed=!yes", out var query, out var error))
+ .IsTrue().Because(error ?? string.Empty);
+
+ await Assert.That(query.Filter.Unnamed).IsEqualTo(FacetChoice.Not(FacetTokens.Yes));
+ await Assert.That(query.Echo.Unnamed).IsEqualTo("!yes");
+
+ var listing = FacetedSearch.Search(Rows, query.Filter);
+
+ await Assert.That(listing.Games.Select(g => g.Name)).IsEquivalentTo(new[] { "Convergence MUSH" });
+ }
+
+ [Test]
+ public async Task UnsetTheyAreStillListed()
+ {
+ await Assert.That(GameFilterBinding.TryRead(string.Empty, out var query, out _)).IsTrue();
+
+ await Assert.That(query.Filter.Unnamed).IsNull();
+ await Assert.That(query.Echo.Unnamed).IsNull();
+ await Assert.That(FacetedSearch.Search(Rows, query.Filter).Games.Count).IsEqualTo(3);
+ }
+
+ [Test]
+ public async Task AWordOutsideTheVocabularyIsRefused()
+ {
+ await Assert.That(GameFilterBinding.TryRead("?unnamed=no", out _, out var error)).IsFalse();
+ await Assert.That(error).Contains("unnamed");
+ }
+
+ [Test]
+ public async Task ThePanelOffersTheSwitchWithALinkThatHidesThem()
+ {
+ var listing = FacetedSearch.Search(Rows, new GameFilter());
+
+ var html = await Render.ComponentAsync(new()
+ {
+ ["Facets"] = listing.Facets,
+ ["Filter"] = new GameFilter(),
+ });
+
+ await Assert.That(Render.Words(html))
+ .Contains(Messages.For(Locales.SourceTag, "facet.unnamed.yes"));
+ await Assert.That(html).Contains("href=\"/games?unnamed=%21yes\"");
+ }
+
+ [Test]
+ public async Task HidingThemIsAChipThatSaysItIsOurListing()
+ {
+ var filter = new GameFilter { Unnamed = FacetChoice.Not(FacetTokens.Yes) };
+ var listing = FacetedSearch.Search(Rows, filter);
+
+ var html = await Render.ComponentAsync(new()
+ {
+ ["Facets"] = listing.Facets,
+ ["Filter"] = filter,
+ ["Query"] = "?unnamed=!yes",
+ });
+
+ await Assert.That(Render.Words(html))
+ .Contains(Messages.For(Locales.SourceTag, "facet.excluded.unnamed"));
+ }
+}