Game/ReplayReactionDatabase.cs

Database helper for storing and reading per-player replay reactions. It reads reactions from a legacy archive and a backend table, enforces limits and validation, and saves or updates the caller's reaction record.

NetworkingFile Access
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Sandbox;
using Sandbox.Services;

namespace BlockParty;

/// <summary>One public, player-owned record per replay. Identity comes from the backend owner.</summary>
internal static class ReplayReactionDatabase
{
	internal const string TableName = "replay-reactions-v1";
	private const int PageSize = 100;
	internal static string KeyFor( string replay, long owner ) => $"{replay}:{owner}";

	internal sealed class Payload
	{
		public string Name { get; set; }
		public ReplayReaction Reaction { get; set; }
	}

	internal static async Task<ReplayReactionRequests.Snapshot> ReadAsync( string replay, int length, CancellationToken token )
	{
		var entries = new Dictionary<long, ReplayReactionRequests.Entry>();
		var nativeOwners = new HashSet<long>();
		foreach ( var entry in ReplayReactionArchive.Read( replay ) )
		{
			if ( IsValid( entry, length ) ) entries[long.Parse( entry.SteamId )] = entry;
		}
		var table = Database.GetTable( TableName );
		long cursor = 0;
		int scanned = 0;
		bool limited = false, foundOwn = false;
		var owner = (long)Game.SteamId;
		do
		{
			var page = await ReplayReactionRequests.WaitAsync( table.List( replay, cursor, PageSize ), token );
			if ( (scanned += page.Records.Count) > ReplayReactionRequests.MaxReactions )
				throw new InvalidOperationException( "Reaction group exceeds the read limit." );
			foreach ( var record in page.Records )
			{
				if ( TryReadEntry( record, replay, length, out var entry ) )
				{
					entries[record.OwnerSteamId] = entry;
					nativeOwners.Add( record.OwnerSteamId );
					foundOwn |= record.OwnerSteamId == owner;
				}
			}
			if ( page.NextCursor is not { } next ) break;
			if ( next <= cursor ) throw new InvalidOperationException( "Reaction cursor did not advance." );
			if ( scanned >= ReplayReactionRequests.MaxReactions )
			{
				limited = true;
				break;
			}
			cursor = next;
		} while ( true );
		// The backend has no atomic per-replay cap. Keep busy replays usable, disclose
		// the bounded read, and include your own edit even if it falls after the cutoff.
		if ( limited && !foundOwn && owner > 0 )
		{
			var own = await FindOwned( table, replay, KeyFor( replay, owner ), token );
			if ( own is not null && TryReadEntry( own, replay, length, out var entry ) ) entries[owner] = entry;
		}
		if ( limited )
		{
			// Unread public pages may replace another player's archive entry. Omit
			// unresolved history from this partial view instead of resurrecting it.
			// Our own entry was resolved separately above, so it can still be kept.
			foreach ( var id in entries.Keys.Where( id => id != owner && !nativeOwners.Contains( id ) ).ToArray() ) entries.Remove( id );
		}
		limited |= entries.Count > ReplayReactionRequests.MaxReactions;
		return new()
		{
			IsPartial = limited,
			Entries = entries.Values.OrderByDescending( e => e.SteamId == owner.ToString() )
				.ThenBy( e => e.SteamId, StringComparer.Ordinal ).Take( ReplayReactionRequests.MaxReactions ).ToArray()
		};
	}

	private static bool TryReadEntry( Database.Record record, string replay, int length, out ReplayReactionRequests.Entry entry )
	{
		entry = null;
		if ( record.Group != replay || !record.IsPublic || record.Key != KeyFor( replay, record.OwnerSteamId ) ) return false;
		try
		{
			var payload = record.Deserialize<Payload>();
			entry = new() { SteamId = record.OwnerSteamId.ToString(), Name = payload?.Name, Reaction = payload?.Reaction };
			return IsValid( entry, length );
		}
		catch ( JsonException ) { return false; }
	}

	internal static bool IsValid( ReplayReactionRequests.Entry entry, int length ) => entry is not null
		&& entry.SteamId?.Length == 17 && long.TryParse( entry.SteamId, out var owner ) && owner > 0
		&& entry.Name?.Length <= 80 && entry.Reaction?.IsValid( length ) == true && !entry.Reaction.Removed;

	internal static async Task<bool> SaveAsync( string replay, int length, ReplayReaction reaction, CancellationToken token )
	{
		if ( reaction?.IsValid( length ) != true || reaction.Removed ) throw new ArgumentException( "Invalid reaction." );
		var owner = (long)Game.SteamId;
		var table = Database.GetTable( TableName );
		var key = KeyFor( replay, owner );
		var record = await FindOwned( table, replay, key, token );
		var name = new Friend( owner ).Name ?? "Player";
		name = new string( name.Where( c => !char.IsControl( c ) ).Take( 80 ).ToArray() );
		var value = JsonSerializer.Serialize( new Payload { Name = name, Reaction = reaction } );
		// Reads may be abandoned at the deadline. Once a write is sent, await its real
		// completion so a timed-out save cannot race a newer edit from this client.
		token.ThrowIfCancellationRequested();
		if ( record is null )
		{
			bool replacesLegacy = ReplayReactionArchive.Read( replay ).Any( e => IsValid( e, length ) && e.SteamId == owner.ToString() );
			token.ThrowIfCancellationRequested();
			await table.Create( key, value, replay, isPublic: true );
			return replacesLegacy;
		}
		// A revision conflict is reported to the player, never blindly retried over another edit.
		record = await record.Update( value );
		if ( !record.IsPublic )
		{
			token.ThrowIfCancellationRequested();
			await record.MakePublic();
		}
		return true;
	}

	private static async Task<Database.Record> FindOwned( Database.Table table, string replay, string key, CancellationToken token )
	{
		long cursor = 0;
		int scanned = 0;
		do
		{
			var page = await ReplayReactionRequests.WaitAsync( table.ListOwned( replay, cursor, PageSize ), token );
			foreach ( var record in page.Records )
				if ( record.Key == key && record.Group == replay && record.OwnerSteamId == (long)Game.SteamId ) return record;
			scanned += page.Records.Count;
			if ( page.NextCursor is not { } next ) return null;
			if ( next <= cursor || scanned >= ReplayReactionRequests.MaxReactions )
				throw new InvalidOperationException( "Owned reaction pagination exceeds the read limit." );
			cursor = next;
		} while ( true );
	}
}