Editor/Core/SettingsMerge.cs

Editor utility that merges scene-level settings represented as JsonObject nodes. It implements rules for merging host and client changes, detecting clashes, computing local changes to send, size checks, cloning and generating human-readable notices.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Nodes;

namespace TeamCreate;

/// <summary>
/// Scene-level settings (what the engine's <c>Scene.SerializeProperties()</c> returns: <c>TimeScale</c>, <c>NetworkInterpolation</c>,
/// <c>WantsSystemScene</c>, <c>Metadata</c>, <c>NavMesh</c>, <c>GameObjectSystems</c> and whatever else a project adds) are one more piece of
/// host-authoritative state per scene. Two people may change them at once, so the host merges each change per TOP-LEVEL key with the rule
/// already chosen for object properties: a key the sender changed takes the sender's value, a key the sender left alone keeps the host's,
/// and when both changed the same key the later change (the one the host processes second) wins and the key is named in a notice.
///
/// A top-level value is one unit even when it is an object: two people changing different fields inside <c>NavMesh</c> clash on
/// <c>NavMesh</c>, and the later <c>NavMesh</c> replaces the earlier whole. Stitching fields together would produce a navigation setup
/// that neither person made, and the engine reads these objects as wholes (<c>NavMesh.Deserialize</c>, the per-system overrides).
///
/// Every comparison goes through <see cref="StateRules.ValuesEqual"/>, so a value that only moved by the engine's 32-bit float round trip
/// is not a change. Engine-independent so CoreTests can exercise it; nothing here mutates or shares nodes with its inputs.
/// </summary>
public static class SettingsMerge
{
    /// <summary>The packet kind of a settings change (client to host) and of its broadcast (host to every member).</summary>
    public const string PacketKind = "settings";

    /// <summary>
    /// The largest settings object, serialised, that the host accepts in one change: 1 MiB. Ordinary settings are a few hundred bytes;
    /// the bound exists because <c>GameObjectSystems</c> may carry project-defined values of any size, every accepted change is broadcast
    /// to every member and written to the journal, and the wire refuses frames above 8 MiB outright.
    /// </summary>
    public const int MaxSettingsBytes = 1024 * 1024;

    /// <summary>
    /// Merges a sender's change into the host's settings. <paramref name="current"/> is what the host holds, <paramref name="before"/> what
    /// the sender last knew and <paramref name="after"/> what the sender has now. Returns false, with no result, when any input is missing.
    /// <paramref name="clashed"/> lists, in key order, the keys where the host's value differed from both the sender's before and after, so
    /// the sender's later value replaced somebody else's change.
    /// </summary>
    public static bool TryMerge( JsonObject current, JsonObject before, JsonObject after, out JsonObject merged, out List<string> clashed )
    {
        merged = null;
        clashed = new List<string>();
        if ( current == null || before == null || after == null )
        {
            return false;
        }

        // Key order: the host's keys first, then keys only the sender has, so a merged object reads like the host's with the change applied.
        var result = new JsonObject();
        foreach ( string key in KeysOf( current, after, before ) )
        {
            bool senderChanged = !Same( before, after, key );
            if ( !senderChanged )
            {
                CopyKey( current, result, key );
                continue;
            }
            // Equal to the sender's new value already (the same change twice) or untouched by anybody else since the sender read it:
            // no clash either way. Otherwise somebody else changed this key meanwhile, and the later change still wins.
            if ( !Same( current, after, key ) && !Same( current, before, key ) )
            {
                clashed.Add( key );
            }
            CopyKey( after, result, key );
        }
        merged = result;
        return true;
    }

    /// <summary>
    /// What the scan loop should send, or null when the user changed nothing. <paramref name="confirmed"/> is the host's settings as this
    /// editor last had them confirmed (<c>Tracked.Settings</c>); <paramref name="applied"/> what this editor last wrote into the scene (the
    /// host's settings, possibly with an unsent local change kept on top); <paramref name="echo"/> what the engine reported right after that
    /// write; <paramref name="live"/> what the scene reports now. <paramref name="applied"/> and <paramref name="echo"/> are null until the
    /// first write, and then <paramref name="confirmed"/> stands in for both.
    ///
    /// The echo matters because the engine does not take every value it is given: <c>Metadata</c> is computed from components, a
    /// <c>GameObjectSystems</c> entry for a type this project lacks is dropped, a blob reference may not resolve. Per top-level key: a key the
    /// user touched since the write (live differs from echo) is sent as live. A key untouched since the write is sent as live when the engine
    /// took the written value (that value is either the host's or an unsent local change, which must still go out), and as the host's value
    /// when the engine did not (that difference is residue, not the user's work: sending it would revert the other person's setting on the
    /// host, and two editors would then revert each other indefinitely).
    /// </summary>
    public static JsonObject LocalChange( JsonObject confirmed, JsonObject applied, JsonObject echo, JsonObject live )
    {
        if ( confirmed == null || live == null )
        {
            return null;
        }
        JsonObject written = applied ?? confirmed;
        JsonObject reported = echo ?? written;
        var result = new JsonObject();
        foreach ( string key in KeysOf( confirmed, live, reported ) )
        {
            bool userTouched = !Same( reported, live, key );
            bool engineTook = Same( written, reported, key );
            CopyKey( userTouched || engineTook ? live : confirmed, result, key );
        }
        return StateRules.ValuesEqual( result, confirmed ) ? null : result;
    }

    /// <summary>The formal sentence for the people in the session when a later settings change replaced an earlier one; null when nothing clashed.</summary>
    public static string ClashNotice( IReadOnlyCollection<string> clashed )
    {
        return ( clashed == null || clashed.Count == 0 ) ? null : "Concurrent changes to the scene settings were combined; the later change was kept for: " + string.Join( ", ", clashed ) + ".";
    }

    /// <summary>The formal sentence when this editor's unsent settings change was kept on top of a collaborator's change to the same keys; null when none.</summary>
    public static string LocalKeptNotice( IReadOnlyCollection<string> clashed )
    {
        return ( clashed == null || clashed.Count == 0 ) ? null : "A collaborator changed the scene settings while your own change was unsent; your change was kept and will be sent for: " + string.Join( ", ", clashed ) + ".";
    }

    /// <summary>UTF-8 size of the compact JSON text of <paramref name="settings"/>; zero for null.</summary>
    public static int SerializedBytes( JsonObject settings )
    {
        return settings == null ? 0 : Encoding.UTF8.GetByteCount( settings.ToJsonString() );
    }

    /// <summary>The formal refusal for a settings object above <see cref="MaxSettingsBytes"/>; null when it is within the bound.</summary>
    public static string SizeError( JsonObject settings )
    {
        int bytes = SerializedBytes( settings );
        return bytes <= MaxSettingsBytes ? null : "The scene settings change was refused: its settings are " + bytes + " bytes, above the limit of " + MaxSettingsBytes + " bytes. The host's settings were kept; reduce the size of the scene's settings (GameObjectSystems or metadata) and change them again.";
    }

    /// <summary>A deep copy of <paramref name="settings"/>, or null.</summary>
    public static JsonObject Clone( JsonObject settings )
    {
        return (JsonObject)settings?.DeepClone();
    }

    private static IEnumerable<string> KeysOf( JsonObject first, JsonObject second, JsonObject third )
    {
        var seen = new HashSet<string>( StringComparer.Ordinal );
        foreach ( JsonObject source in new[] { first, second, third } )
        {
            foreach ( KeyValuePair<string, JsonNode> pair in source )
            {
                if ( seen.Add( pair.Key ) )
                {
                    yield return pair.Key;
                }
            }
        }
    }

    // Absent and present are different values (a present null included), so adding or removing a key is a change like any other.
    private static bool Same( JsonObject a, JsonObject b, string key )
    {
        bool hasA = a.TryGetPropertyValue( key, out JsonNode valueA );
        bool hasB = b.TryGetPropertyValue( key, out JsonNode valueB );
        return hasA == hasB && ( !hasA || StateRules.ValuesEqual( valueA, valueB ) );
    }

    private static void CopyKey( JsonObject source, JsonObject target, string key )
    {
        if ( source.TryGetPropertyValue( key, out JsonNode value ) )
        {
            target[key] = value?.DeepClone();
        }
    }
}