Game/ReplayReactions.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Sandbox;
namespace BlockParty;
/// <summary>Presentation-only annotation. Never added to RunData or the deterministic simulation.</summary>
public sealed class ReplayReaction
{
public int Version { get; set; } = 1;
public int Frame { get; set; }
public float X { get; set; }
public float Y { get; set; }
public string Effect { get; set; }
// Read legacy removal payloads without making those reactions reappear.
public bool Removed { get; set; }
public bool IsVisibleAt( int frame ) => !Removed && frame >= Frame && frame - Frame < Arena.TICK_RATE * 2;
public bool IsValid( int length ) => Version == 1 && Frame >= 0 && Frame < length
&& float.IsFinite( X ) && float.IsFinite( Y )
&& X >= Arena.WALL_SIZE && X <= Arena.WIDTH - Arena.WALL_SIZE
&& Y >= Arena.WALL_SIZE && Y <= Arena.HEIGHT - Arena.WALL_SIZE
&& ReplayReactionEffects.Find( Effect ) is not null;
}
public sealed record ReplayReactionEffect( string Id, string Label, int Width, int Height )
{
public string SpritePath => $"sprites/reactions/{Id}.sprite";
public string ImagePath => $"/sprites/reactions/{Id}.png";
}
public static class ReplayReactionEffects
{
public static readonly IReadOnlyList<ReplayReactionEffect> All = new ReplayReactionEffect[]
{
// Keep persisted IDs when replacing art; existing reactions need no migration.
new( "laugh", "TEARS OF JOY", 11, 11 ), new( "wow", "SUNGLASSES", 11, 11 ),
new( "shock", "SHOCKED", 11, 11 ), new( "lol", "SWEAT DROP", 11, 11 ),
new( "wtf", "EXPLODING HEAD", 11, 11 ), new( "skull", "SKULL", 11, 11 ),
new( "fire", "FIRE", 11, 11 ), new( "heart", "LOVE", 11, 11 ),
new( "thumb", "THUMBS UP", 11, 11 ), new( "thumb_down", "THUMBS DOWN", 11, 11 ),
new( "eyes", "EYES", 11, 11 ), new( "clover", "LUCKY", 11, 11 )
};
// Existing submissions keep working when the palette replaces an effect.
public static ReplayReactionEffect Find( string id ) => All.FirstOrDefault( e => e.Id == (id switch
{ "gg" => "fire", "smile" => "laugh", _ => id }) );
}
/// <summary>One replay visit. Reactions are cosmetic and stored separately from replay data.</summary>
public sealed class ReplayReactions
{
private readonly string _replayId;
private readonly int _length;
private readonly CancellationTokenSource _lifetime = new();
private readonly Dictionary<long, ReplayReaction> _entries = new();
private readonly Dictionary<long, string> _names = new();
private bool _cancelled, _loaded, _edited;
private bool _protectOwn;
private ReplayReaction _remoteOwn;
private DateTime _statusExpires;
private DateTime _nextSubmit;
public static bool Enabled => ReplayReactionRequests.Enabled;
public IReadOnlyDictionary<long, ReplayReaction> Entries => _entries;
public bool HasSnapshot { get; private set; }
private KeyValuePair<long, ReplayReaction>[] _orderedEntries;
private int _orderedRevision = -1;
public IReadOnlyList<KeyValuePair<long, ReplayReaction>> OrderedEntries
{
get
{
if ( _orderedEntries is null || _orderedRevision != Revision )
{
_orderedEntries = _entries.OrderBy( e => e.Value.Frame ).ThenBy( e => e.Key ).ToArray();
_orderedRevision = Revision;
}
return _orderedEntries;
}
}
public int Revision { get; private set; }
public string Status { get; private set; } = "LOADING REACTIONS...";
public string NameFor( long id ) => _names.TryGetValue( id, out var name ) && !string.IsNullOrWhiteSpace( name )
? name : $"Steam player {id}";
private void SetStatus( string text, bool temporary = false )
{
Status = text;
_statusExpires = temporary ? DateTime.UtcNow.AddSeconds( 3 ) : default;
Revision++;
}
public void TickStatus()
{
if ( _statusExpires != default && DateTime.UtcNow >= _statusExpires ) SetStatus( "" );
}
private ReplayReactions( RunData run, long author )
{
if ( author == 0 ) author = (long)Game.SteamId;
_replayId = ReplayViewStats.StatNameFor( run, author ).Replace( "replay_views_", "" );
_length = run.StepCount;
}
public static ReplayReactions Begin( RunData run, long author )
{
if ( !Enabled ) return null;
var visit = new ReplayReactions( run, author );
_ = visit.LoadAsync();
return visit;
}
public void Cancel()
{
if ( _cancelled ) return;
_cancelled = true;
if ( !_loaded ) _lifetime.Cancel();
}
public bool Submit( ReplayReaction reaction )
{
if ( _cancelled || reaction?.IsValid( _length ) != true || reaction.Removed ) return false;
if ( ReplayReactionRequests.IsSaving( _replayId ) || DateTime.UtcNow < _nextSubmit )
{
SetStatus( "PLEASE WAIT BEFORE REACTING AGAIN", temporary: true );
return false;
}
var id = (long)Game.SteamId;
_entries.TryGetValue( id, out var previous );
_edited = true;
bool protectPrevious = _protectOwn;
_protectOwn = true;
_entries[id] = reaction;
_names[id] = new Friend( id ).Name;
SetStatus( "SAVING REACTION..." );
_ = SaveAsync( reaction, previous, protectPrevious );
return true;
}
private async Task SaveAsync( ReplayReaction reaction, ReplayReaction previous, bool protectPrevious )
{
try
{
bool? changed = await ReplayReactionRequests.SaveAsync( _replayId, _length, reaction );
if ( _cancelled ) return;
_nextSubmit = DateTime.UtcNow.AddSeconds( 5 );
// Only the write transaction can reliably distinguish insert from replacement.
// Older Workers omit Changed; keep their generic successful-save wording.
SetStatus( changed == true ? "REACTION CHANGED" : "REACTION SAVED", temporary: true );
}
catch ( Exception )
{
// Do not retain an unsaved preview or claim success after an uncertain response.
if ( _cancelled ) return;
// A failed replacement must retain protection for an earlier acknowledged
// save against the initial GET, which may still contain the old reaction.
_protectOwn = protectPrevious;
var restored = previous ?? _remoteOwn;
if ( restored is null ) _entries.Remove( (long)Game.SteamId );
else _entries[(long)Game.SteamId] = restored;
SetStatus( "COULD NOT CONFIRM SAVE — TRY AGAIN" );
Log.Warning( "BlockParty: replay reaction save was not confirmed." );
}
}
private async Task LoadAsync()
{
try
{
var snapshot = await ReplayReactionRequests.ReadAsync( _replayId, _length, _lifetime.Token );
if ( _cancelled ) return;
foreach ( var entry in snapshot.Entries )
{
var id = long.Parse( entry.SteamId );
if ( id == (long)Game.SteamId )
{
_remoteOwn = entry.Reaction;
if ( _protectOwn ) continue;
}
_entries[id] = entry.Reaction;
_names[id] = entry.Name;
}
HasSnapshot = true;
Revision++;
if ( !_edited ) SetStatus( "" );
}
catch ( Exception )
{
if ( !_cancelled && !_edited ) SetStatus( "REACTIONS UNAVAILABLE" );
}
finally
{
_loaded = true;
_lifetime.Dispose();
}
}
}