com.wallstop-studios.unity-helpers 2.0.4 → 2.1.1

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 (99) hide show
  1. package/Docs/DATA_STRUCTURES.md +7 -7
  2. package/Docs/EFFECTS_SYSTEM.md +836 -8
  3. package/Docs/EFFECTS_SYSTEM_TUTORIAL.md +77 -18
  4. package/Docs/HULLS.md +2 -2
  5. package/Docs/ILIST_SORTING_PERFORMANCE.md +92 -0
  6. package/Docs/ILIST_SORTING_PERFORMANCE.md.meta +7 -0
  7. package/Docs/INDEX.md +10 -1
  8. package/Docs/Images/random_generators.svg +7 -7
  9. package/Docs/RANDOM_PERFORMANCE.md +18 -15
  10. package/Docs/REFLECTION_HELPERS.md +1 -1
  11. package/Docs/RELATIONAL_COMPONENTS.md +51 -6
  12. package/Docs/SERIALIZATION.md +1 -1
  13. package/Docs/SINGLETONS.md +2 -2
  14. package/Docs/SPATIAL_TREES_2D_GUIDE.md +3 -3
  15. package/Docs/SPATIAL_TREES_3D_GUIDE.md +3 -3
  16. package/Docs/SPATIAL_TREE_2D_PERFORMANCE.md +64 -64
  17. package/Docs/SPATIAL_TREE_3D_PERFORMANCE.md +64 -64
  18. package/Docs/SPATIAL_TREE_SEMANTICS.md +7 -7
  19. package/Editor/Core/Helper/AnimationEventHelpers.cs +1 -1
  20. package/Editor/CustomDrawers/WShowIfPropertyDrawer.cs +131 -41
  21. package/Editor/Utils/ScriptableObjectSingletonCreator.cs +175 -18
  22. package/README.md +42 -18
  23. package/Runtime/Core/Extension/IListExtensions.cs +720 -12
  24. package/Runtime/Core/Helper/UnityMainThreadDispatcher.cs +2 -3
  25. package/Runtime/Core/Random/AbstractRandom.cs +52 -5
  26. package/Runtime/Core/Random/DotNetRandom.cs +3 -3
  27. package/Runtime/Core/Random/FlurryBurstRandom.cs +285 -0
  28. package/Runtime/Core/Random/FlurryBurstRandom.cs.meta +3 -0
  29. package/Runtime/Core/Random/IllusionFlow.cs +3 -3
  30. package/Runtime/Core/Random/LinearCongruentialGenerator.cs +3 -3
  31. package/Runtime/Core/Random/PcgRandom.cs +6 -6
  32. package/Runtime/Core/Random/PhotonSpinRandom.cs +387 -0
  33. package/Runtime/Core/Random/PhotonSpinRandom.cs.meta +3 -0
  34. package/Runtime/Core/Random/RomuDuo.cs +3 -3
  35. package/Runtime/Core/Random/SplitMix64.cs +3 -3
  36. package/Runtime/Core/Random/SquirrelRandom.cs +6 -4
  37. package/Runtime/Core/Random/StormDropRandom.cs +271 -0
  38. package/Runtime/Core/Random/StormDropRandom.cs.meta +3 -0
  39. package/Runtime/Core/Random/UnityRandom.cs +3 -3
  40. package/Runtime/Core/Random/WyRandom.cs +6 -4
  41. package/Runtime/Core/Random/XorShiftRandom.cs +3 -3
  42. package/Runtime/Core/Random/XoroShiroRandom.cs +3 -3
  43. package/Runtime/Tags/Attribute.cs +144 -24
  44. package/Runtime/Tags/AttributeEffect.cs +119 -16
  45. package/Runtime/Tags/AttributeMetadataCache.cs +312 -3
  46. package/Runtime/Tags/AttributeModification.cs +59 -29
  47. package/Runtime/Tags/AttributesComponent.cs +20 -0
  48. package/Runtime/Tags/EffectBehavior.cs +171 -0
  49. package/Runtime/Tags/EffectBehavior.cs.meta +4 -0
  50. package/Runtime/Tags/EffectHandle.cs +5 -0
  51. package/Runtime/Tags/EffectHandler.cs +385 -39
  52. package/Runtime/Tags/EffectStackKey.cs +79 -0
  53. package/Runtime/Tags/EffectStackKey.cs.meta +4 -0
  54. package/Runtime/Tags/PeriodicEffectDefinition.cs +102 -0
  55. package/Runtime/Tags/PeriodicEffectDefinition.cs.meta +4 -0
  56. package/Runtime/Tags/PeriodicEffectRuntimeState.cs +40 -0
  57. package/Runtime/Tags/PeriodicEffectRuntimeState.cs.meta +4 -0
  58. package/Samples~/DI - Zenject/README.md +0 -2
  59. package/Tests/Editor/Attributes/WShowIfPropertyDrawerTests.cs +285 -0
  60. package/Tests/Editor/Attributes/WShowIfPropertyDrawerTests.cs.meta +11 -0
  61. package/Tests/Editor/Core/Attributes/RelationalComponentAssignerTests.cs +2 -2
  62. package/Tests/Editor/Tags/AttributeMetadataCacheTests.cs +192 -0
  63. package/Tests/Editor/Tags/AttributeMetadataCacheTests.cs.meta +11 -0
  64. package/{node_modules.meta → Tests/Editor/Tags.meta} +1 -1
  65. package/Tests/Editor/Utils/ScriptableObjectSingletonTests.cs +41 -0
  66. package/Tests/Runtime/Extensions/IListExtensionTests.cs +187 -1
  67. package/Tests/Runtime/Helper/ObjectsTests.cs +3 -3
  68. package/Tests/Runtime/Integrations/Reflex/RelationalComponentsReflexTests.cs +2 -2
  69. package/Tests/Runtime/Performance/IListSortingPerformanceTests.cs +346 -0
  70. package/Tests/Runtime/Performance/IListSortingPerformanceTests.cs.meta +11 -0
  71. package/Tests/Runtime/Performance/RandomPerformanceTests.cs +3 -0
  72. package/Tests/Runtime/Random/FlurryBurstRandomTests.cs +12 -0
  73. package/Tests/Runtime/Random/FlurryBurstRandomTests.cs.meta +3 -0
  74. package/Tests/Runtime/Random/PhotonSpinRandomTests.cs +12 -0
  75. package/Tests/Runtime/Random/PhotonSpinRandomTests.cs.meta +3 -0
  76. package/Tests/Runtime/Random/RandomProtoSerializationTests.cs +14 -0
  77. package/Tests/Runtime/Random/RandomTestBase.cs +39 -4
  78. package/Tests/Runtime/Random/StormDropRandomTests.cs +12 -0
  79. package/Tests/Runtime/Random/StormDropRandomTests.cs.meta +3 -0
  80. package/Tests/Runtime/Serialization/JsonSerializationTest.cs +4 -3
  81. package/Tests/Runtime/Serialization/ProtoInterfaceResolutionEdgeTests.cs +2 -2
  82. package/Tests/Runtime/Serialization/ProtoRootRegistrationTests.cs +1 -1
  83. package/Tests/Runtime/Serialization/ProtoSerializeBehaviorTests.cs +1 -1
  84. package/Tests/Runtime/Tags/AttributeEffectTests.cs +135 -0
  85. package/Tests/Runtime/Tags/AttributeEffectTests.cs.meta +3 -0
  86. package/Tests/Runtime/Tags/AttributeModificationTests.cs +137 -0
  87. package/Tests/Runtime/Tags/AttributeTests.cs +192 -0
  88. package/Tests/Runtime/Tags/AttributeTests.cs.meta +3 -0
  89. package/Tests/Runtime/Tags/EffectBehaviorTests.cs +184 -0
  90. package/Tests/Runtime/Tags/EffectBehaviorTests.cs.meta +3 -0
  91. package/Tests/Runtime/Tags/EffectHandlerTests.cs +618 -0
  92. package/Tests/Runtime/Tags/Helpers/RecordingEffectBehavior.cs +89 -0
  93. package/Tests/Runtime/Tags/Helpers/RecordingEffectBehavior.cs.meta +4 -0
  94. package/Tests/Runtime/Tags/PeriodicEffectDefinitionSerializationTests.cs +92 -0
  95. package/Tests/Runtime/Tags/PeriodicEffectDefinitionSerializationTests.cs.meta +3 -0
  96. package/package.json +1 -1
  97. package/scripts/lint-doc-links.ps1 +156 -11
  98. package/Tests/Runtime/Tags/AttributeDataTests.cs +0 -312
  99. /package/Tests/Runtime/Tags/{AttributeDataTests.cs.meta → AttributeModificationTests.cs.meta} +0 -0
@@ -9,11 +9,61 @@ namespace WallstopStudios.UnityHelpers.Tags
9
9
  using Core.Extension;
10
10
  using Core.Helper;
11
11
  using UnityEngine;
12
+ using WallstopStudios.UnityHelpers.Core.Attributes;
12
13
  using WallstopStudios.UnityHelpers.Utils;
13
14
  #if ODIN_INSPECTOR
14
15
  using Sirenix.OdinInspector;
15
16
  #endif
16
17
 
18
+ /// <summary>
19
+ /// Determines which handles are considered the "same stack" when evaluating stacking policies.
20
+ /// </summary>
21
+ public enum EffectStackGroup
22
+ {
23
+ [Obsolete("Please use a valid EffectStackGroup instead.")]
24
+ None = 0,
25
+
26
+ /// <summary>
27
+ /// Uses the effect asset reference. Each ScriptableObject instance is its own group.
28
+ /// </summary>
29
+ Reference = 1,
30
+
31
+ /// <summary>
32
+ /// Uses a custom string key supplied via <see cref="AttributeEffect.stackGroupKey"/>.
33
+ /// Effects with matching keys share a stack regardless of asset reference.
34
+ /// </summary>
35
+ CustomKey = 2,
36
+ }
37
+
38
+ /// <summary>
39
+ /// Describes how additional applications of an effect interact with existing stacks.
40
+ /// </summary>
41
+ public enum EffectStackingMode
42
+ {
43
+ [Obsolete("Please use a valid EffectStackingMode instead.")]
44
+ None = 0,
45
+
46
+ /// <summary>
47
+ /// Always create a new stack (subject to optional stack limit).
48
+ /// </summary>
49
+ Stack = 1,
50
+
51
+ /// <summary>
52
+ /// Reuse the first existing stack and refresh duration if possible.
53
+ /// </summary>
54
+ Refresh = 2,
55
+
56
+ /// <summary>
57
+ /// Remove existing stacks sharing the same group before creating a new one.
58
+ /// </summary>
59
+ Replace = 3,
60
+
61
+ /// <summary>
62
+ /// Ignore new applications when a stack is already active.
63
+ /// </summary>
64
+ Ignore = 4,
65
+ }
66
+
17
67
  /// <summary>
18
68
  /// Reusable, data‑driven bundle of stat modifications, tags, and cosmetic feedback.
19
69
  /// Serves as the authoring unit for buffs, debuffs, and status effects.
@@ -76,22 +126,29 @@ namespace WallstopStudios.UnityHelpers.Tags
76
126
  /// </summary>
77
127
  public List<AttributeModification> modifications = new();
78
128
 
129
+ /// <summary>
130
+ /// Periodic modifier sets executed on a cadence while the effect remains active.
131
+ /// </summary>
132
+ public List<PeriodicEffectDefinition> periodicEffects = new();
133
+
79
134
  /// <summary>
80
135
  /// Specifies how long this effect should persist (Instant, Duration, or Infinite).
81
136
  /// </summary>
82
137
  public ModifierDurationType durationType = ModifierDurationType.Duration;
83
138
 
84
- #if ODIN_INSPECTOR
85
- [ShowIf("@durationType == ModifierDurationType.Duration")]
86
- #endif
87
139
  /// <summary>
88
140
  /// The duration in seconds for this effect. Only used when <see cref="durationType"/> is <see cref="ModifierDurationType.Duration"/>.
89
141
  /// </summary>
90
- public float duration;
91
-
92
142
  #if ODIN_INSPECTOR
93
143
  [ShowIf("@durationType == ModifierDurationType.Duration")]
144
+ #else
145
+ [WShowIf(
146
+ nameof(durationType),
147
+ expectedValues: new object[] { ModifierDurationType.Duration }
148
+ )]
94
149
  #endif
150
+ public float duration;
151
+
95
152
  /// <summary>
96
153
  /// If true, reapplying this effect while it's already active will reset the duration timer.
97
154
  /// Only used when <see cref="durationType"/> is <see cref="ModifierDurationType.Duration"/>.
@@ -100,6 +157,14 @@ namespace WallstopStudios.UnityHelpers.Tags
100
157
  /// A poison effect with resetDurationOnReapplication=true will restart its 5-second timer
101
158
  /// each time the poison is reapplied, preventing stacking but extending the effect.
102
159
  /// </example>
160
+ #if ODIN_INSPECTOR
161
+ [ShowIf("@durationType == ModifierDurationType.Duration")]
162
+ #else
163
+ [WShowIf(
164
+ nameof(durationType),
165
+ expectedValues: new object[] { ModifierDurationType.Duration }
166
+ )]
167
+ #endif
103
168
  public bool resetDurationOnReapplication;
104
169
 
105
170
  /// <summary>
@@ -112,6 +177,41 @@ namespace WallstopStudios.UnityHelpers.Tags
112
177
  /// </example>
113
178
  public List<string> effectTags = new();
114
179
 
180
+ /// <summary>
181
+ /// A list of cosmetic effect data that defines visual and audio feedback for this effect.
182
+ /// These are applied when the effect becomes active and removed when it expires.
183
+ /// </summary>
184
+ [JsonIgnore]
185
+ public List<CosmeticEffectData> cosmeticEffects = new();
186
+
187
+ /// <summary>
188
+ /// Custom behaviours instantiated per active handle.
189
+ /// </summary>
190
+ [JsonIgnore]
191
+ public List<EffectBehavior> behaviors = new();
192
+
193
+ /// <summary>
194
+ /// Determines how this effect groups stacks for stacking decisions.
195
+ /// </summary>
196
+ public EffectStackGroup stackGroup = EffectStackGroup.Reference;
197
+
198
+ /// <summary>
199
+ /// Optional stack key used when <see cref="stackGroup"/> is set to <see cref="EffectStackGroup.CustomKey"/>.
200
+ /// </summary>
201
+ public string stackGroupKey;
202
+
203
+ /// <summary>
204
+ /// Determines how successive applications interact with existing stacks for the same group.
205
+ /// </summary>
206
+ public EffectStackingMode stackingMode = EffectStackingMode.Refresh;
207
+
208
+ /// <summary>
209
+ /// Optional cap on simultaneous stacks when <see cref="stackingMode"/> is <see cref="EffectStackingMode.Stack"/>.
210
+ /// A value of 0 means unlimited stacks.
211
+ /// </summary>
212
+ [Min(0)]
213
+ public int maximumStacks;
214
+
115
215
  /// <summary>
116
216
  /// Determines whether this effect applies the specified tag.
117
217
  /// </summary>
@@ -272,17 +372,6 @@ namespace WallstopStudios.UnityHelpers.Tags
272
372
  return buffer;
273
373
  }
274
374
 
275
- /// <summary>
276
- /// A list of cosmetic effect data that defines visual and audio feedback for this effect.
277
- /// These are applied when the effect becomes active and removed when it expires.
278
- /// </summary>
279
- [JsonIgnore]
280
- public List<CosmeticEffectData> cosmeticEffects = new();
281
-
282
- private List<string> CosmeticEffectsForJson =>
283
- cosmeticEffects?.Select(cosmeticEffectData => cosmeticEffectData.name).ToList()
284
- ?? new List<string>(0);
285
-
286
375
  /// <summary>
287
376
  /// Converts this effect to a JSON string representation including all modifications, tags, and cosmetic effects.
288
377
  /// </summary>
@@ -300,6 +389,10 @@ namespace WallstopStudios.UnityHelpers.Tags
300
389
  }.ToJson();
301
390
  }
302
391
 
392
+ private List<string> CosmeticEffectsForJson =>
393
+ cosmeticEffects?.Select(cosmeticEffectData => cosmeticEffectData.name).ToList()
394
+ ?? new List<string>(0);
395
+
303
396
  private string BuildDescription()
304
397
  {
305
398
  if (modifications == null)
@@ -382,6 +475,16 @@ namespace WallstopStudios.UnityHelpers.Tags
382
475
  return descriptionBuilder.ToString();
383
476
  }
384
477
 
478
+ internal EffectStackKey GetStackKey()
479
+ {
480
+ if (stackGroup == EffectStackGroup.CustomKey && !string.IsNullOrEmpty(stackGroupKey))
481
+ {
482
+ return EffectStackKey.CreateCustom(stackGroupKey);
483
+ }
484
+
485
+ return EffectStackKey.CreateReference(this);
486
+ }
487
+
385
488
  /// <summary>
386
489
  /// Determines whether this effect is equal to another effect by comparing all fields.
387
490
  /// This is needed because deserialization creates new instances, so reference equality is insufficient.
@@ -484,9 +484,15 @@ namespace WallstopStudios.UnityHelpers.Tags
484
484
  RelationalTypeMetadata[] relationalTypeMetadata
485
485
  )
486
486
  {
487
- _allAttributeNames = allAttributeNames;
488
- _typeMetadata = typeMetadata;
489
- _relationalTypeMetadata = relationalTypeMetadata;
487
+ string[] normalizedAttributeNames = SortAttributeNames(allAttributeNames);
488
+ TypeFieldMetadata[] normalizedTypeMetadata = SortTypeMetadata(typeMetadata);
489
+ RelationalTypeMetadata[] normalizedRelationalMetadata = SortRelationalTypeMetadata(
490
+ relationalTypeMetadata
491
+ );
492
+
493
+ _allAttributeNames = normalizedAttributeNames;
494
+ _typeMetadata = normalizedTypeMetadata;
495
+ _relationalTypeMetadata = normalizedRelationalMetadata;
490
496
  _computedAllAttributeNames = null;
491
497
  _computedAllAttributeNamesIncludesTests = false;
492
498
  _typeFieldsLookup = null;
@@ -495,6 +501,309 @@ namespace WallstopStudios.UnityHelpers.Tags
495
501
  _elementTypeLookup = null;
496
502
  UnityEditor.EditorUtility.SetDirty(this);
497
503
  }
504
+
505
+ private static string[] SortAttributeNames(string[] attributeNames)
506
+ {
507
+ if (attributeNames == null || attributeNames.Length == 0)
508
+ {
509
+ return Array.Empty<string>();
510
+ }
511
+
512
+ string[] result = new string[attributeNames.Length];
513
+ Array.Copy(attributeNames, result, attributeNames.Length);
514
+ Array.Sort(result, StringComparer.Ordinal);
515
+ return result;
516
+ }
517
+
518
+ private static TypeFieldMetadata[] SortTypeMetadata(TypeFieldMetadata[] typeMetadata)
519
+ {
520
+ if (typeMetadata == null || typeMetadata.Length == 0)
521
+ {
522
+ return Array.Empty<TypeFieldMetadata>();
523
+ }
524
+
525
+ int nonNullCount = 0;
526
+ for (int i = 0; i < typeMetadata.Length; i++)
527
+ {
528
+ if (typeMetadata[i] != null)
529
+ {
530
+ nonNullCount++;
531
+ }
532
+ }
533
+
534
+ if (nonNullCount == 0)
535
+ {
536
+ return Array.Empty<TypeFieldMetadata>();
537
+ }
538
+
539
+ TypeFieldMetadata[] result = new TypeFieldMetadata[nonNullCount];
540
+ int resultIndex = 0;
541
+ for (int i = 0; i < typeMetadata.Length; i++)
542
+ {
543
+ TypeFieldMetadata metadata = typeMetadata[i];
544
+ if (metadata == null)
545
+ {
546
+ continue;
547
+ }
548
+
549
+ string typeName = metadata.typeName ?? string.Empty;
550
+ string[] fieldNames = metadata.fieldNames ?? Array.Empty<string>();
551
+ string[] sortedFieldNames =
552
+ fieldNames.Length == 0 ? Array.Empty<string>() : CopyAndSort(fieldNames);
553
+
554
+ result[resultIndex] = new TypeFieldMetadata(typeName, sortedFieldNames);
555
+ resultIndex++;
556
+ }
557
+
558
+ Array.Sort(result, CompareTypeFieldMetadata);
559
+ return result;
560
+ }
561
+
562
+ private static RelationalTypeMetadata[] SortRelationalTypeMetadata(
563
+ RelationalTypeMetadata[] relationalTypeMetadata
564
+ )
565
+ {
566
+ if (relationalTypeMetadata == null || relationalTypeMetadata.Length == 0)
567
+ {
568
+ return Array.Empty<RelationalTypeMetadata>();
569
+ }
570
+
571
+ int nonNullCount = 0;
572
+ for (int i = 0; i < relationalTypeMetadata.Length; i++)
573
+ {
574
+ if (relationalTypeMetadata[i] != null)
575
+ {
576
+ nonNullCount++;
577
+ }
578
+ }
579
+
580
+ if (nonNullCount == 0)
581
+ {
582
+ return Array.Empty<RelationalTypeMetadata>();
583
+ }
584
+
585
+ RelationalTypeMetadata[] result = new RelationalTypeMetadata[nonNullCount];
586
+ int resultIndex = 0;
587
+ for (int i = 0; i < relationalTypeMetadata.Length; i++)
588
+ {
589
+ RelationalTypeMetadata metadata = relationalTypeMetadata[i];
590
+ if (metadata == null)
591
+ {
592
+ continue;
593
+ }
594
+
595
+ string typeName = metadata.typeName ?? string.Empty;
596
+ RelationalFieldMetadata[] sortedFields = SortRelationalFields(metadata.fields);
597
+ result[resultIndex] = new RelationalTypeMetadata(typeName, sortedFields);
598
+ resultIndex++;
599
+ }
600
+
601
+ Array.Sort(result, CompareRelationalTypeMetadata);
602
+ return result;
603
+ }
604
+
605
+ private static RelationalFieldMetadata[] SortRelationalFields(
606
+ RelationalFieldMetadata[] relationalFields
607
+ )
608
+ {
609
+ if (relationalFields == null || relationalFields.Length == 0)
610
+ {
611
+ return Array.Empty<RelationalFieldMetadata>();
612
+ }
613
+
614
+ int nonNullCount = 0;
615
+ for (int i = 0; i < relationalFields.Length; i++)
616
+ {
617
+ if (relationalFields[i] != null)
618
+ {
619
+ nonNullCount++;
620
+ }
621
+ }
622
+
623
+ if (nonNullCount == 0)
624
+ {
625
+ return Array.Empty<RelationalFieldMetadata>();
626
+ }
627
+
628
+ RelationalFieldMetadata[] result = new RelationalFieldMetadata[nonNullCount];
629
+ int resultIndex = 0;
630
+ for (int i = 0; i < relationalFields.Length; i++)
631
+ {
632
+ RelationalFieldMetadata field = relationalFields[i];
633
+ if (field == null)
634
+ {
635
+ continue;
636
+ }
637
+
638
+ result[resultIndex] = new RelationalFieldMetadata(
639
+ field.fieldName,
640
+ field.attributeKind,
641
+ field.fieldKind,
642
+ field.elementTypeName,
643
+ field.isInterface
644
+ );
645
+ resultIndex++;
646
+ }
647
+
648
+ Array.Sort(result, CompareRelationalFieldMetadata);
649
+ return result;
650
+ }
651
+
652
+ private static string[] CopyAndSort(string[] values)
653
+ {
654
+ string[] result = new string[values.Length];
655
+ Array.Copy(values, result, values.Length);
656
+ Array.Sort(result, StringComparer.Ordinal);
657
+ return result;
658
+ }
659
+
660
+ private static int CompareTypeFieldMetadata(TypeFieldMetadata left, TypeFieldMetadata right)
661
+ {
662
+ if (ReferenceEquals(left, right))
663
+ {
664
+ return 0;
665
+ }
666
+
667
+ if (left == null)
668
+ {
669
+ return -1;
670
+ }
671
+
672
+ if (right == null)
673
+ {
674
+ return 1;
675
+ }
676
+
677
+ int typeNameComparison = string.CompareOrdinal(left.typeName, right.typeName);
678
+ if (typeNameComparison != 0)
679
+ {
680
+ return typeNameComparison;
681
+ }
682
+
683
+ string[] leftFields = left.fieldNames ?? Array.Empty<string>();
684
+ string[] rightFields = right.fieldNames ?? Array.Empty<string>();
685
+
686
+ int lengthComparison = leftFields.Length.CompareTo(rightFields.Length);
687
+ if (lengthComparison != 0)
688
+ {
689
+ return lengthComparison;
690
+ }
691
+
692
+ for (int i = 0; i < leftFields.Length; i++)
693
+ {
694
+ int fieldComparison = string.CompareOrdinal(leftFields[i], rightFields[i]);
695
+ if (fieldComparison != 0)
696
+ {
697
+ return fieldComparison;
698
+ }
699
+ }
700
+
701
+ return 0;
702
+ }
703
+
704
+ private static int CompareRelationalTypeMetadata(
705
+ RelationalTypeMetadata left,
706
+ RelationalTypeMetadata right
707
+ )
708
+ {
709
+ if (ReferenceEquals(left, right))
710
+ {
711
+ return 0;
712
+ }
713
+
714
+ if (left == null)
715
+ {
716
+ return -1;
717
+ }
718
+
719
+ if (right == null)
720
+ {
721
+ return 1;
722
+ }
723
+
724
+ int typeNameComparison = string.CompareOrdinal(left.typeName, right.typeName);
725
+ if (typeNameComparison != 0)
726
+ {
727
+ return typeNameComparison;
728
+ }
729
+
730
+ RelationalFieldMetadata[] leftFields =
731
+ left.fields ?? Array.Empty<RelationalFieldMetadata>();
732
+ RelationalFieldMetadata[] rightFields =
733
+ right.fields ?? Array.Empty<RelationalFieldMetadata>();
734
+
735
+ int lengthComparison = leftFields.Length.CompareTo(rightFields.Length);
736
+ if (lengthComparison != 0)
737
+ {
738
+ return lengthComparison;
739
+ }
740
+
741
+ for (int i = 0; i < leftFields.Length; i++)
742
+ {
743
+ int fieldComparison = CompareRelationalFieldMetadata(leftFields[i], rightFields[i]);
744
+ if (fieldComparison != 0)
745
+ {
746
+ return fieldComparison;
747
+ }
748
+ }
749
+
750
+ return 0;
751
+ }
752
+
753
+ private static int CompareRelationalFieldMetadata(
754
+ RelationalFieldMetadata left,
755
+ RelationalFieldMetadata right
756
+ )
757
+ {
758
+ if (ReferenceEquals(left, right))
759
+ {
760
+ return 0;
761
+ }
762
+
763
+ if (left == null)
764
+ {
765
+ return -1;
766
+ }
767
+
768
+ if (right == null)
769
+ {
770
+ return 1;
771
+ }
772
+
773
+ int fieldNameComparison = string.CompareOrdinal(left.fieldName, right.fieldName);
774
+ if (fieldNameComparison != 0)
775
+ {
776
+ return fieldNameComparison;
777
+ }
778
+
779
+ int attributeComparison = left.attributeKind.CompareTo(right.attributeKind);
780
+ if (attributeComparison != 0)
781
+ {
782
+ return attributeComparison;
783
+ }
784
+
785
+ int fieldKindComparison = left.fieldKind.CompareTo(right.fieldKind);
786
+ if (fieldKindComparison != 0)
787
+ {
788
+ return fieldKindComparison;
789
+ }
790
+
791
+ int elementTypeComparison = string.CompareOrdinal(
792
+ left.elementTypeName,
793
+ right.elementTypeName
794
+ );
795
+ if (elementTypeComparison != 0)
796
+ {
797
+ return elementTypeComparison;
798
+ }
799
+
800
+ if (left.isInterface == right.isInterface)
801
+ {
802
+ return 0;
803
+ }
804
+
805
+ return left.isInterface ? -1 : 1;
806
+ }
498
807
  #endif
499
808
  }
500
809
  }
@@ -1,62 +1,68 @@
1
1
  namespace WallstopStudios.UnityHelpers.Tags
2
2
  {
3
3
  using System;
4
+ using System.Text.Json.Serialization;
4
5
  using Core.Extension;
5
6
  using Core.Helper;
7
+ using ProtoBuf;
6
8
 
7
9
  /// <summary>
8
- /// Declarative change to an <see cref="Attribute"/> value (add, multiply, or override).
9
- /// Forms the stat‑modification payload inside an <see cref="AttributeEffect"/>.
10
+ /// Declarative change applied to an <see cref="Attribute"/>.
11
+ /// Each instance represents a single operation (add, multiply, or override) referenced by an <see cref="AttributeEffect"/>.
10
12
  /// </summary>
11
13
  /// <remarks>
14
+ /// <para>Key properties:</para>
15
+ /// <list type="bullet">
16
+ /// <item><description>Non-destructive: temporary handles can add/remove modifications without mutating base values.</description></item>
17
+ /// <item><description>Deterministic ordering: <see cref="Attribute"/> always processes Addition, then Multiplication, then Override.</description></item>
18
+ /// <item><description>Flexible authoring: supports both instant (permanent) and duration-based effects.</description></item>
19
+ /// </list>
20
+ /// <para>Stack processing order:</para>
21
+ /// <list type="number">
22
+ /// <item><description>Addition (value += x)</description></item>
23
+ /// <item><description>Multiplication (value *= x)</description></item>
24
+ /// <item><description>Override (value = x)</description></item>
25
+ /// </list>
12
26
  /// <para>
13
- /// Problems solved:
14
- /// - Non‑destructive stat changes that can be added/removed per effect instance
15
- /// - Clear stacking rules via action ordering
16
- /// - Works with both permanent (Instant) and temporary (Duration/Infinite) effects
27
+ /// The <see cref="attribute"/> field must match an <see cref="Attribute"/> field on the target <see cref="AttributesComponent"/>.
28
+ /// The Attribute Metadata Cache generator can provide editor dropdowns to avoid typos. Unknown names are ignored at runtime.
17
29
  /// </para>
18
- /// <para>
19
- /// Stacking and order: Modifications are applied in this order across a target attribute:
20
- /// 1) Addition (value += x) → 2) Multiplication (value *= x) → 3) Override (value = x).
21
- /// This means Overrides always win last; use with care.
22
- /// </para>
23
- /// <para>
24
- /// Addressing: The <see cref="attribute"/> field names an <see cref="AttributesComponent"/> field of type
25
- /// <see cref="Attribute"/>. Misspelled or missing names are ignored at runtime to keep effects robust.
26
- /// Use the Attribute Metadata Cache generator to populate editor dropdowns and avoid typos.
27
- /// </para>
28
- /// <para>
29
- /// Examples:
30
+ /// <para>Sample definitions:</para>
30
31
  /// <code>
31
- /// // +50 flat Health
32
+ /// // +50 flat health
32
33
  /// new AttributeModification { attribute = "Health", action = ModificationAction.Addition, value = 50f };
33
34
  ///
34
- /// // +50% Speed (i.e., multiply by 1.5)
35
+ /// // +50% speed
35
36
  /// new AttributeModification { attribute = "Speed", action = ModificationAction.Multiplication, value = 1.5f };
36
37
  ///
37
- /// // Set Defense to 0 (hard override)
38
+ /// // Hard-set defense to 0
38
39
  /// new AttributeModification { attribute = "Defense", action = ModificationAction.Override, value = 0f };
39
40
  /// </code>
40
- /// </para>
41
- /// <para>
42
- /// Tips:
43
- /// - Prefer Addition for small buffs/debuffs; prefer Multiplication for % changes.
44
- /// - Avoid frequent Overrides unless you intend to fully clamp a value.
45
- /// - Use negative Addition values to subtract; use Multiplication < 1.0 for % reductions.
46
- /// </para>
41
+ /// <para>Authoring tips:</para>
42
+ /// <list type="bullet">
43
+ /// <item><description>Use Addition for flat buffs/debuffs; Multiplication for percentage-style adjustments.</description></item>
44
+ /// <item><description>Reserve Override for hard clamps (it always executes last).</description></item>
45
+ /// <item><description>Negative Addition subtracts; Multiplication values below 1.0 reduce the attribute.</description></item>
46
+ /// </list>
47
47
  /// </remarks>
48
48
  [Serializable]
49
- public struct AttributeModification : IEquatable<AttributeModification>
49
+ [ProtoContract]
50
+ public struct AttributeModification
51
+ : IEquatable<AttributeModification>,
52
+ IComparable<AttributeModification>,
53
+ IComparable
50
54
  {
51
55
  /// <summary>
52
56
  /// The name of the attribute to modify. This should match a field name in an <see cref="AttributesComponent"/> subclass.
53
57
  /// </summary>
54
58
  [StringInList(typeof(AttributeUtilities), nameof(AttributeUtilities.GetAllAttributeNames))]
59
+ [ProtoMember(1)]
55
60
  public string attribute;
56
61
 
57
62
  /// <summary>
58
63
  /// The type of modification action to perform (Addition, Multiplication, or Override).
59
64
  /// </summary>
65
+ [ProtoMember(2)]
60
66
  public ModificationAction action;
61
67
 
62
68
  /// <summary>
@@ -65,8 +71,17 @@ namespace WallstopStudios.UnityHelpers.Tags
65
71
  /// <para>- Multiplication: The multiplier to apply (e.g., 1.5 for +50%, 0.5 for -50%)</para>
66
72
  /// <para>- Override: The new absolute value to set</para>
67
73
  /// </summary>
74
+ [ProtoMember(3)]
68
75
  public float value;
69
76
 
77
+ [JsonConstructor]
78
+ public AttributeModification(string attribute, ModificationAction action, float value)
79
+ {
80
+ this.attribute = attribute;
81
+ this.action = action;
82
+ this.value = value;
83
+ }
84
+
70
85
  /// <summary>
71
86
  /// Converts this modification to a JSON string representation.
72
87
  /// </summary>
@@ -76,6 +91,21 @@ namespace WallstopStudios.UnityHelpers.Tags
76
91
  return this.ToJson();
77
92
  }
78
93
 
94
+ public int CompareTo(object obj)
95
+ {
96
+ if (obj is AttributeModification other)
97
+ {
98
+ return CompareTo(other);
99
+ }
100
+
101
+ return -1;
102
+ }
103
+
104
+ public int CompareTo(AttributeModification other)
105
+ {
106
+ return ((int)action).CompareTo((int)other.action);
107
+ }
108
+
79
109
  /// <summary>
80
110
  /// Determines whether two attribute modifications are not equal.
81
111
  /// </summary>
@@ -121,6 +121,26 @@ namespace WallstopStudios.UnityHelpers.Tags
121
121
  IEnumerable<AttributeModification> attributeModifications
122
122
  )
123
123
  {
124
+ if (attributeModifications is IReadOnlyList<AttributeModification> readonlyList)
125
+ {
126
+ for (int i = 0; i < readonlyList.Count; ++i)
127
+ {
128
+ AttributeModification modification = readonlyList[i];
129
+ if (!TryGetAttribute(modification.attribute, out Attribute attribute))
130
+ {
131
+ continue;
132
+ }
133
+
134
+ float oldValue = attribute;
135
+ attribute.ApplyAttributeModification(modification);
136
+ float currentValue = attribute;
137
+
138
+ OnAttributeModified?.Invoke(modification.attribute, oldValue, currentValue);
139
+ }
140
+
141
+ return;
142
+ }
143
+
124
144
  foreach (AttributeModification modification in attributeModifications)
125
145
  {
126
146
  if (!TryGetAttribute(modification.attribute, out Attribute attribute))