com.wallstop-studios.unity-helpers 2.0.3 → 2.1.0

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 (58) 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/RANDOM_PERFORMANCE.md +1 -1
  6. package/Docs/REFLECTION_HELPERS.md +1 -1
  7. package/Docs/RELATIONAL_COMPONENTS.md +51 -6
  8. package/Docs/SERIALIZATION.md +1 -1
  9. package/Docs/SINGLETONS.md +2 -2
  10. package/Docs/SPATIAL_TREES_2D_GUIDE.md +3 -3
  11. package/Docs/SPATIAL_TREES_3D_GUIDE.md +3 -3
  12. package/Docs/SPATIAL_TREE_SEMANTICS.md +7 -7
  13. package/Editor/CustomDrawers/WShowIfPropertyDrawer.cs +131 -41
  14. package/Editor/Utils/ScriptableObjectSingletonCreator.cs +175 -18
  15. package/README.md +17 -3
  16. package/Runtime/Core/Helper/UnityMainThreadDispatcher.cs +4 -2
  17. package/Runtime/Tags/Attribute.cs +144 -24
  18. package/Runtime/Tags/AttributeEffect.cs +278 -11
  19. package/Runtime/Tags/AttributeModification.cs +59 -29
  20. package/Runtime/Tags/AttributeUtilities.cs +465 -0
  21. package/Runtime/Tags/AttributesComponent.cs +20 -0
  22. package/Runtime/Tags/EffectBehavior.cs +171 -0
  23. package/Runtime/Tags/EffectBehavior.cs.meta +4 -0
  24. package/Runtime/Tags/EffectHandle.cs +5 -0
  25. package/Runtime/Tags/EffectHandler.cs +564 -39
  26. package/Runtime/Tags/EffectStackKey.cs +79 -0
  27. package/Runtime/Tags/EffectStackKey.cs.meta +4 -0
  28. package/Runtime/Tags/PeriodicEffectDefinition.cs +102 -0
  29. package/Runtime/Tags/PeriodicEffectDefinition.cs.meta +4 -0
  30. package/Runtime/Tags/PeriodicEffectRuntimeState.cs +40 -0
  31. package/Runtime/Tags/PeriodicEffectRuntimeState.cs.meta +4 -0
  32. package/Runtime/Tags/TagHandler.cs +375 -21
  33. package/Samples~/DI - Zenject/README.md +0 -2
  34. package/Tests/Editor/Attributes/WShowIfPropertyDrawerTests.cs +285 -0
  35. package/Tests/Editor/Attributes/WShowIfPropertyDrawerTests.cs.meta +11 -0
  36. package/Tests/Editor/Core/Attributes/RelationalComponentAssignerTests.cs +2 -2
  37. package/Tests/Editor/Utils/ScriptableObjectSingletonTests.cs +41 -0
  38. package/Tests/Runtime/Serialization/JsonSerializationTest.cs +4 -3
  39. package/Tests/Runtime/Tags/AttributeEffectTests.cs +135 -0
  40. package/Tests/Runtime/Tags/AttributeEffectTests.cs.meta +3 -0
  41. package/Tests/Runtime/Tags/AttributeModificationTests.cs +137 -0
  42. package/Tests/Runtime/Tags/AttributeTests.cs +192 -0
  43. package/Tests/Runtime/Tags/AttributeTests.cs.meta +3 -0
  44. package/Tests/Runtime/Tags/AttributeUtilitiesTests.cs +245 -0
  45. package/Tests/Runtime/Tags/CosmeticAndCollisionTests.cs +1 -1
  46. package/Tests/Runtime/Tags/EffectBehaviorTests.cs +184 -0
  47. package/Tests/Runtime/Tags/EffectBehaviorTests.cs.meta +3 -0
  48. package/Tests/Runtime/Tags/EffectHandlerTests.cs +809 -0
  49. package/Tests/Runtime/Tags/Helpers/RecordingEffectBehavior.cs +89 -0
  50. package/Tests/Runtime/Tags/Helpers/RecordingEffectBehavior.cs.meta +4 -0
  51. package/Tests/Runtime/Tags/PeriodicEffectDefinitionSerializationTests.cs +92 -0
  52. package/Tests/Runtime/Tags/PeriodicEffectDefinitionSerializationTests.cs.meta +3 -0
  53. package/Tests/Runtime/Tags/TagHandlerTests.cs +130 -6
  54. package/package.json +1 -1
  55. package/scripts/lint-doc-links.ps1 +156 -11
  56. package/Tests/Runtime/Tags/AttributeDataTests.cs +0 -312
  57. package/node_modules.meta +0 -8
  58. /package/Tests/Runtime/Tags/{AttributeDataTests.cs.meta → AttributeModificationTests.cs.meta} +0 -0
@@ -1,6 +1,8 @@
1
1
  namespace WallstopStudios.UnityHelpers.Tests.Tags
2
2
  {
3
3
  using System.Collections;
4
+ using System.Collections.Generic;
5
+ using System.Text.RegularExpressions;
4
6
  using NUnit.Framework;
5
7
  using UnityEngine;
6
8
  using UnityEngine.TestTools;
@@ -15,6 +17,7 @@ namespace WallstopStudios.UnityHelpers.Tests.Tags
15
17
  {
16
18
  ResetEffectHandleId();
17
19
  RecordingCosmeticComponent.ResetCounters();
20
+ RecordingEffectBehavior.Reset();
18
21
  }
19
22
 
20
23
  [UnityTest]
@@ -238,6 +241,812 @@ namespace WallstopStudios.UnityHelpers.Tests.Tags
238
241
  Assert.AreEqual(1, RecordingCosmeticComponent.RemovedCount);
239
242
  }
240
243
 
244
+ [UnityTest]
245
+ public IEnumerator IsEffectActiveReflectsState()
246
+ {
247
+ (
248
+ GameObject entity,
249
+ EffectHandler handler,
250
+ TestAttributesComponent attributes,
251
+ TagHandler tags
252
+ ) = CreateEntity();
253
+ yield return null;
254
+
255
+ AttributeEffect effect = CreateEffect("Buff");
256
+ Assert.IsFalse(handler.IsEffectActive(effect));
257
+
258
+ EffectHandle handle = handler.ApplyEffect(effect).Value;
259
+ Assert.IsTrue(handler.IsEffectActive(effect));
260
+
261
+ handler.RemoveEffect(handle);
262
+ Assert.IsFalse(handler.IsEffectActive(effect));
263
+ }
264
+
265
+ [UnityTest]
266
+ public IEnumerator GetEffectStackCountSupportsMultipleHandles()
267
+ {
268
+ (
269
+ GameObject entity,
270
+ EffectHandler handler,
271
+ TestAttributesComponent attributes,
272
+ TagHandler tags
273
+ ) = CreateEntity();
274
+ yield return null;
275
+
276
+ AttributeEffect effect = CreateEffect(
277
+ "Stacking",
278
+ e =>
279
+ {
280
+ e.durationType = ModifierDurationType.Infinite;
281
+ e.stackingMode = EffectStackingMode.Stack;
282
+ }
283
+ );
284
+
285
+ EffectHandle first = handler.ApplyEffect(effect).Value;
286
+ EffectHandle second = handler.ApplyEffect(effect).Value;
287
+
288
+ Assert.AreEqual(2, handler.GetEffectStackCount(effect));
289
+
290
+ handler.RemoveEffect(first);
291
+ Assert.AreEqual(1, handler.GetEffectStackCount(effect));
292
+
293
+ handler.RemoveEffect(second);
294
+ Assert.AreEqual(0, handler.GetEffectStackCount(effect));
295
+ }
296
+
297
+ [UnityTest]
298
+ public IEnumerator StackingModeIgnoreReturnsExistingHandle()
299
+ {
300
+ (
301
+ GameObject entity,
302
+ EffectHandler handler,
303
+ TestAttributesComponent attributes,
304
+ TagHandler tags
305
+ ) = CreateEntity();
306
+ yield return null;
307
+
308
+ AttributeEffect effect = CreateEffect(
309
+ "Ignore",
310
+ e =>
311
+ {
312
+ e.durationType = ModifierDurationType.Infinite;
313
+ e.stackingMode = EffectStackingMode.Ignore;
314
+ e.modifications.Add(
315
+ new AttributeModification
316
+ {
317
+ attribute = nameof(TestAttributesComponent.health),
318
+ action = ModificationAction.Addition,
319
+ value = 5f,
320
+ }
321
+ );
322
+ }
323
+ );
324
+
325
+ EffectHandle first = handler.ApplyEffect(effect).Value;
326
+ float afterFirst = attributes.health.CurrentValue;
327
+
328
+ EffectHandle? second = handler.ApplyEffect(effect);
329
+ Assert.IsTrue(second.HasValue);
330
+ Assert.AreEqual(first, second.Value);
331
+ Assert.AreEqual(afterFirst, attributes.health.CurrentValue);
332
+
333
+ handler.RemoveEffect(first);
334
+ }
335
+
336
+ [UnityTest]
337
+ public IEnumerator RefreshModeWithoutResetDurationPreservesTimer()
338
+ {
339
+ (
340
+ GameObject entity,
341
+ EffectHandler handler,
342
+ TestAttributesComponent attributes,
343
+ TagHandler tags
344
+ ) = CreateEntity();
345
+ yield return null;
346
+
347
+ AttributeEffect effect = CreateEffect(
348
+ "RefreshNoReset",
349
+ e =>
350
+ {
351
+ e.duration = 0.3f;
352
+ e.stackingMode = EffectStackingMode.Refresh;
353
+ e.resetDurationOnReapplication = false;
354
+ }
355
+ );
356
+
357
+ EffectHandle handle = handler.ApplyEffect(effect).Value;
358
+ yield return new WaitForSeconds(0.05f);
359
+ Assert.IsTrue(handler.TryGetRemainingDuration(handle, out float beforeReapply));
360
+
361
+ EffectHandle? reapplied = handler.ApplyEffect(effect);
362
+ Assert.IsTrue(reapplied.HasValue);
363
+ Assert.AreEqual(handle, reapplied.Value);
364
+
365
+ Assert.IsTrue(handler.TryGetRemainingDuration(handle, out float afterReapply));
366
+ Assert.LessOrEqual(afterReapply, beforeReapply + 0.01f);
367
+
368
+ handler.RemoveEffect(handle);
369
+ }
370
+
371
+ [UnityTest]
372
+ public IEnumerator CustomStackGroupStackAcrossAssets()
373
+ {
374
+ (
375
+ GameObject entity,
376
+ EffectHandler handler,
377
+ TestAttributesComponent attributes,
378
+ TagHandler tags
379
+ ) = CreateEntity();
380
+ yield return null;
381
+
382
+ AttributeEffect effectA = CreateEffect(
383
+ "GroupA",
384
+ e =>
385
+ {
386
+ e.durationType = ModifierDurationType.Infinite;
387
+ e.stackGroup = EffectStackGroup.CustomKey;
388
+ e.stackGroupKey = "shared";
389
+ e.stackingMode = EffectStackingMode.Stack;
390
+ }
391
+ );
392
+
393
+ AttributeEffect effectB = CreateEffect(
394
+ "GroupB",
395
+ e =>
396
+ {
397
+ e.durationType = ModifierDurationType.Infinite;
398
+ e.stackGroup = EffectStackGroup.CustomKey;
399
+ e.stackGroupKey = "shared";
400
+ e.stackingMode = EffectStackingMode.Stack;
401
+ }
402
+ );
403
+
404
+ EffectHandle a1 = handler.ApplyEffect(effectA).Value;
405
+ EffectHandle b1 = handler.ApplyEffect(effectB).Value;
406
+ EffectHandle a2 = handler.ApplyEffect(effectA).Value;
407
+
408
+ List<EffectHandle> active = handler.GetActiveEffects();
409
+ Assert.AreEqual(3, active.Count);
410
+ Assert.AreEqual(2, handler.GetEffectStackCount(effectA));
411
+ Assert.AreEqual(1, handler.GetEffectStackCount(effectB));
412
+
413
+ handler.RemoveAllEffects();
414
+ }
415
+
416
+ [UnityTest]
417
+ public IEnumerator StackedTagsPersistUntilFinalStackRemoved()
418
+ {
419
+ (
420
+ GameObject entity,
421
+ EffectHandler handler,
422
+ TestAttributesComponent attributes,
423
+ TagHandler tags
424
+ ) = CreateEntity();
425
+ yield return null;
426
+
427
+ AttributeEffect effect = CreateEffect(
428
+ "Tagged",
429
+ e =>
430
+ {
431
+ e.durationType = ModifierDurationType.Infinite;
432
+ e.stackingMode = EffectStackingMode.Stack;
433
+ e.effectTags.Add("Shielded");
434
+ }
435
+ );
436
+
437
+ EffectHandle first = handler.ApplyEffect(effect).Value;
438
+ EffectHandle second = handler.ApplyEffect(effect).Value;
439
+
440
+ Assert.IsTrue(tags.HasTag("Shielded"));
441
+
442
+ handler.RemoveEffect(first);
443
+ Assert.IsTrue(tags.HasTag("Shielded"));
444
+
445
+ handler.RemoveEffect(second);
446
+ Assert.IsFalse(tags.HasTag("Shielded"));
447
+ }
448
+
449
+ [UnityTest]
450
+ public IEnumerator GetActiveEffectsPopulatesBuffer()
451
+ {
452
+ (
453
+ GameObject entity,
454
+ EffectHandler handler,
455
+ TestAttributesComponent attributes,
456
+ TagHandler tags
457
+ ) = CreateEntity();
458
+ yield return null;
459
+
460
+ AttributeEffect effect = CreateEffect(
461
+ "Active",
462
+ e =>
463
+ {
464
+ e.durationType = ModifierDurationType.Infinite;
465
+ e.stackingMode = EffectStackingMode.Stack;
466
+ }
467
+ );
468
+
469
+ EffectHandle first = handler.ApplyEffect(effect).Value;
470
+ EffectHandle second = handler.ApplyEffect(effect).Value;
471
+
472
+ List<EffectHandle> buffer = new();
473
+ handler.GetActiveEffects(buffer);
474
+ CollectionAssert.AreEquivalent(new[] { first, second }, buffer);
475
+
476
+ handler.RemoveEffect(first);
477
+ buffer.Clear();
478
+ handler.GetActiveEffects(buffer);
479
+ CollectionAssert.AreEqual(new[] { second }, buffer);
480
+
481
+ handler.RemoveEffect(second);
482
+ }
483
+
484
+ [UnityTest]
485
+ public IEnumerator PeriodicEffectHonorsInitialDelayAndMaxTicks()
486
+ {
487
+ (
488
+ GameObject entity,
489
+ EffectHandler handler,
490
+ TestAttributesComponent attributes,
491
+ TagHandler tags
492
+ ) = CreateEntity();
493
+ yield return null;
494
+
495
+ AttributeEffect effect = CreateEffect(
496
+ "PeriodicLimited",
497
+ e =>
498
+ {
499
+ e.durationType = ModifierDurationType.Infinite;
500
+ PeriodicEffectDefinition definition = new()
501
+ {
502
+ initialDelay = 0.05f,
503
+ interval = 0.05f,
504
+ maxTicks = 2,
505
+ };
506
+ definition.modifications.Add(
507
+ new AttributeModification
508
+ {
509
+ attribute = nameof(TestAttributesComponent.health),
510
+ action = ModificationAction.Addition,
511
+ value = -10f,
512
+ }
513
+ );
514
+ e.periodicEffects.Add(definition);
515
+ }
516
+ );
517
+
518
+ EffectHandle handle = handler.ApplyEffect(effect).Value;
519
+ yield return null;
520
+ Assert.AreEqual(100f, attributes.health.CurrentValue, 0.01f);
521
+
522
+ yield return new WaitForSeconds(0.03f);
523
+ Assert.AreEqual(100f, attributes.health.CurrentValue, 0.01f);
524
+
525
+ yield return new WaitForSeconds(0.06f);
526
+ Assert.AreEqual(90f, attributes.health.CurrentValue, 0.01f);
527
+
528
+ yield return new WaitForSeconds(0.06f);
529
+ Assert.AreEqual(80f, attributes.health.CurrentValue, 0.01f);
530
+
531
+ yield return new WaitForSeconds(0.06f);
532
+ Assert.AreEqual(80f, attributes.health.CurrentValue, 0.01f);
533
+
534
+ handler.RemoveEffect(handle);
535
+ }
536
+
537
+ [UnityTest]
538
+ public IEnumerator PeriodicEffectUnlimitedTicksStopOnRemoval()
539
+ {
540
+ (
541
+ GameObject entity,
542
+ EffectHandler handler,
543
+ TestAttributesComponent attributes,
544
+ TagHandler tags
545
+ ) = CreateEntity();
546
+ yield return null;
547
+
548
+ AttributeEffect effect = CreateEffect(
549
+ "PeriodicUnlimited",
550
+ e =>
551
+ {
552
+ e.durationType = ModifierDurationType.Infinite;
553
+ PeriodicEffectDefinition definition = new() { interval = 0.05f, maxTicks = 0 };
554
+ definition.modifications.Add(
555
+ new AttributeModification
556
+ {
557
+ attribute = nameof(TestAttributesComponent.health),
558
+ action = ModificationAction.Addition,
559
+ value = -5f,
560
+ }
561
+ );
562
+ e.periodicEffects.Add(definition);
563
+ }
564
+ );
565
+
566
+ EffectHandle handle = handler.ApplyEffect(effect).Value;
567
+ yield return new WaitForSeconds(0.16f);
568
+ float afterTicks = attributes.health.CurrentValue;
569
+ Assert.Less(afterTicks, 100f);
570
+
571
+ handler.RemoveEffect(handle);
572
+ float afterRemoval = attributes.health.CurrentValue;
573
+ yield return new WaitForSeconds(0.1f);
574
+ Assert.AreEqual(afterRemoval, attributes.health.CurrentValue, 0.01f);
575
+ }
576
+
577
+ [UnityTest]
578
+ public IEnumerator MultiplePeriodicDefinitionsAffectAttributesIndependently()
579
+ {
580
+ (
581
+ GameObject entity,
582
+ EffectHandler handler,
583
+ TestAttributesComponent attributes,
584
+ TagHandler tags
585
+ ) = CreateEntity();
586
+ yield return null;
587
+
588
+ AttributeEffect effect = CreateEffect(
589
+ "PeriodicMulti",
590
+ e =>
591
+ {
592
+ e.durationType = ModifierDurationType.Infinite;
593
+
594
+ PeriodicEffectDefinition damage = new()
595
+ {
596
+ initialDelay = 0.05f,
597
+ interval = 0.05f,
598
+ maxTicks = 2,
599
+ };
600
+ damage.modifications.Add(
601
+ new AttributeModification
602
+ {
603
+ attribute = nameof(TestAttributesComponent.health),
604
+ action = ModificationAction.Addition,
605
+ value = -5f,
606
+ }
607
+ );
608
+
609
+ PeriodicEffectDefinition armorGain = new()
610
+ {
611
+ initialDelay = 0.02f,
612
+ interval = 0.1f,
613
+ maxTicks = 3,
614
+ };
615
+ armorGain.modifications.Add(
616
+ new AttributeModification
617
+ {
618
+ attribute = nameof(TestAttributesComponent.armor),
619
+ action = ModificationAction.Addition,
620
+ value = 1f,
621
+ }
622
+ );
623
+
624
+ e.periodicEffects.Add(damage);
625
+ e.periodicEffects.Add(armorGain);
626
+ }
627
+ );
628
+
629
+ EffectHandle handle = handler.ApplyEffect(effect).Value;
630
+
631
+ yield return new WaitForSeconds(0.06f);
632
+ Assert.AreEqual(95f, attributes.health.CurrentValue, 0.01f);
633
+ Assert.AreEqual(51f, attributes.armor.CurrentValue, 0.01f);
634
+
635
+ yield return new WaitForSeconds(0.1f);
636
+ Assert.AreEqual(90f, attributes.health.CurrentValue, 0.01f);
637
+ Assert.AreEqual(52f, attributes.armor.CurrentValue, 0.01f);
638
+
639
+ yield return new WaitForSeconds(0.2f);
640
+ Assert.AreEqual(90f, attributes.health.CurrentValue, 0.01f);
641
+ Assert.AreEqual(53f, attributes.armor.CurrentValue, 0.01f);
642
+
643
+ handler.RemoveEffect(handle);
644
+ }
645
+
646
+ [UnityTest]
647
+ public IEnumerator TryGetRemainingDurationReportsTime()
648
+ {
649
+ (
650
+ GameObject entity,
651
+ EffectHandler handler,
652
+ TestAttributesComponent attributes,
653
+ TagHandler tags
654
+ ) = CreateEntity();
655
+ yield return null;
656
+
657
+ AttributeEffect effect = CreateEffect(
658
+ "Timed",
659
+ e =>
660
+ {
661
+ e.duration = 0.5f;
662
+ }
663
+ );
664
+
665
+ EffectHandle handle = handler.ApplyEffect(effect).Value;
666
+ Assert.IsTrue(handler.TryGetRemainingDuration(handle, out float remaining));
667
+ Assert.Greater(remaining, 0f);
668
+ Assert.LessOrEqual(remaining, effect.duration);
669
+
670
+ handler.RemoveEffect(handle);
671
+ Assert.IsFalse(handler.TryGetRemainingDuration(handle, out float afterRemoval));
672
+ Assert.AreEqual(0f, afterRemoval);
673
+ }
674
+
675
+ [UnityTest]
676
+ public IEnumerator EnsureHandleRefreshesDurationWhenRequested()
677
+ {
678
+ (
679
+ GameObject entity,
680
+ EffectHandler handler,
681
+ TestAttributesComponent attributes,
682
+ TagHandler tags
683
+ ) = CreateEntity();
684
+ yield return null;
685
+
686
+ AttributeEffect effect = CreateEffect(
687
+ "Refreshable",
688
+ e =>
689
+ {
690
+ e.duration = 0.2f;
691
+ e.resetDurationOnReapplication = true;
692
+ }
693
+ );
694
+
695
+ EffectHandle handle = handler.ApplyEffect(effect).Value;
696
+ Assert.IsTrue(handler.TryGetRemainingDuration(handle, out float initialRemaining));
697
+ yield return null;
698
+ Assert.IsTrue(handler.TryGetRemainingDuration(handle, out float beforeRefresh));
699
+ Assert.Less(beforeRefresh, initialRemaining);
700
+
701
+ EffectHandle? ensured = handler.EnsureHandle(effect);
702
+ Assert.IsTrue(ensured.HasValue);
703
+ Assert.AreEqual(handle, ensured.Value);
704
+ Assert.IsTrue(handler.TryGetRemainingDuration(handle, out float afterRefresh));
705
+ Assert.Greater(afterRefresh, beforeRefresh);
706
+
707
+ yield return null;
708
+ Assert.IsTrue(handler.TryGetRemainingDuration(handle, out float beforeNoRefresh));
709
+ EffectHandle? ensuredNoRefresh = handler.EnsureHandle(effect, refreshDuration: false);
710
+ Assert.IsTrue(ensuredNoRefresh.HasValue);
711
+ Assert.AreEqual(handle, ensuredNoRefresh.Value);
712
+ Assert.IsTrue(handler.TryGetRemainingDuration(handle, out float afterNoRefresh));
713
+ Assert.LessOrEqual(afterNoRefresh, beforeNoRefresh);
714
+
715
+ handler.RemoveEffect(handle);
716
+ }
717
+
718
+ [UnityTest]
719
+ public IEnumerator RefreshEffectHonorsReapplicationPolicy()
720
+ {
721
+ (
722
+ GameObject entity,
723
+ EffectHandler handler,
724
+ TestAttributesComponent attributes,
725
+ TagHandler tags
726
+ ) = CreateEntity();
727
+ yield return null;
728
+
729
+ AttributeEffect effect = CreateEffect(
730
+ "Policy",
731
+ e =>
732
+ {
733
+ e.duration = 0.3f;
734
+ e.resetDurationOnReapplication = false;
735
+ }
736
+ );
737
+
738
+ EffectHandle handle = handler.ApplyEffect(effect).Value;
739
+ yield return null;
740
+ Assert.IsTrue(handler.TryGetRemainingDuration(handle, out float beforeRefresh));
741
+
742
+ Assert.IsFalse(handler.RefreshEffect(handle));
743
+ Assert.IsTrue(handler.RefreshEffect(handle, ignoreReapplicationPolicy: true));
744
+ Assert.IsTrue(handler.TryGetRemainingDuration(handle, out float afterRefresh));
745
+ Assert.Greater(afterRefresh, beforeRefresh);
746
+
747
+ handler.RemoveEffect(handle);
748
+ }
749
+
750
+ [UnityTest]
751
+ public IEnumerator PeriodicEffectAppliesTicksAndStops()
752
+ {
753
+ (
754
+ GameObject entity,
755
+ EffectHandler handler,
756
+ TestAttributesComponent attributes,
757
+ TagHandler tags
758
+ ) = CreateEntity();
759
+ yield return null;
760
+
761
+ AttributeEffect effect = CreateEffect(
762
+ "Periodic",
763
+ e =>
764
+ {
765
+ e.durationType = ModifierDurationType.Infinite;
766
+ PeriodicEffectDefinition periodic = new() { interval = 0.1f, maxTicks = 3 };
767
+ periodic.modifications.Add(
768
+ new AttributeModification
769
+ {
770
+ attribute = nameof(TestAttributesComponent.health),
771
+ action = ModificationAction.Addition,
772
+ value = -10f,
773
+ }
774
+ );
775
+ e.periodicEffects.Add(periodic);
776
+ }
777
+ );
778
+
779
+ EffectHandle handle = handler.ApplyEffect(effect).Value;
780
+ yield return new WaitForSeconds(0.35f);
781
+ Assert.AreEqual(70f, attributes.health.CurrentValue, 0.01f);
782
+
783
+ yield return new WaitForSeconds(0.2f);
784
+ handler.RemoveEffect(handle);
785
+ Assert.AreEqual(70f, attributes.health.CurrentValue, 0.01f);
786
+ }
787
+
788
+ [UnityTest]
789
+ public IEnumerator EffectBehaviorReceivesCallbacks()
790
+ {
791
+ (
792
+ GameObject entity,
793
+ EffectHandler handler,
794
+ TestAttributesComponent attributes,
795
+ TagHandler tags
796
+ ) = CreateEntity();
797
+ yield return null;
798
+
799
+ AttributeEffect effect = CreateEffect(
800
+ "Behavior",
801
+ e =>
802
+ {
803
+ e.duration = 0.25f;
804
+ e.periodicEffects.Add(
805
+ new PeriodicEffectDefinition { interval = 0.05f, maxTicks = 2 }
806
+ );
807
+ }
808
+ );
809
+
810
+ RecordingEffectBehavior behavior = Track(
811
+ ScriptableObject.CreateInstance<RecordingEffectBehavior>()
812
+ );
813
+ effect.behaviors.Add(behavior);
814
+
815
+ EffectHandle handle = handler.ApplyEffect(effect).Value;
816
+ Assert.AreEqual(1, RecordingEffectBehavior.ApplyCount);
817
+
818
+ yield return null;
819
+ Assert.Greater(RecordingEffectBehavior.TickCount, 0);
820
+
821
+ yield return new WaitForSeconds(0.12f);
822
+ Assert.GreaterOrEqual(RecordingEffectBehavior.PeriodicTickCount, 1);
823
+
824
+ handler.RemoveEffect(handle);
825
+ Assert.AreEqual(1, RecordingEffectBehavior.RemoveCount);
826
+ }
827
+
828
+ [UnityTest]
829
+ public IEnumerator EffectBehaviorClonesPerHandle()
830
+ {
831
+ (
832
+ GameObject entity,
833
+ EffectHandler handler,
834
+ TestAttributesComponent attributes,
835
+ TagHandler tags
836
+ ) = CreateEntity();
837
+ yield return null;
838
+
839
+ AttributeEffect effect = CreateEffect(
840
+ "BehaviorStacks",
841
+ e =>
842
+ {
843
+ e.durationType = ModifierDurationType.Infinite;
844
+ e.stackingMode = EffectStackingMode.Stack;
845
+ }
846
+ );
847
+
848
+ RecordingEffectBehavior behavior = Track(
849
+ ScriptableObject.CreateInstance<RecordingEffectBehavior>()
850
+ );
851
+ effect.behaviors.Add(behavior);
852
+
853
+ int startingInstances = RecordingEffectBehavior.InstanceCount;
854
+
855
+ EffectHandle first = handler.ApplyEffect(effect).Value;
856
+ Assert.AreEqual(startingInstances + 1, RecordingEffectBehavior.InstanceCount);
857
+
858
+ EffectHandle second = handler.ApplyEffect(effect).Value;
859
+ Assert.AreEqual(startingInstances + 2, RecordingEffectBehavior.InstanceCount);
860
+ Assert.AreEqual(2, RecordingEffectBehavior.ApplyCount);
861
+
862
+ handler.RemoveEffect(first);
863
+ handler.RemoveEffect(second);
864
+ Assert.AreEqual(2, RecordingEffectBehavior.RemoveCount);
865
+ }
866
+
867
+ [UnityTest]
868
+ public IEnumerator EffectBehaviorWithoutPeriodicSkipsPeriodicCallbacks()
869
+ {
870
+ (
871
+ GameObject entity,
872
+ EffectHandler handler,
873
+ TestAttributesComponent attributes,
874
+ TagHandler tags
875
+ ) = CreateEntity();
876
+ yield return null;
877
+
878
+ AttributeEffect effect = CreateEffect(
879
+ "BehaviorNoPeriodic",
880
+ e =>
881
+ {
882
+ e.durationType = ModifierDurationType.Infinite;
883
+ }
884
+ );
885
+
886
+ RecordingEffectBehavior behavior = Track(
887
+ ScriptableObject.CreateInstance<RecordingEffectBehavior>()
888
+ );
889
+ effect.behaviors.Add(behavior);
890
+
891
+ EffectHandle handle = handler.ApplyEffect(effect).Value;
892
+ yield return new WaitForSeconds(0.1f);
893
+
894
+ Assert.AreEqual(0, RecordingEffectBehavior.PeriodicTickCount);
895
+
896
+ handler.RemoveEffect(handle);
897
+ }
898
+
899
+ [UnityTest]
900
+ public IEnumerator StackingModeStackRespectsMaximumStacks()
901
+ {
902
+ (
903
+ GameObject entity,
904
+ EffectHandler handler,
905
+ TestAttributesComponent attributes,
906
+ TagHandler tags
907
+ ) = CreateEntity();
908
+ yield return null;
909
+
910
+ AttributeEffect effect = CreateEffect(
911
+ "Stacking",
912
+ e =>
913
+ {
914
+ e.durationType = ModifierDurationType.Infinite;
915
+ e.stackingMode = EffectStackingMode.Stack;
916
+ e.maximumStacks = 2;
917
+ }
918
+ );
919
+
920
+ EffectHandle first = handler.ApplyEffect(effect).Value;
921
+ EffectHandle second = handler.ApplyEffect(effect).Value;
922
+ EffectHandle third = handler.ApplyEffect(effect).Value;
923
+
924
+ List<EffectHandle> active = handler.GetActiveEffects();
925
+ Assert.AreEqual(2, active.Count);
926
+ CollectionAssert.DoesNotContain(active, first);
927
+ CollectionAssert.Contains(active, second);
928
+ CollectionAssert.Contains(active, third);
929
+
930
+ handler.RemoveAllEffects();
931
+ }
932
+
933
+ [UnityTest]
934
+ public IEnumerator InstantEffectWithPeriodicLogsWarning()
935
+ {
936
+ (
937
+ GameObject entity,
938
+ EffectHandler handler,
939
+ TestAttributesComponent attributes,
940
+ TagHandler tags
941
+ ) = CreateEntity();
942
+ yield return null;
943
+
944
+ AttributeEffect effect = CreateEffect(
945
+ "InstantPeriodic",
946
+ e =>
947
+ {
948
+ e.durationType = ModifierDurationType.Instant;
949
+ PeriodicEffectDefinition definition = new() { interval = 0.05f, maxTicks = 1 };
950
+ definition.modifications.Add(
951
+ new AttributeModification
952
+ {
953
+ attribute = nameof(TestAttributesComponent.health),
954
+ action = ModificationAction.Addition,
955
+ value = -10f,
956
+ }
957
+ );
958
+ e.periodicEffects.Add(definition);
959
+ }
960
+ );
961
+
962
+ LogAssert.Expect(
963
+ LogType.Warning,
964
+ new Regex("defines periodic or behaviour data but is Instant")
965
+ );
966
+
967
+ EffectHandle? handle = handler.ApplyEffect(effect);
968
+ Assert.IsFalse(handle.HasValue);
969
+
970
+ yield return new WaitForSeconds(0.1f);
971
+ Assert.AreEqual(100f, attributes.health.CurrentValue, 0.01f);
972
+ }
973
+
974
+ [UnityTest]
975
+ public IEnumerator StackingModeReplaceSwapsHandles()
976
+ {
977
+ (
978
+ GameObject entity,
979
+ EffectHandler handler,
980
+ TestAttributesComponent attributes,
981
+ TagHandler tags
982
+ ) = CreateEntity();
983
+ yield return null;
984
+
985
+ AttributeEffect effect = CreateEffect(
986
+ "Replace",
987
+ e =>
988
+ {
989
+ e.durationType = ModifierDurationType.Infinite;
990
+ e.stackingMode = EffectStackingMode.Replace;
991
+ }
992
+ );
993
+
994
+ EffectHandle first = handler.ApplyEffect(effect).Value;
995
+ EffectHandle second = handler.ApplyEffect(effect).Value;
996
+
997
+ List<EffectHandle> active = handler.GetActiveEffects();
998
+ Assert.AreEqual(1, active.Count);
999
+ Assert.AreEqual(second, active[0]);
1000
+ Assert.AreNotEqual(first, second);
1001
+
1002
+ handler.RemoveAllEffects();
1003
+ }
1004
+
1005
+ [UnityTest]
1006
+ public IEnumerator CustomStackGroupSharesAcrossEffects()
1007
+ {
1008
+ (
1009
+ GameObject entity,
1010
+ EffectHandler handler,
1011
+ TestAttributesComponent attributes,
1012
+ TagHandler tags
1013
+ ) = CreateEntity();
1014
+ yield return null;
1015
+
1016
+ AttributeEffect effectA = CreateEffect(
1017
+ "GroupA",
1018
+ e =>
1019
+ {
1020
+ e.durationType = ModifierDurationType.Infinite;
1021
+ e.stackGroup = EffectStackGroup.CustomKey;
1022
+ e.stackGroupKey = "shared";
1023
+ e.stackingMode = EffectStackingMode.Replace;
1024
+ }
1025
+ );
1026
+ AttributeEffect effectB = CreateEffect(
1027
+ "GroupB",
1028
+ e =>
1029
+ {
1030
+ e.durationType = ModifierDurationType.Infinite;
1031
+ e.stackGroup = EffectStackGroup.CustomKey;
1032
+ e.stackGroupKey = "shared";
1033
+ e.stackingMode = EffectStackingMode.Replace;
1034
+ }
1035
+ );
1036
+
1037
+ EffectHandle first = handler.ApplyEffect(effectA).Value;
1038
+ EffectHandle second = handler.ApplyEffect(effectB).Value;
1039
+
1040
+ List<EffectHandle> active = handler.GetActiveEffects();
1041
+ Assert.AreEqual(1, active.Count);
1042
+ Assert.AreEqual(second, active[0]);
1043
+ Assert.IsFalse(handler.IsEffectActive(effectA));
1044
+ Assert.IsTrue(handler.IsEffectActive(effectB));
1045
+ Assert.AreNotEqual(first, second);
1046
+
1047
+ handler.RemoveAllEffects();
1048
+ }
1049
+
241
1050
  private (
242
1051
  GameObject entity,
243
1052
  EffectHandler handler,