cz.xprees.core 1.0.34 → 1.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ using UnityEditor;
2
+ using UnityEngine;
3
+ using Xprees.Core.DescriptionAttribute;
4
+
5
+ namespace Xprees.Core.Editor
6
+ {
7
+ /// Draws a string field as a foldout: a single-line preview when collapsed,
8
+ /// and a full-text area when expanded.
9
+ [CustomPropertyDrawer(typeof(DescriptionTextAreaAttribute))]
10
+ public class DescriptionTextAreaDrawer : PropertyDrawer
11
+ {
12
+ // Tweak this to change the font size of both the expanded text area and the collapsed preview.
13
+ private const int textFontSize = 12;
14
+
15
+ // Separator line drawn below the field, separating it from the next one in the Inspector.
16
+ private const float separatorHeight = 1f;
17
+ private const float separatorSpacing = 4f;
18
+ private readonly static Color separatorColor = new(0.5f, 0.5f, 0.5f, 0.5f);
19
+
20
+ private static float LineHeight => EditorGUIUtility.singleLineHeight;
21
+ private static float SeparatorTotalHeight => separatorSpacing + separatorHeight;
22
+
23
+ public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
24
+ {
25
+ if (property.propertyType != SerializedPropertyType.String)
26
+ {
27
+ EditorGUI.PropertyField(position, property, label, true);
28
+ return;
29
+ }
30
+
31
+ var foldoutRect = new Rect(position.x, position.y, position.width, LineHeight);
32
+ property.isExpanded = EditorGUI.Foldout(foldoutRect, property.isExpanded, label, true);
33
+
34
+ var contentRect = new Rect(position.x, foldoutRect.yMax, position.width,
35
+ position.height - LineHeight - SeparatorTotalHeight);
36
+ if (property.isExpanded)
37
+ {
38
+ var textAreaStyle = new GUIStyle(EditorStyles.textArea) { fontSize = textFontSize };
39
+
40
+ EditorGUI.BeginChangeCheck();
41
+ var newValue = EditorGUI.TextArea(contentRect, property.stringValue, textAreaStyle);
42
+ if (EditorGUI.EndChangeCheck())
43
+ {
44
+ property.stringValue = newValue;
45
+ }
46
+ }
47
+ else if (!string.IsNullOrEmpty(property.stringValue))
48
+ {
49
+ var previewStyle = new GUIStyle(EditorStyles.wordWrappedMiniLabel) { fontSize = textFontSize };
50
+ EditorGUI.LabelField(contentRect, GetPreview(property.stringValue), previewStyle);
51
+ }
52
+
53
+ var separatorRect = new Rect(position.x, position.yMax - separatorHeight, position.width, separatorHeight);
54
+ EditorGUI.DrawRect(separatorRect, separatorColor);
55
+ }
56
+
57
+ public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
58
+ {
59
+ if (property.propertyType != SerializedPropertyType.String)
60
+ {
61
+ return EditorGUI.GetPropertyHeight(property, label, true);
62
+ }
63
+
64
+ if (!property.isExpanded)
65
+ {
66
+ var previewLines = string.IsNullOrEmpty(property.stringValue) ? 1 : 2;
67
+ return previewLines * LineHeight + SeparatorTotalHeight;
68
+ }
69
+
70
+ var textAreaAttribute = (DescriptionTextAreaAttribute) attribute;
71
+ var lineCount = string.IsNullOrEmpty(property.stringValue) ? 1 : property.stringValue.Split('\n').Length;
72
+ lineCount = Mathf.Clamp(lineCount + 1, textAreaAttribute.expandedMinLines, textAreaAttribute.expandedMaxLines);
73
+ return LineHeight + lineCount * LineHeight + SeparatorTotalHeight;
74
+ }
75
+
76
+ private static string GetPreview(string value)
77
+ {
78
+ var firstLine = value.Split('\n')[0];
79
+ return firstLine.Length > 100 ? firstLine[..100] + "…" : firstLine;
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,2 @@
1
+ fileFormatVersion: 2
2
+ guid: c65fd31c416dba84a9aceb940eafe5a4
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "cz.xprees.core.editor",
3
+ "rootNamespace": "Xprees.Core",
4
+ "references": [
5
+ "GUID:999c2ca78ab34358a1222750376a501c"
6
+ ],
7
+ "includePlatforms": [
8
+ "Editor"
9
+ ],
10
+ "excludePlatforms": [],
11
+ "allowUnsafeCode": false,
12
+ "overrideReferences": false,
13
+ "precompiledReferences": [],
14
+ "autoReferenced": true,
15
+ "defineConstraints": [],
16
+ "versionDefines": [],
17
+ "noEngineReferences": false
18
+ }
@@ -0,0 +1,3 @@
1
+ fileFormatVersion: 2
2
+ guid: 04cd20f0ff664149a4d1159fb40a8c7c
3
+ timeCreated: 1788342224
package/Editor.meta ADDED
@@ -0,0 +1,3 @@
1
+ fileFormatVersion: 2
2
+ guid: bd2a029389794301a94059f13fdd90f5
3
+ timeCreated: 1788342207
@@ -0,0 +1,18 @@
1
+ using UnityEngine;
2
+
3
+ namespace Xprees.Core.DescriptionAttribute
4
+ {
5
+ /// Marks a multiline string field to be drawn as a collapsible foldout in the Inspector,
6
+ /// showing a single-line preview when collapsed and a full text area when expanded.
7
+ public class DescriptionTextAreaAttribute : PropertyAttribute
8
+ {
9
+ public readonly int expandedMinLines;
10
+ public readonly int expandedMaxLines;
11
+
12
+ public DescriptionTextAreaAttribute(int expandedMinLines = 3, int expandedMaxLines = 10)
13
+ {
14
+ this.expandedMinLines = expandedMinLines;
15
+ this.expandedMaxLines = expandedMaxLines;
16
+ }
17
+ }
18
+ }
@@ -0,0 +1,2 @@
1
+ fileFormatVersion: 2
2
+ guid: e2d84aef13872c74c8df81669c15ff44
@@ -0,0 +1,3 @@
1
+ fileFormatVersion: 2
2
+ guid: e9640b44188041f08d1606fdedf41f63
3
+ timeCreated: 1788342764
@@ -1,16 +1,30 @@
1
1
  using UnityEngine;
2
+ using Xprees.Core.DescriptionAttribute;
2
3
 
3
4
  namespace Xprees.Core
4
5
  {
5
6
  /// Base class for ScriptableObjects with a public description field visible only in Editor.
7
+ /// Supports lifecycle-scoped state management.
6
8
  public class DescriptionBaseSO : ScriptableObject, IResettable
7
9
  {
8
10
  #if UNITY_EDITOR
11
+ [SnapshotIgnore]
9
12
  [Tooltip("Description of the ScriptableObject. Editor only.")]
10
- [TextArea]
13
+ [DescriptionTextArea]
11
14
  public string description;
12
15
  #endif
13
16
 
17
+ [Tooltip("Defines how long this ScriptableObject's runtime state persists before being automatically restored.")]
18
+ [SerializeField] private StateLifetime lifetime = StateLifetime.Scenario;
19
+
20
+ public StateLifetime ConfiguredLifetime => lifetime;
21
+
22
+ public StateLifetime Lifetime
23
+ {
24
+ get => this.GetStateLifetime();
25
+ set => lifetime = value;
26
+ }
27
+
14
28
  public virtual void ResetState()
15
29
  {
16
30
  }
@@ -0,0 +1,11 @@
1
+ namespace Xprees.Core
2
+ {
3
+ /// Implemented by objects that have transient, non-serialized runtime state
4
+ /// (e.g., event subscriptions, CancellationTokenSources, active caches, UI transient variables)
5
+ /// that should be cleared during a scenario reset.
6
+ public interface IRuntimeStateOwner
7
+ {
8
+ /// Clears transient non-serialized state (subscriptions, cancellation tokens, transient caches).
9
+ void ClearTransientState();
10
+ }
11
+ }
@@ -0,0 +1,3 @@
1
+ fileFormatVersion: 2
2
+ guid: 21282b591a2e4051ae4ed0d387c70a35
3
+ timeCreated: 1788207906
@@ -0,0 +1,33 @@
1
+ #if UNITY_EDITOR
2
+ using UnityEditor;
3
+ #endif
4
+
5
+ namespace Xprees.Core
6
+ {
7
+ /// Thread-safe and serialization-safe tracker for play mode state.
8
+ /// Accessing Unity's Application.isPlaying during ISerializationCallbackReceiver throws UnityException.
9
+ /// This pure C# static field can be safely read from any thread or serialization callback.
10
+ public static class PlayModeStateTracker
11
+ {
12
+ #if UNITY_EDITOR
13
+ public static bool IsPlaying { get; private set; }
14
+
15
+ [InitializeOnLoadMethod]
16
+ private static void Init()
17
+ {
18
+ IsPlaying = EditorApplication.isPlaying;
19
+ EditorApplication.playModeStateChanged -= OnPlayModeChanged;
20
+ EditorApplication.playModeStateChanged += OnPlayModeChanged;
21
+ }
22
+
23
+ private static void OnPlayModeChanged(PlayModeStateChange change)
24
+ {
25
+ IsPlaying = change is
26
+ PlayModeStateChange.EnteredPlayMode or PlayModeStateChange.ExitingEditMode;
27
+ }
28
+ #else
29
+ // We must have runtime fallback to code to compile
30
+ public static bool IsPlaying => true;
31
+ #endif
32
+ }
33
+ }
@@ -0,0 +1,3 @@
1
+ fileFormatVersion: 2
2
+ guid: 353d734366454cd28c38cd72c8edf9a0
3
+ timeCreated: 1788207957
@@ -0,0 +1,55 @@
1
+ using System;
2
+
3
+ namespace Xprees.Core
4
+ {
5
+ /// Defines the retention lifecycle and reset scope of a ScriptableObject's runtime state.
6
+ public enum StateLifetime
7
+ {
8
+ /// Scoped strictly to the active Scenario.
9
+ /// Automatically captured and restored to its pristine baseline every time a Scenario starts or restarts.
10
+ /// Example: Scenario-specific variables, emails, dialog progression, and interactables.
11
+ Scenario = 0,
12
+
13
+ /// Persists across multiple scenarios during the entire game execution session,
14
+ /// restored only when quitting the application or starting a new game session from the Main Menu.
15
+ /// Example: global player profile, per session settings etc.
16
+ Session = 1,
17
+
18
+ /// Completely ignored by the runtime state snapshot system.
19
+ /// Represents purely static/constant authored content or data persisted externally (e.g., disk save files / cloud saves).
20
+ /// Example: Audio clips, localization tables, immutable item database definitions.
21
+ Persistent = 2,
22
+ }
23
+
24
+ /// Explicitly declares the state lifetime for an entire ScriptableObject type.
25
+ /// Overrides per-instance serialized lifetime fields.
26
+ [AttributeUsage(AttributeTargets.Class, Inherited = true)]
27
+ public sealed class StatefulLifetimeAttribute : Attribute
28
+ {
29
+ public StateLifetime Lifetime { get; }
30
+
31
+ public StatefulLifetimeAttribute(StateLifetime lifetime)
32
+ {
33
+ Lifetime = lifetime;
34
+ }
35
+ }
36
+
37
+ /// Marks a field on a ScriptableObject to be excluded from automated state snapshots.
38
+ [AttributeUsage(AttributeTargets.Field)]
39
+ public sealed class SnapshotIgnoreAttribute : Attribute
40
+ {
41
+ }
42
+
43
+ /// Marks a ScriptableObject type as entirely stateless/immutable, skipping it from state snapshots and scenario resetting.
44
+ [AttributeUsage(AttributeTargets.Class, Inherited = true)]
45
+ public sealed class StatelessAssetAttribute : Attribute
46
+ {
47
+ }
48
+
49
+ /// Marks a ScriptableObject type or field as entirely stateless/immutable, skipping it from state snapshots and scenario resetting.
50
+ /// Convenient alias for [StatelessAsset].
51
+ [AttributeUsage(AttributeTargets.Class | AttributeTargets.Field, Inherited = true)]
52
+ public sealed class StatelessAttribute : Attribute
53
+ {
54
+ }
55
+ }
@@ -0,0 +1,3 @@
1
+ fileFormatVersion: 2
2
+ guid: b81684bb25be421382c9f5ad7cc88f30
3
+ timeCreated: 1788207904
@@ -0,0 +1,91 @@
1
+ using System;
2
+ using System.Collections.Concurrent;
3
+ using System.Reflection;
4
+ using UnityEngine;
5
+ #if UNITY_EDITOR
6
+ using UnityEditor;
7
+ #endif
8
+
9
+ namespace Xprees.Core
10
+ {
11
+ /// Utility extensions for resolving lifecycle and stateless status of ScriptableObjects.
12
+ public static class StateLifetimeExtensions
13
+ {
14
+ private readonly static ConcurrentDictionary<Type, StateLifetime?> _lifetimeCache = new();
15
+ private readonly static ConcurrentDictionary<Type, bool> _statelessCache = new();
16
+
17
+ public static StateLifetime GetStateLifetime(this ScriptableObject so)
18
+ {
19
+ if (!so) return StateLifetime.Persistent;
20
+
21
+ var type = so.GetType();
22
+ if (_lifetimeCache.TryGetValue(type, out var cached) && cached.HasValue)
23
+ {
24
+ return cached.Value;
25
+ }
26
+
27
+ #if UNITY_EDITOR
28
+ var attrTypes = TypeCache.GetTypesWithAttribute<StatefulLifetimeAttribute>();
29
+ if (attrTypes.Contains(type))
30
+ {
31
+ var attr = (StatefulLifetimeAttribute) Attribute.GetCustomAttribute(type, typeof(StatefulLifetimeAttribute), true);
32
+ if (attr != null)
33
+ {
34
+ _lifetimeCache[type] = attr.Lifetime;
35
+ return attr.Lifetime;
36
+ }
37
+ }
38
+ #endif
39
+ var fallbackAttr = type.GetCustomAttribute<StatefulLifetimeAttribute>(true);
40
+ if (fallbackAttr != null)
41
+ {
42
+ _lifetimeCache[type] = fallbackAttr.Lifetime;
43
+ return fallbackAttr.Lifetime;
44
+ }
45
+
46
+ if (so is DescriptionBaseSO descSo)
47
+ {
48
+ return descSo.ConfiguredLifetime;
49
+ }
50
+
51
+ _lifetimeCache[type] = StateLifetime.Persistent;
52
+ return StateLifetime.Persistent;
53
+ }
54
+
55
+ public static bool IsStateless(this ScriptableObject so)
56
+ {
57
+ if (!so) return true;
58
+
59
+ var type = so.GetType();
60
+ if (_statelessCache.TryGetValue(type, out var cached))
61
+ {
62
+ return cached;
63
+ }
64
+
65
+ #if UNITY_EDITOR
66
+ var statelessTypes = TypeCache.GetTypesWithAttribute<StatelessAssetAttribute>();
67
+ var statelessTypesAlt = TypeCache.GetTypesWithAttribute<StatelessAttribute>();
68
+ if (statelessTypes.Contains(type) || statelessTypesAlt.Contains(type))
69
+ {
70
+ _statelessCache[type] = true;
71
+ return true;
72
+ }
73
+ #endif
74
+ var hasStatelessAttr = type.GetCustomAttribute<StatelessAssetAttribute>(true) != null ||
75
+ type.GetCustomAttribute<StatelessAttribute>(true) != null;
76
+ if (hasStatelessAttr)
77
+ {
78
+ _statelessCache[type] = true;
79
+ return true;
80
+ }
81
+
82
+ // Assets are only stateful if they inherit from DescriptionBaseSO or are explicitly marked with StatefulLifetime
83
+ var hasStatefulAttr = type.GetCustomAttribute<StatefulLifetimeAttribute>(true) != null;
84
+ var isStateful = so is DescriptionBaseSO || hasStatefulAttr;
85
+
86
+ var isStateless = !isStateful;
87
+ _statelessCache[type] = isStateless;
88
+ return isStateless;
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,3 @@
1
+ fileFormatVersion: 2
2
+ guid: 1c425e1e2dd7408aa8a37b4a17e7b530
3
+ timeCreated: 1788514835
@@ -0,0 +1,274 @@
1
+ using System;
2
+ using System.Collections.Concurrent;
3
+ using System.Collections.Generic;
4
+ using System.Reflection;
5
+ using UnityEngine;
6
+ using Object = UnityEngine.Object;
7
+ #if UNITY_EDITOR
8
+ using UnityEditor;
9
+ #endif
10
+
11
+ namespace Xprees.Core
12
+ {
13
+ // TODO later consider adding limits to not blow up memory - probably not an issue
14
+ // TODO consider using OdinSerializer for more robust serialization in future
15
+ /// Core engine service for zero-boilerplate capturing and in-place restoration
16
+ /// of ScriptableObject serialized state between scenario runs.
17
+ public static class StateSnapshotService
18
+ {
19
+ private readonly static Dictionary<int, string> snapshots = new();
20
+ private readonly static HashSet<int> restoringEntities = new();
21
+ private readonly static ConcurrentDictionary<Type, FieldInfo[]> snapshotIgnoreFieldsCache = new();
22
+ #if UNITY_EDITOR
23
+ private readonly static Dictionary<int, ScriptableObject> trackedTargets = new();
24
+ #endif
25
+
26
+ /// Total number of currently tracked baseline snapshots in memory.
27
+ public static int SnapshotCount => snapshots.Count;
28
+
29
+ /// Captures the baseline-serialized state of the ScriptableObject if not already captured.
30
+ public static bool EnsureCaptured(ScriptableObject target)
31
+ {
32
+ if (!target || target.IsStateless() || target.GetStateLifetime() == StateLifetime.Persistent)
33
+ {
34
+ return false;
35
+ }
36
+
37
+ var entityId = target.GetEntityId();
38
+ if (snapshots.ContainsKey(entityId))
39
+ {
40
+ return false;
41
+ }
42
+
43
+ try
44
+ {
45
+ var json = JsonUtility.ToJson(target);
46
+ snapshots[entityId] = json;
47
+ #if UNITY_EDITOR
48
+ trackedTargets[entityId] = target;
49
+ #endif
50
+ return true;
51
+ }
52
+ catch (Exception ex)
53
+ {
54
+ Debug.LogError($"[StateSnapshotService] Failed to capture snapshot for '{target.name}' ({target.GetType().Name}): {ex.Message}",
55
+ target);
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /// Restores the ScriptableObject to its captured baseline state in-place.
61
+ /// If no baseline has been captured yet, the current state is captured as baseline.
62
+ public static bool Restore(ScriptableObject target)
63
+ {
64
+ if (!target || target.IsStateless() || target.GetStateLifetime() == StateLifetime.Persistent)
65
+ {
66
+ return false;
67
+ }
68
+
69
+ var entityId = target.GetEntityId();
70
+ if (!snapshots.TryGetValue(entityId, out var json))
71
+ {
72
+ EnsureCaptured(target);
73
+ return false;
74
+ }
75
+
76
+ // Re-entrancy guard to prevent infinite mutual recursion (e.g. scenario resetting itself)
77
+ if (!restoringEntities.Add(entityId)) return false;
78
+
79
+ try
80
+ {
81
+ RestoreFromJson(json, target);
82
+
83
+ if (target is IRuntimeStateOwner stateOwner)
84
+ {
85
+ stateOwner.ClearTransientState();
86
+ }
87
+
88
+ return true;
89
+ }
90
+ catch (Exception ex)
91
+ {
92
+ Debug.LogError($"[StateSnapshotService] Failed to restore snapshot for '{target.name}' ({target.GetType().Name}): {ex.Message}",
93
+ target);
94
+ return false;
95
+ }
96
+ finally
97
+ {
98
+ restoringEntities.Remove(entityId);
99
+ }
100
+ }
101
+
102
+ /// Restores all currently tracked ScriptableObjects to their baseline state.
103
+ public static int RestoreAll()
104
+ {
105
+ var restoredCount = 0;
106
+ #if UNITY_EDITOR
107
+ foreach (var kvp in trackedTargets)
108
+ {
109
+ var target = kvp.Value;
110
+ if (!target)
111
+ {
112
+ target = EditorUtility.EntityIdToObject(kvp.Key) as ScriptableObject;
113
+ }
114
+
115
+ if (!target || !snapshots.TryGetValue(kvp.Key, out var json)) continue;
116
+ if (!restoringEntities.Add(kvp.Key)) continue;
117
+
118
+ try
119
+ {
120
+ RestoreFromJson(json, target);
121
+
122
+ if (target is IRuntimeStateOwner stateOwner)
123
+ {
124
+ stateOwner.ClearTransientState();
125
+ }
126
+
127
+ EditorUtility.ClearDirty(target);
128
+ restoredCount++;
129
+ }
130
+ catch (Exception ex)
131
+ {
132
+ Debug.LogError($"[StateSnapshotService] Failed to restore target '{target.name}': {ex.Message}", target);
133
+ }
134
+ finally
135
+ {
136
+ restoringEntities.Remove(kvp.Key);
137
+ }
138
+ }
139
+ #endif
140
+ return restoredCount;
141
+ }
142
+
143
+ /// Restores serialized fields from JSON while preserving fields marked with [SnapshotIgnore].
144
+ private static void RestoreFromJson(string json, ScriptableObject target)
145
+ {
146
+ var ignoredFields = GetSnapshotIgnoreFields(target.GetType());
147
+ object[] preservedValues = null;
148
+ if (ignoredFields.Length > 0)
149
+ {
150
+ preservedValues = new object[ignoredFields.Length];
151
+ for (var i = 0; i < ignoredFields.Length; i++)
152
+ {
153
+ preservedValues[i] = ignoredFields[i].GetValue(target);
154
+ }
155
+ }
156
+
157
+ JsonUtility.FromJsonOverwrite(json, target);
158
+ if (ignoredFields.Length <= 0 || preservedValues == null) return;
159
+
160
+ for (var i = 0; i < ignoredFields.Length; i++)
161
+ {
162
+ ignoredFields[i].SetValue(target, preservedValues[i]);
163
+ }
164
+ }
165
+
166
+ private static FieldInfo[] GetSnapshotIgnoreFields(Type type)
167
+ {
168
+ if (snapshotIgnoreFieldsCache.TryGetValue(type, out var fields))
169
+ {
170
+ return fields;
171
+ }
172
+
173
+ var list = new List<FieldInfo>();
174
+ var current = type;
175
+ while (current != null && current != typeof(ScriptableObject) && current != typeof(Object) && current != typeof(object))
176
+ {
177
+ var currentFields =
178
+ current.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly);
179
+ foreach (var field in currentFields)
180
+ {
181
+ if (Attribute.IsDefined(field, typeof(SnapshotIgnoreAttribute)))
182
+ {
183
+ list.Add(field);
184
+ }
185
+ }
186
+
187
+ current = current.BaseType;
188
+ }
189
+
190
+ var result = list.Count > 0 ? list.ToArray() : Array.Empty<FieldInfo>();
191
+ snapshotIgnoreFieldsCache[type] = result;
192
+ return result;
193
+ }
194
+
195
+ /// Checks whether a baseline snapshot currently exists for the specified ScriptableObject.
196
+ public static bool HasSnapshot(ScriptableObject target) => target && snapshots.ContainsKey(target.GetEntityId());
197
+
198
+ /// Gets the raw JSON snapshot string for debugging / state inspector inspection.
199
+ public static string GetSnapshotJson(ScriptableObject target)
200
+ {
201
+ if (target && snapshots.TryGetValue(target.GetEntityId(), out var json))
202
+ {
203
+ return json;
204
+ }
205
+
206
+ return null;
207
+ }
208
+
209
+ /// Evicts a specific ScriptableObject snapshot from memory.
210
+ public static void Evict(ScriptableObject target)
211
+ {
212
+ if (target == null) return;
213
+ var entityId = target.GetEntityId();
214
+ snapshots.Remove(entityId);
215
+ restoringEntities.Remove(entityId);
216
+ #if UNITY_EDITOR
217
+ trackedTargets.Remove(entityId);
218
+ #endif
219
+ }
220
+
221
+ /// Clears all stored state snapshots.
222
+ public static void ClearAll()
223
+ {
224
+ snapshots.Clear();
225
+ restoringEntities.Clear();
226
+ #if UNITY_EDITOR
227
+ trackedTargets.Clear();
228
+ #endif
229
+ }
230
+
231
+ /// Alias for ClearAll to provide clear naming.
232
+ public static void ClearAllSnapshots() => ClearAll();
233
+
234
+ /// Automatically called by Unity when Enter Play Mode Options (Disable Domain Reload) is active,
235
+ /// or when the game initializes.
236
+ /// SubsystemRegistration executes before any Awake/OnEnable calls when entering Play Mode,
237
+ /// ensuring static state dictionaries from previous play sessions or editor runs are completely purged.
238
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
239
+ private static void ResetStatics() => ClearAll();
240
+
241
+ #if UNITY_EDITOR
242
+ [InitializeOnLoadMethod]
243
+ private static void InitEditorLifecycle()
244
+ {
245
+ EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
246
+ EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
247
+ }
248
+
249
+ private static void OnPlayModeStateChanged(PlayModeStateChange change)
250
+ {
251
+ // When exiting Play Mode, restore before scene objects begin tearing down
252
+ if (change == PlayModeStateChange.ExitingPlayMode)
253
+ {
254
+ RestoreAll();
255
+ return;
256
+ }
257
+
258
+ // Once fully transitioned back to Edit Mode, all scene unload and OnDisable/OnDestroy
259
+ // lifecycle calls have finished. Re-run RestoreAll() to guarantee any mutations occurring
260
+ // during teardown are cleanly reverted, then clear the snapshot tracking.
261
+ if (change == PlayModeStateChange.EnteredEditMode)
262
+ {
263
+ var count = RestoreAll();
264
+ if (count > 0)
265
+ {
266
+ Debug.Log($"[StateSnapshotService] Restored {count} stateful ScriptableObjects back to pre-play baseline.");
267
+ }
268
+
269
+ ClearAll();
270
+ }
271
+ }
272
+ #endif
273
+ }
274
+ }
@@ -0,0 +1,3 @@
1
+ fileFormatVersion: 2
2
+ guid: ca7bd4657354461e804f3287821da35c
3
+ timeCreated: 1788207914
@@ -0,0 +1,195 @@
1
+ using NUnit.Framework;
2
+ using UnityEngine;
3
+
4
+ namespace Xprees.Core.Tests
5
+ {
6
+ public class StateSnapshotServiceTests
7
+ {
8
+ [StatefulLifetime(StateLifetime.Scenario)]
9
+ private class TestStatefulSO : ScriptableObject, IRuntimeStateOwner
10
+ {
11
+ public int intValue = 10;
12
+ public string stringValue = "initial";
13
+ public bool transientFlag = false;
14
+
15
+ public void ClearTransientState()
16
+ {
17
+ transientFlag = false;
18
+ }
19
+ }
20
+
21
+ [StatefulLifetime(StateLifetime.Scenario)]
22
+ private class TestSnapshotIgnoreSO : ScriptableObject
23
+ {
24
+ public int capturedValue = 10;
25
+ [SnapshotIgnore] public string ignoredValue = "initial";
26
+ }
27
+
28
+ private class TestPlainSO : ScriptableObject
29
+ {
30
+ public int count = 5;
31
+ }
32
+
33
+ [StatelessAsset]
34
+ private class TestStatelessSO : ScriptableObject
35
+ {
36
+ public int count = 5;
37
+ }
38
+
39
+ [Stateless]
40
+ private class TestStatelessAliasSO : ScriptableObject
41
+ {
42
+ public int count = 5;
43
+ }
44
+
45
+ [StatefulLifetime(StateLifetime.Persistent)]
46
+ private class TestPersistentSO : ScriptableObject
47
+ {
48
+ public int score = 100;
49
+ }
50
+
51
+ [SetUp]
52
+ public void Setup()
53
+ {
54
+ StateSnapshotService.ClearAllSnapshots();
55
+ }
56
+
57
+ [TearDown]
58
+ public void TearDown()
59
+ {
60
+ StateSnapshotService.ClearAllSnapshots();
61
+ }
62
+
63
+ [Test]
64
+ public void EnsureCaptured_And_Restore_RevertsMutatedState()
65
+ {
66
+ var so = ScriptableObject.CreateInstance<TestStatefulSO>();
67
+ so.intValue = 42;
68
+ so.stringValue = "pristine";
69
+
70
+ StateSnapshotService.EnsureCaptured(so);
71
+ Assert.IsTrue(StateSnapshotService.HasSnapshot(so));
72
+
73
+ // Mutate
74
+ so.intValue = 999;
75
+ so.stringValue = "corrupted";
76
+
77
+ // Restore
78
+ StateSnapshotService.Restore(so);
79
+
80
+ Assert.AreEqual(42, so.intValue);
81
+ Assert.AreEqual("pristine", so.stringValue);
82
+
83
+ Object.DestroyImmediate(so);
84
+ }
85
+
86
+ [Test]
87
+ public void SnapshotIgnore_PreservesFieldValueAcrossRestore()
88
+ {
89
+ var so = ScriptableObject.CreateInstance<TestSnapshotIgnoreSO>();
90
+ so.capturedValue = 10;
91
+ so.ignoredValue = "before_capture";
92
+
93
+ StateSnapshotService.EnsureCaptured(so);
94
+
95
+ // Mutate both
96
+ so.capturedValue = 99;
97
+ so.ignoredValue = "mutated_after_capture";
98
+
99
+ StateSnapshotService.Restore(so);
100
+
101
+ // capturedValue should be restored to baseline (10)
102
+ Assert.AreEqual(10, so.capturedValue);
103
+ // ignoredValue was excluded from overwrite, so it retains its mutated value ("mutated_after_capture")
104
+ Assert.AreEqual("mutated_after_capture", so.ignoredValue);
105
+
106
+ Object.DestroyImmediate(so);
107
+ }
108
+
109
+ [Test]
110
+ public void RestoreAll_RestoresAllTrackedTargets()
111
+ {
112
+ var so1 = ScriptableObject.CreateInstance<TestStatefulSO>();
113
+ so1.intValue = 10;
114
+ so1.stringValue = "first";
115
+
116
+ var so2 = ScriptableObject.CreateInstance<TestStatefulSO>();
117
+ so2.intValue = 20;
118
+ so2.stringValue = "second";
119
+
120
+ StateSnapshotService.EnsureCaptured(so1);
121
+ StateSnapshotService.EnsureCaptured(so2);
122
+
123
+ // Mutate both
124
+ so1.intValue = 111;
125
+ so2.intValue = 222;
126
+
127
+ // RestoreAll
128
+ var restored = StateSnapshotService.RestoreAll();
129
+ Assert.AreEqual(2, restored);
130
+ Assert.AreEqual(10, so1.intValue);
131
+ Assert.AreEqual(20, so2.intValue);
132
+
133
+ Object.DestroyImmediate(so1);
134
+ Object.DestroyImmediate(so2);
135
+ }
136
+
137
+ [Test]
138
+ public void PlainScriptableObject_WithoutAttributeOrDescriptionBaseSO_IsStatelessAndIgnored()
139
+ {
140
+ var so = ScriptableObject.CreateInstance<TestPlainSO>();
141
+ Assert.IsTrue(so.IsStateless());
142
+ Assert.AreEqual(StateLifetime.Persistent, so.GetStateLifetime());
143
+ Assert.IsFalse(StateSnapshotService.EnsureCaptured(so));
144
+ Object.DestroyImmediate(so);
145
+ }
146
+
147
+ [Test]
148
+ public void StatelessAsset_IsIgnoredBySnapshotEngine()
149
+ {
150
+ var so = ScriptableObject.CreateInstance<TestStatelessSO>();
151
+ so.count = 50;
152
+
153
+ StateSnapshotService.EnsureCaptured(so);
154
+ Assert.IsFalse(StateSnapshotService.HasSnapshot(so));
155
+
156
+ Object.DestroyImmediate(so);
157
+ }
158
+
159
+ [Test]
160
+ public void StatelessAttribute_IsIgnoredBySnapshotEngine()
161
+ {
162
+ var so = ScriptableObject.CreateInstance<TestStatelessAliasSO>();
163
+ so.count = 50;
164
+
165
+ StateSnapshotService.EnsureCaptured(so);
166
+ Assert.IsFalse(StateSnapshotService.HasSnapshot(so));
167
+
168
+ Object.DestroyImmediate(so);
169
+ }
170
+
171
+ [Test]
172
+ public void PersistentLifetime_IsIgnoredBySnapshotEngine()
173
+ {
174
+ var so = ScriptableObject.CreateInstance<TestPersistentSO>();
175
+ so.score = 200;
176
+
177
+ StateSnapshotService.EnsureCaptured(so);
178
+ Assert.IsFalse(StateSnapshotService.HasSnapshot(so));
179
+
180
+ Object.DestroyImmediate(so);
181
+ }
182
+
183
+ [Test]
184
+ public void ClearTransientState_ResetsNonSerializedFlags()
185
+ {
186
+ var so = ScriptableObject.CreateInstance<TestStatefulSO>();
187
+ so.transientFlag = true;
188
+
189
+ so.ClearTransientState();
190
+ Assert.IsFalse(so.transientFlag);
191
+
192
+ Object.DestroyImmediate(so);
193
+ }
194
+ }
195
+ }
@@ -0,0 +1,3 @@
1
+ fileFormatVersion: 2
2
+ guid: 372c79cab401438cbcb3acb2d207e388
3
+ timeCreated: 1788208202
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "cz.xprees.core.Tests.Editor",
3
+ "rootNamespace": "Xprees.Core.Tests",
4
+ "references": [
5
+ "cz.xprees.core",
6
+ "UnityEngine.TestRunner",
7
+ "UnityEditor.TestRunner"
8
+ ],
9
+ "includePlatforms": [
10
+ "Editor"
11
+ ],
12
+ "excludePlatforms": [],
13
+ "allowUnsafeCode": false,
14
+ "overrideReferences": true,
15
+ "precompiledReferences": [
16
+ "nunit.framework.dll"
17
+ ],
18
+ "autoReferenced": false,
19
+ "defineConstraints": [
20
+ "UNITY_INCLUDE_TESTS"
21
+ ],
22
+ "versionDefines": [],
23
+ "noEngineReferences": false
24
+ }
@@ -0,0 +1,3 @@
1
+ fileFormatVersion: 2
2
+ guid: 0e8276ba88644ade9d2e1b915a633ef5
3
+ timeCreated: 1788208198
@@ -0,0 +1,3 @@
1
+ fileFormatVersion: 2
2
+ guid: a6549e6501ce4edf9760028c767f654a
3
+ timeCreated: 1788208198
package/Tests.meta ADDED
@@ -0,0 +1,3 @@
1
+ fileFormatVersion: 2
2
+ guid: 0f91d1411ac146c38a51780ec1158bea
3
+ timeCreated: 1788208198
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cz.xprees.core",
3
3
  "displayName": "Core Classes",
4
- "version": "1.0.34",
4
+ "version": "1.0.39",
5
5
  "unity": "2021.3",
6
6
  "description": "This package contains core classes and interfaces for Unity projects. It is intended to be used as a dependency for other packages. By providing base classes in a separate package, you can easily update them across all your projects.",
7
7
  "license": "Apache-2.0",