Skip to content
Closed
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
30 changes: 30 additions & 0 deletions docs/specs/2026-10-15-sharpmush-mssp-variables.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Six more MSSP variables, read for what they say

SharpMUSH reports six variables beyond the generic MSSP set. Any codebase may send them; they arrive
over option 70 or the plaintext `MSSP-REQUEST` like every other variable, are stored under the `mssp`
source, and render as **declared** — the game's statement about itself, never a measurement of ours.
None of them reaches presence, reachability or a ranking. Names and reading rules live in one place,
`SelfReport` (`src/MUI.Catalog/Fields/SelfReport.cs`).

| Variable | Holds | How it is stored and shown |
|---|---|---|
| `PLAYERS DAY` | distinct players connected in the last 24 hours | `FieldReconciler.MovingFields`: overwritten without a change row, refresh window one day (`FieldRegistry.Rolling`) |
| `PLAYERS WEEK` | distinct players connected in the last 7 days | as `PLAYERS DAY` |
| `LOGINS` | `1`/`0`: players may log in | `FieldShape.Flag`, shown yes/no; a flip is a change-feed event |
| `PLAYER CREATION` | `1`/`0`: new characters at the login screen | as `LOGINS` |
| `GUESTS` | `1`/`0`: guests may connect | as `LOGINS` |
| `PORTAL APPLICATION` | repeated: an absolute URL, a space, a display name | every value kept (`SelfReport.Lists`, one per line); a line is a link only when it is an http(s) address and a name |

**The two counts move without an event.** They change whenever somebody new connects, so a
`field_change` row per probe would bury every real event in the feed — the reason `PLAYERS` and
`UPTIME` are not stored at all. Unlike those, nothing else holds these numbers, so they are kept,
dated by `last_confirmed_at`, and overwritten in place. The game page says them under our measured
figure and apart from it — "By the game's own count: 12 different players in the last day, 40 in the
last week" with a declared chip and its age — and never feeds them into the presence series: that
series holds counts read at a moment, and a game's own sum over a window is not one.

**Rule 4 holds on both sides of the store.** A count that is not a whole number at or above zero is
kept verbatim, because the row holds what the game said, but it is never read as a number: it stays
under "Declared by the game" in the game's own words and the sentence leaves that side out. A flag
other than `1` or `0` prints as sent rather than as our paraphrase of it. A portal line that is not an
address and a name prints as text — refused as a link, never hidden.
15 changes: 13 additions & 2 deletions src/MUI.Catalog/Fields/Fields.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ public enum FieldShape

/// <summary>An email address — or, in practice, a contact page. See <see cref="ExternalUrl"/>.</summary>
Email,

/// <summary>
/// <c>1</c> or <c>0</c>, shown as yes or no. Never a link; anything else prints as written.
/// See <see cref="SelfReport.Flag"/>.
/// </summary>
Flag,
}

/// <summary>
Expand Down Expand Up @@ -142,7 +148,8 @@ Task<IReadOnlyList<GameField>> ForGameAsync(
/// <summary>
/// Turns observations into stored fields, doing exactly one of two things to each (spec §5.1):
/// <b>confirm</b> — bump <c>last_confirmed_at</c> and write nothing else — or <b>change</b> — update
/// the row and append a <see cref="FieldChange"/>.
/// the row and append a <see cref="FieldChange"/>. A field that moves by nature
/// (<c>FieldReconciler.MovingFields</c>) is changed without the append.
/// </summary>
public interface IFieldReconciler
{
Expand All @@ -154,7 +161,11 @@ Task<FieldReconciliation> ApplyAsync(
}

/// <summary>What a reconciliation did, so a caller can assert on it rather than on the store.</summary>
public sealed record FieldReconciliation(int Confirmed, int Changed, int Added)
/// <param name="Overwritten">
/// Rows of <c>FieldReconciler.MovingFields</c> given a new value with no change row — counted apart
/// from <paramref name="Changed"/>, which means "reached the change feed".
/// </param>
public sealed record FieldReconciliation(int Confirmed, int Changed, int Added, int Overwritten = 0)
{
public static readonly FieldReconciliation Nothing = new(0, 0, 0);
}
Expand Down
2 changes: 1 addition & 1 deletion src/MUI.Catalog/Fields/MsspLint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ importance is MsspImportance.Required
// A bare hostname like "www.slothmud.org" is not a linkable URL and we will not guess a
// scheme for it — a link we invented would be our error published under their name. It
// renders as text and the operator is told what edit fixes it.
if (FieldRegistry.Instance.Find(field) is { Shape: not FieldShape.Text } shaped
if (FieldRegistry.Instance.Find(field) is { Shape: FieldShape.Url or FieldShape.Email } shaped
&& !ExternalUrl.IsLinkable(row.Value, shaped.Shape))
{
findings.Add(new MsspFinding(
Expand Down
214 changes: 214 additions & 0 deletions src/MUI.Catalog/Fields/SelfReport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
using System.Globalization;

namespace MUI.Catalog;

/// <summary>
/// Unofficial MSSP variables this site reads for more than their text: names, and the rules for
/// reading each.
/// </summary>
/// <remarks>
/// <para>
/// SharpMUSH reports all six; any codebase may. Every one is the game's own statement about itself,
/// stored under <see cref="FieldSource.Mssp"/> like any other variable and rendered as declared —
/// none of them is something we observed, and none of them reaches presence, reachability or a
/// ranking.
/// </para>
/// <para>
/// One spelling here, read by the crawler (which variables keep every value), the reconciler (which
/// variables move without an event) and every surface, so the three cannot disagree about a name.
/// </para>
/// </remarks>
public static class SelfReport
{
/// <summary>Distinct players connected in the trailing 24 hours, by the game's own count.</summary>
public const string PlayersDay = "PLAYERS DAY";

/// <summary>Distinct players connected in the trailing seven days, by the game's own count.</summary>
public const string PlayersWeek = "PLAYERS WEEK";

/// <summary><c>1</c> when players may log in.</summary>
public const string Logins = "LOGINS";

/// <summary><c>1</c> when a new character may be created at the login screen.</summary>
public const string PlayerCreation = "PLAYER CREATION";

/// <summary><c>1</c> when guests may connect.</summary>
public const string Guests = "GUESTS";

/// <summary>
/// A page of the game's web portal anyone signed in may open: an absolute URL, one space, a
/// display name. Repeated, one value per page.
/// </summary>
public const string PortalApplication = "PORTAL APPLICATION";

/// <summary>
/// The variables MSSP sends repeated where every value is a separate fact, and which are
/// therefore stored as all of them rather than the last.
/// </summary>
/// <remarks>
/// Everything else keeps MSSP's own rule, last value wins. A list is joined with
/// <see cref="ListSeparator"/>, which cannot occur inside one of these values as defined — a
/// URL holds no line break and the name is the rest of one line.
/// </remarks>
public static IReadOnlySet<string> Lists { get; } =
new HashSet<string>(StringComparer.Ordinal) { PortalApplication };

/// <summary>How the values of one of <see cref="Lists"/> are joined into one stored value.</summary>
public const char ListSeparator = '\n';

/// <summary>
/// A flag's value as yes or no, or null for anything else — which then prints as the game wrote
/// it rather than as our reading of it.
/// </summary>
/// <remarks>
/// Only <c>1</c> and <c>0</c>. MSSP's capability rule (anything not an explicit no is a yes)
/// is not borrowed: a <c>LOGINS</c> of <c>wizards only</c> is a sentence, and calling it "yes"
/// would be our paraphrase published as their answer.
/// </remarks>
public static bool? Flag(string? value) => value?.Trim() switch
{
"1" => true,
"0" => false,
_ => null,
};

/// <summary>
/// A count as a number, or null when it is not a whole number at or above zero.
/// </summary>
/// <remarks>
/// Invariant and unsigned for the same reason <see cref="MsspLint"/> reads counts that way: a
/// current-culture parse would accept a grouped <c>1,024</c> on one deployment and refuse it on
/// another, and no count of players means anything below zero. A value that fails is not
/// dropped — the row keeps exactly what the game sent — it is only never read as a number.
/// </remarks>
public static int? Count(string? value) =>
int.TryParse(value?.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var count)
? count
: null;
}

/// <summary>
/// The game's own count of distinct players over the last day and week, as the game page reads it.
/// </summary>
/// <remarks>
/// Built only from rows whose value is a count (<see cref="SelfReport.Count"/>); a side that is not
/// a count is null here and stays on the page verbatim under "Declared by the game". Never a
/// presence sample and never a ranking input — it is a figure the game computed about itself, and
/// the presence series holds only counts read at a moment.
/// </remarks>
public sealed record RecentPlayers(int? Day, int? Week, ProvenanceChip Provenance)
{
/// <summary>
/// What the game said, or null where it said neither as a count.
/// </summary>
/// <param name="declared">A game page's declared fields, keyed lower-invariant.</param>
/// <remarks>
/// One chip for the sentence: the two arrive in one report, so they share a source; the older of
/// the two ages is the one shown, since a sentence dated by its fresher half would overstate the
/// staler one.
/// </remarks>
public static RecentPlayers? From(IReadOnlyDictionary<string, ProvenanceChip> declared)
{
ArgumentNullException.ThrowIfNull(declared);

var day = Read(declared, SelfReport.PlayersDay);
var week = Read(declared, SelfReport.PlayersWeek);

if (day is null && week is null)
{
return null;
}

var chip = (day, week) switch
{
({ } d, { } w) => d.Chip.LastConfirmedAt <= w.Chip.LastConfirmedAt ? d.Chip : w.Chip,
({ } d, null) => d.Chip,
(null, { } w) => w.Chip,
_ => throw new InvalidOperationException("unreachable"),
};

return new RecentPlayers(day?.Count, week?.Count, chip);
}

/// <summary>
/// Whether a declared field is said by this sentence and so need not be listed again.
/// </summary>
public static bool Says(string field, ProvenanceChip chip) =>
(string.Equals(field, SelfReport.PlayersDay, StringComparison.OrdinalIgnoreCase)
|| string.Equals(field, SelfReport.PlayersWeek, StringComparison.OrdinalIgnoreCase))
&& SelfReport.Count(chip.Value) is not null;

private static (int Count, ProvenanceChip Chip)? Read(
IReadOnlyDictionary<string, ProvenanceChip> declared,
string field) =>
declared.TryGetValue(field.ToLowerInvariant(), out var chip)
&& SelfReport.Count(chip.Value) is { } count
? (count, chip)
: null;
}

/// <summary>One page of a game's web portal, as the game named it.</summary>
/// <param name="Href">
/// Normalised through <see cref="ExternalUrl"/>, never the stored text, so a surface putting it in an
/// <c>href</c> need not judge a stranger's scheme. Null when the line is not a URL and a name.
/// </param>
/// <param name="Name">The name the game gave the page, or null where <see cref="Href"/> is.</param>
/// <param name="Shown">The line exactly as the game sent it, for the reader who wants it.</param>
public sealed record PortalApplication(string? Href, string? Name, string Shown);

/// <summary>Reads <see cref="SelfReport.PortalApplication"/>.</summary>
/// <remarks>
/// <para>
/// <b>It refuses rather than repairs, like <see cref="ExternalUrl"/>.</b> A line becomes a link only
/// when its first word is an absolute http or https address and something follows it: a URL with no
/// name would make us choose the link text, and a name with no URL is not a destination. Anything
/// else comes back with no <see cref="PortalApplication.Href"/> and renders as the text the game
/// sent, so nothing it published is hidden and nothing it did not publish is linked.
/// </para>
/// <para>
/// The first space separates and every later one belongs to the name — <c>Character Application</c>
/// is one name, not a URL followed by two words.
/// </para>
/// </remarks>
public static class PortalApplications
{
public static IReadOnlyList<PortalApplication> Parse(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return [];
}

var pages = new List<PortalApplication>();

foreach (var raw in value.Split(SelfReport.ListSeparator))
{
var line = raw.Trim();

if (line.Length == 0)
{
continue;
}

pages.Add(Read(line));
}

return pages;
}

private static PortalApplication Read(string line)
{
var space = line.IndexOf(' ', StringComparison.Ordinal);

if (space <= 0)
{
return new PortalApplication(null, null, line);
}

var name = line[(space + 1)..].Trim();

return name.Length > 0 && ExternalUrl.Normalise(line[..space], FieldShape.Url) is { } href
? new PortalApplication(href, name, line)
: new PortalApplication(null, null, line);
}
}
36 changes: 34 additions & 2 deletions src/MUI.Catalog/Persistence/Fields/FieldReconciler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ namespace MUI.Catalog.Persistence;
/// <summary>
/// Turns observations into stored fields (spec §5.1). Every observation does exactly one of two
/// things: <b>confirm</b> — bump <c>last_confirmed_at</c> and write nothing else — or <b>change</b> —
/// rewrite the row and append one <see cref="FieldChange"/>.
/// rewrite the row and append one <see cref="FieldChange"/>. The one exception is
/// <see cref="MovingFields"/>, rewritten without the append.
/// </summary>
/// <remarks>
/// The distinction is the whole economics of §5.1: a game whose <c>GENRE</c> never moves costs one
Expand All @@ -26,6 +27,26 @@ public sealed partial class FieldReconciler(IGameFieldStore store) : IFieldRecon
public static IReadOnlySet<string> VolatileFields { get; } =
new HashSet<string>(StringComparer.Ordinal) { "PLAYERS", "UPTIME" };

/// <summary>
/// Fields that move on most probes but are still worth keeping, with their age.
/// </summary>
/// <remarks>
/// <para>
/// The middle case between <see cref="VolatileFields"/> and everything else. <c>PLAYERS DAY</c>
/// and <c>PLAYERS WEEK</c> are a game's own count over a trailing window: unlike <c>PLAYERS</c>
/// there is no presence series to hold them, so dropping them would lose the only place they are
/// said; but like <c>PLAYERS</c> they move whenever somebody new connects, and a
/// <see cref="FieldChange"/> per probe would bury every real event in the feed.
/// </para>
/// <para>
/// So a new value overwrites the row and appends nothing. <c>last_confirmed_at</c> still dates
/// it, which is all a reader of a trailing count needs; a history of one would be presence
/// arithmetic on a number we did not measure, and nothing here wants it.
/// </para>
/// </remarks>
public static IReadOnlySet<string> MovingFields { get; } =
new HashSet<string>(StringComparer.Ordinal) { SelfReport.PlayersDay, SelfReport.PlayersWeek };

public async Task<FieldReconciliation> ApplyAsync(
Guid gameId,
IReadOnlyList<FieldObservation> observed,
Expand All @@ -47,6 +68,7 @@ public async Task<FieldReconciliation> ApplyAsync(
var confirmed = 0;
var changed = 0;
var added = 0;
var overwritten = 0;

foreach (var observation in observed)
{
Expand Down Expand Up @@ -78,6 +100,16 @@ await store.UpsertAsync(
continue;
}

if (MovingFields.Contains(observation.Field))
{
// Overwrite: the value moved, and it is the kind that always does. No change row.
await store.UpsertAsync(
existing with { Value = observation.Value, LastConfirmedAt = at },
cancellationToken);
overwritten++;
continue;
}

// Change: first_seen_at still means "when this (game, field, source) was first seen",
// not "when this value was" — the change feed carries the value's history separately.
await store.UpsertAsync(
Expand All @@ -95,7 +127,7 @@ await store.RecordChangeAsync(
changed++;
}

return new FieldReconciliation(confirmed, changed, added);
return new FieldReconciliation(confirmed, changed, added, overwritten);
}

/// <summary>
Expand Down
Loading
Loading