cz.xprees.core 1.0.34 → 1.0.40

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.
Files changed (31) hide show
  1. package/Editor/DescriptionTextAreaDrawer.cs +82 -0
  2. package/Editor/DescriptionTextAreaDrawer.cs.meta +2 -0
  3. package/Editor/StatePlayModeLifecycleHook.cs +78 -0
  4. package/Editor/StatePlayModeLifecycleHook.cs.meta +3 -0
  5. package/Editor/cz.xprees.core.editor.asmdef +18 -0
  6. package/Editor/cz.xprees.core.editor.asmdef.meta +3 -0
  7. package/Editor.meta +3 -0
  8. package/README.md +80 -7
  9. package/Runtime/DescriptionAttribute/DescriptionTextAreaAttribute.cs +18 -0
  10. package/Runtime/DescriptionAttribute/DescriptionTextAreaAttribute.cs.meta +2 -0
  11. package/Runtime/DescriptionAttribute.meta +3 -0
  12. package/Runtime/DescriptionBaseSO.cs +15 -1
  13. package/Runtime/IRuntimeStateOwner.cs +11 -0
  14. package/Runtime/IRuntimeStateOwner.cs.meta +3 -0
  15. package/Runtime/PlayModeStateTracker.cs +33 -0
  16. package/Runtime/PlayModeStateTracker.cs.meta +3 -0
  17. package/Runtime/ResetOnPlayModeAttribute.cs +33 -0
  18. package/Runtime/ResetOnPlayModeAttribute.cs.meta +3 -0
  19. package/Runtime/StateLifetime.cs +55 -0
  20. package/Runtime/StateLifetime.cs.meta +3 -0
  21. package/Runtime/StateLifetimeExtensions.cs +91 -0
  22. package/Runtime/StateLifetimeExtensions.cs.meta +3 -0
  23. package/Runtime/StateSnapshotService.cs +270 -0
  24. package/Runtime/StateSnapshotService.cs.meta +3 -0
  25. package/Tests/Editor/StateSnapshotServiceTests.cs +195 -0
  26. package/Tests/Editor/StateSnapshotServiceTests.cs.meta +3 -0
  27. package/Tests/Editor/cz.xprees.core.Tests.Editor.asmdef +24 -0
  28. package/Tests/Editor/cz.xprees.core.Tests.Editor.asmdef.meta +3 -0
  29. package/Tests/Editor.meta +3 -0
  30. package/Tests.meta +3 -0
  31. package/package.json +1 -1
@@ -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,78 @@
1
+ using System;
2
+ using System.Collections.Concurrent;
3
+ using System.Reflection;
4
+ using UnityEditor;
5
+ using UnityEngine;
6
+
7
+ namespace Xprees.Core.Editor
8
+ {
9
+ /// Centralized Editor runner that coordinates Play Mode entry and exit transitions for stateful ScriptableObjects.
10
+ /// Resets objects marked with [ResetOnPlayMode] and restores mutated assets upon exiting Play Mode.
11
+ /// Can be migrated to future Unity lifecycle hooks in future versions.
12
+ [InitializeOnLoad]
13
+ public static class StatePlayModeLifecycleHook
14
+ {
15
+ private readonly static ConcurrentDictionary<Type, PlayModeResetTiming> timingCache = new();
16
+
17
+ static StatePlayModeLifecycleHook()
18
+ {
19
+ EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
20
+ EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
21
+ }
22
+
23
+ private static void OnPlayModeStateChanged(PlayModeStateChange change)
24
+ {
25
+ switch (change)
26
+ {
27
+ case PlayModeStateChange.EnteredPlayMode:
28
+ ResetPlayModeObjects(PlayModeResetTiming.EnterPlayMode);
29
+ break;
30
+
31
+ case PlayModeStateChange.ExitingPlayMode:
32
+ // Automatically revert all mutated ScriptableObjects to baseline before returning to Edit Mode
33
+ StateSnapshotService.RestoreAll();
34
+ ResetPlayModeObjects(PlayModeResetTiming.ExitPlayMode);
35
+ break;
36
+
37
+ case PlayModeStateChange.EnteredEditMode:
38
+ StateSnapshotService.ClearAll();
39
+ break;
40
+ }
41
+ }
42
+
43
+ /// Resets loaded ScriptableObjects decorated with [ResetOnPlayMode] matching the specified timing.
44
+ /// Respects StateLifetime (skips StateLifetime.Persistent).
45
+ public static void ResetPlayModeObjects(PlayModeResetTiming timing)
46
+ {
47
+ var loadedObjects = Resources.FindObjectsOfTypeAll<ScriptableObject>();
48
+ foreach (var so in loadedObjects)
49
+ {
50
+ if (!so || so.IsStateless() || so.GetStateLifetime() == StateLifetime.Persistent) continue;
51
+
52
+ var type = so.GetType();
53
+ var configuredTiming = GetPlayModeTiming(type);
54
+ if ((configuredTiming & timing) == 0) continue;
55
+
56
+ var resetMethod = type.GetMethod("ResetToDefault", BindingFlags.Public | BindingFlags.Instance);
57
+ if (resetMethod != null)
58
+ {
59
+ resetMethod.Invoke(so, null);
60
+ }
61
+ else if (so is IRuntimeStateOwner stateOwner)
62
+ {
63
+ stateOwner.ClearTransientState();
64
+ }
65
+ }
66
+ }
67
+
68
+ public static PlayModeResetTiming GetPlayModeTiming(Type type)
69
+ {
70
+ if (timingCache.TryGetValue(type, out var timing)) return timing;
71
+
72
+ var attr = type.GetCustomAttribute<ResetOnPlayModeAttribute>(true);
73
+ timing = attr?.Timing ?? PlayModeResetTiming.None;
74
+ timingCache[type] = timing;
75
+ return timing;
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,3 @@
1
+ fileFormatVersion: 2
2
+ guid: fc5682634fa047ffba504454cebd6951
3
+ timeCreated: 1789122780
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "cz.xprees.core.editor",
3
+ "rootNamespace": "Xprees.Core.Editor",
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
package/README.md CHANGED
@@ -1,15 +1,12 @@
1
- # Unity Core classes
1
+ # Unity Core classes - cz.xprees.core
2
2
 
3
3
  [![NPM Version](https://img.shields.io/npm/v/cz.xprees.core)](https://www.npmjs.com/package/cz.xprees.core)
4
4
 
5
-
6
- This is base package containing a few common classes for all of my Unity projects.
5
+ Base package containing core systems, state lifecycle orchestration, and ScriptableObject utilities for Unity projects.
7
6
 
8
7
  ## Installation
9
8
 
10
- Install the package using npm scoped registry in `Project Settings > Package Manager > Scoped Registries`
11
-
12
- [Unity Docs - Install a UPM package from a Git URL](https://docs.unity3d.com/6000.1/Documentation/Manual/upm-ui-giturl.html)
9
+ Install the package using npm scoped registry in `Project Settings > Package Manager > Scoped Registries`:
13
10
 
14
11
  ```json
15
12
  {
@@ -19,7 +16,83 @@ Install the package using npm scoped registry in `Project Settings > Package Man
19
16
  "cz.xprees"
20
17
  ]
21
18
  }
19
+ ```
20
+
21
+ Then install `cz.xprees.core` via the Unity Package Manager.
22
+
23
+ ---
24
+
25
+ ## State Management & Lifecycle Architecture
26
+
27
+ The package provides automated, zero-boilerplate state snapshotting and restoration for ScriptableObjects across scenario boundaries and Editor play
28
+ mode transitions.
29
+
30
+ ### 1. State Lifetime (`StateLifetime`)
31
+
32
+ Every stateful ScriptableObject inherits from `DescriptionBaseSO` or declares its lifecycle scope via `[StatefulLifetime]`:
33
+
34
+ - **`StateLifetime.Scenario` (Default)**:
35
+ Scoped strictly to the active scenario. Captured before mutations and automatically restored to its baseline on scenario start/restart.
36
+ - **`StateLifetime.Session`**:
37
+ Persists across scenarios throughout a play session. Restored only on game/session boundaries or play mode exit.
38
+ - **`StateLifetime.Persistent`**:
39
+ Excluded from state snapshots. Ideal for immutable databases, user settings, or content saved externally to disk.
40
+
41
+ ```csharp
42
+ // Per-instance: Set the "Lifetime" dropdown in the Inspector.
43
+ // Per-class: Override via attribute:
44
+ [StatefulLifetime(StateLifetime.Persistent)]
45
+ public class GlobalPlayerConfigSO : DescriptionBaseSO { }
46
+ ```
47
+
48
+ ### 2. State Snapshot Service (`StateSnapshotService`)
49
+
50
+ A centralized, pure runtime service that handles:
51
+
52
+ - **`EnsureCaptured(ScriptableObject target)`**: Serializes the baseline JSON state prior to the first mutation.
53
+ - **`Restore(ScriptableObject target)`**: Reverts serialized fields in-place from baseline JSON and triggers
54
+ `IRuntimeStateOwner.ClearTransientState()`.
55
+ - **`RestoreAll(IEnumerable<ScriptableObject> targets, StateLifetime maxLifetime)`**: Multi-pass restoration that ensures all targets are
56
+ baseline-captured before restoring, preventing inter-object dependency races.
57
+ - **`[SnapshotIgnore]`**: Decorate fields to preserve their values across snapshot restorations.
22
58
 
59
+ ### 3. Editor PlayMode Lifecycle (`StatePlayModeLifecycleHook`)
60
+
61
+ Located in `cz.xprees.core.editor`, this centralized runner hooks into `EditorApplication.playModeStateChanged`:
62
+
63
+ - **`EnteredPlayMode`**: Resets stateful objects decorated with `[ResetOnPlayMode(PlayModeResetTiming.EnterPlayMode)]` (skips `Persistent` objects).
64
+ - **`ExitingPlayMode`**: Restores all mutated ScriptableObjects in-place back to their baseline via `StateSnapshotService.RestoreAll()` and clears
65
+ editor dirty flags before returning to edit mode.
66
+ - **`EnteredEditMode`**: Purges snapshot dictionaries.
67
+
68
+ ### 4. Play Mode Reset Attribute (`ResetOnPlayModeAttribute`)
69
+
70
+ Control Play Mode transition resets declaratively on classes:
71
+
72
+ ```csharp
73
+ [Flags]
74
+ public enum PlayModeResetTiming
75
+ {
76
+ None = 0,
77
+ EnterPlayMode = 1 << 0,
78
+ ExitPlayMode = 1 << 1,
79
+ Both = EnterPlayMode | ExitPlayMode
80
+ }
81
+
82
+ // Example: opt-out of play mode reset
83
+ [ResetOnPlayMode(PlayModeResetTiming.None)]
84
+ public class PersistentSettingsSO : ScriptableObject { }
85
+ ```
86
+
87
+ ### 5. Runtime State Ownership (`IRuntimeStateOwner`)
88
+
89
+ Implement `IRuntimeStateOwner` on objects with non-serialized runtime state (active handles, listeners, temporary collections):
90
+
91
+ ```csharp
92
+ public interface IRuntimeStateOwner
93
+ {
94
+ void ClearTransientState();
95
+ }
23
96
  ```
24
97
 
25
- Then simply install the package using the Unity Package Manager using the _NPM - xprees_ scope or by the package name `cz.xprees.core`.
98
+ Invoked automatically after serialized fields are restored by `StateSnapshotService`.
@@ -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,33 @@
1
+ using System;
2
+
3
+ namespace Xprees.Core
4
+ {
5
+ [Flags]
6
+ public enum PlayModeResetTiming
7
+ {
8
+ /// No reset will occur on Play Mode transitions
9
+ None = 0,
10
+
11
+ /// Reset state when entering Play Mode
12
+ EnterPlayMode = 1 << 0,
13
+
14
+ /// Reset state when exiting Play Mode
15
+ ExitPlayMode = 1 << 1,
16
+
17
+ /// Reset state on both entering and exiting Play Mode
18
+ Both = EnterPlayMode | ExitPlayMode,
19
+ }
20
+
21
+ /// Controls whether and when a stateful ScriptableObject (such as a Variable) resets to its authored default value during Editor Play Mode transitions.
22
+ /// Hooked and executed centrally by StateSnapshotService.
23
+ [AttributeUsage(AttributeTargets.Class, Inherited = true)]
24
+ public sealed class ResetOnPlayModeAttribute : Attribute
25
+ {
26
+ public PlayModeResetTiming Timing { get; }
27
+
28
+ public ResetOnPlayModeAttribute(PlayModeResetTiming timing = PlayModeResetTiming.EnterPlayMode)
29
+ {
30
+ Timing = timing;
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,3 @@
1
+ fileFormatVersion: 2
2
+ guid: 83876b95fdaf4120a761253103ef2bbc
3
+ timeCreated: 1789121968
@@ -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,270 @@
1
+ using System;
2
+ using System.Collections.Concurrent;
3
+ using System.Collections.Generic;
4
+ using System.Linq;
5
+ using System.Reflection;
6
+ using UnityEngine;
7
+ using Object = UnityEngine.Object;
8
+ #if UNITY_EDITOR
9
+ using UnityEditor;
10
+ #endif
11
+
12
+ namespace Xprees.Core
13
+ {
14
+ // TODO later consider adding limits to not blow up memory - probably not an issue
15
+ // TODO consider using OdinSerializer for more robust serialization in future
16
+
17
+ /// Core engine service for zero-boilerplate capturing and in-place restoration
18
+ /// of ScriptableObject serialized state across scenario and session boundaries.
19
+ public static class StateSnapshotService
20
+ {
21
+ private readonly static Dictionary<int, string> snapshots = new();
22
+ private readonly static HashSet<int> restoringEntities = new();
23
+ private readonly static ConcurrentDictionary<Type, FieldInfo[]> snapshotIgnoreFieldsCache = new();
24
+ #if UNITY_EDITOR
25
+ private readonly static Dictionary<int, ScriptableObject> trackedTargets = new();
26
+ #endif
27
+
28
+ /// Total number of currently tracked baseline snapshots in memory.
29
+ public static int SnapshotCount => snapshots.Count;
30
+
31
+ /// Captures the baseline-serialized state of the ScriptableObject if not already captured.
32
+ public static bool EnsureCaptured(ScriptableObject target)
33
+ {
34
+ if (!target || target.IsStateless() || target.GetStateLifetime() == StateLifetime.Persistent)
35
+ {
36
+ return false;
37
+ }
38
+
39
+ var entityId = target.GetEntityId();
40
+ if (snapshots.ContainsKey(entityId))
41
+ {
42
+ return false;
43
+ }
44
+
45
+ try
46
+ {
47
+ var json = JsonUtility.ToJson(target);
48
+ snapshots[entityId] = json;
49
+ #if UNITY_EDITOR
50
+ trackedTargets[entityId] = target;
51
+ #endif
52
+ return true;
53
+ }
54
+ catch (Exception ex)
55
+ {
56
+ Debug.LogError($"[StateSnapshotService] Failed to capture snapshot for '{target.name}' ({target.GetType().Name}): {ex.Message}",
57
+ target);
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /// Restores the ScriptableObject to its captured baseline state in-place.
63
+ /// If no baseline has been captured yet, the current state is captured as baseline.
64
+ public static bool Restore(ScriptableObject target)
65
+ {
66
+ if (!target || target.IsStateless() || target.GetStateLifetime() == StateLifetime.Persistent)
67
+ {
68
+ return false;
69
+ }
70
+
71
+ var entityId = target.GetEntityId();
72
+ if (!snapshots.TryGetValue(entityId, out var json))
73
+ {
74
+ EnsureCaptured(target);
75
+ return false;
76
+ }
77
+
78
+ // Re-entrancy guard to prevent infinite mutual recursion (e.g. scenario resetting itself)
79
+ if (!restoringEntities.Add(entityId)) return false;
80
+
81
+ try
82
+ {
83
+ RestoreFromJson(json, target);
84
+
85
+ if (target is IRuntimeStateOwner stateOwner)
86
+ {
87
+ stateOwner.ClearTransientState();
88
+ }
89
+
90
+ return true;
91
+ }
92
+ catch (Exception ex)
93
+ {
94
+ Debug.LogError($"[StateSnapshotService] Failed to restore snapshot for '{target.name}' ({target.GetType().Name}): {ex.Message}",
95
+ target);
96
+ return false;
97
+ }
98
+ finally
99
+ {
100
+ restoringEntities.Remove(entityId);
101
+ }
102
+ }
103
+
104
+ /// Restores a collection of ScriptableObjects to their baseline state up to the specified max lifetime.
105
+ /// Multi-pass restoration:
106
+ /// Pass 1 captures the baseline for any uncaptured objects before modifying state.
107
+ /// Pass 2 restores serialized fields and triggers IRuntimeStateOwner.ClearTransientState.
108
+ public static void RestoreAll(IEnumerable<ScriptableObject> targets, StateLifetime maxLifetime = StateLifetime.Scenario)
109
+ {
110
+ if (targets == null) return;
111
+
112
+ var targetsList = targets as ScriptableObject[] ?? targets.ToArray();
113
+
114
+ // Pass 1: Ensure baseline is captured for ALL objects before any object is restored
115
+ foreach (var so in targetsList)
116
+ {
117
+ if (!so || so.IsStateless() || so.GetStateLifetime() > maxLifetime) continue;
118
+
119
+ EnsureCaptured(so);
120
+ }
121
+
122
+ // Pass 2: Restore all objects to baseline (and clear transient state via Restore)
123
+ foreach (var so in targetsList)
124
+ {
125
+ if (!so || so.IsStateless() || so.GetStateLifetime() > maxLifetime) continue;
126
+
127
+ Restore(so);
128
+ }
129
+ }
130
+
131
+ /// Restores all currently tracked ScriptableObjects to their baseline state.
132
+ public static int RestoreAll()
133
+ {
134
+ var restoredCount = 0;
135
+ #if UNITY_EDITOR
136
+ foreach (var kvp in trackedTargets)
137
+ {
138
+ var target = kvp.Value;
139
+ if (!target)
140
+ {
141
+ target = EditorUtility.EntityIdToObject(kvp.Key) as ScriptableObject;
142
+ }
143
+
144
+ if (!target || !snapshots.TryGetValue(kvp.Key, out var json)) continue;
145
+ if (!restoringEntities.Add(kvp.Key)) continue;
146
+
147
+ try
148
+ {
149
+ RestoreFromJson(json, target);
150
+
151
+ if (target is IRuntimeStateOwner stateOwner)
152
+ {
153
+ stateOwner.ClearTransientState();
154
+ }
155
+
156
+ EditorUtility.ClearDirty(target);
157
+ restoredCount++;
158
+ }
159
+ catch (Exception ex)
160
+ {
161
+ Debug.LogError($"[StateSnapshotService] Failed to restore target '{target.name}': {ex.Message}", target);
162
+ }
163
+ finally
164
+ {
165
+ restoringEntities.Remove(kvp.Key);
166
+ }
167
+ }
168
+ #endif
169
+ return restoredCount;
170
+ }
171
+
172
+ /// Restores serialized fields from JSON while preserving fields marked with [SnapshotIgnore].
173
+ private static void RestoreFromJson(string json, ScriptableObject target)
174
+ {
175
+ var ignoredFields = GetSnapshotIgnoreFields(target.GetType());
176
+ object[] preservedValues = null;
177
+ if (ignoredFields.Length > 0)
178
+ {
179
+ preservedValues = new object[ignoredFields.Length];
180
+ for (var i = 0; i < ignoredFields.Length; i++)
181
+ {
182
+ preservedValues[i] = ignoredFields[i].GetValue(target);
183
+ }
184
+ }
185
+
186
+ JsonUtility.FromJsonOverwrite(json, target);
187
+ if (ignoredFields.Length <= 0 || preservedValues == null) return;
188
+
189
+ for (var i = 0; i < ignoredFields.Length; i++)
190
+ {
191
+ ignoredFields[i].SetValue(target, preservedValues[i]);
192
+ }
193
+ }
194
+
195
+ private static FieldInfo[] GetSnapshotIgnoreFields(Type type)
196
+ {
197
+ if (snapshotIgnoreFieldsCache.TryGetValue(type, out var fields))
198
+ {
199
+ return fields;
200
+ }
201
+
202
+ var list = new List<FieldInfo>();
203
+ var current = type;
204
+ while (current != null && current != typeof(ScriptableObject) && current != typeof(Object) && current != typeof(object))
205
+ {
206
+ var currentFields =
207
+ current.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly);
208
+ foreach (var field in currentFields)
209
+ {
210
+ if (Attribute.IsDefined(field, typeof(SnapshotIgnoreAttribute)))
211
+ {
212
+ list.Add(field);
213
+ }
214
+ }
215
+
216
+ current = current.BaseType;
217
+ }
218
+
219
+ var result = list.Count > 0 ? list.ToArray() : Array.Empty<FieldInfo>();
220
+ snapshotIgnoreFieldsCache[type] = result;
221
+ return result;
222
+ }
223
+
224
+ /// Checks whether a baseline snapshot currently exists for the specified ScriptableObject.
225
+ public static bool HasSnapshot(ScriptableObject target) => target && snapshots.ContainsKey(target.GetEntityId());
226
+
227
+ /// Gets the raw JSON snapshot string for debugging / state inspector inspection.
228
+ public static string GetSnapshotJson(ScriptableObject target)
229
+ {
230
+ if (target && snapshots.TryGetValue(target.GetEntityId(), out var json))
231
+ {
232
+ return json;
233
+ }
234
+
235
+ return null;
236
+ }
237
+
238
+ /// Evicts a specific ScriptableObject snapshot from memory.
239
+ public static void Evict(ScriptableObject target)
240
+ {
241
+ if (target == null) return;
242
+ var entityId = target.GetEntityId();
243
+ snapshots.Remove(entityId);
244
+ restoringEntities.Remove(entityId);
245
+ #if UNITY_EDITOR
246
+ trackedTargets.Remove(entityId);
247
+ #endif
248
+ }
249
+
250
+ /// Clears all stored state snapshots.
251
+ public static void ClearAll()
252
+ {
253
+ snapshots.Clear();
254
+ restoringEntities.Clear();
255
+ #if UNITY_EDITOR
256
+ trackedTargets.Clear();
257
+ #endif
258
+ }
259
+
260
+ /// Alias for ClearAll to provide clear naming.
261
+ public static void ClearAllSnapshots() => ClearAll();
262
+
263
+ /// Automatically called by Unity when Enter Play Mode Options (Disable Domain Reload) is active,
264
+ /// or when the game initializes.
265
+ /// SubsystemRegistration executes before any Awake/OnEnable calls when entering Play Mode,
266
+ /// ensuring static state dictionaries from previous play sessions or editor runs are completely purged.
267
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
268
+ private static void ResetStaticFields() => ClearAll();
269
+ }
270
+ }
@@ -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.40",
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",