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
594 changes: 594 additions & 0 deletions src/MUI.Catalog/Facets.cs

Large diffs are not rendered by default.

127 changes: 98 additions & 29 deletions src/MUI.Catalog/Persistence/NpgsqlGameQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,28 @@ public sealed class NpgsqlGameQueries(NpgsqlDataSource source, IFieldRegistry? r
/// </summary>
public Func<DateTimeOffset> Clock { get; init; } = () => DateTimeOffset.UtcNow;

public async Task<IReadOnlyList<GameSummary>> ListAsync(
/// <summary>
/// The listing and its facets (spec §9), from one pass over one set of games.
/// </summary>
/// <remarks>
/// <para>
/// The database narrows on the one thing that is not a facet — the archive toggle — and
/// everything else is decided by <see cref="FacetedSearch"/> over <see cref="GameFacetRow"/>.
/// That is deliberate rather than laziness about SQL: a facet count has to be measured against
/// the same set the listing came from, and a <c>WHERE</c> clause that filtered here beside a
/// <c>GROUP BY</c> that counted there would be two answers to one question. Sharing the
/// arithmetic also means the demo fixture and this class cannot disagree about what a filter
/// means, which they already did for <c>band=archived</c>.
/// </para>
/// <para>
/// The cost is a pass over the unarchived catalogue and its fields per listing request — the
/// same order as before, since <c>FieldsForAsync</c> already read every field of every listed
/// game. The point at which that stops being affordable is aggregation in the database, and the
/// counts would then need pinning against the listing rather than being the same arithmetic by
/// construction.
/// </para>
/// </remarks>
public async Task<GameListing> SearchAsync(
GameFilter filter,
CancellationToken cancellationToken = default)
{
Expand All @@ -91,57 +112,42 @@ public async Task<IReadOnlyList<GameSummary>> ListAsync(

await using var connection = await source.OpenConnectionAsync(cancellationToken);

var capabilityFields = filter.MeasuredProtocols
.Select(CapabilityFields.Measured)
.ToArray();
// Archived games leave the default listing and nothing else (spec §7.5) — and asking for the
// archived band is asking for them, so it lifts the exclusion by itself. Without that the one
// facet value naming the archive returned nothing at all, while the fixture returned the
// archive: one filter, two answers, and only one of them was tested.
var includeArchived = filter.IncludeArchived || filter.Band is ActivityBand.Archived;

var rows = (await connection.QueryAsync<GameRow>(new CommandDefinition(
"""
SELECT g.id AS Id, g.slug AS Slug, g.name AS Name, g.tagline AS Tagline,
g.state AS State, g.is_claimed AS IsClaimed, g.last_reachable_at AS LastReachableAt
FROM game g
WHERE (@includeArchived OR g.state <> 'archived')
AND (@text IS NULL OR g.name ILIKE @text)
AND (cardinality(@capabilityFields::text[]) = 0 OR (
SELECT count(DISTINCT f.field)
FROM game_field f
WHERE f.game_id = g.id
AND f.field = ANY(@capabilityFields)
AND f.value = 'true') = cardinality(@capabilityFields::text[]))
ORDER BY g.name
""",
new
{
includeArchived = filter.IncludeArchived,
text = string.IsNullOrWhiteSpace(filter.Text) ? null : $"%{filter.Text.Trim()}%",
capabilityFields,
},
new { includeArchived },
cancellationToken: cancellationToken))).ToList();

if (rows.Count == 0)
{
return [];
return GameListing.Empty;
}

var ids = rows.Select(row => row.Id).ToArray();
var fields = await FieldsForAsync(connection, ids, cancellationToken);
var presence = await PresenceDigestAsync(connection, ids, now, cancellationToken);
var tls = await TlsEndpointsAsync(connection, ids, cancellationToken);

var summaries = new List<GameSummary>(rows.Count);
var facetRows = new List<GameFacetRow>(rows.Count);

foreach (var row in rows)
{
var forGame = fields.TryGetValue(row.Id, out var list) ? list : [];
var digest = presence.TryGetValue(row.Id, out var found) ? found : PresenceDigest.None;
var state = SqlEnums.ToLifecycleState(row.State);
var band = BandOf(state, row.LastReachableAt, digest, now);

if (filter.Band is { } wanted && band != wanted)
{
continue;
}

summaries.Add(new GameSummary(
var summary = new GameSummary(
row.Id,
row.Slug,
row.Name,
Expand All @@ -150,10 +156,72 @@ ORDER BY g.name
row.IsClaimed,
digest.CountNow,
Winner(forGame, "CODEBASE")?.Value,
MeasuredProtocolsOf(forGame)));
MeasuredProtocolsOf(forGame),
row.LastReachableAt);

facetRows.Add(new GameFacetRow(
summary,
BandOf(state, row.LastReachableAt, digest, now),
FacetedSearch.LastSeenOf(row.LastReachableAt, now),
TlsMeasured: tls.Contains(row.Id),
Charset: NegotiatedCharset(forGame),
Language: Winner(forGame, "LANGUAGE")?.Value,
Codebase: summary.Codebase,
Family: Winner(forGame, "FAMILY")?.Value,
Genre: Winner(forGame, "GENRE")?.Value));
}

return summaries;
return FacetedSearch.Search(facetRows, filter);
}

/// <summary>A listing with no panel — the same query, projected.</summary>
public async Task<IReadOnlyList<GameSummary>> ListAsync(
GameFilter filter,
CancellationToken cancellationToken = default) =>
(await SearchAsync(filter, cancellationToken)).Games;

/// <summary>
/// The encoding CHARSET settled on, and never the game's MSSP claim about one.
/// </summary>
/// <remarks>
/// Deliberately not the precedence winner. <c>CHARSET</c> is one of the few fields both a
/// handshake and MSSP write, so the winner is the handshake's <em>when there is one</em> and
/// silently the game's own assertion when there is not — which would make a facet advertised as
/// measured answer from the declared column for every server that never negotiates, without
/// saying so anywhere. Games with no measurement belong in the unknown bucket, which is a
/// different answer and an honest one.
/// </remarks>
private static string? NegotiatedCharset(IReadOnlyList<GameField> fields) =>
fields.FirstOrDefault(f =>
string.Equals(f.Field, "CHARSET", StringComparison.Ordinal)
&& f.Source is FieldSource.Handshake)?.Value;

/// <summary>
/// The games we have completed a TLS connection to.
/// </summary>
/// <remarks>
/// An endpoint row, not a capability claim. <c>capability.ssl.declared</c> exists and says only
/// that somebody typed <c>SSL 4202</c> into their configuration; an endpoint of kind <c>tls</c>
/// says a socket was opened. Nothing writes one yet — <c>CatalogueBinder</c> records what it
/// dialled and the crawler dials plaintext — so this comes back empty and the facet does not
/// render at all, which is the honest rendering of a measurement nobody has taken. It becomes a
/// real facet the day the crawler takes it, with no change here.
/// </remarks>
private static async Task<HashSet<Guid>> TlsEndpointsAsync(
NpgsqlConnection connection,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await connection.QueryAsync<Guid>(new CommandDefinition(
"""
SELECT DISTINCT game_id
FROM game_endpoint
WHERE game_id = ANY(@ids) AND kind = 'tls' AND state <> 'gone'
""",
new { ids },
cancellationToken: cancellationToken));

return [.. rows];
}

public async Task<GamePage?> FindAsync(string slug, CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -197,7 +265,8 @@ FROM game
row.IsClaimed,
digest.CountNow,
Winner(fields, "CODEBASE")?.Value,
MeasuredProtocolsOf(fields));
MeasuredProtocolsOf(fields),
row.LastReachableAt);

return new GamePage(
summary,
Expand Down
65 changes: 63 additions & 2 deletions src/MUI.Catalog/Views.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ public sealed record ActivityCell(int DayOfWeek, int Hour, int? Count, bool Prob
}

/// <summary>A game as the listing shows it.</summary>
/// <remarks>
/// <see cref="LastReachableAt"/> is carried because the last-seen facet (spec §9) filters on it, and
/// a facet whose value cannot be read off the rows it returned is one a reader has to take on trust.
/// Null means we have never once reached the game, which is a different fact from "reachable a long
/// time ago" and is never rendered as the older of the two.
/// </remarks>
public sealed record GameSummary(
Guid Id,
string Slug,
Expand All @@ -87,7 +93,8 @@ public sealed record GameSummary(
bool IsClaimed,
int? PlayersNow,
string? Codebase,
IReadOnlyList<string> MeasuredProtocols);
IReadOnlyList<string> MeasuredProtocols,
DateTimeOffset? LastReachableAt = null);

/// <summary>A game as its own page shows it.</summary>
/// <remarks>
Expand Down Expand Up @@ -116,15 +123,49 @@ public sealed record GameEndpointView(string Host, int Port, string Kind, bool T
public sealed record ChangeEntry(DateTimeOffset At, string Summary);

/// <summary>What the listing was asked for. A plain GET form's worth of state and nothing more.</summary>
/// <remarks>
/// <para>
/// Every member here is one control on the panel and one querystring parameter, named in
/// <see cref="FacetKeys"/>. That correspondence is what makes a filtered listing linkable, the back
/// button work and the read API answer the same question the page does — there is no filter state
/// anywhere else, and nothing here needs a session to mean something.
/// </para>
/// <para>
/// <see cref="MeasuredProtocols"/> and <see cref="Tls"/> read observations; <see cref="Codebase"/>,
/// <see cref="Family"/>, <see cref="Genre"/> and <see cref="Language"/> read what a game says about
/// itself. <see cref="Charset"/> is the odd one and is deliberately on the measured side: it is what
/// CHARSET settled on in the handshake, never the game's MSSP claim about an encoding.
/// </para>
/// </remarks>
public sealed record GameFilter
{
public string? Text { get; init; }

public bool IncludeArchived { get; init; }

/// <summary>
/// Protocols the handshake was observed offering, intersected. Never what MSSP declared —
/// <c>capability.*.measured</c> and <c>capability.*.declared</c> are two fields for exactly this
/// reason, and a facet reading the second would be the central lie of the project.
/// </summary>
public IReadOnlyList<string> MeasuredProtocols { get; init; } = [];

/// <summary>An endpoint we completed a TLS connection to — not an <c>SSL</c> line in MSSP.</summary>
public bool Tls { get; init; }

public ActivityBand? Band { get; init; }

public LastSeenBand? LastSeen { get; init; }

public FacetChoice? Charset { get; init; }

public FacetChoice? Codebase { get; init; }

public FacetChoice? Family { get; init; }

public FacetChoice? Genre { get; init; }

public FacetChoice? Language { get; init; }
}

/// <summary>
Expand Down Expand Up @@ -152,7 +193,27 @@ public enum ActivityBand
/// </remarks>
public interface IGameQueries
{
Task<IReadOnlyList<GameSummary>> ListAsync(GameFilter filter, CancellationToken cancellationToken = default);
/// <summary>
/// The listing and the facet counts that describe it, from one pass (spec §9).
/// </summary>
/// <remarks>
/// One method rather than a listing call and a counts call, because a facet must not be able to
/// lie about what a click will produce. Two calls are two answers to two slightly different
/// questions, and the first time they disagreed the panel would be advertising a count the
/// listing could not deliver.
/// </remarks>
Task<GameListing> SearchAsync(GameFilter filter, CancellationToken cancellationToken = default);

/// <summary>
/// Just the games — for the callers that want a listing and no panel.
/// </summary>
/// <remarks>
/// Every implementation answers it by projecting <see cref="SearchAsync"/>, so there is no route
/// by which a caller that does not want facets gets a different listing from one that does.
/// </remarks>
Task<IReadOnlyList<GameSummary>> ListAsync(
GameFilter filter,
CancellationToken cancellationToken = default);

Task<GamePage?> FindAsync(string slug, CancellationToken cancellationToken = default);

Expand Down
21 changes: 21 additions & 0 deletions src/MUI.Web/Api/ApiMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,30 @@ public static class ApiMapper
Counted(game.PlayersNow),
game.Codebase,
game.MeasuredProtocols,
game.LastReachableAt,
ApiRoutes.Page(game.Slug),
ApiRoutes.Game(game.Id));

/// <summary>
/// One facet, carried across exactly as the catalogue counted it.
/// </summary>
/// <remarks>
/// Nothing is recomputed, re-ordered or trimmed here. A count is only trustworthy because it
/// came from the same pass as the listing beside it, and a mapper that adjusted one would break
/// that with no surface left to say so.
/// </remarks>
public static FacetGroupView Facet(FacetGroup group)
{
ArgumentNullException.ThrowIfNull(group);

return new FacetGroupView(
group.Key,
group.Evidence,
group.Kind,
group.Total,
[.. group.Values.Select(v => new FacetValueView(v.Token, v.Count, v.IsSelected, v.IsUnknown))]);
}

public static GameView Game(
GamePage page,
IReadOnlyList<AvailabilityInterval> availability,
Expand Down
Loading