Editor/Core/DriftDetector.cs

Editor-side DriftDetector for TeamCreate. Tracks per-member scene digests and timestamps to decide whether to request a resync, mark persistent drift, or do nothing based on consecutive mismatches, revisions, and rate limits.

using System;
using System.Collections.Generic;

namespace TeamCreate;

/// <summary>What the host should do about one member after a ping that carried a scene digest.</summary>
public enum DriftVerdict
{
    /// <summary>Nothing: the member agrees with the host, edits are in flight, the evidence is too thin, or a recent resync is still settling.</summary>
    None,

    /// <summary>The member's scene has silently differed from the host's across consecutive pings: send it a fresh snapshot.</summary>
    Resync,

    /// <summary>Resyncs have not held. The host should tell the user rather than send another snapshot.</summary>
    Persistent
}

/// <summary>
/// Decides when a member's scene has drifted from the host's, from the digest each ping carries. Pure and clock-free (the caller passes
/// the time) so every rule can be tested without waiting.
///
/// A difference in digests only means drift when nothing is in flight, so the comparison happens only when the member has seen the
/// host's latest revision, and a single mismatch is never enough: a ping can cross an edit on the wire. Two mismatches in a row at equal
/// revisions mean the member really holds something else. A member that keeps drifting is limited to one resync per
/// <see cref="MinSecondsBetweenResyncs"/> seconds, and after <see cref="PersistentResyncCount"/> resyncs within
/// <see cref="PersistentWindowSeconds"/> seconds the verdict becomes <see cref="DriftVerdict.Persistent"/> instead of looping.
/// </summary>
public sealed class DriftDetector
{
    /// <summary>Mismatches in a row, at equal revisions, before a resync. Two, so one ping that crossed an edit cannot trigger it.</summary>
    public const int MismatchesToResync = 2;

    /// <summary>The least time between two resyncs of one member.</summary>
    public const double MinSecondsBetweenResyncs = 10;

    /// <summary>Resyncs within the window after which more are pointless and the user is told instead.</summary>
    public const int PersistentResyncCount = 5;

    /// <summary>The window, in seconds, that <see cref="PersistentResyncCount"/> is counted over.</summary>
    public const double PersistentWindowSeconds = 300;

    private sealed class MemberState
    {
        public string Scene;

        public int Streak;

        public readonly List<double> Resyncs = new List<double>();
    }

    private readonly Dictionary<string, MemberState> members = new Dictionary<string, MemberState>( StringComparer.Ordinal );

    /// <summary>
    /// Judges one ping. <paramref name="clientRevision"/> is the last revision the member had applied and <paramref name="hostRevision"/>
    /// the host's current one; the digests are the member's confirmed state and the host's, of <paramref name="scene"/>.
    /// <paramref name="nowSeconds"/> is any monotonic clock in seconds.
    /// </summary>
    public DriftVerdict ShouldResync( string memberKey, string scene, long clientRevision, string clientDigest, long hostRevision, string hostDigest, double nowSeconds )
    {
        if ( memberKey == null )
        {
            return DriftVerdict.None;
        }
        if ( !members.TryGetValue( memberKey, out MemberState state ) )
        {
            state = new MemberState();
            members[memberKey] = state;
        }

        // Without both digests nothing can be compared, and a gap in the evidence breaks "in a row": the safe reading is that the
        // member is not known to be drifting.
        if ( string.IsNullOrEmpty( clientDigest ) || string.IsNullOrEmpty( hostDigest ) )
        {
            state.Streak = 0;
            return DriftVerdict.None;
        }

        // A streak belongs to one scene: two mismatches about different scenes are not the same drift confirmed twice.
        if ( !string.Equals( state.Scene, scene, StringComparison.Ordinal ) )
        {
            state.Scene = scene;
            state.Streak = 0;
        }

        // A member that has not yet seen the host's latest revision is behind, or has edits in flight, and a difference is then
        // expected. Only equal revisions make the digests comparable.
        if ( clientRevision != hostRevision )
        {
            state.Streak = 0;
            return DriftVerdict.None;
        }

        if ( string.Equals( clientDigest, hostDigest, StringComparison.Ordinal ) )
        {
            state.Streak = 0;
            return DriftVerdict.None;
        }

        state.Streak++;
        if ( state.Streak < MismatchesToResync )
        {
            return DriftVerdict.None;
        }

        state.Resyncs.RemoveAll( at => nowSeconds - at > PersistentWindowSeconds );
        if ( state.Resyncs.Count >= PersistentResyncCount )
        {
            // Starting the count again keeps the verdict from being repeated on every ping; it is returned again after two more
            // mismatches, which is as often as anyone needs the notice refreshed.
            state.Streak = 0;
            return DriftVerdict.Persistent;
        }

        // Within the cap the streak is kept: the member is still drifting, so the first mismatch after the cap lifts resyncs at once.
        if ( state.Resyncs.Count > 0 && nowSeconds - state.Resyncs[state.Resyncs.Count - 1] < MinSecondsBetweenResyncs )
        {
            return DriftVerdict.None;
        }

        state.Resyncs.Add( nowSeconds );
        state.Streak = 0;
        return DriftVerdict.Resync;
    }

    /// <summary>Drops everything remembered about a member, for when it leaves, so a new member never inherits a stranger's history.</summary>
    public void Forget( string memberKey )
    {
        if ( memberKey != null )
        {
            members.Remove( memberKey );
        }
    }
}