Editor utility for rebasing concurrent object state changes in a collaborative scene. It computes how the local live scene should update when host or remote edits arrive, merges properties, produces user-visible notices, and sanitises names and messages.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Nodes;
namespace TeamCreate;
/// <summary>What the live scene should become for one object, and what (if anything) to tell the user about it.</summary>
/// <param name="NewLocal">The state the live scene must hold for the object; null means the object must not exist locally. Never aliases any input.</param>
/// <param name="Notice">A formal sentence for the user, or null when nothing needs saying.</param>
public sealed record RebaseResult( ObjectState NewLocal, string Notice );
/// <summary>
/// The client half of the host's property-level merge. The host answers concurrent edits by combining them; this decides what
/// the editor's live scene must become when such a result arrives while the user holds an edit the host has not seen yet.
/// Without it a half-finished drag made the host's change fail the whole session, and a merged receipt was never applied, so
/// the next scan sent the pre-merge local state back and reverted the other person's change.
/// </summary>
/// <remarks>
/// Pure and engine-independent: every decision lives here so the editor glue stays a loop that captures the scene, calls these
/// functions and writes the answer back. After either call the editor must treat the host's state as the confirmed one
/// (<c>Known</c>), whatever <see cref="RebaseResult.NewLocal"/> says: any difference between the two is the user's unsent
/// edit, which the next scan sends on top of the host's state.
/// </remarks>
public static class ChangeRebase
{
// Names arrive from another machine and are quoted inside a sentence shown in the dock.
private const int MaxNameLength = 60;
private const int MaxNoticeLength = 300;
/// <summary>
/// Another collaborator's change (<paramref name="hostAfter"/>, null = deleted) reached this editor. <paramref name="known"/> is the
/// last state the host confirmed for the id and <paramref name="local"/> what the live scene holds now; they differ exactly when
/// the user has an edit the host has not seen. The user's edit is kept on top of the host's change.
/// </summary>
public static RebaseResult RebaseOntoHostChange( string id, ObjectState known, ObjectState local, ObjectState hostAfter )
{
// No unsent edit: the scene simply follows the host.
if ( StateRules.Equal( local, known ) )
{
return new RebaseResult( Clone( hostAfter ), null );
}
if ( hostAfter is null )
{
// The object is gone for everybody. Nothing to report when the user had deleted it too.
if ( local is null )
{
return new RebaseResult( null, null );
}
return new RebaseResult( null, "Your unsent change to \"" + NameOf( id, local, known ) + "\" was discarded because the object was deleted by another collaborator." );
}
if ( local is null )
{
// The user's deletion has not gone out yet. It goes out next and, being the later edit, wins.
return new RebaseResult( null, null );
}
if ( known is null || !PropertyMerge.TryMerge( hostAfter, known, local, out ObjectState merged, out List<string> clashed ) )
{
return new RebaseResult( Clone( hostAfter ), CouldNotCombine( id, local, hostAfter ) );
}
string notice = clashed.Count == 0
? null
: "Your unsent change to \"" + NameOf( id, local, hostAfter ) + "\" replaced another collaborator's edit to: " + string.Join( ", ", clashed.Select( Clean ) ) + ".";
return new RebaseResult( merged, notice );
}
/// <summary>
/// The same as <see cref="RebaseOntoHostChange"/> for a change as it arrives on the wire. The follower's <paramref name="known"/> is
/// expected to equal the host's <paramref name="hostBefore"/>; when it does not (a bookkeeping gap, never the user's fault) the
/// session must not fail, because the host is the authority. An object the user has not touched then simply follows the host, and
/// an edited one is merged using the host's <paramref name="hostBefore"/> as the common ancestor. A creation (hostBefore null)
/// has no ancestor to compare against and uses <paramref name="known"/> as it is.
/// </summary>
public static RebaseResult RebaseOntoRemoteChange( string id, ObjectState known, ObjectState local, ObjectState hostBefore, ObjectState hostAfter )
{
ObjectState ancestor = known;
if ( hostBefore is not null && !StateRules.Equal( known, hostBefore ) )
{
if ( StateRules.Equal( local, known ) )
{
return new RebaseResult( Clone( hostAfter ), null );
}
ancestor = hostBefore;
}
return RebaseOntoHostChange( id, ancestor, local, hostAfter );
}
/// <summary>
/// The host answered this editor's own edit. <paramref name="sentAfter"/> is what the editor sent for the id (null when it sent a
/// deletion or nothing), <paramref name="applied"/> what the host actually kept (null = the object is gone). When they differ the
/// host combined the edit with somebody else's, and the scene must take the host's result, with whatever the user did after
/// sending merged on top.
/// </summary>
public static RebaseResult RebaseOntoOwnReceipt( string id, ObjectState sentAfter, ObjectState local, ObjectState applied )
{
if ( applied is null )
{
// The host's merge removed the object. An edit made after sending has nothing left to apply to.
bool lostEdit = local is not null && !StateRules.Equal( local, sentAfter );
return new RebaseResult( null, lostEdit ? "Your unsent change to \"" + NameOf( id, local, sentAfter ) + "\" was discarded because the object was deleted by another collaborator." : null );
}
// Nothing to apply: the scene already shows the host's result (this also covers a receipt whose sent edit is unknown), or the
// host kept exactly what was sent and whatever the user did since stays for the next scan to send.
if ( StateRules.Equal( local, applied ) )
{
return new RebaseResult( Clone( applied ), null );
}
if ( StateRules.Equal( applied, sentAfter ) )
{
return new RebaseResult( Clone( local ), null );
}
// The host's result differs from what was sent. If the user has done nothing since, the scene takes it.
if ( StateRules.Equal( local, sentAfter ) )
{
return new RebaseResult( Clone( applied ), null );
}
if ( local is null )
{
// Deleted after sending: that deletion has not gone out yet and, being later, wins.
return new RebaseResult( null, null );
}
// The user edited further while the receipt was in flight.
if ( sentAfter is null || !PropertyMerge.TryMerge( applied, sentAfter, local, out ObjectState merged, out List<string> clashed ) )
{
return new RebaseResult( Clone( applied ), CouldNotCombine( id, local, applied ) );
}
string notice = clashed.Count == 0
? null
: "Your unsent change to \"" + NameOf( id, local, applied ) + "\" replaced another collaborator's edit to: " + string.Join( ", ", clashed.Select( Clean ) ) + ".";
return new RebaseResult( merged, notice );
}
/// <summary>
/// Whether an own receipt needs <see cref="RebaseOntoOwnReceipt"/> at all. The host sets <paramref name="hostSaysMerged"/> when it
/// combined something; independently, an applied state that differs from the one sent means the same. When the sent edit is not
/// known (<paramref name="sentKnown"/> false: the receipt outlived the pending packet, for example after a reconnect) only the
/// host's flag can say, because there is nothing to compare with. When nothing is merged the editor does no extra work.
/// </summary>
public static bool NeedsOwnReceiptRebase( bool hostSaysMerged, bool sentKnown, ObjectState sentAfter, ObjectState applied )
{
return hostSaysMerged || ( sentKnown && !StateRules.Equal( applied, sentAfter ) );
}
/// <summary>
/// Joins the sentences gathered while processing one packet into the single line the dock shows: a space between them, empty ones
/// dropped, control characters replaced, at most 300 characters (the last three are an ellipsis when it was cut). Null when there is nothing to say.
/// </summary>
public static string JoinNotices( params string[] notices )
{
string joined = string.Join( " ", ( notices ?? Array.Empty<string>() ).Where( x => !string.IsNullOrWhiteSpace( x ) ).Select( Clean ) ).TrimEnd();
if ( joined.Length == 0 )
{
return null;
}
if ( joined.Length <= MaxNoticeLength )
{
return joined;
}
return joined.Substring( 0, MaxNoticeLength - 3 ) + "...";
}
private static string CouldNotCombine( string id, ObjectState local, ObjectState theirs )
{
return "Your unsent change to \"" + NameOf( id, local, theirs ) + "\" could not be combined with another collaborator's edit and was replaced by theirs.";
}
// The name a person recognises: the first non-null state's Name, else the id. Sanitised and cut because it came from another machine.
private static string NameOf( string id, params ObjectState[] states )
{
foreach ( ObjectState state in states )
{
if ( state?.Data?["Name"] is JsonValue value && value.TryGetValue( out string name ) && !string.IsNullOrWhiteSpace( name ) )
{
return Cut( Clean( name ), MaxNameLength );
}
if ( state is not null )
{
break;
}
}
return Cut( Clean( id ?? string.Empty ), MaxNameLength );
}
private static string Clean( string text )
{
if ( string.IsNullOrEmpty( text ) )
{
return text ?? string.Empty;
}
StringBuilder builder = new StringBuilder( text.Length );
foreach ( char c in text )
{
builder.Append( char.IsControl( c ) ? ' ' : c );
}
return builder.ToString();
}
// Cuts at `max` UTF-16 units without leaving half of a surrogate pair behind.
private static string Cut( string text, int max )
{
if ( text.Length <= max )
{
return text;
}
int length = char.IsHighSurrogate( text[max - 1] ) ? max - 1 : max;
return text.Substring( 0, length );
}
private static ObjectState Clone( ObjectState state )
{
return state is null ? null : new ObjectState( state.Id, state.Parent, (JsonObject)state.Data?.DeepClone() );
}
}