SboxSuperuser/Core/Alts/AltLedger.cs

The alt-account ledger for a moderation tool called Superuser. Remembers per-account names, IP addresses, Steam party IDs and family-share owners, links accounts that share any of them, and ranks suspects by link strength and links to banned accounts. Data is bounded per account and by retention.

namespace SboxSuperuser.Core.Alts;

/// <summary>How strongly two accounts are tied together.</summary>
internal enum AltEvidence
{
	/// <summary>Joined in the same Steam party; usually friends, sometimes one person on two accounts.</summary>
	SameParty = 1,

	/// <summary>Connected from the same IP address; the same household or the same person.</summary>
	SameNetwork = 2,

	/// <summary>One plays on the other's copy of the game through Steam Family Sharing.</summary>
	FamilyShare = 3
}

/// <summary>One account linked to another, with every reason and when it was last seen together.</summary>
internal sealed record AltLink( string SteamId, IReadOnlyList<AltEvidence> Evidence, DateTimeOffset LastSeenUtc )
{
	internal AltEvidence Strongest => Evidence.Max();
}

/// <summary>One IP address an account connected from, and when.</summary>
internal sealed record AltAddress( string Address, DateTimeOffset FirstSeenUtc, DateTimeOffset LastSeenUtc );

/// <summary>What Superuser remembers about one account.</summary>
internal sealed class AltIdentity
{
	internal const int MaximumAddresses = 5;
	internal const int MaximumNames = 4;
	internal const int MaximumMarks = 4;

	internal string SteamId { get; init; } = "";
	internal DateTimeOffset FirstSeenUtc { get; set; }
	internal DateTimeOffset LastSeenUtc { get; set; }
	internal List<string> Names { get; init; } = new();
	/// <summary>IP addresses, most recent first, with when each was first and last seen.</summary>
	internal List<AltAddress> Addresses { get; init; } = new();
	internal IEnumerable<string> Networks => Addresses.Select( address => address.Address );
	internal List<string> Owners { get; init; } = new();
	internal List<string> Parties { get; init; } = new();
}

/// <summary>
/// Remembers who connected with which family-share owner, network and party, and links accounts that share them.
/// Records are bounded per account and in total, and accounts not seen for a while are forgotten.
/// </summary>
internal sealed class AltLedger
{
	// Sized so the whole ledger fits one stored document (512 KB).
	internal const int MaximumIdentities = 500;
	internal static readonly TimeSpan Retention = TimeSpan.FromDays( 90 );

	private readonly Dictionary<string, AltIdentity> _identities = new( StringComparer.Ordinal );
	// Who shares each network, party and family-share copy; rebuilt only after records change, so finding an
	// account's alts is a few lookups rather than a pass over every account.
	private readonly Dictionary<string, List<string>> _byNetwork = new( StringComparer.Ordinal );
	private readonly Dictionary<string, List<string>> _byParty = new( StringComparer.Ordinal );
	private readonly Dictionary<string, List<string>> _byCopy = new( StringComparer.Ordinal );
	private bool _indexed;

	internal IReadOnlyCollection<AltIdentity> Identities => _identities.Values;

	internal AltIdentity Find( string steamId ) => _identities.TryGetValue( steamId, out var identity ) ? identity : null;

	/// <summary>Records a connection. Owner is the family-share owner (the same ID when not shared); address and party may be empty.</summary>
	internal void Record( string steamId, string name, string owner, string address, string party, DateTimeOffset now )
	{
		if ( !_identities.TryGetValue( steamId, out var identity ) )
			_identities[ steamId ] = identity = new AltIdentity { SteamId = steamId, FirstSeenUtc = now };
		identity.LastSeenUtc = now;
		Remember( identity.Names, name, AltIdentity.MaximumNames );
		if ( owner != steamId ) Remember( identity.Owners, owner, AltIdentity.MaximumMarks );
		RememberAddress( identity.Addresses, address, now );
		Remember( identity.Parties, party, AltIdentity.MaximumMarks );
		Prune( now );
		_indexed = false;
	}

	internal void Load( IEnumerable<AltIdentity> identities )
	{
		_identities.Clear();
		foreach ( var identity in identities.Take( MaximumIdentities ) ) _identities[ identity.SteamId ] = identity;
		_indexed = false;
	}

	/// <summary>Every account tied to this one, strongest evidence first.</summary>
	internal IReadOnlyList<AltLink> AltsOf( string steamId )
	{
		if ( Find( steamId ) is not { } identity ) return Array.Empty<AltLink>();
		Index();
		var links = new Dictionary<string, (HashSet<AltEvidence> Evidence, DateTimeOffset Seen)>( StringComparer.Ordinal );
		void Link( IEnumerable<string> others, AltEvidence evidence )
		{
			foreach ( var other in others )
			{
				if ( other == steamId ) continue;
				var seen = Find( other )?.LastSeenUtc ?? identity.LastSeenUtc;
				if ( !links.TryGetValue( other, out var link ) ) links[ other ] = link = (new HashSet<AltEvidence>(), seen);
				link.Evidence.Add( evidence );
			}
		}
		// Family sharing goes both ways: the copy's owner, and anyone else playing on the same copy.
		foreach ( var copy in identity.Owners.Append( steamId ) )
			if ( _byCopy.TryGetValue( copy, out var sharers ) ) Link( sharers.Append( copy ), AltEvidence.FamilyShare );
		foreach ( var network in identity.Networks )
			if ( _byNetwork.TryGetValue( network, out var sharing ) ) Link( sharing, AltEvidence.SameNetwork );
		foreach ( var party in identity.Parties )
			if ( _byParty.TryGetValue( party, out var members ) ) Link( members, AltEvidence.SameParty );
		return links.Select( pair => new AltLink( pair.Key, pair.Value.Evidence.OrderByDescending( evidence => evidence ).ToArray(), pair.Value.Seen ) )
			.OrderByDescending( link => link.Strongest ).ThenByDescending( link => link.LastSeenUtc ).ToArray();
	}

	/// <summary>
	/// Accounts worth a look, riskiest first: anyone linked to a banned account, then by the strength and number of
	/// their links. Accounts with no links aren't suspects.
	/// </summary>
	internal IReadOnlyList<(string SteamId, int Score, IReadOnlyList<AltLink> Alts)> Suspects( Func<string, bool> isBanned )
	{
		return _identities.Keys.Select( steamId =>
			{
				var alts = AltsOf( steamId );
				var score = alts.Sum( link => (int)link.Strongest * 10 + (isBanned( link.SteamId ) ? 100 : 0) );
				return (steamId, score, alts);
			} )
			.Where( suspect => suspect.alts.Count > 0 )
			.OrderByDescending( suspect => suspect.score ).ThenBy( suspect => suspect.steamId, StringComparer.Ordinal )
			.Select( suspect => (suspect.steamId, suspect.score, (IReadOnlyList<AltLink>)suspect.alts) ).ToArray();
	}

	private void Index()
	{
		if ( _indexed ) return;
		_byNetwork.Clear();
		_byParty.Clear();
		_byCopy.Clear();
		static void Add( Dictionary<string, List<string>> index, string key, string steamId )
		{
			if ( !index.TryGetValue( key, out var list ) ) index[ key ] = list = new List<string>();
			list.Add( steamId );
		}
		foreach ( var identity in _identities.Values )
		{
			foreach ( var network in identity.Networks ) Add( _byNetwork, network, identity.SteamId );
			foreach ( var party in identity.Parties ) Add( _byParty, party, identity.SteamId );
			foreach ( var owner in identity.Owners ) Add( _byCopy, owner, identity.SteamId );
		}
		_indexed = true;
	}

	private void Prune( DateTimeOffset now )
	{
		foreach ( var stale in _identities.Values.Where( identity => now - identity.LastSeenUtc > Retention ).Select( identity => identity.SteamId ).ToArray() )
			_identities.Remove( stale );
		if ( _identities.Count <= MaximumIdentities ) return;
		foreach ( var oldest in _identities.Values.OrderBy( identity => identity.LastSeenUtc ).Take( _identities.Count - MaximumIdentities ).Select( identity => identity.SteamId ).ToArray() )
			_identities.Remove( oldest );
	}

	// Most recent first: a known address keeps when it was first seen and moves to the front.
	private static void RememberAddress( List<AltAddress> addresses, string address, DateTimeOffset now )
	{
		if ( string.IsNullOrWhiteSpace( address ) ) return;
		var known = addresses.FindIndex( entry => entry.Address == address );
		var first = known >= 0 ? addresses[ known ].FirstSeenUtc : now;
		if ( known >= 0 ) addresses.RemoveAt( known );
		addresses.Insert( 0, new AltAddress( address, first, now ) );
		if ( addresses.Count > AltIdentity.MaximumAddresses ) addresses.RemoveRange( AltIdentity.MaximumAddresses, addresses.Count - AltIdentity.MaximumAddresses );
	}

	// Most recent first, without repeats, keeping the newest few.
	private static void Remember( List<string> values, string value, int maximum )
	{
		if ( string.IsNullOrWhiteSpace( value ) ) return;
		values.Remove( value );
		values.Insert( 0, value );
		if ( values.Count > maximum ) values.RemoveRange( maximum, values.Count - maximum );
	}
}