784 lines
28 KiB
C#
784 lines
28 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using UnityEditor;
|
|
using UnityEngine;
|
|
|
|
namespace UnityTcp.Editor.Helpers
|
|
{
|
|
/// <summary>
|
|
/// Centralized state composition and revision tracking for Unity Editor state.
|
|
/// Provides consistent state snapshots and incremental state_delta generation.
|
|
/// </summary>
|
|
public static class StateComposer
|
|
{
|
|
// Global state revision counter (incremented on every state change)
|
|
private static int _globalRevision = 0;
|
|
private static readonly object _revisionLock = new object();
|
|
|
|
// Console state tracking (shared with ReadConsole)
|
|
private static string _currentConsoleToken = null;
|
|
private static int _consoleUnreadCount = 0;
|
|
private static readonly List<object> _lastConsoleErrors = new List<object>();
|
|
private static readonly object _consoleLock = new object();
|
|
|
|
// Touched assets tracking
|
|
private static readonly List<object> _touchedAssets = new List<object>();
|
|
private static readonly object _assetsLock = new object();
|
|
|
|
// Pending operations tracking
|
|
private static readonly List<object> _pendingOperations = new List<object>();
|
|
private static readonly object _operationsLock = new object();
|
|
|
|
/// <summary>
|
|
/// Increment and return the next global revision number.
|
|
/// Thread-safe.
|
|
/// </summary>
|
|
public static int IncrementRevision()
|
|
{
|
|
lock (_revisionLock)
|
|
{
|
|
return ++_globalRevision;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get current global revision without incrementing.
|
|
/// </summary>
|
|
public static int GetCurrentRevision()
|
|
{
|
|
lock (_revisionLock)
|
|
{
|
|
return _globalRevision;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a complete Unity state snapshot with current revision.
|
|
/// Note: Does NOT auto-increment revision - caller should decide when to increment.
|
|
/// </summary>
|
|
public static object BuildFullState()
|
|
{
|
|
int currentRev;
|
|
lock (_revisionLock)
|
|
{
|
|
currentRev = _globalRevision;
|
|
}
|
|
|
|
var state = new
|
|
{
|
|
editor = BuildEditorState(),
|
|
project = BuildProjectState(),
|
|
scene = BuildSceneState(),
|
|
selection = BuildSelectionState(),
|
|
console = BuildConsoleState(),
|
|
assets = BuildAssetsState(),
|
|
operations = BuildOperationsState(),
|
|
policy = BuildPolicyState(),
|
|
rev = currentRev
|
|
};
|
|
|
|
return state;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a complete Unity state snapshot and increments revision.
|
|
/// Use this for read operations that need to return fresh state.
|
|
/// </summary>
|
|
public static object BuildFullStateAndIncrement()
|
|
{
|
|
int newRev = IncrementRevision();
|
|
|
|
var state = new
|
|
{
|
|
editor = BuildEditorState(),
|
|
project = BuildProjectState(),
|
|
scene = BuildSceneState(),
|
|
selection = BuildSelectionState(),
|
|
console = BuildConsoleState(),
|
|
assets = BuildAssetsState(),
|
|
operations = BuildOperationsState(),
|
|
policy = BuildPolicyState(),
|
|
rev = newRev
|
|
};
|
|
|
|
return state;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds editor-specific state.
|
|
/// </summary>
|
|
public static object BuildEditorState()
|
|
{
|
|
var playMode = EditorApplication.isPlaying ? "playing" :
|
|
(EditorApplication.isPaused ? "paused" : "stopped");
|
|
|
|
// Get focused window
|
|
string focusedWindow = null;
|
|
if (EditorWindow.focusedWindow != null)
|
|
{
|
|
focusedWindow = EditorWindow.focusedWindow.GetType().Name;
|
|
}
|
|
|
|
// Determine if operations require focus
|
|
// This is a heuristic - some operations need the editor to be focused
|
|
bool requiresFocusForOperations = DetermineIfFocusRequired();
|
|
|
|
return new
|
|
{
|
|
playMode = playMode,
|
|
focusedWindow = focusedWindow,
|
|
requiresFocusForOperations = requiresFocusForOperations,
|
|
isCompiling = EditorApplication.isCompiling,
|
|
isUpdating = EditorApplication.isUpdating,
|
|
lastCompilation = BuildLastCompilationState(),
|
|
timeSinceStartup = (float)EditorApplication.timeSinceStartup
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds last compilation state.
|
|
///
|
|
/// NOTE:
|
|
/// - This is intentionally minimal and only reports whether Unity is
|
|
/// currently compiling ("started" vs "idle").
|
|
/// - It is NOT a per-compilation snapshot and does NOT expose error/
|
|
/// warning counts for any specific pipeline.
|
|
/// - For accurate diagnostics (including error/warning counts), callers
|
|
/// must use:
|
|
/// * Compilation deltas from StateComposer.CreateCompilationDelta
|
|
/// (returned by wait_for_compile), and
|
|
/// * The Unity console (read_console / unity_console) with sinceToken.
|
|
/// </summary>
|
|
private static object BuildLastCompilationState()
|
|
{
|
|
var status = EditorApplication.isCompiling ? "started" : "idle";
|
|
|
|
return new
|
|
{
|
|
status = status
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines if current operations require focus.
|
|
/// </summary>
|
|
private static bool DetermineIfFocusRequired()
|
|
{
|
|
// Heuristic: Some operations need focus, especially during Play mode
|
|
// or when performing visual operations like scene manipulation
|
|
if (EditorApplication.isPlaying || EditorApplication.isPaused)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Check if SceneView needs focus for certain operations
|
|
var sceneView = EditorWindow.focusedWindow as SceneView;
|
|
if (sceneView != null)
|
|
{
|
|
return false; // Already focused
|
|
}
|
|
|
|
return false; // Default: focus not strictly required
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds project-specific state.
|
|
/// </summary>
|
|
public static object BuildProjectState()
|
|
{
|
|
// Detect Render Pipeline
|
|
string srp = "builtin";
|
|
var currentRP = UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline;
|
|
if (currentRP != null)
|
|
{
|
|
string rpName = currentRP.GetType().Name.ToLowerInvariant();
|
|
if (rpName.Contains("urp") || rpName.Contains("universal"))
|
|
{
|
|
srp = "urp";
|
|
}
|
|
else if (rpName.Contains("hdrp") || rpName.Contains("highdefinition"))
|
|
{
|
|
srp = "hdrp";
|
|
}
|
|
}
|
|
|
|
return new
|
|
{
|
|
srp = srp,
|
|
defineSymbols = GetScriptingDefineSymbols(),
|
|
packages = GetInstalledPackages(),
|
|
dirty = false // Would track if project settings are modified
|
|
};
|
|
}
|
|
|
|
private static string[] GetScriptingDefineSymbols()
|
|
{
|
|
// Get scripting define symbols for current build target
|
|
var buildTargetGroup = EditorUserBuildSettings.selectedBuildTargetGroup;
|
|
var symbols = PlayerSettings.GetScriptingDefineSymbolsForGroup(buildTargetGroup);
|
|
return string.IsNullOrEmpty(symbols) ?
|
|
new string[0] :
|
|
symbols.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
|
}
|
|
|
|
private static string[] GetInstalledPackages()
|
|
{
|
|
// Simplified - in production would use PackageManager API
|
|
return new string[0];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds scene-specific state.
|
|
/// </summary>
|
|
public static object BuildSceneState()
|
|
{
|
|
var activeScene = UnityEngine.SceneManagement.SceneManager.GetActiveScene();
|
|
|
|
return new
|
|
{
|
|
activeScenePath = activeScene.path,
|
|
dirty = activeScene.isDirty,
|
|
hasNavMeshData = HasNavMeshData(),
|
|
hasLightingData = HasLightingData()
|
|
};
|
|
}
|
|
|
|
private static bool HasNavMeshData()
|
|
{
|
|
// Check if current scene has NavMesh data using runtime reflection
|
|
try
|
|
{
|
|
// First, try to check NavMeshSurface components (com.unity.ai.navigation package)
|
|
Type navMeshSurfaceType = Type.GetType("Unity.AI.Navigation.NavMeshSurface, Unity.AI.Navigation");
|
|
if (navMeshSurfaceType == null)
|
|
{
|
|
// Fallback: search in loaded assemblies
|
|
foreach (var assembly in System.AppDomain.CurrentDomain.GetAssemblies())
|
|
{
|
|
navMeshSurfaceType = assembly.GetType("Unity.AI.Navigation.NavMeshSurface");
|
|
if (navMeshSurfaceType != null) break;
|
|
}
|
|
}
|
|
|
|
if (navMeshSurfaceType != null)
|
|
{
|
|
// Check NavMeshSurface components for navMeshData
|
|
var activeSurfacesProperty = navMeshSurfaceType.GetProperty("activeSurfaces", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
|
|
if (activeSurfacesProperty != null)
|
|
{
|
|
var activeSurfaces = activeSurfacesProperty.GetValue(null);
|
|
if (activeSurfaces is System.Collections.IList surfaceList && surfaceList.Count > 0)
|
|
{
|
|
var navMeshDataProperty = navMeshSurfaceType.GetProperty("navMeshData");
|
|
if (navMeshDataProperty != null)
|
|
{
|
|
foreach (var surface in surfaceList)
|
|
{
|
|
if (surface != null)
|
|
{
|
|
var navMeshData = navMeshDataProperty.GetValue(surface);
|
|
if (navMeshData != null)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also check all NavMeshSurface components in the scene (including inactive)
|
|
var allSurfaces = Resources.FindObjectsOfTypeAll(navMeshSurfaceType);
|
|
if (allSurfaces != null && allSurfaces.Length > 0)
|
|
{
|
|
var navMeshDataProperty = navMeshSurfaceType.GetProperty("navMeshData");
|
|
if (navMeshDataProperty != null)
|
|
{
|
|
foreach (var surface in allSurfaces)
|
|
{
|
|
if (surface != null)
|
|
{
|
|
var navMeshData = navMeshDataProperty.GetValue(surface);
|
|
if (navMeshData != null)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: Try to find NavMesh type using reflection (for built-in NavMesh)
|
|
Type navMeshType = Type.GetType("UnityEngine.AI.NavMesh, UnityEngine.AIModule");
|
|
if (navMeshType == null)
|
|
{
|
|
// Fallback: search in loaded assemblies
|
|
foreach (var assembly in System.AppDomain.CurrentDomain.GetAssemblies())
|
|
{
|
|
navMeshType = assembly.GetType("UnityEngine.AI.NavMesh");
|
|
if (navMeshType != null) break;
|
|
}
|
|
}
|
|
|
|
if (navMeshType == null)
|
|
return false;
|
|
|
|
// Get CalculateTriangulation method
|
|
MethodInfo calculateTriangulationMethod = navMeshType.GetMethod("CalculateTriangulation", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
|
|
if (calculateTriangulationMethod == null)
|
|
return false;
|
|
|
|
// Call CalculateTriangulation using reflection
|
|
var triangulation = calculateTriangulationMethod.Invoke(null, null);
|
|
if (triangulation == null)
|
|
return false;
|
|
|
|
// Get vertices property
|
|
var verticesProperty = triangulation.GetType().GetProperty("vertices");
|
|
if (verticesProperty == null)
|
|
return false;
|
|
|
|
var vertices = verticesProperty.GetValue(triangulation) as Array;
|
|
return vertices != null && vertices.Length > 0;
|
|
}
|
|
catch
|
|
{
|
|
// If any error occurs, assume no NavMesh data
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool HasLightingData()
|
|
{
|
|
// Check if current scene has baked lighting
|
|
return Lightmapping.giWorkflowMode == Lightmapping.GIWorkflowMode.OnDemand ||
|
|
Lightmapping.lightingDataAsset != null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds selection state.
|
|
/// </summary>
|
|
public static object BuildSelectionState()
|
|
{
|
|
var activeObject = Selection.activeGameObject;
|
|
object activeObjectInfo = null;
|
|
|
|
if (activeObject != null)
|
|
{
|
|
activeObjectInfo = new
|
|
{
|
|
id = activeObject.GetInstanceID(),
|
|
name = activeObject.name,
|
|
hierarchy_path = GetHierarchyPath(activeObject)
|
|
};
|
|
}
|
|
|
|
return new
|
|
{
|
|
activeObject = activeObjectInfo
|
|
};
|
|
}
|
|
|
|
private static string GetHierarchyPath(GameObject go)
|
|
{
|
|
if (go == null) return "";
|
|
|
|
var path = go.name;
|
|
var parent = go.transform.parent;
|
|
|
|
while (parent != null)
|
|
{
|
|
path = parent.name + "/" + path;
|
|
parent = parent.parent;
|
|
}
|
|
|
|
return path;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds console state with real tracking data.
|
|
/// </summary>
|
|
public static object BuildConsoleState()
|
|
{
|
|
lock (_consoleLock)
|
|
{
|
|
return new
|
|
{
|
|
sinceToken = _currentConsoleToken,
|
|
unreadCount = _consoleUnreadCount,
|
|
lastErrors = _lastConsoleErrors.ToArray()
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates console state tracking. Called by ReadConsole.
|
|
/// </summary>
|
|
public static void UpdateConsoleState(string sinceToken, int unreadCount = 0, object[] lastErrors = null)
|
|
{
|
|
lock (_consoleLock)
|
|
{
|
|
_currentConsoleToken = sinceToken;
|
|
_consoleUnreadCount = unreadCount;
|
|
_lastConsoleErrors.Clear();
|
|
if (lastErrors != null)
|
|
{
|
|
_lastConsoleErrors.AddRange(lastErrors);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the current console token.
|
|
/// </summary>
|
|
public static string GetCurrentConsoleToken()
|
|
{
|
|
lock (_consoleLock)
|
|
{
|
|
return _currentConsoleToken;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds assets state with tracked touched assets.
|
|
/// </summary>
|
|
public static object BuildAssetsState()
|
|
{
|
|
lock (_assetsLock)
|
|
{
|
|
return new
|
|
{
|
|
touched = _touchedAssets.ToArray()
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a touched asset to tracking. Called by asset operations.
|
|
/// </summary>
|
|
public static void AddTouchedAsset(string path, bool imported = false, bool hasMeta = true)
|
|
{
|
|
lock (_assetsLock)
|
|
{
|
|
_touchedAssets.Add(new { path, imported, hasMeta });
|
|
// Keep only last 100 entries
|
|
while (_touchedAssets.Count > 100)
|
|
{
|
|
_touchedAssets.RemoveAt(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears touched assets list.
|
|
/// </summary>
|
|
public static void ClearTouchedAssets()
|
|
{
|
|
lock (_assetsLock)
|
|
{
|
|
_touchedAssets.Clear();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds pending operations state from AsyncOperationTracker.
|
|
/// </summary>
|
|
public static object BuildOperationsState()
|
|
{
|
|
// Get pending operations from AsyncOperationTracker
|
|
var pendingJobs = AsyncOperationTracker.GetPendingJobs();
|
|
var pending = pendingJobs.Select(job => new
|
|
{
|
|
id = job.OpId,
|
|
type = job.Type.ToString(),
|
|
progress = job.Progress,
|
|
message = job.Message
|
|
}).ToArray();
|
|
|
|
return new
|
|
{
|
|
pending = pending
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds policy state.
|
|
/// </summary>
|
|
public static object BuildPolicyState()
|
|
{
|
|
return new
|
|
{
|
|
writeGuardInPlayMode = "deny", // Default: deny writes in Play mode
|
|
refreshMode = "debounced",
|
|
consoleReadPolicy = "must_clear_before_read"
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a Console state delta.
|
|
/// </summary>
|
|
public static object CreateConsoleDelta(string sinceToken = null, int? unreadCount = null, object[] lastErrors = null)
|
|
{
|
|
var consoleDelta = new Dictionary<string, object>();
|
|
|
|
if (sinceToken != null) consoleDelta["sinceToken"] = sinceToken;
|
|
if (unreadCount.HasValue) consoleDelta["unreadCount"] = unreadCount.Value;
|
|
if (lastErrors != null) consoleDelta["lastErrors"] = lastErrors;
|
|
|
|
return new { console = consoleDelta };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a Compilation state delta.
|
|
/// </summary>
|
|
public static object CreateCompilationDelta(bool? isCompiling = null, string status = null, int? errors = null, int? warnings = null)
|
|
{
|
|
var editorDelta = new Dictionary<string, object>();
|
|
var compilationDelta = new Dictionary<string, object>();
|
|
|
|
if (isCompiling.HasValue) editorDelta["isCompiling"] = isCompiling.Value;
|
|
|
|
if (status != null) compilationDelta["status"] = status;
|
|
if (errors.HasValue) compilationDelta["errors"] = errors.Value;
|
|
if (warnings.HasValue) compilationDelta["warnings"] = warnings.Value;
|
|
|
|
if (compilationDelta.Count > 0)
|
|
{
|
|
editorDelta["lastCompilation"] = compilationDelta;
|
|
}
|
|
|
|
return new { editor = editorDelta };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a Scene state delta.
|
|
/// </summary>
|
|
public static object CreateSceneDelta(string activeScenePath = null, bool? dirty = null)
|
|
{
|
|
var sceneDelta = new Dictionary<string, object>();
|
|
|
|
if (activeScenePath != null) sceneDelta["activeScenePath"] = activeScenePath;
|
|
if (dirty.HasValue) sceneDelta["dirty"] = dirty.Value;
|
|
|
|
return new { scene = sceneDelta };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates an Asset state delta.
|
|
/// </summary>
|
|
public static object CreateAssetDelta(object[] touchedAssets)
|
|
{
|
|
return new
|
|
{
|
|
assets = new
|
|
{
|
|
touched = touchedAssets
|
|
}
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates an Editor state delta.
|
|
/// </summary>
|
|
public static object CreateEditorDelta(string focusedWindow = null, bool? isUpdating = null)
|
|
{
|
|
var editorDelta = new Dictionary<string, object>();
|
|
|
|
if (focusedWindow != null) editorDelta["focusedWindow"] = focusedWindow;
|
|
if (isUpdating.HasValue) editorDelta["isUpdating"] = isUpdating.Value;
|
|
|
|
return new { editor = editorDelta };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates an Operations state delta.
|
|
/// </summary>
|
|
public static object CreateOperationsDelta(object[] pendingOperations)
|
|
{
|
|
return new
|
|
{
|
|
operations = new
|
|
{
|
|
pending = pendingOperations
|
|
}
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates client state revision and returns conflict response if mismatched.
|
|
/// Returns null if validation passes.
|
|
/// </summary>
|
|
public static object ValidateClientRevision(int? clientRev)
|
|
{
|
|
if (!clientRev.HasValue)
|
|
{
|
|
// No client revision provided - accept but don't enforce
|
|
return null;
|
|
}
|
|
|
|
int currentRev = GetCurrentRevision();
|
|
if (clientRev.Value != currentRev)
|
|
{
|
|
// State mismatch - return 409-like conflict response with fresh state
|
|
return new
|
|
{
|
|
success = false,
|
|
message = $"State revision mismatch. Client: {clientRev.Value}, Server: {currentRev}. Please refresh state.",
|
|
code = "state_revision_conflict",
|
|
state = BuildFullStateAndIncrement()
|
|
};
|
|
}
|
|
|
|
return null; // Validation passed
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates client state revision from JObject params.
|
|
/// Returns null if validation passes, error response if conflict.
|
|
/// </summary>
|
|
public static object ValidateClientRevisionFromParams(Codely.Newtonsoft.Json.Linq.JObject @params)
|
|
{
|
|
int? clientRev = @params?["client_state_rev"]?.ToObject<int?>();
|
|
return ValidateClientRevision(clientRev);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Merges multiple state deltas into one combined delta.
|
|
/// </summary>
|
|
public static object MergeStateDeltas(params object[] deltas)
|
|
{
|
|
if (deltas == null || deltas.Length == 0) return null;
|
|
if (deltas.Length == 1) return deltas[0];
|
|
|
|
// Preserve legacy behavior: if only one non-null delta is provided, return it as-is.
|
|
int nonNullCount = 0;
|
|
object single = null;
|
|
foreach (var d in deltas)
|
|
{
|
|
if (d == null) continue;
|
|
nonNullCount++;
|
|
single = d;
|
|
if (nonNullCount > 1) break;
|
|
}
|
|
if (nonNullCount == 0) return null;
|
|
if (nonNullCount == 1) return single;
|
|
|
|
var merged = new Dictionary<string, object>();
|
|
|
|
foreach (var delta in deltas)
|
|
{
|
|
if (delta == null) continue;
|
|
|
|
// Prefer a JSON/dictionary representation to avoid reflection issues
|
|
// (e.g., when a state_delta is already a JObject/JToken).
|
|
Dictionary<string, object> deltaDict = null;
|
|
try
|
|
{
|
|
// Codely.Newtonsoft.Json.Linq types (JObject / JToken)
|
|
if (delta is Codely.Newtonsoft.Json.Linq.JObject jObj)
|
|
{
|
|
deltaDict = jObj.ToObject<Dictionary<string, object>>();
|
|
}
|
|
else if (delta is Codely.Newtonsoft.Json.Linq.JToken jTok &&
|
|
jTok.Type == Codely.Newtonsoft.Json.Linq.JTokenType.Object)
|
|
{
|
|
var asObj = jTok as Codely.Newtonsoft.Json.Linq.JObject;
|
|
deltaDict = (asObj ?? Codely.Newtonsoft.Json.Linq.JObject.FromObject(jTok))
|
|
.ToObject<Dictionary<string, object>>();
|
|
}
|
|
else if (delta is IDictionary<string, object> iDict)
|
|
{
|
|
deltaDict = new Dictionary<string, object>(iDict);
|
|
}
|
|
else
|
|
{
|
|
// Last resort: serialize arbitrary objects into a JObject then into a dictionary.
|
|
var obj = Codely.Newtonsoft.Json.Linq.JObject.FromObject(delta);
|
|
deltaDict = obj.ToObject<Dictionary<string, object>>();
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
deltaDict = null;
|
|
}
|
|
|
|
if (deltaDict != null)
|
|
{
|
|
foreach (var kv in deltaDict)
|
|
{
|
|
if (kv.Value == null) continue;
|
|
|
|
if (merged.ContainsKey(kv.Key))
|
|
{
|
|
// Merge nested dictionaries (one level deep, consistent with legacy behavior)
|
|
var existingDict = merged[kv.Key] as Dictionary<string, object>;
|
|
var newDict = kv.Value as Dictionary<string, object>;
|
|
if (existingDict != null && newDict != null)
|
|
{
|
|
foreach (var nk in newDict)
|
|
{
|
|
existingDict[nk.Key] = nk.Value;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
merged[kv.Key] = kv.Value;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
merged[kv.Key] = kv.Value;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Fallback: reflection-based merge (skip indexer properties to avoid invocation errors)
|
|
try
|
|
{
|
|
var props = delta.GetType().GetProperties();
|
|
foreach (var prop in props)
|
|
{
|
|
if (prop.GetIndexParameters().Length > 0) continue;
|
|
|
|
object value = null;
|
|
try { value = prop.GetValue(delta); } catch { continue; }
|
|
if (value == null) continue;
|
|
|
|
if (merged.ContainsKey(prop.Name))
|
|
{
|
|
// Merge nested dictionaries
|
|
if (merged[prop.Name] is Dictionary<string, object> existingDict &&
|
|
value is Dictionary<string, object> newDict)
|
|
{
|
|
foreach (var kv in newDict)
|
|
{
|
|
existingDict[kv.Key] = kv.Value;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
merged[prop.Name] = value;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
merged[prop.Name] = value;
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Ignore merge errors from unexpected delta shapes.
|
|
}
|
|
}
|
|
|
|
return merged.Count > 0 ? merged : null;
|
|
}
|
|
}
|
|
}
|
|
|