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
55 changes: 55 additions & 0 deletions src/MUI.Catalog/Games/AddressName.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System.Globalization;
using System.Text.RegularExpressions;

namespace MUI.Catalog;

/// <summary>
/// Whether a game's name is only the address we found it at — <c>mud.example.org:4000</c>,
/// <c>203.0.113.7:23</c> — because the game told us no name of its own.
/// </summary>
/// <remarks>
/// <para>
/// <c>CatalogueBinder.NameOf</c> falls back to <c>{host}:{port}</c> 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.
/// </para>
/// <para>
/// A statement about the name <em>we</em> list the game under, never that the game has no name —
/// it may well have one it did not publish (rule 5).
/// </para>
/// <para>
/// 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 <c>Babylon:5</c> carries no dot
/// and stays a name.
/// </para>
/// </remarks>
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(
@"^(?<host>\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9.-]+|[0-9A-Fa-f:.]+):(?<port>[0-9]{1,5})$",
RegexOptions.CultureInvariant)]
private static partial Regex Shape();
}
8 changes: 8 additions & 0 deletions src/MUI.Catalog/Games/Views.cs
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,14 @@ public sealed record GameFilter

public FacetChoice? Language { get; init; }

/// <summary>
/// The games listed under their address rather than a name (<see cref="FacetKeys.Unnamed"/>).
/// </summary>
/// <remarks>
/// <c>!yes</c> drops them. Unset by default, so every existing caller still gets them.
/// </remarks>
public FacetChoice? Unnamed { get; init; }

/// <summary>
/// What order the answer comes back in.
/// </summary>
Expand Down
9 changes: 9 additions & 0 deletions src/MUI.Catalog/Search/Facets.FacetedSearch.Choices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
];
}
11 changes: 11 additions & 0 deletions src/MUI.Catalog/Search/Facets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,17 @@ public static class FacetKeys
/// </remarks>
public const string Unreachable = "unreachable";

/// <summary>
/// Games listed under their address because they told us no name — see <see cref="AddressName"/>.
/// </summary>
/// <remarks>
/// What crawlers and API clients reach for to skip rows like <c>mud.example.org:4000</c>:
/// <c>?unnamed=!yes</c>. Ours, labelled <see cref="FacetEvidence.Derived"/>: the name is the one
/// <em>we</em> 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).
/// </remarks>
public const string Unnamed = "unnamed";

/// <summary>
/// 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.
Expand Down
11 changes: 9 additions & 2 deletions src/MUI.Web/Api/ApiModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,13 @@ public sealed record FilterView(
GameSort Sort,

/// <summary>The old name for <see cref="Codebase"/>, at the same value. Prefer the new one.</summary>
string? CodebaseFamily = null)
string? CodebaseFamily = null,

/// <summary>
/// Whether the games listed under their address rather than a name were asked for, or dropped.
/// </summary>
/// <remarks>Last and defaulted, so the positional shape every earlier field has is unchanged.</remarks>
string? Unnamed = null)
{
public static FilterView Of(GameFilter filter)
{
Expand All @@ -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);
}
}

Expand Down
4 changes: 3 additions & 1 deletion src/MUI.Web/Api/GameFilterBinding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{
Expand Down Expand Up @@ -98,6 +99,7 @@ private static bool TryRead(
Trending = trending,
Genre = Choice(read, FacetKeys.Genre),
Language = Choice(read, FacetKeys.Language),
Unnamed = unnamed,
Sort = sort,
};

Expand Down Expand Up @@ -135,7 +137,7 @@ .. read(FacetKeys.Protocol)
}

/// <summary>
/// One of the two measurement switches: <c>yes</c>, <c>!yes</c>, or not asked.
/// One of the yes-or-nothing switches (could not count, could not reach, listed by address): <c>yes</c>, <c>!yes</c>, or not asked.
/// </summary>
/// <remarks>
/// Refused rather than ignored: its vocabulary is one word, so anything else is a typo, and a typo
Expand Down
6 changes: 6 additions & 0 deletions src/MUI.Web/Api/OpenApiDocument.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
3 changes: 3 additions & 0 deletions src/MUI.Web/Components/Text/FacetWords.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/MUI.Web/IndexableFacet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/MUI.Web/Localization/Messages.Chrome.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions src/MUI.Web/Resources/Messages.de.resx
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,15 @@
<data name="facet.group.language" xml:space="preserve">
<value>Sprache</value>
</data>
<data name="facet.group.unnamed" xml:space="preserve">
<value>Name</value>
</data>
<data name="facet.unnamed.yes" xml:space="preserve">
<value>unter der Adresse gelistet</value>
</data>
<data name="facet.excluded.unnamed" xml:space="preserve">
<value>aus dieser Auflistung ausgeblendet</value>
</data>
<data name="facet.band.playersNow" xml:space="preserve">
<value>jetzt verbunden</value>
</data>
Expand Down
9 changes: 9 additions & 0 deletions src/MUI.Web/Resources/Messages.ja.resx
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,15 @@
<data name="facet.group.language" xml:space="preserve">
<value>言語</value>
</data>
<data name="facet.group.unnamed" xml:space="preserve">
<value>名前</value>
</data>
<data name="facet.unnamed.yes" xml:space="preserve">
<value>アドレスで掲載</value>
</data>
<data name="facet.excluded.unnamed" xml:space="preserve">
<value>この一覧では非表示</value>
</data>
<data name="facet.band.playersNow" xml:space="preserve">
<value>現在接続あり</value>
</data>
Expand Down
9 changes: 9 additions & 0 deletions src/MUI.Web/Resources/Messages.nl.resx
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,15 @@
<data name="facet.group.language" xml:space="preserve">
<value>taal</value>
</data>
<data name="facet.group.unnamed" xml:space="preserve">
<value>naam</value>
</data>
<data name="facet.unnamed.yes" xml:space="preserve">
<value>vermeld onder adres</value>
</data>
<data name="facet.excluded.unnamed" xml:space="preserve">
<value>niet getoond in deze lijst</value>
</data>
<data name="facet.band.playersNow" xml:space="preserve">
<value>nu verbonden</value>
</data>
Expand Down
12 changes: 12 additions & 0 deletions src/MUI.Web/Resources/Messages.resx
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,18 @@
<value>language</value>
<comment>facet groups</comment>
</data>
<data name="facet.group.unnamed" xml:space="preserve">
<value>name</value>
<comment>facet over games listed under their host:port because they published no name</comment>
</data>
<data name="facet.unnamed.yes" xml:space="preserve">
<value>listed by address</value>
<comment>facet over games listed under their host:port because they published no name</comment>
</data>
<data name="facet.excluded.unnamed" xml:space="preserve">
<value>hidden from this listing</value>
<comment>facet over games listed under their host:port because they published no name</comment>
</data>
<data name="facet.band.playersNow" xml:space="preserve">
<value>connected now</value>
<comment>facet values</comment>
Expand Down
9 changes: 9 additions & 0 deletions src/MUI.Web/Resources/Messages.zh-Hans.resx
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,15 @@
<data name="facet.group.language" xml:space="preserve">
<value>语言</value>
</data>
<data name="facet.group.unnamed" xml:space="preserve">
<value>名称</value>
</data>
<data name="facet.unnamed.yes" xml:space="preserve">
<value>以地址列出</value>
</data>
<data name="facet.excluded.unnamed" xml:space="preserve">
<value>已从此列表中隐藏</value>
</data>
<data name="facet.band.playersNow" xml:space="preserve">
<value>当前有连接</value>
</data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down
29 changes: 29 additions & 0 deletions tests/MUI.Catalog.Tests/Search/AddressNameTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace MUI.Catalog.Tests;

/// <summary>
/// Which names are only the address a game was found at — the fallback <c>CatalogueBinder.NameOf</c>
/// writes when a game published no name.
/// </summary>
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();
}
35 changes: 35 additions & 0 deletions tests/MUI.Catalog.Tests/Search/FacetedSearchTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down
1 change: 1 addition & 0 deletions tests/MUI.Web.Tests/FacetSurfaceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading