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.
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 );
}
}