From 11f087de59adadba4c497685eae092054060f964 Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 8 Sep 2025 21:09:08 -0300 Subject: [PATCH 01/87] Added AbilityData --- Forge/Abilities/AbilitTriggerData.cs | 15 ++++++ Forge/Abilities/Ability.cs | 21 ++++++++ Forge/Abilities/AbilityData.cs | 57 ++++++++++++++++++++++ Forge/Abilities/AbilityInstancingPolicy.cs | 25 ++++++++++ Forge/Abilities/AbitityTriggerSource.cs | 28 +++++++++++ Forge/Core/IForgeEntity.cs | 6 +++ 6 files changed, 152 insertions(+) create mode 100644 Forge/Abilities/AbilitTriggerData.cs create mode 100644 Forge/Abilities/Ability.cs create mode 100644 Forge/Abilities/AbilityData.cs create mode 100644 Forge/Abilities/AbilityInstancingPolicy.cs create mode 100644 Forge/Abilities/AbitityTriggerSource.cs diff --git a/Forge/Abilities/AbilitTriggerData.cs b/Forge/Abilities/AbilitTriggerData.cs new file mode 100644 index 0000000..7838514 --- /dev/null +++ b/Forge/Abilities/AbilitTriggerData.cs @@ -0,0 +1,15 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Represents data associated with an ability trigger, including the trigger's tag and source. +/// +/// The tag identifying the specific trigger. This value is used to categorize or distinguish +/// the trigger. +/// The source of the trigger, indicating where or how the trigger originated. +public readonly record struct AbilitTriggerData( + Tag TriggerTag, + AbitityTriggerSource TriggerSource); diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs new file mode 100644 index 0000000..82cbce6 --- /dev/null +++ b/Forge/Abilities/Ability.cs @@ -0,0 +1,21 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Abilities; + +public class Ability(AbilityData abilityData, float cooldown, int level = 1) +{ + /// + /// Gets the ability data for this ability. + /// + public AbilityData AbilityData { get; } = abilityData; + + /// + /// Gets the current cooldown o this ability. + /// + public float Cooldown { get; } = cooldown; + + /// + /// Gets the current level o this ability. + /// + public int Level { get; } = level; +} diff --git a/Forge/Abilities/AbilityData.cs b/Forge/Abilities/AbilityData.cs new file mode 100644 index 0000000..39f59ef --- /dev/null +++ b/Forge/Abilities/AbilityData.cs @@ -0,0 +1,57 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Represents the data required to define an ability, including its name, effects, tags, and activation policies. +/// +/// +/// This structure encapsulates all the metadata and configuration necessary to describe an ability in a system. It +/// includes optional effects for cost and cooldown, various tag-based requirements and restrictions, and policies for +/// instancing and retriggering abilities. Use this type to define the behavior and constraints of abilities in a +/// consistent and extensible manner. +/// +/// The name of the ability. +/// The effect that represents the cost of using the ability called when the ability is +/// commited. +/// The effect that represents the cooldown of the ability. +/// Tags associated with the ability for categorization and filtering. +/// The instancing policy for the ability, determining how instances are created and +/// managed. +/// Flag indicating whether an instanced ability can be reexecuted while it is +/// Still active. If on, iIt will stop and re-trigger the ability. +/// The trigger data associated with the ability, defining how and when the ability can +/// be executed. +/// Abilities with any of these tags will be canceled when this ability is +/// executed. +/// Abilities with any of these tags will be blocked from being executed while this +/// ability is active. +/// Tags that will be applied to the owner when the ability is activated. +/// Tags required on the owner to activate the ability. +/// Tags that, if present on the owner, will block the ability from being activated. +/// +/// Tags required on the source to activate the ability. +/// Tags that, if present on the source, will block the ability from being activated. +/// +/// Tags required on the target to activate the ability. +/// Tags that, if present on the target, will block the ability from being. +public readonly record struct AbilityData( + string Name, + EffectData? CostEffect = null, + EffectData? CooldownEffect = null, + TagContainer? AbilityTags = null, + AbilityInstancingPolicy InstancingPolicy = AbilityInstancingPolicy.PerEntity, + bool RetriggerInstancedAbility = false, + AbilitTriggerData? AbilitTriggerData = null, + TagContainer? CancelAbilitiesWithTag = null, + TagContainer? BlockAbilitiesWithTag = null, + TagContainer? ActivationOwnedTags = null, + TagContainer? ActivationRequiredTags = null, + TagContainer? ActivationBlockedTags = null, + TagContainer? SourceRequiredTags = null, + TagContainer? SourceBlockedTags = null, + TagContainer? TargetRequiredTags = null, + TagContainer? TargetBlockedTags = null); diff --git a/Forge/Abilities/AbilityInstancingPolicy.cs b/Forge/Abilities/AbilityInstancingPolicy.cs new file mode 100644 index 0000000..82560a1 --- /dev/null +++ b/Forge/Abilities/AbilityInstancingPolicy.cs @@ -0,0 +1,25 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Specifies the instancing policy for abilities, determining how ability instances are created and managed. +/// +/// This enumeration defines the instancing behavior for abilities, which can affect their lifecycle and +/// usage: Each entity gets its own +/// instance of the ability, ensuring that the ability's state is isolated per entity. +/// A new instance of the ability is created for each execution, +/// allowing for stateless or transient behavior. Choose the appropriate policy based on +/// whether the ability requires persistent state per entity or should be stateless and transient. +public enum AbilityInstancingPolicy : byte +{ + /// + /// Abilities are instantiated per entity, meaning each entity has its own instance of the ability. + /// + PerEntity = 0, + + /// + /// Abilities are instantiated per execution, meaning a new instance is created each time the ability is used. + /// + PerExecution = 1, +} diff --git a/Forge/Abilities/AbitityTriggerSource.cs b/Forge/Abilities/AbitityTriggerSource.cs new file mode 100644 index 0000000..08b362e --- /dev/null +++ b/Forge/Abilities/AbitityTriggerSource.cs @@ -0,0 +1,28 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Specifies the source or condition that triggers an ability. +/// +/// +/// This enumeration defines the possible ways an ability can be triggered, such as through explicit events or changes +/// in the entity's state (e.g., the addition or presence of specific tags). +/// +public enum AbitityTriggerSource : byte +{ + /// + /// Ability is triggered by an explicit event call. + /// + Event = 0, + + /// + /// Ability is triggered when a specific tag is added to the entity. + /// + TagAdded = 1, + + /// + /// Ability is triggered when a specific tag is added on the entity and removed when the tag is gone. + /// + TagPresent = 2, +} diff --git a/Forge/Core/IForgeEntity.cs b/Forge/Core/IForgeEntity.cs index b0dc8d6..128d2aa 100644 --- a/Forge/Core/IForgeEntity.cs +++ b/Forge/Core/IForgeEntity.cs @@ -1,5 +1,6 @@ // Copyright © Gamesmiths Guild. +using Gamesmiths.Forge.Abilities; using Gamesmiths.Forge.Effects; namespace Gamesmiths.Forge.Core; @@ -23,4 +24,9 @@ public interface IForgeEntity /// Gets the effects manager for this entity. /// EffectsManager EffectsManager { get; } + + /// + /// Gets the abitilies manager for this entity. + /// + EntityAbilities Abilities { get; } } From a81fc1066ec14fc52131a512cbb0112bfe229d20 Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 8 Sep 2025 21:09:28 -0300 Subject: [PATCH 02/87] Fixed summary formatting --- Forge/Core/EntityAttributes.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Forge/Core/EntityAttributes.cs b/Forge/Core/EntityAttributes.cs index 545b9e6..c482faf 100644 --- a/Forge/Core/EntityAttributes.cs +++ b/Forge/Core/EntityAttributes.cs @@ -6,7 +6,8 @@ namespace Gamesmiths.Forge.Core; /// -/// Container class which handles and manages all s and s of an entity. +/// Container class which handles and manages all s and s of an +/// entity. /// Attributes can be accessed with the indexer. /// public class EntityAttributes : IEnumerable From 63dd718e30e3965ce47d39f839e492044d2feede Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 8 Sep 2025 21:12:52 -0300 Subject: [PATCH 03/87] Fixed typo --- Forge/Abilities/AbilityData.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Forge/Abilities/AbilityData.cs b/Forge/Abilities/AbilityData.cs index 39f59ef..8fd341b 100644 --- a/Forge/Abilities/AbilityData.cs +++ b/Forge/Abilities/AbilityData.cs @@ -21,8 +21,8 @@ namespace Gamesmiths.Forge.Abilities; /// Tags associated with the ability for categorization and filtering. /// The instancing policy for the ability, determining how instances are created and /// managed. -/// Flag indicating whether an instanced ability can be reexecuted while it is -/// Still active. If on, iIt will stop and re-trigger the ability. +/// Flag indicating whether an instanced ability can be re-triggered while it is +/// Still active. If on, it will stop and re-trigger the ability. /// The trigger data associated with the ability, defining how and when the ability can /// be executed. /// Abilities with any of these tags will be canceled when this ability is From 9ff58582d27dca7af0c345b1cdda0ebf4d3215af Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 18 Sep 2025 21:13:52 -0300 Subject: [PATCH 04/87] Base EntityAbility implementation --- .../Effects/CustomCalculatorsEffectsTests.cs | 3 + Forge.Tests/Helpers/TestEntity.cs | 3 + Forge.Tests/Samples/QuickStartTests.cs | 2 + Forge/Abilities/Ability.cs | 57 +++++++++++++++++-- Forge/Abilities/AbilityHandle.cs | 29 ++++++++++ .../Abilities/GrantedAbilityRemovalPolicy.cs | 29 ++++++++++ Forge/Core/EntityAbilities.cs | 57 +++++++++++++++++++ Forge/Core/IForgeEntity.cs | 1 - Forge/Effects/ActiveEffect.cs | 24 ++++---- Forge/Effects/ActiveEffectHandle.cs | 5 ++ 10 files changed, 191 insertions(+), 19 deletions(-) create mode 100644 Forge/Abilities/AbilityHandle.cs create mode 100644 Forge/Abilities/GrantedAbilityRemovalPolicy.cs create mode 100644 Forge/Core/EntityAbilities.cs diff --git a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs index 9c5a6ab..2d78127 100644 --- a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs +++ b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs @@ -741,11 +741,14 @@ private sealed class NoAttributesEntity : IForgeEntity public EffectsManager EffectsManager { get; } + public EntityAbilities Abilities { get; } + public NoAttributesEntity(TagsManager tagsManager, CuesManager cuesManager) { EffectsManager = new(this, cuesManager); Attributes = new(); Tags = new(new TagContainer(tagsManager)); + Abilities = new(); } } } diff --git a/Forge.Tests/Helpers/TestEntity.cs b/Forge.Tests/Helpers/TestEntity.cs index 3e83dcd..7705084 100644 --- a/Forge.Tests/Helpers/TestEntity.cs +++ b/Forge.Tests/Helpers/TestEntity.cs @@ -17,6 +17,8 @@ public class TestEntity : IForgeEntity public EffectsManager EffectsManager { get; } + public EntityAbilities Abilities { get; } + public TestEntity(TagsManager tagsManager, CuesManager cuesManager) { PlayerAttributeSet = new TestAttributeSet(); @@ -30,5 +32,6 @@ public TestEntity(TagsManager tagsManager, CuesManager cuesManager) EffectsManager = new(this, cuesManager); Attributes = new(PlayerAttributeSet); Tags = new(originalTags); + Abilities = new(); } } diff --git a/Forge.Tests/Samples/QuickStartTests.cs b/Forge.Tests/Samples/QuickStartTests.cs index 2019026..45a5303 100644 --- a/Forge.Tests/Samples/QuickStartTests.cs +++ b/Forge.Tests/Samples/QuickStartTests.cs @@ -747,6 +747,7 @@ public class Player : IForgeEntity public EntityAttributes Attributes { get; } public EntityTags Tags { get; } public EffectsManager EffectsManager { get; } + public EntityAbilities Abilities { get; } public Player(TagsManager tagsManager, CuesManager cuesManager) { @@ -761,6 +762,7 @@ public Player(TagsManager tagsManager, CuesManager cuesManager) Attributes = new EntityAttributes(new PlayerAttributeSet()); Tags = new EntityTags(baseTags); EffectsManager = new EffectsManager(this, cuesManager); + Abilities = new(); } } diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 82cbce6..27f0da7 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -1,21 +1,66 @@ // Copyright © Gamesmiths Guild. +using Gamesmiths.Forge.Core; + namespace Gamesmiths.Forge.Abilities; -public class Ability(AbilityData abilityData, float cooldown, int level = 1) +/// +/// Instance of an ability that has been granted to an entity. +/// +internal class Ability { + private int _activeCount; + /// /// Gets the ability data for this ability. /// - public AbilityData AbilityData { get; } = abilityData; + internal AbilityData AbilityData { get; } /// - /// Gets the current cooldown o this ability. + /// Gets the current level o this ability. /// - public float Cooldown { get; } = cooldown; + internal int Level { get; } /// - /// Gets the current level o this ability. + /// Gets the policy that determines when this granted ability should be removed. + /// + internal GrantedAbilityRemovalPolicy GrantedAbilityRemovalPolicy { get; } + + /// + /// Gets the entity that is the source of this ability. + /// + internal IForgeEntity? SourceEntity { get; } + + internal AbilityHandle Handle { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The data defining this ability. + /// The level of the ability. + /// The policy that determines when this granted ability should be removed. + /// + /// The entity that granted us this ability. + internal Ability( + AbilityData abilityData, + int level, + GrantedAbilityRemovalPolicy grantedAbilityRemovalPolicy = GrantedAbilityRemovalPolicy.CancelImmediately, + IForgeEntity? sourceEntity = null) + { + AbilityData = abilityData; + Level = level; + GrantedAbilityRemovalPolicy = grantedAbilityRemovalPolicy; + SourceEntity = sourceEntity; + + Handle = new AbilityHandle(this); + } + + /// + /// Activates the ability and increments the active count. /// - public int Level { get; } = level; + internal void Activate() + { + _activeCount++; + Console.WriteLine($"Ability {AbilityData.Name} activated. Active count: {_activeCount}"); + } } diff --git a/Forge/Abilities/AbilityHandle.cs b/Forge/Abilities/AbilityHandle.cs new file mode 100644 index 0000000..b98a096 --- /dev/null +++ b/Forge/Abilities/AbilityHandle.cs @@ -0,0 +1,29 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Represents a handle to a granted ability. +/// +public class AbilityHandle +{ + internal Ability? Ability { get; private set; } + + internal AbilityHandle(Ability ability) + { + Ability = ability; + } + + /// + /// Activates the ability associated with this handle. + /// + public void Activate() + { + Ability?.Activate(); + } + + internal void Free() + { + Ability = null; + } +} diff --git a/Forge/Abilities/GrantedAbilityRemovalPolicy.cs b/Forge/Abilities/GrantedAbilityRemovalPolicy.cs new file mode 100644 index 0000000..fb468f1 --- /dev/null +++ b/Forge/Abilities/GrantedAbilityRemovalPolicy.cs @@ -0,0 +1,29 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Specifies the policy for removing a granted ability when the effect that granted it ends. +/// +/// +/// This enumeration defines the behavior for handling the removal of abilities that are granted by a specific effect. +/// The policy determines whether the ability is removed immediately, allowed to complete its current execution, or +/// retained indefinitely. +/// +public enum GrantedAbilityRemovalPolicy : byte +{ + /// + /// Ability is removed immediately when the granting effect ends. + /// + CancelImmediately = 0, + + /// + /// Ability is removed when the granting effect ends, but it is allowed to finish its current execution first. + /// + RemoveOnEnd = 1, + + /// + /// Granted ability is not removed when the granting effect ends. + /// + DoNotRemove = 2, +} diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs new file mode 100644 index 0000000..7709129 --- /dev/null +++ b/Forge/Core/EntityAbilities.cs @@ -0,0 +1,57 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Abilities; + +namespace Gamesmiths.Forge.Core; + +/// +/// Manager for handling an entity's abilities. +/// +public class EntityAbilities +{ + /// + /// Gets the set of abilities currently granted to the entity. + /// + public HashSet GrantedAbilities { get; } = []; + + /// + /// Grants a new ability to the entity. + /// + /// Ability data defining the ability to be granted. + /// The level of the granted ability. + /// Removal policy for the granted ability. + /// The entity that is the source of this ability. + /// Returns the newly granted ability instance. + internal AbilityHandle GrantAbility( + AbilityData abilityData, + int abilityLevel, + GrantedAbilityRemovalPolicy removalPolicy, + IForgeEntity? source) + { + var newAbility = new Ability(abilityData, abilityLevel, removalPolicy, source); + GrantedAbilities.Add(newAbility.Handle); + return newAbility.Handle; + } + + internal void RemoveGrantedAbility(AbilityData abilityData) + { + // TODO: Implement removal policies. + AbilityHandle? abilityToRemove = GrantedAbilities.FirstOrDefault(x => x.Ability?.AbilityData == abilityData); + + if (abilityToRemove is not null) + { + abilityToRemove.Free(); + GrantedAbilities.Remove(abilityToRemove); + } + } + + internal void RemoveGrantedAbility(AbilityHandle abilityHandle) + { + // TODO: Implement removal policies. + if (GrantedAbilities.Contains(abilityHandle)) + { + abilityHandle.Free(); + GrantedAbilities.Remove(abilityHandle); + } + } +} diff --git a/Forge/Core/IForgeEntity.cs b/Forge/Core/IForgeEntity.cs index 128d2aa..48bb96e 100644 --- a/Forge/Core/IForgeEntity.cs +++ b/Forge/Core/IForgeEntity.cs @@ -1,6 +1,5 @@ // Copyright © Gamesmiths Guild. -using Gamesmiths.Forge.Abilities; using Gamesmiths.Forge.Effects; namespace Gamesmiths.Forge.Core; diff --git a/Forge/Effects/ActiveEffect.cs b/Forge/Effects/ActiveEffect.cs index daa6783..e8d4648 100644 --- a/Forge/Effects/ActiveEffect.cs +++ b/Forge/Effects/ActiveEffect.cs @@ -18,12 +18,12 @@ internal sealed class ActiveEffect private double _internalTime; - private bool _isInhibited; - internal ActiveEffectHandle Handle { get; } internal EffectEvaluatedData EffectEvaluatedData { get; private set; } + internal bool IsInhibited { get; private set; } + internal double RemainingDuration { get; set; } internal double NextPeriodicTick { get; private set; } @@ -62,7 +62,7 @@ internal void Apply(bool reApplication = false, bool inhibited = false) { ExecutionCount = 0; _internalTime = 0; - _isInhibited = inhibited; + IsInhibited = inhibited; RemainingDuration = EffectEvaluatedData.Duration; if (!EffectData.SnapshopLevel) @@ -79,7 +79,7 @@ internal void Apply(bool reApplication = false, bool inhibited = false) if (EffectData.PeriodicData.HasValue) { if (EffectData.PeriodicData.Value.ExecuteOnApplication && - !reApplication && !_isInhibited) + !reApplication && !IsInhibited) { Execute(); } @@ -89,7 +89,7 @@ internal void Apply(bool reApplication = false, bool inhibited = false) NextPeriodicTick = EffectEvaluatedData.Period; } } - else if (!_isInhibited) + else if (!IsInhibited) { ApplyModifiers(); } @@ -97,7 +97,7 @@ internal void Apply(bool reApplication = false, bool inhibited = false) internal void Unapply(bool reApplication = false) { - if (!EffectData.PeriodicData.HasValue && !_isInhibited) + if (!EffectData.PeriodicData.HasValue && !IsInhibited) { ApplyModifiers(true); } @@ -255,7 +255,7 @@ internal bool AddStack(Effect effect, int stacks = 1) NextPeriodicTick = EffectEvaluatedData.Period; } - if (stackingData.ExecuteOnSuccessfulApplication == true && !_isInhibited) + if (stackingData.ExecuteOnSuccessfulApplication == true && !IsInhibited) { Execute(); } @@ -327,16 +327,16 @@ internal void Update(double deltaTime) internal void SetInhibit(bool value) { - if (_isInhibited == value) + if (IsInhibited == value) { return; } - _isInhibited = value; + IsInhibited = value; if (EffectData.PeriodicData.HasValue) { - if (_isInhibited) + if (IsInhibited) { return; } @@ -356,7 +356,7 @@ internal void SetInhibit(bool value) return; } - ApplyModifiers(_isInhibited); + ApplyModifiers(IsInhibited); } private void ExecutePeriodicEffects(double deltaTime) @@ -367,7 +367,7 @@ private void ExecutePeriodicEffects(double deltaTime) { while (_internalTime >= NextPeriodicTick - Epsilon) { - if (!_isInhibited) + if (!IsInhibited) { Execute(); } diff --git a/Forge/Effects/ActiveEffectHandle.cs b/Forge/Effects/ActiveEffectHandle.cs index 13a9f25..08f8025 100644 --- a/Forge/Effects/ActiveEffectHandle.cs +++ b/Forge/Effects/ActiveEffectHandle.cs @@ -7,6 +7,11 @@ namespace Gamesmiths.Forge.Effects; /// public class ActiveEffectHandle { + /// + /// Gets a value indicating whether the effect is currently inhibited. + /// + public bool IsInhibited => ActiveEffect?.IsInhibited ?? false; + internal ActiveEffect? ActiveEffect { get; private set; } internal ActiveEffectHandle(ActiveEffect activeEffect) From 59d22bb7a80cc7bd6552c7f1d8fcbf3a68ad8af8 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 21 Sep 2025 17:12:17 -0300 Subject: [PATCH 05/87] Moved LevelComparison to Core namespace --- Forge/{Effects/Stacking => Core}/LevelComparison.cs | 4 ++-- Forge/Effects/Stacking/StackingData.cs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) rename Forge/{Effects/Stacking => Core}/LevelComparison.cs (73%) diff --git a/Forge/Effects/Stacking/LevelComparison.cs b/Forge/Core/LevelComparison.cs similarity index 73% rename from Forge/Effects/Stacking/LevelComparison.cs rename to Forge/Core/LevelComparison.cs index 082f4a1..27c188c 100644 --- a/Forge/Effects/Stacking/LevelComparison.cs +++ b/Forge/Core/LevelComparison.cs @@ -1,9 +1,9 @@ // Copyright © Gamesmiths Guild. -namespace Gamesmiths.Forge.Effects.Stacking; +namespace Gamesmiths.Forge.Core; /// -/// Type of level comparison for when is set. +/// Flags for comparing levels. /// [Flags] public enum LevelComparison : byte diff --git a/Forge/Effects/Stacking/StackingData.cs b/Forge/Effects/Stacking/StackingData.cs index 3bafd8a..62c6dbf 100644 --- a/Forge/Effects/Stacking/StackingData.cs +++ b/Forge/Effects/Stacking/StackingData.cs @@ -1,5 +1,6 @@ // Copyright © Gamesmiths Guild. +using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Effects.Magnitudes; namespace Gamesmiths.Forge.Effects.Stacking; From 795a2457c308529f93c9456a5f0d33f610c251a6 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 21 Sep 2025 17:13:12 -0300 Subject: [PATCH 06/87] Added GrantAbilityEffectComponent --- Forge/Abilities/Ability.cs | 25 +++++- Forge/Core/EntityAbilities.cs | 77 ++++++++++++++++--- .../Effects/Components/GrantAbilityConfig.cs | 21 +++++ .../Components/GrantAbilityEffectComponent.cs | 71 +++++++++++++++++ 4 files changed, 182 insertions(+), 12 deletions(-) create mode 100644 Forge/Effects/Components/GrantAbilityConfig.cs create mode 100644 Forge/Effects/Components/GrantAbilityEffectComponent.cs diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 27f0da7..6186da1 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -11,15 +11,17 @@ internal class Ability { private int _activeCount; + internal event Action? OnAbilityDeactivated; + /// /// Gets the ability data for this ability. /// internal AbilityData AbilityData { get; } /// - /// Gets the current level o this ability. + /// Gets or sets the current level o this ability. /// - internal int Level { get; } + internal int Level { get; set; } /// /// Gets the policy that determines when this granted ability should be removed. @@ -33,6 +35,8 @@ internal class Ability internal AbilityHandle Handle { get; } + internal bool IsActive => _activeCount > 0; + /// /// Initializes a new instance of the class. /// @@ -52,6 +56,8 @@ internal Ability( GrantedAbilityRemovalPolicy = grantedAbilityRemovalPolicy; SourceEntity = sourceEntity; + _activeCount = 0; + Handle = new AbilityHandle(this); } @@ -63,4 +69,19 @@ internal void Activate() _activeCount++; Console.WriteLine($"Ability {AbilityData.Name} activated. Active count: {_activeCount}"); } + + internal void Deactivate() + { + OnAbilityDeactivated?.Invoke(this); + + if (_activeCount > 0) + { + _activeCount--; + Console.WriteLine($"Ability {AbilityData.Name} deactivated. Active count: {_activeCount}"); + } + else + { + Console.WriteLine($"Ability {AbilityData.Name} is not active."); + } + } } diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index 7709129..070a065 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -9,6 +9,8 @@ namespace Gamesmiths.Forge.Core; /// public class EntityAbilities { + private readonly Dictionary _grantetAbilitiesCounts = []; + /// /// Gets the set of abilities currently granted to the entity. /// @@ -20,38 +22,93 @@ public class EntityAbilities /// Ability data defining the ability to be granted. /// The level of the granted ability. /// Removal policy for the granted ability. + /// The policy for overriding the level if the ability already exists. /// The entity that is the source of this ability. /// Returns the newly granted ability instance. internal AbilityHandle GrantAbility( AbilityData abilityData, int abilityLevel, GrantedAbilityRemovalPolicy removalPolicy, + LevelComparison levelOverridePolicy, IForgeEntity? source) { + Ability? abilityToAdd = GrantedAbilities.FirstOrDefault(x => x?.Ability?.AbilityData == abilityData)?.Ability; + + if (abilityToAdd is not null && abilityToAdd.SourceEntity == source) + { + // Ability already granted, just increment the count. + _grantetAbilitiesCounts[abilityToAdd]++; + + var shouldOverride = + (levelOverridePolicy.HasFlag(LevelComparison.Higher) && abilityLevel > abilityToAdd.Level) || + (levelOverridePolicy.HasFlag(LevelComparison.Lower) && abilityLevel < abilityToAdd.Level) || + (levelOverridePolicy.HasFlag(LevelComparison.Equal) && abilityLevel == abilityToAdd.Level); + + if (shouldOverride) + { + abilityToAdd.Level = abilityLevel; + } + + return abilityToAdd.Handle; + } + var newAbility = new Ability(abilityData, abilityLevel, removalPolicy, source); GrantedAbilities.Add(newAbility.Handle); + _grantetAbilitiesCounts[newAbility] = 1; return newAbility.Handle; } internal void RemoveGrantedAbility(AbilityData abilityData) { - // TODO: Implement removal policies. - AbilityHandle? abilityToRemove = GrantedAbilities.FirstOrDefault(x => x.Ability?.AbilityData == abilityData); + RemoveGrantedAbility(GrantedAbilities.FirstOrDefault(x => x?.Ability?.AbilityData == abilityData)?.Ability); + } + + internal void RemoveGrantedAbility(AbilityHandle abilityHandle) + { + RemoveGrantedAbility(GrantedAbilities.FirstOrDefault(x => x == abilityHandle)?.Ability); + } + + internal void RemoveGrantedAbility(Ability? abilityToRemove) + { + if (abilityToRemove is null) + { + return; + } - if (abilityToRemove is not null) + switch (abilityToRemove.GrantedAbilityRemovalPolicy) { - abilityToRemove.Free(); - GrantedAbilities.Remove(abilityToRemove); + case GrantedAbilityRemovalPolicy.DoNotRemove: + return; + + case GrantedAbilityRemovalPolicy.CancelImmediately: + if (abilityToRemove.IsActive) + { + abilityToRemove.Deactivate(); + } + + RemoveAbility(abilityToRemove); + return; + + case GrantedAbilityRemovalPolicy.RemoveOnEnd: + if (abilityToRemove.IsActive) + { + abilityToRemove.OnAbilityDeactivated += RemoveAbility; + } + + return; } } - internal void RemoveGrantedAbility(AbilityHandle abilityHandle) + private void RemoveAbility(Ability abilityToRemove) { - // TODO: Implement removal policies. - if (GrantedAbilities.Contains(abilityHandle)) + abilityToRemove.OnAbilityDeactivated -= RemoveAbility; + + _grantetAbilitiesCounts[abilityToRemove]--; + + if (_grantetAbilitiesCounts[abilityToRemove] == 0) { - abilityHandle.Free(); - GrantedAbilities.Remove(abilityHandle); + abilityToRemove.Handle.Free(); + GrantedAbilities.Remove(abilityToRemove.Handle); } } } diff --git a/Forge/Effects/Components/GrantAbilityConfig.cs b/Forge/Effects/Components/GrantAbilityConfig.cs new file mode 100644 index 0000000..3361ba1 --- /dev/null +++ b/Forge/Effects/Components/GrantAbilityConfig.cs @@ -0,0 +1,21 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Abilities; +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Effects.Magnitudes; + +namespace Gamesmiths.Forge.Effects.Components; + +/// +/// Configuration for granting an ability to an entity. +/// +/// The data defining the ability to be granted. +/// The level of the granted ability, which can scale based on the effect level. +/// Which policy to use when determining when to remove the granted ability. +/// How to override the level of the granted ability if it already exists on the +/// target. +public readonly record struct GrantAbilityConfig( + AbilityData AbilityData, + ScalableInt ScalableLevel, + GrantedAbilityRemovalPolicy RemovalPolicy, + LevelComparison LevelOverridePolicy = LevelComparison.None); diff --git a/Forge/Effects/Components/GrantAbilityEffectComponent.cs b/Forge/Effects/Components/GrantAbilityEffectComponent.cs new file mode 100644 index 0000000..852f913 --- /dev/null +++ b/Forge/Effects/Components/GrantAbilityEffectComponent.cs @@ -0,0 +1,71 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Abilities; +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Effects.Components; + +/// +/// Grant an ability to the target when the effect is applied. +/// +/// Configurations for the abilities to be granted. +public class GrantAbilityEffectComponent(GrantAbilityConfig[] grantAbilityConfigs) : IEffectComponent +{ + private readonly GrantAbilityConfig[] _grantAbilityConfigs = grantAbilityConfigs; + + private readonly AbilityHandle[] _grantedAbilities = new AbilityHandle[grantAbilityConfigs.Length]; + + /// + public void OnEffectApplied(IForgeEntity target, in EffectEvaluatedData effectEvaluatedData) + { + GrantAbilities(target, effectEvaluatedData); + } + + /// + public void OnActiveEffectUnapplied( + IForgeEntity target, + in ActiveEffectEvaluatedData activeEffectEvaluatedData, + bool removed) + { + if (removed) + { + RemoveGrantedAbilities(target); + } + } + + /// + public void OnActiveEffectChanged(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) + { + if (activeEffectEvaluatedData.ActiveEffectHandle.IsInhibited) + { + RemoveGrantedAbilities(target); + } + else + { + GrantAbilities(target, activeEffectEvaluatedData.EffectEvaluatedData); + } + } + + private void GrantAbilities(IForgeEntity target, in EffectEvaluatedData effectEvaluatedData) + { + for (var i = 0; i < _grantAbilityConfigs.Length; i++) + { + GrantAbilityConfig config = _grantAbilityConfigs[i]; + + _grantedAbilities[i] = target.Abilities.GrantAbility( + config.AbilityData, + config.ScalableLevel.GetValue(effectEvaluatedData.Level), + config.RemovalPolicy, + config.LevelOverridePolicy, + effectEvaluatedData.Effect.Ownership.Owner); + } + } + + private void RemoveGrantedAbilities(IForgeEntity target) + { + foreach (AbilityHandle ability in _grantedAbilities) + { + target.Abilities.RemoveGrantedAbility(ability); + } + } +} From c85e44198ce04efbead04336225694e73b7f5d3e Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 6 Oct 2025 20:30:08 -0300 Subject: [PATCH 07/87] Added ability inhibition --- Forge.Tests/Tags/TagTests.cs | 4 +- Forge/Abilities/Ability.cs | 16 +- Forge/Abilities/AbilityDeactivationPolicy.cs | 30 +++ Forge/Abilities/AbilityHandle.cs | 18 ++ .../Abilities/GrantedAbilityRemovalPolicy.cs | 29 --- Forge/Core/EntityAbilities.cs | 230 +++++++++++++++--- Forge/Effects/ActiveEffect.cs | 2 + .../Effects/Components/GrantAbilityConfig.cs | 5 +- .../Components/GrantAbilityEffectComponent.cs | 57 ++++- Forge/Effects/Components/IEffectComponent.cs | 2 +- .../TargetTagRequirementsEffectComponent.cs | 3 +- 11 files changed, 310 insertions(+), 86 deletions(-) create mode 100644 Forge/Abilities/AbilityDeactivationPolicy.cs delete mode 100644 Forge/Abilities/GrantedAbilityRemovalPolicy.cs diff --git a/Forge.Tests/Tags/TagTests.cs b/Forge.Tests/Tags/TagTests.cs index 8e670c8..33b48e4 100644 --- a/Forge.Tests/Tags/TagTests.cs +++ b/Forge.Tests/Tags/TagTests.cs @@ -159,7 +159,7 @@ public void Higher_than_InvalidTagNetIndex_value_deserialization_throws_exceptio } [Theory] - [Trait("IsValid", "Correct")] + [Trait("IsActive", "Correct")] [InlineData("Entity.Attributes.Strengh")] [InlineData("item.consumable.potion.stamina")] [InlineData("color.black")] @@ -170,7 +170,7 @@ public void Correctly_formatted_string_is_a_valid_tag_keys(string tagKey) } [Theory] - [Trait("IsValid", "Incorrect")] + [Trait("IsActive", "Incorrect")] [InlineData(" Entity,Attr ibutes,Strength ", "Entity_Attr_ibutes_Strength")] [InlineData("item.consumable.potion.stamina ", "item.consumable.potion.stamina")] [InlineData("color.dark.green.", "color.dark.green")] diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 6186da1..bdc6d6d 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -26,7 +26,12 @@ internal class Ability /// /// Gets the policy that determines when this granted ability should be removed. /// - internal GrantedAbilityRemovalPolicy GrantedAbilityRemovalPolicy { get; } + internal AbilityDeactivationPolicy GrantedAbilityRemovalPolicy { get; } + + /// + /// Gets the policy that determines how this ability behaves when it is inhibited. + /// + internal AbilityDeactivationPolicy GrantedAbilityInhibitionPolicy { get; } /// /// Gets the entity that is the source of this ability. @@ -35,6 +40,8 @@ internal class Ability internal AbilityHandle Handle { get; } + internal bool IsInhibited { get; set; } + internal bool IsActive => _activeCount > 0; /// @@ -44,19 +51,24 @@ internal class Ability /// The level of the ability. /// The policy that determines when this granted ability should be removed. /// + /// The policy that determines how this ability behaves when it is + /// inhibited. /// The entity that granted us this ability. internal Ability( AbilityData abilityData, int level, - GrantedAbilityRemovalPolicy grantedAbilityRemovalPolicy = GrantedAbilityRemovalPolicy.CancelImmediately, + AbilityDeactivationPolicy grantedAbilityRemovalPolicy = AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy grantedAbilityInhibitionPolicy = AbilityDeactivationPolicy.CancelImmediately, IForgeEntity? sourceEntity = null) { AbilityData = abilityData; Level = level; GrantedAbilityRemovalPolicy = grantedAbilityRemovalPolicy; + GrantedAbilityInhibitionPolicy = grantedAbilityInhibitionPolicy; SourceEntity = sourceEntity; _activeCount = 0; + IsInhibited = false; Handle = new AbilityHandle(this); } diff --git a/Forge/Abilities/AbilityDeactivationPolicy.cs b/Forge/Abilities/AbilityDeactivationPolicy.cs new file mode 100644 index 0000000..59e7a0f --- /dev/null +++ b/Forge/Abilities/AbilityDeactivationPolicy.cs @@ -0,0 +1,30 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Specifies the policy for removing or inhibiting a granted ability based on its activation state. +/// +/// +/// This enumeration defines the behavior for handling the removal and inhibition of abilities. +/// The policy determines whether the ability is removed or inhibited immediately, allowed to complete its current +/// execution, or retained indefinitely. +/// +public enum AbilityDeactivationPolicy : byte +{ + /// + /// Ability is removed or inhibited immediately. + /// + CancelImmediately = 0, + + /// + /// Ability is removed or inhibited, but it is allowed to finish its current execution + /// first. + /// + RemoveOnEnd = 1, + + /// + /// Granted ability is not removed or inhibited. + /// + Ignore = 2, +} diff --git a/Forge/Abilities/AbilityHandle.cs b/Forge/Abilities/AbilityHandle.cs index b98a096..79f74b2 100644 --- a/Forge/Abilities/AbilityHandle.cs +++ b/Forge/Abilities/AbilityHandle.cs @@ -7,6 +7,16 @@ namespace Gamesmiths.Forge.Abilities; /// public class AbilityHandle { + /// + /// Gets a value indicating whether the ability associated with this handle is valid and active. + /// + public bool IsActive => Ability?.IsActive == true; + + /// + /// Gets a value indicating whether the ability associated with this handle is currently inhibited. + /// + public bool IsInhibited => Ability?.IsInhibited == true; + internal Ability? Ability { get; private set; } internal AbilityHandle(Ability ability) @@ -22,6 +32,14 @@ public void Activate() Ability?.Activate(); } + /// + /// End the ability associated with this handle. + /// + public void End() + { + Ability?.Deactivate(); + } + internal void Free() { Ability = null; diff --git a/Forge/Abilities/GrantedAbilityRemovalPolicy.cs b/Forge/Abilities/GrantedAbilityRemovalPolicy.cs deleted file mode 100644 index fb468f1..0000000 --- a/Forge/Abilities/GrantedAbilityRemovalPolicy.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright © Gamesmiths Guild. - -namespace Gamesmiths.Forge.Abilities; - -/// -/// Specifies the policy for removing a granted ability when the effect that granted it ends. -/// -/// -/// This enumeration defines the behavior for handling the removal of abilities that are granted by a specific effect. -/// The policy determines whether the ability is removed immediately, allowed to complete its current execution, or -/// retained indefinitely. -/// -public enum GrantedAbilityRemovalPolicy : byte -{ - /// - /// Ability is removed immediately when the granting effect ends. - /// - CancelImmediately = 0, - - /// - /// Ability is removed when the granting effect ends, but it is allowed to finish its current execution first. - /// - RemoveOnEnd = 1, - - /// - /// Granted ability is not removed when the granting effect ends. - /// - DoNotRemove = 2, -} diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index 070a065..85b8079 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -1,6 +1,8 @@ // Copyright © Gamesmiths Guild. +using System.Diagnostics; using Gamesmiths.Forge.Abilities; +using Gamesmiths.Forge.Effects; namespace Gamesmiths.Forge.Core; @@ -9,78 +11,152 @@ namespace Gamesmiths.Forge.Core; /// public class EntityAbilities { - private readonly Dictionary _grantetAbilitiesCounts = []; + private readonly Dictionary?> _grantSources = []; + + private readonly Dictionary?> _inhibitSources = []; /// /// Gets the set of abilities currently granted to the entity. /// public HashSet GrantedAbilities { get; } = []; - /// - /// Grants a new ability to the entity. - /// - /// Ability data defining the ability to be granted. - /// The level of the granted ability. - /// Removal policy for the granted ability. - /// The policy for overriding the level if the ability already exists. - /// The entity that is the source of this ability. - /// Returns the newly granted ability instance. - internal AbilityHandle GrantAbility( + internal void GrantAbilityPermanently( AbilityData abilityData, int abilityLevel, - GrantedAbilityRemovalPolicy removalPolicy, + AbilityDeactivationPolicy removalPolicy, + AbilityDeactivationPolicy inhibitionPolicy, LevelComparison levelOverridePolicy, - IForgeEntity? source) + IForgeEntity? sourceEntity) { - Ability? abilityToAdd = GrantedAbilities.FirstOrDefault(x => x?.Ability?.AbilityData == abilityData)?.Ability; + Ability? existingAbility = + GrantedAbilities.FirstOrDefault(x => x?.Ability?.AbilityData == abilityData)?.Ability; - if (abilityToAdd is not null && abilityToAdd.SourceEntity == source) + if (existingAbility is not null && existingAbility.SourceEntity == sourceEntity) { - // Ability already granted, just increment the count. - _grantetAbilitiesCounts[abilityToAdd]++; + _grantSources[existingAbility] = null; + _inhibitSources.Remove(existingAbility); + + // If the ability was fully inhibited, this permanent grant should re-enable it. + existingAbility.IsInhibited = false; var shouldOverride = - (levelOverridePolicy.HasFlag(LevelComparison.Higher) && abilityLevel > abilityToAdd.Level) || - (levelOverridePolicy.HasFlag(LevelComparison.Lower) && abilityLevel < abilityToAdd.Level) || - (levelOverridePolicy.HasFlag(LevelComparison.Equal) && abilityLevel == abilityToAdd.Level); + (levelOverridePolicy.HasFlag(LevelComparison.Higher) && abilityLevel > existingAbility.Level) || + (levelOverridePolicy.HasFlag(LevelComparison.Lower) && abilityLevel < existingAbility.Level) || + (levelOverridePolicy.HasFlag(LevelComparison.Equal) && abilityLevel == existingAbility.Level); if (shouldOverride) { - abilityToAdd.Level = abilityLevel; + existingAbility.Level = abilityLevel; } - return abilityToAdd.Handle; + return; } - var newAbility = new Ability(abilityData, abilityLevel, removalPolicy, source); + var newAbility = new Ability(abilityData, abilityLevel, removalPolicy, inhibitionPolicy, sourceEntity); GrantedAbilities.Add(newAbility.Handle); - _grantetAbilitiesCounts[newAbility] = 1; - return newAbility.Handle; } - internal void RemoveGrantedAbility(AbilityData abilityData) + internal AbilityHandle GrantAbility( + AbilityData abilityData, + int abilityLevel, + AbilityDeactivationPolicy removalPolicy, + AbilityDeactivationPolicy inhibitionPolicy, + LevelComparison levelOverridePolicy, + ActiveEffectHandle sourceActiveEffectHandle, + IForgeEntity? sourceEntity) { - RemoveGrantedAbility(GrantedAbilities.FirstOrDefault(x => x?.Ability?.AbilityData == abilityData)?.Ability); + Ability? existingAbility = + GrantedAbilities.FirstOrDefault(x => x?.Ability?.AbilityData == abilityData)?.Ability; + + if (existingAbility is not null && existingAbility.SourceEntity == sourceEntity) + { + List? grantSources = _grantSources[existingAbility]; + if (grantSources is not null) + { + List? inhibitSources = _inhibitSources[existingAbility]; + + Debug.Assert( + inhibitSources is not null, + "InhibitAbilityBasedOnPolicy grantSources should not be null if grant grantSources are not null."); + + // Ability already granted, just add the new source to the mapping. + grantSources.Add(sourceActiveEffectHandle); + + if (sourceActiveEffectHandle.IsInhibited) + { + inhibitSources.Add(sourceActiveEffectHandle); + } + + // If the ability was fully inhibited, this new grant may need to re-enable it. + existingAbility.IsInhibited = inhibitSources.Count == grantSources.Count; + } + + var shouldOverride = + (levelOverridePolicy.HasFlag(LevelComparison.Higher) && abilityLevel > existingAbility.Level) || + (levelOverridePolicy.HasFlag(LevelComparison.Lower) && abilityLevel < existingAbility.Level) || + (levelOverridePolicy.HasFlag(LevelComparison.Equal) && abilityLevel == existingAbility.Level); + + if (shouldOverride) + { + existingAbility.Level = abilityLevel; + } + + return existingAbility.Handle; + } + + var newAbility = new Ability(abilityData, abilityLevel, removalPolicy, inhibitionPolicy, sourceEntity); + GrantedAbilities.Add(newAbility.Handle); + _grantSources[newAbility] = [sourceActiveEffectHandle]; + + newAbility.IsInhibited = sourceActiveEffectHandle.IsInhibited; + _inhibitSources[newAbility] = newAbility.IsInhibited ? [sourceActiveEffectHandle] : []; + + return newAbility.Handle; } - internal void RemoveGrantedAbility(AbilityHandle abilityHandle) + internal void RemoveGrantedAbility(AbilityHandle abilityHandle, ActiveEffectHandle effectHandle) { - RemoveGrantedAbility(GrantedAbilities.FirstOrDefault(x => x == abilityHandle)?.Ability); + RemoveGrantedAbility(GrantedAbilities.FirstOrDefault(x => x == abilityHandle)?.Ability, effectHandle); } - internal void RemoveGrantedAbility(Ability? abilityToRemove) + internal void RemoveGrantedAbility(Ability? abilityToRemove, ActiveEffectHandle effectHandle) { - if (abilityToRemove is null) + if (abilityToRemove is null + || abilityToRemove.GrantedAbilityRemovalPolicy == AbilityDeactivationPolicy.Ignore + || _grantSources[abilityToRemove] is null) { return; } + List? grantSources = _grantSources[abilityToRemove]; + List? inhibitSources = _inhibitSources[abilityToRemove]; + + Debug.Assert( + grantSources is not null, + "Grant grantSources should not be null at this point."); + Debug.Assert( + inhibitSources is not null, + "InhibitAbilityBasedOnPolicy grantSources should not be null if grant grantSources are not null."); + + grantSources.Remove(effectHandle); + inhibitSources.Remove(effectHandle); + + if (grantSources.Count > 0) + { + if (inhibitSources.Count == grantSources.Count) + { + InhibitAbilityBasedOnPolicy(abilityToRemove); + } + + return; + } + switch (abilityToRemove.GrantedAbilityRemovalPolicy) { - case GrantedAbilityRemovalPolicy.DoNotRemove: + case AbilityDeactivationPolicy.Ignore: return; - case GrantedAbilityRemovalPolicy.CancelImmediately: + case AbilityDeactivationPolicy.CancelImmediately: if (abilityToRemove.IsActive) { abilityToRemove.Deactivate(); @@ -89,7 +165,7 @@ internal void RemoveGrantedAbility(Ability? abilityToRemove) RemoveAbility(abilityToRemove); return; - case GrantedAbilityRemovalPolicy.RemoveOnEnd: + case AbilityDeactivationPolicy.RemoveOnEnd: if (abilityToRemove.IsActive) { abilityToRemove.OnAbilityDeactivated += RemoveAbility; @@ -99,16 +175,94 @@ internal void RemoveGrantedAbility(Ability? abilityToRemove) } } + internal void InhibitGrantedAbility(AbilityHandle abilityHandle, bool inhibit, ActiveEffectHandle effectHandle) + { + InhibitGrantedAbility(GrantedAbilities.FirstOrDefault(x => x == abilityHandle)?.Ability, inhibit, effectHandle); + } + + internal void InhibitGrantedAbility(Ability? abilityToInhibit, bool inhibit, ActiveEffectHandle effectHandle) + { + if (abilityToInhibit is null + || abilityToInhibit.GrantedAbilityInhibitionPolicy == AbilityDeactivationPolicy.Ignore + || _grantSources[abilityToInhibit] is null) + { + return; + } + + List? inhibitSources = _inhibitSources[abilityToInhibit]; + + Debug.Assert( + inhibitSources is not null, + "InhibitAbilityBasedOnPolicy grantSources should not be null if grant grantSources are not null."); + + if (inhibit) + { + inhibitSources.Add(effectHandle); + + if (inhibitSources.Count > 1) + { + return; + } + + InhibitAbilityBasedOnPolicy(abilityToInhibit); + } + else + { + inhibitSources.Remove(effectHandle); + + if (inhibitSources.Count == 0) + { + abilityToInhibit.IsInhibited = false; + } + } + } + + private void InhibitAbilityBasedOnPolicy(Ability abilityToInhibit) + { + switch (abilityToInhibit.GrantedAbilityInhibitionPolicy) + { + case AbilityDeactivationPolicy.Ignore: + return; + + case AbilityDeactivationPolicy.CancelImmediately: + if (abilityToInhibit.IsActive) + { + abilityToInhibit.Deactivate(); + } + + InhibitAbility(abilityToInhibit); + return; + + case AbilityDeactivationPolicy.RemoveOnEnd: + if (abilityToInhibit.IsActive) + { + abilityToInhibit.OnAbilityDeactivated += InhibitAbility; + } + + return; + } + } + private void RemoveAbility(Ability abilityToRemove) { abilityToRemove.OnAbilityDeactivated -= RemoveAbility; - _grantetAbilitiesCounts[abilityToRemove]--; + if (_grantSources[abilityToRemove]?.Count > 0) + { + return; + } + + abilityToRemove.Handle.Free(); + GrantedAbilities.Remove(abilityToRemove.Handle); + } + + private void InhibitAbility(Ability abilityToInhibit) + { + abilityToInhibit.OnAbilityDeactivated -= InhibitAbility; - if (_grantetAbilitiesCounts[abilityToRemove] == 0) + if (_grantSources[abilityToInhibit]?.Count == _inhibitSources[abilityToInhibit]?.Count) { - abilityToRemove.Handle.Free(); - GrantedAbilities.Remove(abilityToRemove.Handle); + abilityToInhibit.IsInhibited = true; } } } diff --git a/Forge/Effects/ActiveEffect.cs b/Forge/Effects/ActiveEffect.cs index e8d4648..913f572 100644 --- a/Forge/Effects/ActiveEffect.cs +++ b/Forge/Effects/ActiveEffect.cs @@ -357,6 +357,8 @@ internal void SetInhibit(bool value) } ApplyModifiers(IsInhibited); + + EffectEvaluatedData.Target.EffectsManager.OnActiveEffectChanged_InternalCall(this); } private void ExecutePeriodicEffects(double deltaTime) diff --git a/Forge/Effects/Components/GrantAbilityConfig.cs b/Forge/Effects/Components/GrantAbilityConfig.cs index 3361ba1..779254c 100644 --- a/Forge/Effects/Components/GrantAbilityConfig.cs +++ b/Forge/Effects/Components/GrantAbilityConfig.cs @@ -12,10 +12,13 @@ namespace Gamesmiths.Forge.Effects.Components; /// The data defining the ability to be granted. /// The level of the granted ability, which can scale based on the effect level. /// Which policy to use when determining when to remove the granted ability. +/// Which policy to use when determining how the granted ability behaves when it is +/// inhibited. /// How to override the level of the granted ability if it already exists on the /// target. public readonly record struct GrantAbilityConfig( AbilityData AbilityData, ScalableInt ScalableLevel, - GrantedAbilityRemovalPolicy RemovalPolicy, + AbilityDeactivationPolicy RemovalPolicy, + AbilityDeactivationPolicy InhibitionPolicy, LevelComparison LevelOverridePolicy = LevelComparison.None); diff --git a/Forge/Effects/Components/GrantAbilityEffectComponent.cs b/Forge/Effects/Components/GrantAbilityEffectComponent.cs index 852f913..7f06953 100644 --- a/Forge/Effects/Components/GrantAbilityEffectComponent.cs +++ b/Forge/Effects/Components/GrantAbilityEffectComponent.cs @@ -15,10 +15,20 @@ public class GrantAbilityEffectComponent(GrantAbilityConfig[] grantAbilityConfig private readonly AbilityHandle[] _grantedAbilities = new AbilityHandle[grantAbilityConfigs.Length]; + private bool _isInhibited; + /// - public void OnEffectApplied(IForgeEntity target, in EffectEvaluatedData effectEvaluatedData) + public void OnEffectExecuted(IForgeEntity target, in EffectEvaluatedData effectEvaluatedData) { - GrantAbilities(target, effectEvaluatedData); + GrantAbilitiesPermanently(target, effectEvaluatedData); + } + + /// + public bool OnActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) + { + GrantAbilities(target, activeEffectEvaluatedData); + + return true; } /// @@ -29,24 +39,37 @@ public void OnActiveEffectUnapplied( { if (removed) { - RemoveGrantedAbilities(target); + RemoveGrantedAbilities(target, activeEffectEvaluatedData.ActiveEffectHandle); } } /// public void OnActiveEffectChanged(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) { - if (activeEffectEvaluatedData.ActiveEffectHandle.IsInhibited) + if (_isInhibited != activeEffectEvaluatedData.ActiveEffectHandle.IsInhibited) { - RemoveGrantedAbilities(target); + _isInhibited = activeEffectEvaluatedData.ActiveEffectHandle.IsInhibited; + InhibitGrantedAbilities(target, _isInhibited, activeEffectEvaluatedData.ActiveEffectHandle); } - else + } + + private void GrantAbilitiesPermanently(IForgeEntity target, in EffectEvaluatedData effectEvaluatedData) + { + for (var i = 0; i < _grantAbilityConfigs.Length; i++) { - GrantAbilities(target, activeEffectEvaluatedData.EffectEvaluatedData); + GrantAbilityConfig config = _grantAbilityConfigs[i]; + + target.Abilities.GrantAbilityPermanently( + config.AbilityData, + config.ScalableLevel.GetValue(effectEvaluatedData.Level), + config.RemovalPolicy, + config.InhibitionPolicy, + config.LevelOverridePolicy, + effectEvaluatedData.Effect.Ownership.Owner); } } - private void GrantAbilities(IForgeEntity target, in EffectEvaluatedData effectEvaluatedData) + private void GrantAbilities(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) { for (var i = 0; i < _grantAbilityConfigs.Length; i++) { @@ -54,18 +77,28 @@ private void GrantAbilities(IForgeEntity target, in EffectEvaluatedData effectEv _grantedAbilities[i] = target.Abilities.GrantAbility( config.AbilityData, - config.ScalableLevel.GetValue(effectEvaluatedData.Level), + config.ScalableLevel.GetValue(activeEffectEvaluatedData.EffectEvaluatedData.Level), config.RemovalPolicy, + config.InhibitionPolicy, config.LevelOverridePolicy, - effectEvaluatedData.Effect.Ownership.Owner); + activeEffectEvaluatedData.ActiveEffectHandle, + activeEffectEvaluatedData.EffectEvaluatedData.Effect.Ownership.Owner); + } + } + + private void RemoveGrantedAbilities(IForgeEntity target, ActiveEffectHandle activeEffectHandle) + { + foreach (AbilityHandle ability in _grantedAbilities) + { + target.Abilities.RemoveGrantedAbility(ability, activeEffectHandle); } } - private void RemoveGrantedAbilities(IForgeEntity target) + private void InhibitGrantedAbilities(IForgeEntity target, bool inhibit, ActiveEffectHandle effectHandle) { foreach (AbilityHandle ability in _grantedAbilities) { - target.Abilities.RemoveGrantedAbility(ability); + target.Abilities.InhibitGrantedAbility(ability, inhibit, effectHandle); } } } diff --git a/Forge/Effects/Components/IEffectComponent.cs b/Forge/Effects/Components/IEffectComponent.cs index 6ebab92..4e6b232 100644 --- a/Forge/Effects/Components/IEffectComponent.cs +++ b/Forge/Effects/Components/IEffectComponent.cs @@ -30,7 +30,7 @@ bool CanApplyEffect(in IForgeEntity target, in Effect effect) /// /// The target receiving the active effect. /// The evaluated data for the active effect being added. - /// if the applied effect remains active; if it has been + /// if the applied effect remains active; if it has been /// inhibited by the component during application. bool OnActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) { diff --git a/Forge/Effects/Components/TargetTagRequirementsEffectComponent.cs b/Forge/Effects/Components/TargetTagRequirementsEffectComponent.cs index 0c326a5..103df88 100644 --- a/Forge/Effects/Components/TargetTagRequirementsEffectComponent.cs +++ b/Forge/Effects/Components/TargetTagRequirementsEffectComponent.cs @@ -77,7 +77,8 @@ void Handler(TagContainer tags) _subscriptionMap[handle] = Handler; target.Tags.OnTagsChanged += Handler; - return OngoingTagRequirements?.IsEmpty != false || OngoingTagRequirements.Value.RequirementsMet(target.Tags.CombinedTags); + return OngoingTagRequirements?.IsEmpty != false + || OngoingTagRequirements.Value.RequirementsMet(target.Tags.CombinedTags); } /// From 99c265575b8fe500ef53e8cd8ae3822e0df53508 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 12 Oct 2025 10:53:16 -0300 Subject: [PATCH 08/87] Added some AbilityTests --- Forge.Tests/Abilities/AbilitiesTests.cs | 784 ++++++++++++++++++++++++ Forge/Abilities/Ability.cs | 6 + Forge/Core/EntityAbilities.cs | 34 +- 3 files changed, 801 insertions(+), 23 deletions(-) create mode 100644 Forge.Tests/Abilities/AbilitiesTests.cs diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs new file mode 100644 index 0000000..8ed3abb --- /dev/null +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -0,0 +1,784 @@ +// Copyright © Gamesmiths Guild. + +using System.Diagnostics; +using FluentAssertions; +using Gamesmiths.Forge.Abilities; +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Cues; +using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Effects.Components; +using Gamesmiths.Forge.Effects.Duration; +using Gamesmiths.Forge.Effects.Magnitudes; +using Gamesmiths.Forge.Effects.Modifiers; +using Gamesmiths.Forge.Tags; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Abilities; + +public class AbilitiesTests(TagsAndCuesFixture tagsAndCuesFixture) : IClassFixture +{ + private readonly TagsManager _tagsManager = tagsAndCuesFixture.TagsManager; + private readonly CuesManager _cuesManager = tagsAndCuesFixture.CuesManager; + + [Fact] + [Trait("Grant ability", null)] + public void Abilitie_are_granted_successfully() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle); + + Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); + Debug.Assert(effectHandle is not null, "effectHandle is not null."); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle.Activate(); + + abilityHandle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("Remove ability", null)] + public void Removed_ability_is_deactivated_immediately() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle); + + Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); + Debug.Assert(effectHandle is not null, "effectHandle is not null."); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle.Activate(); + + abilityHandle.IsActive.Should().BeTrue(); + + entity.EffectsManager.UnapplyEffect(effectHandle); + + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_is_only_removed_after_being_deactivated() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle, + AbilityDeactivationPolicy.RemoveOnEnd, + AbilityDeactivationPolicy.RemoveOnEnd); + + Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); + Debug.Assert(effectHandle is not null, "effectHandle is not null."); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle.Activate(); + + abilityHandle.IsActive.Should().BeTrue(); + + entity.EffectsManager.UnapplyEffect(effectHandle); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle.End(); + + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Inhibit ability", null)] + public void Inhibited_effect_inhibites_ability_temporarily() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags))); + + Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); + Debug.Assert(effectHandle is not null, "effectHandle is not null."); + Debug.Assert(ignoreTags is not null, "ignoreTags is not null."); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle.Activate(); + + abilityHandle.IsActive.Should().BeTrue(); + + var tagEffectData = new EffectData( + "Tag Effect", + new DurationData(DurationType.Infinite), + effectComponents: [new ModifierTagsEffectComponent(ignoreTags)]); + + var tagEffect = new Effect( + tagEffectData, + new EffectOwnership(entity, null)); + + ActiveEffectHandle? tagEffectHandle = entity.EffectsManager.ApplyEffect(tagEffect); + + Debug.Assert(tagEffectHandle is not null, "tagEffectHandle is not null."); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle.Activate(); + + abilityHandle.IsActive.Should().BeFalse(); + abilityHandle.IsInhibited.Should().BeTrue(); + + entity.EffectsManager.UnapplyEffect(tagEffectHandle); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle.Activate(); + + abilityHandle.IsActive.Should().BeTrue(); + abilityHandle.IsInhibited.Should().BeFalse(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Granted_ability_is_not_removed_when_deactivation_policy_is_ignore() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle, + AbilityDeactivationPolicy.Ignore, + AbilityDeactivationPolicy.Ignore); + + Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); + Debug.Assert(effectHandle is not null, "effectHandle is not null."); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle.Activate(); + + abilityHandle.IsActive.Should().BeTrue(); + + entity.EffectsManager.UnapplyEffect(effectHandle); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle.End(); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_granted_by_multiple_effects_is_removed_only_when_all_effects_are_removed() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle1 = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle1); + + AbilityHandle? abilityHandle2 = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle2); + + Debug.Assert(abilityHandle1 is not null, "abilityHandle1 is not null."); + Debug.Assert(effectHandle1 is not null, "effectHandle1 is not null."); + Debug.Assert(abilityHandle2 is not null, "abilityHandle2 is not null."); + Debug.Assert(effectHandle2 is not null, "effectHandle2 is not null."); + + abilityHandle1.Should().Be(abilityHandle2); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + entity.EffectsManager.UnapplyEffect(effectHandle1); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + entity.EffectsManager.UnapplyEffect(effectHandle2); + + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_is_not_granted_if_target_has_blocking_tags() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? blockingTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + + Debug.Assert(blockingTags is not null, "blockingTags is not null."); + + var grantAbilityConfig = new GrantAbilityConfig( + abilityData, + new ScalableInt(1), + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.CancelImmediately); + + var grantAbilityEffectData = new EffectData( + "Grant Fireball", + new DurationData(DurationType.Infinite), + effectComponents: + [ + new GrantAbilityEffectComponent([grantAbilityConfig]), + new TargetTagRequirementsEffectComponent(applicationTagRequirements: new TagRequirements(IgnoreTags: blockingTags)) + ]); + + var grantAbilityEffect = new Effect( + grantAbilityEffectData, + new EffectOwnership(entity, null)); + + var tagEffectData = new EffectData( + "Tag Effect", + new DurationData(DurationType.Infinite), + effectComponents: [new ModifierTagsEffectComponent(blockingTags)]); + + var tagEffect = new Effect( + tagEffectData, + new EffectOwnership(entity, null)); + + entity.EffectsManager.ApplyEffect(tagEffect); + + entity.EffectsManager.ApplyEffect(grantAbilityEffect); + + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_granted_by_instant_effect_is_permanent() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + // Grant ability with an instant effect, making it permanent. + AbilityHandle? permanentAbilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + durationData: new DurationData(DurationType.Instant)); + + // Grant the same ability with a non-instant effect. + AbilityHandle? temporaryAbilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? temporaryEffectHandle); + + Debug.Assert(permanentAbilityHandle is not null, "permanentAbilityHandle is not null."); + Debug.Assert(temporaryAbilityHandle is not null, "temporaryAbilityHandle is not null."); + Debug.Assert(temporaryEffectHandle is not null, "temporaryEffectHandle is not null."); + + permanentAbilityHandle.Should().Be(temporaryAbilityHandle); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + // Remove the temporary effect. + entity.EffectsManager.UnapplyEffect(temporaryEffectHandle); + + // The ability should still be granted because of the initial permanent grant. + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_granted_by_late_instant_effect_is_permanent() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + // Grant the same ability with a non-instant effect. + AbilityHandle? temporaryAbilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? temporaryEffectHandle); + + Debug.Assert(temporaryAbilityHandle is not null, "temporaryAbilityHandle is not null."); + Debug.Assert(temporaryEffectHandle is not null, "temporaryEffectHandle is not null."); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + // Grant ability with an instant effect, making it permanent. + AbilityHandle? permanentAbilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + durationData: new DurationData(DurationType.Instant)); + + permanentAbilityHandle.Should().Be(temporaryAbilityHandle); + + // Remove the temporary effect. + entity.EffectsManager.UnapplyEffect(temporaryEffectHandle); + + // The ability should still be granted because of the initial permanent grant. + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_granted_by_instant_effect_is_not_inhibited() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + // Grant ability with an instant effect, making it permanent. + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + durationData: new DurationData(DurationType.Instant)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + + // Grant the same ability with a non-instant, inhibitable effect. + SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? temporaryEffectHandle, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags))); + + Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); + Debug.Assert(temporaryEffectHandle is not null, "temporaryEffectHandle is not null."); + Debug.Assert(ignoreTags is not null, "ignoreTags is not null."); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + // Inhibit the temporary effect by adding the tag. + var tagEffectData = new EffectData( + "Tag Effect", + new DurationData(DurationType.Infinite), + effectComponents: [new ModifierTagsEffectComponent(ignoreTags)]); + var tagEffect = new Effect(tagEffectData, new EffectOwnership(entity, null)); + entity.EffectsManager.ApplyEffect(tagEffect); + + // The ability should not be inhibited because it was granted permanently. + abilityHandle.IsInhibited.Should().BeFalse(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_granted_by_late_instant_effect_is_not_inhibited() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + + // Grant the same ability with a non-instant, inhibitable effect. + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? temporaryEffectHandle, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags))); + + Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); + Debug.Assert(temporaryEffectHandle is not null, "temporaryEffectHandle is not null."); + Debug.Assert(ignoreTags is not null, "ignoreTags is not null."); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + // Inhibit the temporary effect by adding the tag. + var tagEffectData = new EffectData( + "Tag Effect", + new DurationData(DurationType.Infinite), + effectComponents: [new ModifierTagsEffectComponent(ignoreTags)]); + var tagEffect = new Effect(tagEffectData, new EffectOwnership(entity, null)); + entity.EffectsManager.ApplyEffect(tagEffect); + + // The ability should not be inhibited because it was granted permanently. + abilityHandle.IsInhibited.Should().BeTrue(); + + // Grant ability with an instant effect, making it permanent. + SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + durationData: new DurationData(DurationType.Instant)); + + // The ability should not be inhibited because it was granted permanently. + abilityHandle.IsInhibited.Should().BeFalse(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_is_inhibited_only_when_all_granting_effects_are_inhibited() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags1 = Tag.RequestTag(_tagsManager, "Simple.Tag").GetSingleTagContainer(); + TagContainer? ignoreTags2 = Tag.RequestTag(_tagsManager, "Other.Tag").GetSingleTagContainer(); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags1))); + + SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags2))); + + Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); + Debug.Assert(ignoreTags1 is not null, "ignoreTags1 is not null."); + Debug.Assert(ignoreTags2 is not null, "ignoreTags2 is not null."); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + abilityHandle.Activate(); + abilityHandle.IsActive.Should().BeTrue(); + + // Inhibit the first effect. + var tagEffect1 = new Effect( + new EffectData("Tag Effect 1", new DurationData(DurationType.Infinite), effectComponents: [new ModifierTagsEffectComponent(ignoreTags1)]), + new EffectOwnership(entity, null)); + entity.EffectsManager.ApplyEffect(tagEffect1); + + // Ability should not be inhibited yet. + abilityHandle.IsInhibited.Should().BeFalse(); + abilityHandle.IsActive.Should().BeFalse(); // It deactivates because one source is inhibited. + + // Inhibit the second effect. + var tagEffect2 = new Effect( + new EffectData("Tag Effect 2", new DurationData(DurationType.Infinite), effectComponents: [new ModifierTagsEffectComponent(ignoreTags2)]), + new EffectOwnership(entity, null)); + entity.EffectsManager.ApplyEffect(tagEffect2); + + // Now the ability should be fully inhibited. + abilityHandle.IsInhibited.Should().BeTrue(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Inhibited_ability_becomes_active_if_new_non_inhibited_source_is_added() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags))); + + Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); + Debug.Assert(ignoreTags is not null, "ignoreTags is not null."); + + // Inhibit the ability. + var tagEffect = new Effect( + new EffectData("Tag Effect", new DurationData(DurationType.Infinite), effectComponents: [new ModifierTagsEffectComponent(ignoreTags)]), + new EffectOwnership(entity, null)); + entity.EffectsManager.ApplyEffect(tagEffect); + + abilityHandle.IsInhibited.Should().BeTrue(); + + // Add a new, non-inhibited source for the same ability. + SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + // The ability should no longer be inhibited. + abilityHandle.IsInhibited.Should().BeFalse(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Inhibition_policy_RemoveOnEnd_inhibits_after_deactivation() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + grantedAbilityInhibitionPolicy: AbilityDeactivationPolicy.RemoveOnEnd, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags))); + + Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); + Debug.Assert(ignoreTags is not null, "ignoreTags is not null."); + + abilityHandle.Activate(); + abilityHandle.IsActive.Should().BeTrue(); + + // Inhibit the granting effect. + var tagEffect = new Effect( + new EffectData("Tag Effect", new DurationData(DurationType.Infinite), effectComponents: [new ModifierTagsEffectComponent(ignoreTags)]), + new EffectOwnership(entity, null)); + entity.EffectsManager.ApplyEffect(tagEffect); + + // With RemoveOnEnd policy, the ability is not inhibited while active. + abilityHandle.IsInhibited.Should().BeFalse(); + abilityHandle.IsActive.Should().BeTrue(); + + // End the ability. + abilityHandle.End(); + + // Now that it's no longer active, it should become inhibited. + abilityHandle.IsActive.Should().BeFalse(); + abilityHandle.IsInhibited.Should().BeTrue(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Inhibition_policy_Ignore_prevents_inhibition() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + grantedAbilityInhibitionPolicy: AbilityDeactivationPolicy.Ignore, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags))); + + Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); + Debug.Assert(ignoreTags is not null, "ignoreTags is not null."); + + abilityHandle.Activate(); + + // Inhibit the granting effect. + var tagEffect = new Effect( + new EffectData("Tag Effect", new DurationData(DurationType.Infinite), effectComponents: [new ModifierTagsEffectComponent(ignoreTags)]), + new EffectOwnership(entity, null)); + entity.EffectsManager.ApplyEffect(tagEffect); + + // With Ignore policy, the ability is never inhibited. + abilityHandle.IsInhibited.Should().BeFalse(); + abilityHandle.IsActive.Should().BeTrue(); + + abilityHandle.End(); + + abilityHandle.IsInhibited.Should().BeFalse(); + abilityHandle.IsActive.Should().BeFalse(); + } + + private static AbilityData CreateAbiltyData( + string abilityName, + ScalableFloat cooldownDuration, + string costAttribute, + ScalableFloat costAmmount) + { + var costEffectData = new EffectData( + "Fireball Cooldown", + new DurationData(DurationType.HasDuration, cooldownDuration)); + + var cooldownEffectData = new EffectData( + "Fireball Cost", + new DurationData(DurationType.Instant), + [ + new Modifier( + costAttribute, + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, costAmmount)) + ]); + + return new( + abilityName, + costEffectData, + cooldownEffectData); + } + + private static AbilityHandle? SetupAbility( + TestEntity entity, + AbilityData abilityData, + ScalableInt abilityLevelScaling, + out ActiveEffectHandle? effectHandle, + AbilityDeactivationPolicy grantedAbilityRemovalPolicy = AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy grantedAbilityInhibitionPolicy = AbilityDeactivationPolicy.CancelImmediately, + DurationData? durationData = null, + IEffectComponent? extraComponent = null) + { + GrantAbilityConfig grantAbilityConfig = new( + abilityData, + abilityLevelScaling, + grantedAbilityRemovalPolicy, + grantedAbilityInhibitionPolicy); + + Effect grantAbilityEffect = CreateAbilityApplierEffect( + "Grant Fireball", + grantAbilityConfig, + entity, + durationData, + extraComponent); + + effectHandle = entity.EffectsManager.ApplyEffect(grantAbilityEffect); + + return entity.Abilities.GrantedAbilities.FirstOrDefault(); + } + + private static Effect CreateAbilityApplierEffect( + string effectName, + GrantAbilityConfig grantAbilityConfig, + IForgeEntity source, + DurationData? durationData, + IEffectComponent? extraComponent) + { + durationData ??= new DurationData(DurationType.Infinite); + + List effectComponents = [new GrantAbilityEffectComponent([grantAbilityConfig])]; + + if (extraComponent is not null) + { + effectComponents.Add(extraComponent); + } + + var grantAbilityEffectData = new EffectData( + effectName, + durationData.Value, + effectComponents: [.. effectComponents]); + + return new Effect( + grantAbilityEffectData, + new EffectOwnership(source, null)); + } +} diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index bdc6d6d..98b84c1 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -78,6 +78,12 @@ internal Ability( /// internal void Activate() { + if (IsInhibited) + { + Console.WriteLine($"Ability {AbilityData.Name} is inhibited and cannot be activated."); + return; + } + _activeCount++; Console.WriteLine($"Ability {AbilityData.Name} activated. Active count: {_activeCount}"); } diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index 85b8079..6543b7e 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -70,14 +70,14 @@ internal AbilityHandle GrantAbility( if (existingAbility is not null && existingAbility.SourceEntity == sourceEntity) { - List? grantSources = _grantSources[existingAbility]; - if (grantSources is not null) + if (_grantSources.TryGetValue(existingAbility, out List? grantSources) + && grantSources is not null) { List? inhibitSources = _inhibitSources[existingAbility]; Debug.Assert( inhibitSources is not null, - "InhibitAbilityBasedOnPolicy grantSources should not be null if grant grantSources are not null."); + "InhibitAbilityBasedOnPolicy inhibitSources should not be null if grant grantSources are not null."); // Ability already granted, just add the new source to the mapping. grantSources.Add(sourceActiveEffectHandle); @@ -123,20 +123,17 @@ internal void RemoveGrantedAbility(Ability? abilityToRemove, ActiveEffectHandle { if (abilityToRemove is null || abilityToRemove.GrantedAbilityRemovalPolicy == AbilityDeactivationPolicy.Ignore - || _grantSources[abilityToRemove] is null) + || !_grantSources.TryGetValue(abilityToRemove, out List? grantSources) + || grantSources is null) { return; } - List? grantSources = _grantSources[abilityToRemove]; List? inhibitSources = _inhibitSources[abilityToRemove]; - Debug.Assert( - grantSources is not null, - "Grant grantSources should not be null at this point."); Debug.Assert( inhibitSources is not null, - "InhibitAbilityBasedOnPolicy grantSources should not be null if grant grantSources are not null."); + "InhibitAbilityBasedOnPolicy inhibitSources should not be null if grant grantSources are not null."); grantSources.Remove(effectHandle); inhibitSources.Remove(effectHandle); @@ -184,33 +181,23 @@ internal void InhibitGrantedAbility(Ability? abilityToInhibit, bool inhibit, Act { if (abilityToInhibit is null || abilityToInhibit.GrantedAbilityInhibitionPolicy == AbilityDeactivationPolicy.Ignore - || _grantSources[abilityToInhibit] is null) + || !_inhibitSources.TryGetValue(abilityToInhibit, out List? inhibitSources) + || inhibitSources is null) { return; } - List? inhibitSources = _inhibitSources[abilityToInhibit]; - - Debug.Assert( - inhibitSources is not null, - "InhibitAbilityBasedOnPolicy grantSources should not be null if grant grantSources are not null."); - if (inhibit) { inhibitSources.Add(effectHandle); - if (inhibitSources.Count > 1) - { - return; - } - InhibitAbilityBasedOnPolicy(abilityToInhibit); } else { inhibitSources.Remove(effectHandle); - if (inhibitSources.Count == 0) + if (_inhibitSources[abilityToInhibit]?.Count < _grantSources[abilityToInhibit]?.Count) { abilityToInhibit.IsInhibited = false; } @@ -247,7 +234,8 @@ private void RemoveAbility(Ability abilityToRemove) { abilityToRemove.OnAbilityDeactivated -= RemoveAbility; - if (_grantSources[abilityToRemove]?.Count > 0) + if (_grantSources.TryGetValue(abilityToRemove, out List? grantSources) + && grantSources?.Count > 0) { return; } From d582f9979ea6e78887f58adfb43ab2b7782111f3 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 12 Oct 2025 11:17:32 -0300 Subject: [PATCH 09/87] Reviewed and refactored AbilityTests --- Forge.Tests/Abilities/AbilitiesTests.cs | 240 ++++++++++-------------- 1 file changed, 94 insertions(+), 146 deletions(-) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index 8ed3abb..a83fe02 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -1,6 +1,5 @@ // Copyright © Gamesmiths Guild. -using System.Diagnostics; using FluentAssertions; using Gamesmiths.Forge.Abilities; using Gamesmiths.Forge.Core; @@ -22,7 +21,7 @@ public class AbilitiesTests(TagsAndCuesFixture tagsAndCuesFixture) : IClassFixtu [Fact] [Trait("Grant ability", null)] - public void Abilitie_are_granted_successfully() + public void Ability_is_granted_successfully() { TestEntity entity = new(_tagsManager, _cuesManager); @@ -36,14 +35,12 @@ public void Abilitie_are_granted_successfully() entity, abilityData, new ScalableInt(1), - out ActiveEffectHandle? effectHandle); - - Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); - Debug.Assert(effectHandle is not null, "effectHandle is not null."); + out _); + abilityHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle.Activate(); + abilityHandle!.Activate(); abilityHandle.IsActive.Should().BeTrue(); } @@ -66,24 +63,22 @@ public void Removed_ability_is_deactivated_immediately() new ScalableInt(1), out ActiveEffectHandle? effectHandle); - Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); - Debug.Assert(effectHandle is not null, "effectHandle is not null."); - + abilityHandle.Should().NotBeNull(); + effectHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle.Activate(); + abilityHandle!.Activate(); abilityHandle.IsActive.Should().BeTrue(); - entity.EffectsManager.UnapplyEffect(effectHandle); + entity.EffectsManager.UnapplyEffect(effectHandle!); entity.Abilities.GrantedAbilities.Should().BeEmpty(); - abilityHandle.IsActive.Should().BeFalse(); } [Fact] - [Trait("Grant ability", null)] + [Trait("Remove ability", null)] public void Ability_is_only_removed_after_being_deactivated() { TestEntity entity = new(_tagsManager, _cuesManager); @@ -102,29 +97,27 @@ public void Ability_is_only_removed_after_being_deactivated() AbilityDeactivationPolicy.RemoveOnEnd, AbilityDeactivationPolicy.RemoveOnEnd); - Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); - Debug.Assert(effectHandle is not null, "effectHandle is not null."); - + abilityHandle.Should().NotBeNull(); + effectHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle.Activate(); + abilityHandle!.Activate(); abilityHandle.IsActive.Should().BeTrue(); - entity.EffectsManager.UnapplyEffect(effectHandle); + entity.EffectsManager.UnapplyEffect(effectHandle!); entity.Abilities.GrantedAbilities.Should().ContainSingle(); abilityHandle.End(); entity.Abilities.GrantedAbilities.Should().BeEmpty(); - abilityHandle.IsActive.Should().BeFalse(); } [Fact] [Trait("Inhibit ability", null)] - public void Inhibited_effect_inhibites_ability_temporarily() + public void Ability_gets_inhibited_temporarily_while_granting_effect_is_inhibited() { TestEntity entity = new(_tagsManager, _cuesManager); @@ -135,38 +128,25 @@ public void Inhibited_effect_inhibites_ability_temporarily() new ScalableFloat(-1)); TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); AbilityHandle? abilityHandle = SetupAbility( entity, abilityData, new ScalableInt(1), - out ActiveEffectHandle? effectHandle, + out _, extraComponent: new TargetTagRequirementsEffectComponent( ongoingTagRequirements: new TagRequirements( IgnoreTags: ignoreTags))); - Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); - Debug.Assert(effectHandle is not null, "effectHandle is not null."); - Debug.Assert(ignoreTags is not null, "ignoreTags is not null."); - + abilityHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle.Activate(); + abilityHandle!.Activate(); abilityHandle.IsActive.Should().BeTrue(); - var tagEffectData = new EffectData( - "Tag Effect", - new DurationData(DurationType.Infinite), - effectComponents: [new ModifierTagsEffectComponent(ignoreTags)]); - - var tagEffect = new Effect( - tagEffectData, - new EffectOwnership(entity, null)); - - ActiveEffectHandle? tagEffectHandle = entity.EffectsManager.ApplyEffect(tagEffect); - - Debug.Assert(tagEffectHandle is not null, "tagEffectHandle is not null."); + ActiveEffectHandle? tagEffectHandle = CreateAndApplyTagEffect(entity, ignoreTags!); entity.Abilities.GrantedAbilities.Should().ContainSingle(); @@ -175,7 +155,7 @@ public void Inhibited_effect_inhibites_ability_temporarily() abilityHandle.IsActive.Should().BeFalse(); abilityHandle.IsInhibited.Should().BeTrue(); - entity.EffectsManager.UnapplyEffect(tagEffectHandle); + entity.EffectsManager.UnapplyEffect(tagEffectHandle!); entity.Abilities.GrantedAbilities.Should().ContainSingle(); @@ -186,7 +166,7 @@ public void Inhibited_effect_inhibites_ability_temporarily() } [Fact] - [Trait("Grant ability", null)] + [Trait("Remove ability", null)] public void Granted_ability_is_not_removed_when_deactivation_policy_is_ignore() { TestEntity entity = new(_tagsManager, _cuesManager); @@ -205,29 +185,27 @@ public void Granted_ability_is_not_removed_when_deactivation_policy_is_ignore() AbilityDeactivationPolicy.Ignore, AbilityDeactivationPolicy.Ignore); - Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); - Debug.Assert(effectHandle is not null, "effectHandle is not null."); - + abilityHandle.Should().NotBeNull(); + effectHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle.Activate(); + abilityHandle!.Activate(); abilityHandle.IsActive.Should().BeTrue(); - entity.EffectsManager.UnapplyEffect(effectHandle); + entity.EffectsManager.UnapplyEffect(effectHandle!); entity.Abilities.GrantedAbilities.Should().ContainSingle(); abilityHandle.End(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle.IsActive.Should().BeFalse(); } [Fact] [Trait("Grant ability", null)] - public void Ability_granted_by_multiple_effects_is_removed_only_when_all_effects_are_removed() + public void Ability_granted_by_multiple_effects_is_removed_only_when_all_granting_effects_are_removed() { TestEntity entity = new(_tagsManager, _cuesManager); @@ -249,20 +227,18 @@ public void Ability_granted_by_multiple_effects_is_removed_only_when_all_effects new ScalableInt(1), out ActiveEffectHandle? effectHandle2); - Debug.Assert(abilityHandle1 is not null, "abilityHandle1 is not null."); - Debug.Assert(effectHandle1 is not null, "effectHandle1 is not null."); - Debug.Assert(abilityHandle2 is not null, "abilityHandle2 is not null."); - Debug.Assert(effectHandle2 is not null, "effectHandle2 is not null."); - + abilityHandle1.Should().NotBeNull(); + effectHandle1.Should().NotBeNull(); + abilityHandle2.Should().NotBeNull(); + effectHandle2.Should().NotBeNull(); abilityHandle1.Should().Be(abilityHandle2); - entity.Abilities.GrantedAbilities.Should().ContainSingle(); - entity.EffectsManager.UnapplyEffect(effectHandle1); + entity.EffectsManager.UnapplyEffect(effectHandle1!); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - entity.EffectsManager.UnapplyEffect(effectHandle2); + entity.EffectsManager.UnapplyEffect(effectHandle2!); entity.Abilities.GrantedAbilities.Should().BeEmpty(); } @@ -280,8 +256,7 @@ public void Ability_is_not_granted_if_target_has_blocking_tags() new ScalableFloat(-1)); TagContainer? blockingTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); - - Debug.Assert(blockingTags is not null, "blockingTags is not null."); + blockingTags.Should().NotBeNull(); var grantAbilityConfig = new GrantAbilityConfig( abilityData, @@ -302,16 +277,7 @@ public void Ability_is_not_granted_if_target_has_blocking_tags() grantAbilityEffectData, new EffectOwnership(entity, null)); - var tagEffectData = new EffectData( - "Tag Effect", - new DurationData(DurationType.Infinite), - effectComponents: [new ModifierTagsEffectComponent(blockingTags)]); - - var tagEffect = new Effect( - tagEffectData, - new EffectOwnership(entity, null)); - - entity.EffectsManager.ApplyEffect(tagEffect); + CreateAndApplyTagEffect(entity, blockingTags!); entity.EffectsManager.ApplyEffect(grantAbilityEffect); @@ -345,15 +311,14 @@ public void Ability_granted_by_instant_effect_is_permanent() new ScalableInt(1), out ActiveEffectHandle? temporaryEffectHandle); - Debug.Assert(permanentAbilityHandle is not null, "permanentAbilityHandle is not null."); - Debug.Assert(temporaryAbilityHandle is not null, "temporaryAbilityHandle is not null."); - Debug.Assert(temporaryEffectHandle is not null, "temporaryEffectHandle is not null."); - + permanentAbilityHandle.Should().NotBeNull(); + temporaryAbilityHandle.Should().NotBeNull(); + temporaryEffectHandle.Should().NotBeNull(); permanentAbilityHandle.Should().Be(temporaryAbilityHandle); entity.Abilities.GrantedAbilities.Should().ContainSingle(); // Remove the temporary effect. - entity.EffectsManager.UnapplyEffect(temporaryEffectHandle); + entity.EffectsManager.UnapplyEffect(temporaryEffectHandle!); // The ability should still be granted because of the initial permanent grant. entity.Abilities.GrantedAbilities.Should().ContainSingle(); @@ -378,9 +343,8 @@ public void Ability_granted_by_late_instant_effect_is_permanent() new ScalableInt(1), out ActiveEffectHandle? temporaryEffectHandle); - Debug.Assert(temporaryAbilityHandle is not null, "temporaryAbilityHandle is not null."); - Debug.Assert(temporaryEffectHandle is not null, "temporaryEffectHandle is not null."); - + temporaryAbilityHandle.Should().NotBeNull(); + temporaryEffectHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); // Grant ability with an instant effect, making it permanent. @@ -394,14 +358,14 @@ public void Ability_granted_by_late_instant_effect_is_permanent() permanentAbilityHandle.Should().Be(temporaryAbilityHandle); // Remove the temporary effect. - entity.EffectsManager.UnapplyEffect(temporaryEffectHandle); + entity.EffectsManager.UnapplyEffect(temporaryEffectHandle!); // The ability should still be granted because of the initial permanent grant. entity.Abilities.GrantedAbilities.Should().ContainSingle(); } [Fact] - [Trait("Grant ability", null)] + [Trait("Inhibit ability", null)] public void Ability_granted_by_instant_effect_is_not_inhibited() { TestEntity entity = new(_tagsManager, _cuesManager); @@ -421,37 +385,30 @@ public void Ability_granted_by_instant_effect_is_not_inhibited() durationData: new DurationData(DurationType.Instant)); TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); // Grant the same ability with a non-instant, inhibitable effect. SetupAbility( entity, abilityData, new ScalableInt(1), - out ActiveEffectHandle? temporaryEffectHandle, + out _, extraComponent: new TargetTagRequirementsEffectComponent( ongoingTagRequirements: new TagRequirements( IgnoreTags: ignoreTags))); - Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); - Debug.Assert(temporaryEffectHandle is not null, "temporaryEffectHandle is not null."); - Debug.Assert(ignoreTags is not null, "ignoreTags is not null."); - + abilityHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); // Inhibit the temporary effect by adding the tag. - var tagEffectData = new EffectData( - "Tag Effect", - new DurationData(DurationType.Infinite), - effectComponents: [new ModifierTagsEffectComponent(ignoreTags)]); - var tagEffect = new Effect(tagEffectData, new EffectOwnership(entity, null)); - entity.EffectsManager.ApplyEffect(tagEffect); + CreateAndApplyTagEffect(entity, ignoreTags!); // The ability should not be inhibited because it was granted permanently. - abilityHandle.IsInhibited.Should().BeFalse(); + abilityHandle!.IsInhibited.Should().BeFalse(); } [Fact] - [Trait("Grant ability", null)] + [Trait("Inhibit ability", null)] public void Ability_granted_by_late_instant_effect_is_not_inhibited() { TestEntity entity = new(_tagsManager, _cuesManager); @@ -463,35 +420,28 @@ public void Ability_granted_by_late_instant_effect_is_not_inhibited() new ScalableFloat(-1)); TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); // Grant the same ability with a non-instant, inhibitable effect. AbilityHandle? abilityHandle = SetupAbility( entity, abilityData, new ScalableInt(1), - out ActiveEffectHandle? temporaryEffectHandle, + out _, extraComponent: new TargetTagRequirementsEffectComponent( ongoingTagRequirements: new TagRequirements( IgnoreTags: ignoreTags))); - Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); - Debug.Assert(temporaryEffectHandle is not null, "temporaryEffectHandle is not null."); - Debug.Assert(ignoreTags is not null, "ignoreTags is not null."); - + abilityHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); // Inhibit the temporary effect by adding the tag. - var tagEffectData = new EffectData( - "Tag Effect", - new DurationData(DurationType.Infinite), - effectComponents: [new ModifierTagsEffectComponent(ignoreTags)]); - var tagEffect = new Effect(tagEffectData, new EffectOwnership(entity, null)); - entity.EffectsManager.ApplyEffect(tagEffect); + CreateAndApplyTagEffect(entity, ignoreTags!); - // The ability should not be inhibited because it was granted permanently. - abilityHandle.IsInhibited.Should().BeTrue(); + // The ability should now be inhibited. + abilityHandle!.IsInhibited.Should().BeTrue(); - // Grant ability with an instant effect, making it permanent. + // Grant ability with an instant effect, making it permanent and removing inhibition. SetupAbility( entity, abilityData, @@ -499,12 +449,12 @@ public void Ability_granted_by_late_instant_effect_is_not_inhibited() out _, durationData: new DurationData(DurationType.Instant)); - // The ability should not be inhibited because it was granted permanently. + // The ability should no longer be inhibited. abilityHandle.IsInhibited.Should().BeFalse(); } [Fact] - [Trait("Grant ability", null)] + [Trait("Inhibit ability", null)] public void Ability_is_inhibited_only_when_all_granting_effects_are_inhibited() { TestEntity entity = new(_tagsManager, _cuesManager); @@ -517,6 +467,8 @@ public void Ability_is_inhibited_only_when_all_granting_effects_are_inhibited() TagContainer? ignoreTags1 = Tag.RequestTag(_tagsManager, "Simple.Tag").GetSingleTagContainer(); TagContainer? ignoreTags2 = Tag.RequestTag(_tagsManager, "Other.Tag").GetSingleTagContainer(); + ignoreTags1.Should().NotBeNull(); + ignoreTags2.Should().NotBeNull(); AbilityHandle? abilityHandle = SetupAbility( entity, @@ -536,36 +488,27 @@ public void Ability_is_inhibited_only_when_all_granting_effects_are_inhibited() ongoingTagRequirements: new TagRequirements( IgnoreTags: ignoreTags2))); - Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); - Debug.Assert(ignoreTags1 is not null, "ignoreTags1 is not null."); - Debug.Assert(ignoreTags2 is not null, "ignoreTags2 is not null."); - + abilityHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle.Activate(); + abilityHandle!.Activate(); abilityHandle.IsActive.Should().BeTrue(); // Inhibit the first effect. - var tagEffect1 = new Effect( - new EffectData("Tag Effect 1", new DurationData(DurationType.Infinite), effectComponents: [new ModifierTagsEffectComponent(ignoreTags1)]), - new EffectOwnership(entity, null)); - entity.EffectsManager.ApplyEffect(tagEffect1); + CreateAndApplyTagEffect(entity, ignoreTags1!); - // Ability should not be inhibited yet. + // Ability should not be inhibited yet, but it should be deactivated. abilityHandle.IsInhibited.Should().BeFalse(); - abilityHandle.IsActive.Should().BeFalse(); // It deactivates because one source is inhibited. + abilityHandle.IsActive.Should().BeFalse(); // Inhibit the second effect. - var tagEffect2 = new Effect( - new EffectData("Tag Effect 2", new DurationData(DurationType.Infinite), effectComponents: [new ModifierTagsEffectComponent(ignoreTags2)]), - new EffectOwnership(entity, null)); - entity.EffectsManager.ApplyEffect(tagEffect2); + CreateAndApplyTagEffect(entity, ignoreTags2!); // Now the ability should be fully inhibited. abilityHandle.IsInhibited.Should().BeTrue(); } [Fact] - [Trait("Grant ability", null)] + [Trait("Inhibit ability", null)] public void Inhibited_ability_becomes_active_if_new_non_inhibited_source_is_added() { TestEntity entity = new(_tagsManager, _cuesManager); @@ -577,6 +520,7 @@ public void Inhibited_ability_becomes_active_if_new_non_inhibited_source_is_adde new ScalableFloat(-1)); TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); AbilityHandle? abilityHandle = SetupAbility( entity, @@ -587,16 +531,12 @@ public void Inhibited_ability_becomes_active_if_new_non_inhibited_source_is_adde ongoingTagRequirements: new TagRequirements( IgnoreTags: ignoreTags))); - Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); - Debug.Assert(ignoreTags is not null, "ignoreTags is not null."); + abilityHandle.Should().NotBeNull(); // Inhibit the ability. - var tagEffect = new Effect( - new EffectData("Tag Effect", new DurationData(DurationType.Infinite), effectComponents: [new ModifierTagsEffectComponent(ignoreTags)]), - new EffectOwnership(entity, null)); - entity.EffectsManager.ApplyEffect(tagEffect); + CreateAndApplyTagEffect(entity, ignoreTags!); - abilityHandle.IsInhibited.Should().BeTrue(); + abilityHandle!.IsInhibited.Should().BeTrue(); // Add a new, non-inhibited source for the same ability. SetupAbility( @@ -610,7 +550,7 @@ public void Inhibited_ability_becomes_active_if_new_non_inhibited_source_is_adde } [Fact] - [Trait("Grant ability", null)] + [Trait("Inhibit ability", null)] public void Inhibition_policy_RemoveOnEnd_inhibits_after_deactivation() { TestEntity entity = new(_tagsManager, _cuesManager); @@ -622,6 +562,7 @@ public void Inhibition_policy_RemoveOnEnd_inhibits_after_deactivation() new ScalableFloat(-1)); TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); AbilityHandle? abilityHandle = SetupAbility( entity, @@ -633,17 +574,13 @@ public void Inhibition_policy_RemoveOnEnd_inhibits_after_deactivation() ongoingTagRequirements: new TagRequirements( IgnoreTags: ignoreTags))); - Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); - Debug.Assert(ignoreTags is not null, "ignoreTags is not null."); + abilityHandle.Should().NotBeNull(); - abilityHandle.Activate(); + abilityHandle!.Activate(); abilityHandle.IsActive.Should().BeTrue(); // Inhibit the granting effect. - var tagEffect = new Effect( - new EffectData("Tag Effect", new DurationData(DurationType.Infinite), effectComponents: [new ModifierTagsEffectComponent(ignoreTags)]), - new EffectOwnership(entity, null)); - entity.EffectsManager.ApplyEffect(tagEffect); + CreateAndApplyTagEffect(entity, ignoreTags!); // With RemoveOnEnd policy, the ability is not inhibited while active. abilityHandle.IsInhibited.Should().BeFalse(); @@ -658,7 +595,7 @@ public void Inhibition_policy_RemoveOnEnd_inhibits_after_deactivation() } [Fact] - [Trait("Grant ability", null)] + [Trait("Inhibit ability", null)] public void Inhibition_policy_Ignore_prevents_inhibition() { TestEntity entity = new(_tagsManager, _cuesManager); @@ -670,6 +607,7 @@ public void Inhibition_policy_Ignore_prevents_inhibition() new ScalableFloat(-1)); TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); AbilityHandle? abilityHandle = SetupAbility( entity, @@ -681,16 +619,12 @@ public void Inhibition_policy_Ignore_prevents_inhibition() ongoingTagRequirements: new TagRequirements( IgnoreTags: ignoreTags))); - Debug.Assert(abilityHandle is not null, "abilityHandle is not null."); - Debug.Assert(ignoreTags is not null, "ignoreTags is not null."); + abilityHandle.Should().NotBeNull(); - abilityHandle.Activate(); + abilityHandle!.Activate(); // Inhibit the granting effect. - var tagEffect = new Effect( - new EffectData("Tag Effect", new DurationData(DurationType.Infinite), effectComponents: [new ModifierTagsEffectComponent(ignoreTags)]), - new EffectOwnership(entity, null)); - entity.EffectsManager.ApplyEffect(tagEffect); + CreateAndApplyTagEffect(entity, ignoreTags!); // With Ignore policy, the ability is never inhibited. abilityHandle.IsInhibited.Should().BeFalse(); @@ -781,4 +715,18 @@ private static Effect CreateAbilityApplierEffect( grantAbilityEffectData, new EffectOwnership(source, null)); } + + private static ActiveEffectHandle? CreateAndApplyTagEffect(TestEntity entity, TagContainer tags) + { + var tagEffectData = new EffectData( + "Tag Effect", + new DurationData(DurationType.Infinite), + effectComponents: [new ModifierTagsEffectComponent(tags)]); + + var tagEffect = new Effect( + tagEffectData, + new EffectOwnership(entity, null)); + + return entity.EffectsManager.ApplyEffect(tagEffect); + } } From 3d7b2b05f506bf347330fd3458261af2c39d9f0a Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 12 Oct 2025 21:06:06 -0300 Subject: [PATCH 10/87] Implemented OnPostActiveEffectAdded --- .../Components/GrantAbilityEffectComponent.cs | 10 ++++++++++ Forge/Effects/Components/IEffectComponent.cs | 11 +++++++++++ Forge/Effects/EffectsManager.cs | 12 ++++++++++++ 3 files changed, 33 insertions(+) diff --git a/Forge/Effects/Components/GrantAbilityEffectComponent.cs b/Forge/Effects/Components/GrantAbilityEffectComponent.cs index 7f06953..a7a93c9 100644 --- a/Forge/Effects/Components/GrantAbilityEffectComponent.cs +++ b/Forge/Effects/Components/GrantAbilityEffectComponent.cs @@ -31,6 +31,16 @@ public bool OnActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedDat return true; } + /// + public void OnPostActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) + { + if (activeEffectEvaluatedData.ActiveEffectHandle.IsInhibited) + { + _isInhibited = activeEffectEvaluatedData.ActiveEffectHandle.IsInhibited; + InhibitGrantedAbilities(target, _isInhibited, activeEffectEvaluatedData.ActiveEffectHandle); + } + } + /// public void OnActiveEffectUnapplied( IForgeEntity target, diff --git a/Forge/Effects/Components/IEffectComponent.cs b/Forge/Effects/Components/IEffectComponent.cs index 4e6b232..2805296 100644 --- a/Forge/Effects/Components/IEffectComponent.cs +++ b/Forge/Effects/Components/IEffectComponent.cs @@ -37,6 +37,17 @@ bool OnActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedData activ return true; } + /// + /// Executes and implements extra functionality for when an is added to a target after + /// all other components have processed and finished evaluating. + /// + /// The target receiving the active effect. + /// The evaluated data for the active effect being added. + void OnPostActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) + { + // This method is intentionally left blank. + } + /// /// Executes and implements extra functionality for when an is unapplied from a /// target. It's also called when a single stack is removed. The data diff --git a/Forge/Effects/EffectsManager.cs b/Forge/Effects/EffectsManager.cs index 8c8983e..f79f2b1 100644 --- a/Forge/Effects/EffectsManager.cs +++ b/Forge/Effects/EffectsManager.cs @@ -285,6 +285,18 @@ private ActiveEffect ApplyNewEffect(Effect effect) effectEvaluatedData.Target.Attributes.ApplyPendingValueChanges(); + foreach (IEffectComponent component in effect.EffectData.EffectComponents) + { + component.OnPostActiveEffectAdded( + Owner, + new ActiveEffectEvaluatedData( + activeEffect.Handle, + activeEffect.EffectEvaluatedData, + activeEffect.RemainingDuration, + activeEffect.NextPeriodicTick, + activeEffect.ExecutionCount)); + } + return activeEffect; } From f328888ea0a48e2f80386d9894c9e1cfebf02f81 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 12 Oct 2025 21:06:27 -0300 Subject: [PATCH 11/87] Added test for inhibited grant effects --- Forge.Tests/Abilities/AbilitiesTests.cs | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index a83fe02..9eecb51 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -636,6 +636,36 @@ public void Inhibition_policy_Ignore_prevents_inhibition() abilityHandle.IsActive.Should().BeFalse(); } + [Fact] + [Trait("Inhibit ability", null)] + public void Effect_inhibited_at_application_grant_inhibited_abilities() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + + CreateAndApplyTagEffect(entity, ignoreTags!); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags))); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + abilityHandle!.IsInhibited.Should().BeTrue(); + } + private static AbilityData CreateAbiltyData( string abilityName, ScalableFloat cooldownDuration, From 0e11e5839bd2b5a22d7b85dc20595c96088b4efa Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 12 Oct 2025 21:25:42 -0300 Subject: [PATCH 12/87] Convert Debug.Assert to Validation.Assert --- Forge/Core/EntityAbilities.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index 6543b7e..3c59ecd 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -75,7 +75,7 @@ internal AbilityHandle GrantAbility( { List? inhibitSources = _inhibitSources[existingAbility]; - Debug.Assert( + Validation.Assert( inhibitSources is not null, "InhibitAbilityBasedOnPolicy inhibitSources should not be null if grant grantSources are not null."); @@ -131,7 +131,7 @@ internal void RemoveGrantedAbility(Ability? abilityToRemove, ActiveEffectHandle List? inhibitSources = _inhibitSources[abilityToRemove]; - Debug.Assert( + Validation.Assert( inhibitSources is not null, "InhibitAbilityBasedOnPolicy inhibitSources should not be null if grant grantSources are not null."); From 095a4c126cf17b9890b1218483776aa482c347ee Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 12 Oct 2025 21:25:57 -0300 Subject: [PATCH 13/87] Remove unnecessary asserts from tests --- Forge.Tests/Cues/CueTests.cs | 9 ++--- .../Effects/CustomCalculatorsEffectsTests.cs | 9 ++--- Forge.Tests/Effects/EffectsTests.cs | 27 +++++--------- .../Effects/ModifierTagsComponentTests.cs | 16 +++----- .../TargetTagRequirementsComponentTests.cs | 37 +++++++------------ 5 files changed, 35 insertions(+), 63 deletions(-) diff --git a/Forge.Tests/Cues/CueTests.cs b/Forge.Tests/Cues/CueTests.cs index 548226f..1d7ee25 100644 --- a/Forge.Tests/Cues/CueTests.cs +++ b/Forge.Tests/Cues/CueTests.cs @@ -605,8 +605,7 @@ public void Infinite_effect_triggers_apply_and_remove_cues_with_expected_results ActiveEffectHandle? activeEffectHandler = entity.EffectsManager.ApplyEffect(effect); TestCueExecutionData(TestCueExecutionType.Application, cueTestDatas1); - Validation.Assert(activeEffectHandler is not null, "Effect should not be null here."); - entity.EffectsManager.UnapplyEffect(activeEffectHandler); + entity.EffectsManager.UnapplyEffect(activeEffectHandler!); TestCueExecutionData(TestCueExecutionType.Application, cueTestDatas2); } @@ -1206,8 +1205,7 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas2); TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas2); - Validation.Assert(activeEffectHandler is not null, "Effect should not be null here."); - entity.EffectsManager.UnapplyEffect(activeEffectHandler); + entity.EffectsManager.UnapplyEffect(activeEffectHandler!); TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas3); TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas3); } @@ -1567,8 +1565,7 @@ public void Attributre_based_modifiers_triggers_update_cues_when_attribute_chang TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas2); TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas2); - Validation.Assert(activeEffectHandler is not null, "Effect should not be null here."); - entity.EffectsManager.UnapplyEffect(activeEffectHandler); + entity.EffectsManager.UnapplyEffect(activeEffectHandler!); TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas3); TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas3); } diff --git a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs index 2d78127..3f473e9 100644 --- a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs +++ b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs @@ -315,8 +315,7 @@ public void Custom_calculator_class_non_snapshot_modifies_attribute_accordingly( TestUtils.TestAttribute(target, targetAttribute, expectedResults2); - Validation.Assert(effectHandler is not null, "effectHandler should never be null here"); - effect2Target.EffectsManager.UnapplyEffect(effectHandler); + effect2Target.EffectsManager.UnapplyEffect(effectHandler!); TestUtils.TestAttribute(target, targetAttribute, expectedResults1); } @@ -435,14 +434,12 @@ public void Custom_executions_modifies_update_with_non_snapshot_attributes() TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [29, 1, 28, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [13, 2, 11, 0]); - Validation.Assert(effectHandler2 is not null, "effectHandler2 should never be null here"); - owner.EffectsManager.UnapplyEffect(effectHandler2); + owner.EffectsManager.UnapplyEffect(effectHandler2!); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [21, 1, 20, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); - Validation.Assert(effectHandler1 is not null, "effectHandler1 should never be null here"); - owner.EffectsManager.UnapplyEffect(effectHandler1); + owner.EffectsManager.UnapplyEffect(effectHandler1!); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 2, 8, 0]); diff --git a/Forge.Tests/Effects/EffectsTests.cs b/Forge.Tests/Effects/EffectsTests.cs index 46a1d76..b39a293 100644 --- a/Forge.Tests/Effects/EffectsTests.cs +++ b/Forge.Tests/Effects/EffectsTests.cs @@ -363,11 +363,10 @@ public void Override_values_are_applied_temporarily( TestUtils.TestAttribute(target, targetAttribute, firstExpectedResults); ActiveEffectHandle? activeEffect2handle = target.EffectsManager.ApplyEffect(effect2); - Validation.Assert(activeEffect2handle is not null, "Effect handle should have a value."); TestUtils.TestAttribute(target, targetAttribute, secondExpectedResult); - target.EffectsManager.UnapplyEffect(activeEffect2handle); + target.EffectsManager.UnapplyEffect(activeEffect2handle!); TestUtils.TestAttribute(target, targetAttribute, firstExpectedResults); } @@ -466,39 +465,34 @@ public void Multiple_override_values_are_applied_and_removed_correctly( TestUtils.TestAttribute(target, targetAttribute, expectedResults1); // 1 ActiveEffectHandle? activeEffect2Handle1 = target.EffectsManager.ApplyEffect(effect2); - Validation.Assert(activeEffect2Handle1 is not null, "Effect handle should have a value."); TestUtils.TestAttribute(target, targetAttribute, expectedResults2); // 1,2 ActiveEffectHandle? activeEffect3Handle = target.EffectsManager.ApplyEffect(effect3); - Validation.Assert(activeEffect3Handle is not null, "Effect handle should have a value."); TestUtils.TestAttribute(target, targetAttribute, expectedResults3); // 1,2,3 ActiveEffectHandle? activeEffect4Handle = target.EffectsManager.ApplyEffect(effect4); - Validation.Assert(activeEffect4Handle is not null, "Effect handle should have a value."); TestUtils.TestAttribute(target, targetAttribute, expectedResults4); // 1,2,3,4 ActiveEffectHandle? activeEffect1Handle = target.EffectsManager.ApplyEffect(effect1); - Validation.Assert(activeEffect1Handle is not null, "Effect handle should have a value."); TestUtils.TestAttribute(target, targetAttribute, expectedResults1); // 1,2,3,4,1 - target.EffectsManager.UnapplyEffect(activeEffect1Handle); + target.EffectsManager.UnapplyEffect(activeEffect1Handle!); TestUtils.TestAttribute(target, targetAttribute, expectedResults4); // 1,2,3,4 - target.EffectsManager.UnapplyEffect(activeEffect2Handle1); + target.EffectsManager.UnapplyEffect(activeEffect2Handle1!); TestUtils.TestAttribute(target, targetAttribute, expectedResults4); // 1,3,4 - target.EffectsManager.UnapplyEffect(activeEffect4Handle); + target.EffectsManager.UnapplyEffect(activeEffect4Handle!); TestUtils.TestAttribute(target, targetAttribute, expectedResults3); // 1,3 ActiveEffectHandle? activeEffect2Handle2 = target.EffectsManager.ApplyEffect(effect2); - Validation.Assert(activeEffect2Handle2 is not null, "Effect handle should have a value."); TestUtils.TestAttribute(target, targetAttribute, expectedResults2); // 1,3,2 - target.EffectsManager.UnapplyEffect(activeEffect3Handle); + target.EffectsManager.UnapplyEffect(activeEffect3Handle!); TestUtils.TestAttribute(target, targetAttribute, expectedResults2); // 1,2 - target.EffectsManager.UnapplyEffect(activeEffect2Handle2); + target.EffectsManager.UnapplyEffect(activeEffect2Handle2!); TestUtils.TestAttribute(target, targetAttribute, expectedResults1); // 1 static EffectData CreateOverrideEffect( @@ -3030,7 +3024,6 @@ public void Infinite_stackable_effect_unnaplies_correctly( new EffectOwnership(owner, owner)); ActiveEffectHandle? effectHandle = target.EffectsManager.ApplyEffect(effect); - Validation.Assert(effectHandle is not null, "Effect handle should not be null."); TestUtils.TestAttribute(target, targetAttribute, firstExpectedResults); @@ -3041,7 +3034,7 @@ public void Infinite_stackable_effect_unnaplies_correctly( owner, target); - target.EffectsManager.UnapplyEffect(effectHandle, forceUnapply); + target.EffectsManager.UnapplyEffect(effectHandle!, forceUnapply); TestUtils.TestAttribute(target, targetAttribute, secondExpectedResults); @@ -3147,20 +3140,18 @@ public void Unapply_duration_effect_restores_original_attribute_values( } ActiveEffectHandle? activeEffectHandle1 = target.EffectsManager.ApplyEffect(effect); - Validation.Assert(activeEffectHandle1 is not null, "Effect handle should not be null."); TestUtils.TestAttribute(target, targetAttribute, firstExpectedResults); ActiveEffectHandle? activeEffectHandle2 = target.EffectsManager.ApplyEffect(effect); - Validation.Assert(activeEffectHandle2 is not null, "Effect handle should not be null."); TestUtils.TestAttribute(target, targetAttribute, secondExpectedResults); - target.EffectsManager.UnapplyEffect(activeEffectHandle1); + target.EffectsManager.UnapplyEffect(activeEffectHandle1!); TestUtils.TestAttribute(target, targetAttribute, firstExpectedResults); - target.EffectsManager.UnapplyEffect(activeEffectHandle2); + target.EffectsManager.UnapplyEffect(activeEffectHandle2!); TestUtils.TestAttribute( target, diff --git a/Forge.Tests/Effects/ModifierTagsComponentTests.cs b/Forge.Tests/Effects/ModifierTagsComponentTests.cs index 0df6e5d..9ffcecd 100644 --- a/Forge.Tests/Effects/ModifierTagsComponentTests.cs +++ b/Forge.Tests/Effects/ModifierTagsComponentTests.cs @@ -1,7 +1,6 @@ // Copyright © Gamesmiths Guild. using FluentAssertions; -using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Cues; using Gamesmiths.Forge.Effects; using Gamesmiths.Forge.Effects.Components; @@ -118,7 +117,6 @@ public void Manual_removal_removes_tags_instantly(string[] tagKeys) var validationContainer = new TagContainer(_tagsManager, validationTags); ActiveEffectHandle? activeEffectHandle = entity.EffectsManager.ApplyEffect(effect); - Validation.Assert(activeEffectHandle is not null, "Effect handle should have a value."); entity.Tags.CombinedTags.Equals(validationContainer).Should().BeTrue(); entity.Tags.ModifierTags.Equals(modifierTagsContainer).Should().BeTrue(); @@ -127,7 +125,7 @@ public void Manual_removal_removes_tags_instantly(string[] tagKeys) entity.Tags.CombinedTags.Equals(validationContainer).Should().BeTrue(); entity.Tags.ModifierTags.Equals(modifierTagsContainer).Should().BeTrue(); - entity.EffectsManager.UnapplyEffect(activeEffectHandle); + entity.EffectsManager.UnapplyEffect(activeEffectHandle!); entity.Tags.CombinedTags.Equals(baseTagsContainer).Should().BeTrue(); entity.Tags.ModifierTags.IsEmpty.Should().BeTrue(); } @@ -246,19 +244,18 @@ public void Stackable_effects_keep_tags_until_completely_removed(string[] tagKey var validationContainer = new TagContainer(_tagsManager, validationTags); ActiveEffectHandle? activeEffectHandle = entity.EffectsManager.ApplyEffect(effect); - Validation.Assert(activeEffectHandle is not null, "Effect handle should have a value."); - + entity.Tags.CombinedTags.Equals(validationContainer).Should().BeTrue(); entity.Tags.ModifierTags.Equals(modifierTagsContainer).Should().BeTrue(); for (var i = 0; i < stacks - 1; i++) { - entity.EffectsManager.UnapplyEffect(activeEffectHandle); + entity.EffectsManager.UnapplyEffect(activeEffectHandle!); entity.Tags.CombinedTags.Equals(validationContainer).Should().BeTrue(); entity.Tags.ModifierTags.Equals(modifierTagsContainer).Should().BeTrue(); } - entity.EffectsManager.UnapplyEffect(activeEffectHandle); + entity.EffectsManager.UnapplyEffect(activeEffectHandle!); entity.Tags.CombinedTags.Equals(baseTagsContainer).Should().BeTrue(); entity.Tags.ModifierTags.IsEmpty.Should().BeTrue(); } @@ -284,12 +281,11 @@ public void Stackable_effects_removes_tags_when_forcibly_removed(string[] tagKey var validationContainer = new TagContainer(_tagsManager, validationTags); ActiveEffectHandle? activeEffectHandle = entity.EffectsManager.ApplyEffect(effect); - Validation.Assert(activeEffectHandle is not null, "Effect handle should have a value."); - + entity.Tags.CombinedTags.Equals(validationContainer).Should().BeTrue(); entity.Tags.ModifierTags.Equals(modifierTagsContainer).Should().BeTrue(); - entity.EffectsManager.UnapplyEffect(activeEffectHandle, true); + entity.EffectsManager.UnapplyEffect(activeEffectHandle!, true); entity.Tags.CombinedTags.Equals(baseTagsContainer).Should().BeTrue(); entity.Tags.ModifierTags.IsEmpty.Should().BeTrue(); } diff --git a/Forge.Tests/Effects/TargetTagRequirementsComponentTests.cs b/Forge.Tests/Effects/TargetTagRequirementsComponentTests.cs index 3d9e03d..a66ab6a 100644 --- a/Forge.Tests/Effects/TargetTagRequirementsComponentTests.cs +++ b/Forge.Tests/Effects/TargetTagRequirementsComponentTests.cs @@ -299,7 +299,6 @@ public void Effect_gets_removed_after_modifier_tag_is_removed( var modifierTagEffect = new Effect(modifierTagEffectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? activeModifierEffectHandle = entity.EffectsManager.ApplyEffect(modifierTagEffect); - Validation.Assert(activeModifierEffectHandle is not null, "Effect handle should have a value."); entity.EffectsManager.ApplyEffect(effect); @@ -310,7 +309,7 @@ public void Effect_gets_removed_after_modifier_tag_is_removed( entity, entity); - entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle); + entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle!); TestUtils.TestStackData( entity.EffectsManager.GetEffectInfo(effectData), @@ -430,7 +429,6 @@ public void Effect_with_ongoing_requirement_initializes_normally( var effect = new Effect(effectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? activeEffectHandle = entity.EffectsManager.ApplyEffect(effect); - Validation.Assert(activeEffectHandle is not null, "Effect handle should have a value."); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); TestUtils.TestStackData( @@ -440,7 +438,7 @@ public void Effect_with_ongoing_requirement_initializes_normally( entity, entity); - entity.EffectsManager.UnapplyEffect(activeEffectHandle); + entity.EffectsManager.UnapplyEffect(activeEffectHandle!); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); TestUtils.TestStackData( @@ -475,7 +473,6 @@ public void Effect_without_ongoing_requirement_initializes_inhibited( var effect = new Effect(effectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? activeEffectHandle = entity.EffectsManager.ApplyEffect(effect); - Validation.Assert(activeEffectHandle is not null, "Effect handle should have a value."); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); TestUtils.TestStackData( @@ -485,7 +482,7 @@ public void Effect_without_ongoing_requirement_initializes_inhibited( entity, entity); - entity.EffectsManager.UnapplyEffect(activeEffectHandle); + entity.EffectsManager.UnapplyEffect(activeEffectHandle!); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); TestUtils.TestStackData( @@ -531,11 +528,10 @@ public void Effect_with_ongoing_requirement_gets_inhibited_after_modifier_tag_ap TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); ActiveEffectHandle? activeModifierEffectHandle = entity.EffectsManager.ApplyEffect(modifierTagEffect); - Validation.Assert(activeModifierEffectHandle is not null, "Effect handle should have a value."); - + TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); - entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle); + entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle!); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); } @@ -571,12 +567,11 @@ public void Effect_with_ongoing_requirement_gets_inhibited_after_modifier_tag_re var modifierTagEffect = new Effect(modifierTagEffectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? activeModifierEffectHandle = entity.EffectsManager.ApplyEffect(modifierTagEffect); - Validation.Assert(activeModifierEffectHandle is not null, "Effect handle should have a value."); - + entity.EffectsManager.ApplyEffect(effect); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); - entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle); + entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle!); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); } @@ -605,8 +600,7 @@ public void Periodic_effect_with_ongoing_requirement_executes_normally( var effect = new Effect(effectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? activeEffectHandle = entity.EffectsManager.ApplyEffect(effect); - Validation.Assert(activeEffectHandle is not null, "Effect handle should have a value."); - + TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [11, 11, 0, 0]); TestUtils.TestStackData( entity.EffectsManager.GetEffectInfo(effectData), @@ -619,7 +613,7 @@ public void Periodic_effect_with_ongoing_requirement_executes_normally( TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [41, 41, 0, 0]); - entity.EffectsManager.UnapplyEffect(activeEffectHandle); + entity.EffectsManager.UnapplyEffect(activeEffectHandle!); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [41, 41, 0, 0]); TestUtils.TestStackData( @@ -655,8 +649,7 @@ public void Periodic_effect_without_ongoing_requirement_does_not_execute( var effect = new Effect(effectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? activeEffectHandle = entity.EffectsManager.ApplyEffect(effect); - Validation.Assert(activeEffectHandle is not null, "Effect handle should have a value."); - + entity.EffectsManager.UpdateEffects(100f); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); @@ -667,7 +660,7 @@ public void Periodic_effect_without_ongoing_requirement_does_not_execute( entity, entity); - entity.EffectsManager.UnapplyEffect(activeEffectHandle); + entity.EffectsManager.UnapplyEffect(activeEffectHandle!); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); TestUtils.TestStackData( @@ -781,12 +774,11 @@ public void Periodic_effect_with_ongoing_requirement_gets_inhibited_after_modifi TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", secondExpectedResults); ActiveEffectHandle? activeModifierEffectHandle = entity.EffectsManager.ApplyEffect(modifierTagEffect); - Validation.Assert(activeModifierEffectHandle is not null, "Effect handle should have a value."); - + entity.EffectsManager.UpdateEffects(secondUpdatePeriod); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", thirdExpectedResults); - entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle); + entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle!); entity.EffectsManager.UpdateEffects(thirdUpdatePeriod); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", fourthExpectedResults); } @@ -888,7 +880,6 @@ public void Periodic_effect_with_ongoing_requirement_gets_inhibited_after_modifi var modifierTagEffect = new Effect(modifierTagEffectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? activeModifierEffectHandle = entity.EffectsManager.ApplyEffect(modifierTagEffect); - Validation.Assert(activeModifierEffectHandle is not null, "Effect handle should have a value."); entity.EffectsManager.ApplyEffect(effect); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", firstExpectedResults); @@ -896,7 +887,7 @@ public void Periodic_effect_with_ongoing_requirement_gets_inhibited_after_modifi entity.EffectsManager.UpdateEffects(firstUpdatePeriod); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", secondExpectedResults); - entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle); + entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle!); entity.EffectsManager.UpdateEffects(secondUpdatePeriod); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", thirdExpectedResults); From 41b4ca12337088bbdaabc3f1374f3830f476528c Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 12 Oct 2025 21:39:06 -0300 Subject: [PATCH 14/87] Refactored CueHandler tests to no longer depend on console output --- Forge.Tests/Samples/ExamplesTestFixture.cs | 4 +- Forge.Tests/Samples/QuickStartTests.cs | 106 ++++++++------------- 2 files changed, 42 insertions(+), 68 deletions(-) diff --git a/Forge.Tests/Samples/ExamplesTestFixture.cs b/Forge.Tests/Samples/ExamplesTestFixture.cs index 023208f..9c72c1c 100644 --- a/Forge.Tests/Samples/ExamplesTestFixture.cs +++ b/Forge.Tests/Samples/ExamplesTestFixture.cs @@ -14,6 +14,8 @@ public class ExamplesTestFixture public CuesManager CuesManager { get; } + public MockCueHandler MockCueHandler { get; } = new(); + public ExamplesTestFixture() { CuesManager = new CuesManager(); @@ -29,7 +31,7 @@ public ExamplesTestFixture() CuesManager.RegisterCue( Tag.RequestTag(TagsManager, "cues.damage.fire"), - new FireDamageCueHandler() + MockCueHandler ); } } diff --git a/Forge.Tests/Samples/QuickStartTests.cs b/Forge.Tests/Samples/QuickStartTests.cs index 45a5303..76ccfbd 100644 --- a/Forge.Tests/Samples/QuickStartTests.cs +++ b/Forge.Tests/Samples/QuickStartTests.cs @@ -612,9 +612,8 @@ public void Advanced_creating_a_custom_execution() public void Triggering_a_cue_through_effects() { // Arrange - var stringWriter = new StringWriter(); - var originalOut = Console.Out; - Console.SetOut(stringWriter); + var mockCueHandler = tagsAndCueFixture.MockCueHandler; + mockCueHandler.Reset(); // Initialize managers var tagsManager = _tagsManager; @@ -655,31 +654,15 @@ public void Triggering_a_cue_through_effects() var burningEffect = new Effect(burningEffectData, new EffectOwnership(player, player)); - try - { - // Apply the burning effect - player.EffectsManager.ApplyEffect(burningEffect); - player.EffectsManager.UpdateEffects(5f); // Simulate 5 seconds of game time - - var output = "Fire damage cue applied to target.\n" + - "Fire damage executed: -5\n" + - "Fire damage executed: -5\n" + - "Fire damage executed: -5\n" + - "Fire damage executed: -5\n" + - "Fire damage executed: -5\n" + - "Fire damage executed: -5\n" + - "Fire damage cue removed."; - - // Normalize line endings to be consistent across environments - var normalizedOutput = output.Replace("\n", Environment.NewLine); - - stringWriter.ToString().Should().Contain(normalizedOutput); - } - finally - { - // Cleanup - Console.SetOut(originalOut); - } + // Act + player.EffectsManager.ApplyEffect(burningEffect); + player.EffectsManager.UpdateEffects(5f); // Simulate 5 seconds of game time + + // Assert + mockCueHandler.ApplyCount.Should().Be(1); + mockCueHandler.ExecuteCount.Should().Be(6); // 1 on application + 5 from periodic ticks + mockCueHandler.Magnitudes.Should().OnlyContain(x => x == -5); + mockCueHandler.RemoveCount.Should().Be(1); } [Fact] @@ -687,9 +670,8 @@ public void Triggering_a_cue_through_effects() public void Manually_triggering_a_cue() { // Arrange - var stringWriter = new StringWriter(); - var originalOut = Console.Out; - Console.SetOut(stringWriter); + var mockCueHandler = tagsAndCueFixture.MockCueHandler; + mockCueHandler.Reset(); // Initialize managers var tagsManager = _tagsManager; @@ -710,22 +692,16 @@ public void Manually_triggering_a_cue() } ); - try - { - cuesManager.ExecuteCue( - cueTag: Tag.RequestTag(tagsManager, "cues.damage.fire"), - target: player, - parameters: parameters - ); - - stringWriter.ToString().Should().Contain("Fire damage executed: 25"); - } - finally - { - // Cleanup - Console.SetOut(originalOut); - } + // Act + cuesManager.ExecuteCue( + cueTag: Tag.RequestTag(tagsManager, "cues.damage.fire"), + target: player, + parameters: parameters + ); + // Assert + mockCueHandler.ExecuteCount.Should().Be(1); + mockCueHandler.Magnitudes.Should().Contain(25); } public class PlayerAttributeSet : AttributeSet @@ -894,38 +870,34 @@ public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeE } } - public class FireDamageCueHandler : ICueHandler + public class MockCueHandler : ICueHandler { + public int ApplyCount { get; private set; } + public int ExecuteCount { get; private set; } + public int RemoveCount { get; private set; } + public List Magnitudes { get; } = new(); + + public void OnApply(IForgeEntity? target, CueParameters? parameters) => ApplyCount++; + public void OnExecute(IForgeEntity? target, CueParameters? parameters) { + ExecuteCount++; if (parameters.HasValue) { - Console.WriteLine($"Fire damage executed: {parameters.Value.Magnitude}"); + Magnitudes.Add(parameters.Value.Magnitude); } } - public void OnApply(IForgeEntity? target, CueParameters? parameters) - { - // Logic for when a persistent cue starts (e.g., play fire animation) - if (target != null) - { - Console.WriteLine("Fire damage cue applied to target."); - } - } + public void OnRemove(IForgeEntity? target, bool interrupted) => RemoveCount++; - public void OnRemove(IForgeEntity? target, bool interrupted) - { - // Logic for when a cue ends (e.g., stop fire animation) - Console.WriteLine("Fire damage cue removed."); - } + public void OnUpdate(IForgeEntity? target, CueParameters? parameters) { } - public void OnUpdate(IForgeEntity? target, CueParameters? parameters) + public void Reset() { - // Logic for updating persistent cues (e.g., adjust fire intensity) - if (parameters.HasValue) - { - Console.WriteLine($"Fire damage cue updated with Magnitude: {parameters.Value.Magnitude}"); - } + ApplyCount = 0; + ExecuteCount = 0; + RemoveCount = 0; + Magnitudes.Clear(); } } } From d3c25ee6c6be8baf7a0ee69a0d7e2b8308fff017 Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 13 Oct 2025 01:09:52 -0300 Subject: [PATCH 15/87] Added TryGetAbility method --- Forge.Tests/Abilities/AbilitiesTests.cs | 4 +++- Forge/Core/EntityAbilities.cs | 21 ++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index 9eecb51..6feab85 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -717,7 +717,9 @@ private static AbilityData CreateAbiltyData( effectHandle = entity.EffectsManager.ApplyEffect(grantAbilityEffect); - return entity.Abilities.GrantedAbilities.FirstOrDefault(); + entity.Abilities.TryGetAbility(abilityData, out AbilityHandle? abilityHandle); + + return abilityHandle; } private static Effect CreateAbilityApplierEffect( diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index 3c59ecd..ad614b2 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -1,6 +1,6 @@ // Copyright © Gamesmiths Guild. -using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using Gamesmiths.Forge.Abilities; using Gamesmiths.Forge.Effects; @@ -20,6 +20,25 @@ public class EntityAbilities /// public HashSet GrantedAbilities { get; } = []; + /// + /// Tries to get a granted ability from its data. + /// + /// The data of the ability to find. + /// The handle of the found ability. + /// true if the ability was found; otherwise, false. + public bool TryGetAbility(AbilityData abilityData, [NotNullWhen(true)] out AbilityHandle? abilityHandle) + { + Ability? ability = GrantedAbilities.FirstOrDefault(x => x?.Ability?.AbilityData == abilityData)?.Ability; + if (ability is not null) + { + abilityHandle = ability.Handle; + return true; + } + + abilityHandle = null; + return false; + } + internal void GrantAbilityPermanently( AbilityData abilityData, int abilityLevel, From dcc2315413e7c4645a49e2f2c5d2659868018fe4 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 14 Oct 2025 21:48:34 -0300 Subject: [PATCH 16/87] Added tests for level and instances --- Forge.Tests/Abilities/AbilitiesTests.cs | 178 ++++++++++++++++-- Forge/Abilities/AbilityHandle.cs | 5 + Forge/Core/EntityAbilities.cs | 9 +- .../Components/GrantAbilityEffectComponent.cs | 4 +- 4 files changed, 181 insertions(+), 15 deletions(-) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index 6feab85..904ad5e 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -10,6 +10,7 @@ using Gamesmiths.Forge.Effects.Magnitudes; using Gamesmiths.Forge.Effects.Modifiers; using Gamesmiths.Forge.Tags; +using Gamesmiths.Forge.Tests.Core; using Gamesmiths.Forge.Tests.Helpers; namespace Gamesmiths.Forge.Tests.Abilities; @@ -666,6 +667,155 @@ public void Effect_inhibited_at_application_grant_inhibited_abilities() abilityHandle!.IsInhibited.Should().BeTrue(); } + [Fact] + [Trait("Grant ability", null)] + public void Ability_level_is_set_correctly() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(5), // Grant at level 5 + out _); + + abilityHandle.Should().NotBeNull(); + abilityHandle!.Level.Should().Be(5); + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_level_scales_with_curve() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var levelCurve = new Curve( + [ + new CurveKey(1, 1f), // Effect level 1 -> Ability level 1 + new CurveKey(2, 3f), // Effect level 2 -> Ability level 3 + new CurveKey(3, 5f), // Effect level 3 -> Ability level 5 + ]); + + AbilityData abilityData = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + // Grant ability with a scaling level based on the effect's level + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1, levelCurve), + out _, + effectLevel: 2); // Granting effect is level 2 + + abilityHandle.Should().NotBeNull(); + abilityHandle!.Level.Should().Be(3); // Curve should evaluate to 3 + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_level_override_policy_works_correctly() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + // Grant at level 2 + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(2), + out _); + + abilityHandle.Should().NotBeNull(); + abilityHandle!.Level.Should().Be(2); + + // Grant again at level 3 with override policy for higher levels + SetupAbility( + entity, + abilityData, + new ScalableInt(3), + out _, + levelOverridePolicy: LevelComparison.Higher); + + abilityHandle.Level.Should().Be(3); + + // Grant again at level 1 with the same policy; level should not change + SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + levelOverridePolicy: LevelComparison.Higher); + + abilityHandle.Level.Should().Be(3); + + // Grant again at level 5 with override policy for lower or equal levels + SetupAbility( + entity, + abilityData, + new ScalableInt(5), + out _, + levelOverridePolicy: LevelComparison.Lower | LevelComparison.Equal); + + abilityHandle.Level.Should().Be(3); + } + + [Fact] + [Trait("Grant ability", null)] + public void Abilities_with_different_sources_are_separate_instances() + { + TestEntity entity1 = new(_tagsManager, _cuesManager); + TestEntity entity2 = new(_tagsManager, _cuesManager); + + // Create two different AbilityData instances (differ by name) + AbilityData abilityData1 = CreateAbiltyData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + // Grant the first ability + AbilityHandle? abilityHandle1 = SetupAbility( + entity1, + abilityData1, + new ScalableInt(1), + out _, + sourceEntity: entity1); + + // Grant the second ability + AbilityHandle? abilityHandle2 = SetupAbility( + entity1, + abilityData1, + new ScalableInt(1), + out _, + sourceEntity: entity2); + + abilityHandle1.Should().NotBeNull(); + abilityHandle2.Should().NotBeNull(); + + // The handles should be for different ability instances + abilityHandle1.Should().NotBe(abilityHandle2); + entity1.Abilities.GrantedAbilities.Should().HaveCount(2); + + // Activate one and ensure the other is not affected + abilityHandle1!.Activate(); + abilityHandle1.IsActive.Should().BeTrue(); + abilityHandle2!.IsActive.Should().BeFalse(); + } + private static AbilityData CreateAbiltyData( string abilityName, ScalableFloat cooldownDuration, @@ -693,41 +843,46 @@ private static AbilityData CreateAbiltyData( } private static AbilityHandle? SetupAbility( - TestEntity entity, + TestEntity targetEntity, AbilityData abilityData, ScalableInt abilityLevelScaling, out ActiveEffectHandle? effectHandle, AbilityDeactivationPolicy grantedAbilityRemovalPolicy = AbilityDeactivationPolicy.CancelImmediately, AbilityDeactivationPolicy grantedAbilityInhibitionPolicy = AbilityDeactivationPolicy.CancelImmediately, + IForgeEntity? sourceEntity = null, DurationData? durationData = null, - IEffectComponent? extraComponent = null) + IEffectComponent? extraComponent = null, + int effectLevel = 1, + LevelComparison levelOverridePolicy = LevelComparison.Higher) { GrantAbilityConfig grantAbilityConfig = new( abilityData, abilityLevelScaling, grantedAbilityRemovalPolicy, - grantedAbilityInhibitionPolicy); + grantedAbilityInhibitionPolicy, + levelOverridePolicy); Effect grantAbilityEffect = CreateAbilityApplierEffect( "Grant Fireball", grantAbilityConfig, - entity, + sourceEntity, durationData, - extraComponent); - - effectHandle = entity.EffectsManager.ApplyEffect(grantAbilityEffect); + extraComponent, + effectLevel); - entity.Abilities.TryGetAbility(abilityData, out AbilityHandle? abilityHandle); + effectHandle = targetEntity.EffectsManager.ApplyEffect(grantAbilityEffect); + targetEntity.Abilities.TryGetAbility(abilityData, out AbilityHandle? abilityHandle, sourceEntity); return abilityHandle; } private static Effect CreateAbilityApplierEffect( string effectName, GrantAbilityConfig grantAbilityConfig, - IForgeEntity source, + IForgeEntity? sourceEntity, DurationData? durationData, - IEffectComponent? extraComponent) + IEffectComponent? extraComponent, + int effectLevel) { durationData ??= new DurationData(DurationType.Infinite); @@ -745,7 +900,8 @@ private static Effect CreateAbilityApplierEffect( return new Effect( grantAbilityEffectData, - new EffectOwnership(source, null)); + new EffectOwnership(null, sourceEntity), + effectLevel); } private static ActiveEffectHandle? CreateAndApplyTagEffect(TestEntity entity, TagContainer tags) diff --git a/Forge/Abilities/AbilityHandle.cs b/Forge/Abilities/AbilityHandle.cs index 79f74b2..92dbd94 100644 --- a/Forge/Abilities/AbilityHandle.cs +++ b/Forge/Abilities/AbilityHandle.cs @@ -17,6 +17,11 @@ public class AbilityHandle /// public bool IsInhibited => Ability?.IsInhibited == true; + /// + /// Gets a value indicating the level of the ability associated with this handle. + /// + public int Level => Ability?.Level ?? 0; + internal Ability? Ability { get; private set; } internal AbilityHandle(Ability ability) diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index ad614b2..0ef2a20 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -25,10 +25,15 @@ public class EntityAbilities /// /// The data of the ability to find. /// The handle of the found ability. + /// The source entity of the ability, if any. /// true if the ability was found; otherwise, false. - public bool TryGetAbility(AbilityData abilityData, [NotNullWhen(true)] out AbilityHandle? abilityHandle) + public bool TryGetAbility( + AbilityData abilityData, + [NotNullWhen(true)] out AbilityHandle? abilityHandle, + IForgeEntity? source = null) { - Ability? ability = GrantedAbilities.FirstOrDefault(x => x?.Ability?.AbilityData == abilityData)?.Ability; + Ability? ability = GrantedAbilities.FirstOrDefault( + x => x?.Ability?.AbilityData == abilityData && x.Ability?.SourceEntity == source)?.Ability; if (ability is not null) { abilityHandle = ability.Handle; diff --git a/Forge/Effects/Components/GrantAbilityEffectComponent.cs b/Forge/Effects/Components/GrantAbilityEffectComponent.cs index a7a93c9..db8bc67 100644 --- a/Forge/Effects/Components/GrantAbilityEffectComponent.cs +++ b/Forge/Effects/Components/GrantAbilityEffectComponent.cs @@ -75,7 +75,7 @@ private void GrantAbilitiesPermanently(IForgeEntity target, in EffectEvaluatedDa config.RemovalPolicy, config.InhibitionPolicy, config.LevelOverridePolicy, - effectEvaluatedData.Effect.Ownership.Owner); + effectEvaluatedData.Effect.Ownership.Source); } } @@ -92,7 +92,7 @@ private void GrantAbilities(IForgeEntity target, in ActiveEffectEvaluatedData ac config.InhibitionPolicy, config.LevelOverridePolicy, activeEffectEvaluatedData.ActiveEffectHandle, - activeEffectEvaluatedData.EffectEvaluatedData.Effect.Ownership.Owner); + activeEffectEvaluatedData.EffectEvaluatedData.Effect.Ownership.Source); } } From 552089dc28d81a53e8e820608d3b9d7a2a1a99c7 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 23 Oct 2025 22:03:32 -0300 Subject: [PATCH 17/87] Added support for ModifierMagnitudes for duration --- Forge.Tests/Abilities/AbilitiesTests.cs | 4 +- Forge.Tests/Cues/CueTests.cs | 180 +++++------ Forge.Tests/Effects/EffectsTests.cs | 280 +++++++++++++++++- .../Effects/ModifierTagsComponentTests.cs | 8 +- .../TargetTagRequirementsComponentTests.cs | 11 +- Forge.Tests/Samples/QuickStartTests.cs | 42 ++- Forge/Effects/ActiveEffect.cs | 60 +++- Forge/Effects/Components/IEffectComponent.cs | 10 +- Forge/Effects/Duration/DurationData.cs | 5 +- Forge/Effects/EffectData.cs | 2 +- Forge/Effects/EffectEvaluatedData.cs | 19 +- Forge/Effects/EffectsManager.cs | 5 + 12 files changed, 482 insertions(+), 144 deletions(-) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index 904ad5e..f9f011a 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -824,7 +824,9 @@ private static AbilityData CreateAbiltyData( { var costEffectData = new EffectData( "Fireball Cooldown", - new DurationData(DurationType.HasDuration, cooldownDuration)); + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, cooldownDuration))); var cooldownEffectData = new EffectData( "Fireball Cost", diff --git a/Forge.Tests/Cues/CueTests.cs b/Forge.Tests/Cues/CueTests.cs index 1d7ee25..a84557b 100644 --- a/Forge.Tests/Cues/CueTests.cs +++ b/Forge.Tests/Cues/CueTests.cs @@ -329,24 +329,24 @@ private enum TestCueExecutionType public void Instant_effect_triggers_execute_cues_with_expected_results( object[] modifiersData, bool requireModifierSuccessToTriggerCue, - object[] cueDatas, - object[] cueTestDatas1, - object[] cueTestDatas2) + object[] cueData, + object[] cueTestData1, + object[] cueTestData2) { var entity = new TestEntity(_tagsManager, _cuesManager); EffectData effectData = CreateInstantEffectData( CreateModifiers(modifiersData), requireModifierSuccessToTriggerCue, - CreateCueDatas(cueDatas)); + CreateCueData(cueData)); var effect = new Effect(effectData, new EffectOwnership(entity, entity)); ResetCues(); entity.EffectsManager.ApplyEffect(effect); - TestCueExecutionData(TestCueExecutionType.Execution, cueTestDatas1); + TestCueExecutionData(TestCueExecutionType.Execution, cueTestData1); effect.LevelUp(); entity.EffectsManager.ApplyEffect(effect); - TestCueExecutionData(TestCueExecutionType.Execution, cueTestDatas2); + TestCueExecutionData(TestCueExecutionType.Execution, cueTestData2); } [Theory] @@ -589,24 +589,24 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( public void Infinite_effect_triggers_apply_and_remove_cues_with_expected_results( object[] modifiersData, bool requireModifierSuccessToTriggerCue, - object[] cueDatas, - object[] cueTestDatas1, - object[] cueTestDatas2) + object[] cueData, + object[] cueTestData1, + object[] cueTestData2) { var entity = new TestEntity(_tagsManager, _cuesManager); EffectData effectData = CreateInfiniteEffectData( CreateModifiers(modifiersData), true, requireModifierSuccessToTriggerCue, - CreateCueDatas(cueDatas)); + CreateCueData(cueData)); var effect = new Effect(effectData, new EffectOwnership(entity, entity)); ResetCues(); ActiveEffectHandle? activeEffectHandler = entity.EffectsManager.ApplyEffect(effect); - TestCueExecutionData(TestCueExecutionType.Application, cueTestDatas1); + TestCueExecutionData(TestCueExecutionType.Application, cueTestData1); entity.EffectsManager.UnapplyEffect(activeEffectHandler!); - TestCueExecutionData(TestCueExecutionType.Application, cueTestDatas2); + TestCueExecutionData(TestCueExecutionType.Application, cueTestData2); } [Theory] @@ -867,13 +867,13 @@ public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected bool requireModifierSuccessToTriggerCue, float firstDeltaUpdate, float secondDeltaUpdate, - object[] cueDatas, - object[] applicationCueTestDatas1, - object[] executionCueTestDatas1, - object[] applicationCueTestDatas2, - object[] executionCueTestDatas2, - object[] applicationCueTestDatas3, - object[] executionCueTestDatas3, + object[] cueData, + object[] applicationCueTestData1, + object[] executionCueTestData1, + object[] applicationCueTestData2, + object[] executionCueTestData2, + object[] applicationCueTestData3, + object[] executionCueTestData3, bool applyTriggered, bool executeTriggered, bool isFiredInCorrectOrder) @@ -885,7 +885,7 @@ public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected executeOnApplication, CreateModifiers(modifiersData), requireModifierSuccessToTriggerCue, - CreateCueDatas(cueDatas)); + CreateCueData(cueData)); var effect = new Effect(effectData, new EffectOwnership(entity, entity)); var firstTriggered = new ManualResetEventSlim(false); @@ -908,18 +908,18 @@ public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected secondTriggered.IsSet.Should().Be(executeTriggered); isOrderCorrect.Should().Be(isFiredInCorrectOrder); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas1); - TestCueExecutionData(TestCueExecutionType.Execution, executionCueTestDatas1); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData1); + TestCueExecutionData(TestCueExecutionType.Execution, executionCueTestData1); entity.EffectsManager.UpdateEffects(firstDeltaUpdate); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas2); - TestCueExecutionData(TestCueExecutionType.Execution, executionCueTestDatas2); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData2); + TestCueExecutionData(TestCueExecutionType.Execution, executionCueTestData2); entity.EffectsManager.UpdateEffects(secondDeltaUpdate); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas3); - TestCueExecutionData(TestCueExecutionType.Execution, executionCueTestDatas3); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData3); + TestCueExecutionData(TestCueExecutionType.Execution, executionCueTestData3); } [Theory] @@ -1180,34 +1180,34 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res object[] modifiersData, bool snapshotLevel, bool requireModifierSuccessToTriggerCue, - object[] cueDatas, - object[] applicationCueTestDatas1, - object[] updateCueTestDatas1, - object[] applicationCueTestDatas2, - object[] updateCueTestDatas2, - object[] applicationCueTestDatas3, - object[] updateCueTestDatas3) + object[] cueData, + object[] applicationCueTestData1, + object[] updateCueTestData1, + object[] applicationCueTestData2, + object[] updateCueTestData2, + object[] applicationCueTestData3, + object[] updateCueTestData3) { var entity = new TestEntity(_tagsManager, _cuesManager); EffectData effectData = CreateInfiniteEffectData( CreateModifiers(modifiersData), snapshotLevel, requireModifierSuccessToTriggerCue, - CreateCueDatas(cueDatas)); + CreateCueData(cueData)); var effect = new Effect(effectData, new EffectOwnership(entity, entity)); ResetCues(); ActiveEffectHandle? activeEffectHandler = entity.EffectsManager.ApplyEffect(effect); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas1); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas1); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData1); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData1); effect.LevelUp(); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas2); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas2); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData2); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData2); entity.EffectsManager.UnapplyEffect(activeEffectHandler!); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas3); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas3); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData3); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData3); } [Theory] @@ -1528,46 +1528,46 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res new object[] { 0, 0, 0, 0f, true }, new object[] { 1, 0, 0, 0f, true }, })] - public void Attributre_based_modifiers_triggers_update_cues_when_attribute_changes_with_expected_results( + public void Attribute_based_modifiers_triggers_update_cues_when_attribute_changes_with_expected_results( object[] attributeBasedModifiersData, object[] modifiersData, bool requireModifierSuccessToTriggerCue, - object[] cueDatas, - object[] applicationCueTestDatas1, - object[] updateCueTestDatas1, - object[] applicationCueTestDatas2, - object[] updateCueTestDatas2, - object[] applicationCueTestDatas3, - object[] updateCueTestDatas3) + object[] cueData, + object[] applicationCueTestData1, + object[] updateCueTestData1, + object[] applicationCueTestData2, + object[] updateCueTestData2, + object[] applicationCueTestData3, + object[] updateCueTestData3) { var entity = new TestEntity(_tagsManager, _cuesManager); EffectData effectData1 = CreateInfiniteEffectData( CreateAttributeBasedModifiers(attributeBasedModifiersData), true, requireModifierSuccessToTriggerCue, - CreateCueDatas(cueDatas)); + CreateCueData(cueData)); var effect1 = new Effect(effectData1, new EffectOwnership(entity, entity)); EffectData effectData2 = CreateInfiniteEffectData( CreateModifiers(modifiersData), true, requireModifierSuccessToTriggerCue, - CreateCueDatas([])); + CreateCueData([])); var effect2 = new Effect(effectData2, new EffectOwnership(entity, entity)); ResetCues(); entity.EffectsManager.ApplyEffect(effect1); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas1); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas1); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData1); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData1); ActiveEffectHandle? activeEffectHandler = entity.EffectsManager.ApplyEffect(effect2); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas2); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas2); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData2); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData2); entity.EffectsManager.UnapplyEffect(activeEffectHandler!); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas3); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas3); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData3); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData3); } [Theory] @@ -2181,19 +2181,19 @@ public void Stackable_effect_triggers_update_cues_when_attribute_changes_with_ex int stackLimit, bool requireModifierSuccessToTriggerCue, bool suppressStackingCues, - object[] cueDatas, + object[] cueData, float deltaUpdate1, float deltaUpdate2, - object[] applicationCueTestDatas1, - object[] updateCueTestDatas1, - object[] applicationCueTestDatas2, - object[] updateCueTestDatas2, - object[] applicationCueTestDatas3, - object[] updateCueTestDatas3, - object[] applicationCueTestDatas4, - object[] updateCueTestDatas4, - object[] applicationCueTestDatas5, - object[] updateCueTestDatas5) + object[] applicationCueTestData1, + object[] updateCueTestData1, + object[] applicationCueTestData2, + object[] updateCueTestData2, + object[] applicationCueTestData3, + object[] updateCueTestData3, + object[] applicationCueTestData4, + object[] updateCueTestData4, + object[] applicationCueTestData5, + object[] updateCueTestData5) { var entity = new TestEntity(_tagsManager, _cuesManager); EffectData effectData1 = CreateDurationStackableEffectData( @@ -2203,36 +2203,36 @@ public void Stackable_effect_triggers_update_cues_when_attribute_changes_with_ex CreateModifiers(attributeBasedModifiersData), requireModifierSuccessToTriggerCue, suppressStackingCues, - CreateCueDatas(cueDatas)); + CreateCueData(cueData)); var effect1 = new Effect(effectData1, new EffectOwnership(entity, entity)); ResetCues(); entity.EffectsManager.ApplyEffect(effect1); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas1); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas1); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData1); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData1); entity.EffectsManager.UpdateEffects(deltaUpdate1); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas2); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas2); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData2); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData2); effect1.LevelUp(); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas3); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas3); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData3); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData3); entity.EffectsManager.ApplyEffect(effect1); entity.EffectsManager.ApplyEffect(effect1); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas4); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas4); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData4); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData4); entity.EffectsManager.UpdateEffects(deltaUpdate2); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas5); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas5); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData5); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData5); } [Fact] [Trait("Invalid cue", null)] - public void Invalid_cue_fails_gracefullys() + public void Invalid_cue_fails_gracefully() { var entity = new TestEntity(_tagsManager, _cuesManager); EffectData effectData = CreateInstantEffectData( @@ -2422,7 +2422,9 @@ private static EffectData CreateDurationPeriodicEffectData( { return new EffectData( "Infinite Effect", - new DurationData(DurationType.HasDuration, new ScalableFloat(duration)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(duration))), modifiers, periodicData: new PeriodicData( new ScalableFloat(period), @@ -2443,7 +2445,9 @@ private static EffectData CreateDurationStackableEffectData( { return new EffectData( "Infinite Effect", - new DurationData(DurationType.HasDuration, new ScalableFloat(duration)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(duration))), modifiers, new StackingData( new ScalableInt(stackLimit), @@ -2524,13 +2528,13 @@ private static Modifier[] CreateAttributeBasedModifiers(object[] modifiersData) return result; } - private CueData[] CreateCueDatas(object[] cueDatas) + private CueData[] CreateCueData(object[] cueDataArray) { - var result = new CueData[cueDatas.Length]; + var result = new CueData[cueDataArray.Length]; - for (var i = 0; i < cueDatas.Length; i++) + for (var i = 0; i < cueDataArray.Length; i++) { - var cueData = (object[])cueDatas[i]; + var cueData = (object[])cueDataArray[i]; result[i] = new CueData( Tag.RequestTag(_tagsManager, $"Test.Cue{(int)cueData[0] + 1}").GetSingleTagContainer(), @@ -2543,11 +2547,11 @@ private CueData[] CreateCueDatas(object[] cueDatas) return result; } - private void TestCueExecutionData(TestCueExecutionType cueDataType, object[] cueTestDatas) + private void TestCueExecutionData(TestCueExecutionType cueDataType, object[] cueTestDataArray) { - for (var i = 0; i < cueTestDatas.Length; i++) + for (var i = 0; i < cueTestDataArray.Length; i++) { - var cueTestData = (object[])cueTestDatas[i]; + var cueTestData = (object[])cueTestDataArray[i]; TestCue testCue = _testCues[(int)cueTestData[0]]; CueExecutionData cueExecutionData = cueDataType switch diff --git a/Forge.Tests/Effects/EffectsTests.cs b/Forge.Tests/Effects/EffectsTests.cs index b39a293..7ab1eec 100644 --- a/Forge.Tests/Effects/EffectsTests.cs +++ b/Forge.Tests/Effects/EffectsTests.cs @@ -4,6 +4,7 @@ using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Cues; using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Effects.Calculator; using Gamesmiths.Forge.Effects.Duration; using Gamesmiths.Forge.Effects.Magnitudes; using Gamesmiths.Forge.Effects.Modifiers; @@ -696,7 +697,7 @@ public void Periodic_effect_with_invalid_period_throws_exception( [InlineData("TestAttributeSet.Attribute5", 20, 33f, 999f, new int[] { 25, 5, 20, 0 })] [InlineData("TestAttributeSet.Attribute90", 100, 1f, 60f, new int[] { 99, 90, 100, 91 })] [InlineData("Invalid.Attribute", 100, 1f, 60f, new int[] { })] - public void Inifinite_effect_modify_attribute_modifier_value( + public void Infinite_effect_modify_attribute_modifier_value( string targetAttribute, float modifierBaseMagnitude, float simulatedFPS, @@ -1006,7 +1007,9 @@ public void Duration_effect_modifies_attribute_modifier_value_and_expire_after_d var effectData = new EffectData( "Buff", - new DurationData(DurationType.HasDuration, new ScalableFloat(baseDuration)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(baseDuration))), [ new Modifier( targetAttribute, @@ -1263,7 +1266,7 @@ public void Snapshot_periodic_effect_modifies_base_attribute_with_same_value_eve 0, 1f, 0)] - public void Non_snapshot_priodic_effect_with_attribute_based_magnitude_should_update_when_attribute_updates( + public void Non_snapshot_periodic_effect_with_attribute_based_magnitude_should_update_when_attribute_updates( string targetAttribute, string backingAttribute, float coefficient, @@ -1531,7 +1534,9 @@ public void Periodic_effect_modifies_base_attribute_value_and_expire_after_durat var effectData = new EffectData( "Buff", - new DurationData(DurationType.HasDuration, new ScalableFloat(duration)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(duration))), [ new Modifier( targetAttribute, @@ -1782,7 +1787,7 @@ public void Stackable_effect_from_two_different_sources_gives_expected_stack_dat [Theory] [Trait("Stackable", null)] - // Effects with stack level policy Seggregate don't stack. + // Effects with stack level policy Segregate don't stack. [InlineData( new int[] { 6, 1, 5, 0 }, new int[] { 16, 1, 15, 0 }, @@ -2727,7 +2732,7 @@ public void Stackable_periodic_effect_with_duration_updates_correctly( object[] thirdExpectedStackData, int fourthExpectedStackDataCount, object[] fourthExpectedStackData, - float effectDutation, + float effectDuration, string targetAttribute, int modifierMagnitude, float effectPeriod, @@ -2755,7 +2760,9 @@ public void Stackable_periodic_effect_with_duration_updates_correctly( var effectData = new EffectData( "Buff", - new DurationData(DurationType.HasDuration, new ScalableFloat(effectDutation)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(effectDuration))), [ new Modifier( targetAttribute, @@ -3045,7 +3052,7 @@ public void Infinite_stackable_effect_unnaplies_correctly( owner, target); - target.EffectsManager.UnapplyEffect(effectHandle, forceUnapply); + target.EffectsManager.UnapplyEffect(effectHandle!, forceUnapply); TestUtils.TestAttribute(target, targetAttribute, thirdExpectedResults); @@ -3056,7 +3063,7 @@ public void Infinite_stackable_effect_unnaplies_correctly( owner, target); - target.EffectsManager.UnapplyEffect(effectHandle, forceUnapply); + target.EffectsManager.UnapplyEffect(effectHandle!, forceUnapply); TestUtils.TestAttribute(target, targetAttribute, fourthExpectedResults); @@ -3260,4 +3267,259 @@ public void Attribute_based_modifier_with_null_ownership_does_not_apply_changes( TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); } + + [Fact] + [Trait("Duration", null)] + public void Attribute_based_duration_uses_source_attribute_to_set_expiration() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + // Owner TestAttributeSet.Attribute5 base is 5. Duration = 5 * 2 = 10 seconds. + var durationMagnitude = new ModifierMagnitude( + MagnitudeCalculationType.AttributeBased, + attributeBasedFloat: new AttributeBasedFloat( + new AttributeCaptureDefinition("TestAttributeSet.Attribute5", AttributeCaptureSource.Source), + AttributeCalculationType.BaseValue, + new ScalableFloat(2f), // coefficient + new ScalableFloat(0f), // pre-add + new ScalableFloat(0f))); // post-add + + var effectData = new EffectData( + "AB Duration", + new DurationData(DurationType.HasDuration, durationMagnitude), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) + ]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + // Apply and verify modifier is applied + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + // Before expiration + target.EffectsManager.UpdateEffects(9.9f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + // Cross expiration (10s total) + target.EffectsManager.UpdateEffects(10f - 9.9f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); + } + + [Fact] + [Trait("Duration", null)] + public void Set_by_caller_duration_sets_expiration_from_runtime_value() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var durationTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + var durationMagnitude = new ModifierMagnitude( + MagnitudeCalculationType.SetByCaller, + setByCallerFloat: new SetByCallerFloat(durationTag)); + + var effectData = new EffectData( + "SBC Duration", + new DurationData(DurationType.HasDuration, durationMagnitude), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) + ]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + // Set duration to 0.5 seconds at runtime + effect.SetSetByCallerMagnitude(durationTag, 0.5f); + + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + // Before expiration + target.EffectsManager.UpdateEffects(0.49f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + // Cross expiration + target.EffectsManager.UpdateEffects(0.5f - 0.49f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); + } + + [Fact] + [Trait("Duration", null)] + public void Duration_uses_custom_calculator_to_set_expiration() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var durationMagnitude = new ModifierMagnitude( + MagnitudeCalculationType.CustomCalculatorClass, + customCalculationBasedFloat: new CustomCalculationBasedFloat( + new DurationFromSourceAttributeCalculator(), + new ScalableFloat(1f), // coefficient + new ScalableFloat(0f), // pre-add + new ScalableFloat(0f))); // post-add + + var effectData = new EffectData( + "CC Duration", + new DurationData(DurationType.HasDuration, durationMagnitude), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) + ]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + // Before expiration (1.0s) + target.EffectsManager.UpdateEffects(0.99f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + // Cross expiration + target.EffectsManager.UpdateEffects(1f - 0.99f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); + } + + [Fact] + [Trait("Duration", null)] + public void Attribute_based_duration_updates_duration_with_attribute_changes() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + // Owner TestAttributeSet.Attribute5 base is 5. Duration = 5 * 2 = 10 seconds. + var durationMagnitude = new ModifierMagnitude( + MagnitudeCalculationType.AttributeBased, + attributeBasedFloat: new AttributeBasedFloat( + new AttributeCaptureDefinition("TestAttributeSet.Attribute5", AttributeCaptureSource.Source, false), + AttributeCalculationType.BaseValue, + new ScalableFloat(2f), // coefficient + new ScalableFloat(0f), // pre-add + new ScalableFloat(0f))); // post-add + + var effectData = new EffectData( + "AB Duration", + new DurationData(DurationType.HasDuration, durationMagnitude), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) + ]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + var buffEffectData = new EffectData( + "Buff Attribute5", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute5", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(5))) + ]); + + var buffEffect = new Effect(buffEffectData, new EffectOwnership(owner, owner)); + + // Apply and verify modifier is applied + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + // Before expiration + target.EffectsManager.UpdateEffects(9.9f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + owner.EffectsManager.ApplyEffect(buffEffect); + + // Cross expiration (10s total) + target.EffectsManager.UpdateEffects(10f - 9.9f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + // Before expiration + target.EffectsManager.UpdateEffects(10f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); + } + + [Fact] + [Trait("Duration", null)] + public void Attribute_based_duration_finishes_with_negative_attribute_changes() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + // Owner TestAttributeSet.Attribute5 base is 5. Duration = 5 * 2 = 10 seconds. + var durationMagnitude = new ModifierMagnitude( + MagnitudeCalculationType.AttributeBased, + attributeBasedFloat: new AttributeBasedFloat( + new AttributeCaptureDefinition("TestAttributeSet.Attribute5", AttributeCaptureSource.Source, false), + AttributeCalculationType.BaseValue, + new ScalableFloat(2f), // coefficient + new ScalableFloat(0f), // pre-add + new ScalableFloat(0f))); // post-add + + var effectData = new EffectData( + "AB Duration", + new DurationData(DurationType.HasDuration, durationMagnitude), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) + ]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + var buffEffectData = new EffectData( + "Buff Attribute5", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute5", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-3))) + ]); + + var debuffEffect = new Effect(buffEffectData, new EffectOwnership(owner, owner)); + + // Apply and verify modifier is applied + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + // Before expiration + target.EffectsManager.UpdateEffects(9.0f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + owner.EffectsManager.ApplyEffect(debuffEffect); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); + } + + private sealed class DurationFromSourceAttributeCalculator : CustomModifierMagnitudeCalculator + { + private readonly AttributeCaptureDefinition _sourceAttr; + + public DurationFromSourceAttributeCalculator() + { + // Use owner's Attribute2 (base 2), duration = captured * 0.5 => 1.0 second + _sourceAttr = new AttributeCaptureDefinition( + "TestAttributeSet.Attribute2", + AttributeCaptureSource.Source, + Snapshot: false); + AttributesToCapture.Add(_sourceAttr); + } + + public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target) + { + var value = CaptureAttributeMagnitude(_sourceAttr, effect, target); + return value * 0.5f; // 2 * 0.5 = 1.0 + } + } } diff --git a/Forge.Tests/Effects/ModifierTagsComponentTests.cs b/Forge.Tests/Effects/ModifierTagsComponentTests.cs index 9ffcecd..25cb669 100644 --- a/Forge.Tests/Effects/ModifierTagsComponentTests.cs +++ b/Forge.Tests/Effects/ModifierTagsComponentTests.cs @@ -244,7 +244,7 @@ public void Stackable_effects_keep_tags_until_completely_removed(string[] tagKey var validationContainer = new TagContainer(_tagsManager, validationTags); ActiveEffectHandle? activeEffectHandle = entity.EffectsManager.ApplyEffect(effect); - + entity.Tags.CombinedTags.Equals(validationContainer).Should().BeTrue(); entity.Tags.ModifierTags.Equals(modifierTagsContainer).Should().BeTrue(); @@ -281,7 +281,7 @@ public void Stackable_effects_removes_tags_when_forcibly_removed(string[] tagKey var validationContainer = new TagContainer(_tagsManager, validationTags); ActiveEffectHandle? activeEffectHandle = entity.EffectsManager.ApplyEffect(effect); - + entity.Tags.CombinedTags.Equals(validationContainer).Should().BeTrue(); entity.Tags.ModifierTags.Equals(modifierTagsContainer).Should().BeTrue(); @@ -296,7 +296,9 @@ private EffectData CreateDurationEffectData(string[] tagKeys, float duration) return new EffectData( "Test Effect", - new DurationData(DurationType.HasDuration, new ScalableFloat(duration)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(duration))), effectComponents: [ new ModifierTagsEffectComponent(new TagContainer(_tagsManager, tags)) diff --git a/Forge.Tests/Effects/TargetTagRequirementsComponentTests.cs b/Forge.Tests/Effects/TargetTagRequirementsComponentTests.cs index a66ab6a..6cce44b 100644 --- a/Forge.Tests/Effects/TargetTagRequirementsComponentTests.cs +++ b/Forge.Tests/Effects/TargetTagRequirementsComponentTests.cs @@ -1,6 +1,5 @@ // Copyright © Gamesmiths Guild. -using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Cues; using Gamesmiths.Forge.Effects; using Gamesmiths.Forge.Effects.Components; @@ -528,7 +527,7 @@ public void Effect_with_ongoing_requirement_gets_inhibited_after_modifier_tag_ap TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); ActiveEffectHandle? activeModifierEffectHandle = entity.EffectsManager.ApplyEffect(modifierTagEffect); - + TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle!); @@ -567,7 +566,7 @@ public void Effect_with_ongoing_requirement_gets_inhibited_after_modifier_tag_re var modifierTagEffect = new Effect(modifierTagEffectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? activeModifierEffectHandle = entity.EffectsManager.ApplyEffect(modifierTagEffect); - + entity.EffectsManager.ApplyEffect(effect); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); @@ -600,7 +599,7 @@ public void Periodic_effect_with_ongoing_requirement_executes_normally( var effect = new Effect(effectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? activeEffectHandle = entity.EffectsManager.ApplyEffect(effect); - + TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [11, 11, 0, 0]); TestUtils.TestStackData( entity.EffectsManager.GetEffectInfo(effectData), @@ -649,7 +648,7 @@ public void Periodic_effect_without_ongoing_requirement_does_not_execute( var effect = new Effect(effectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? activeEffectHandle = entity.EffectsManager.ApplyEffect(effect); - + entity.EffectsManager.UpdateEffects(100f); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); @@ -774,7 +773,7 @@ public void Periodic_effect_with_ongoing_requirement_gets_inhibited_after_modifi TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", secondExpectedResults); ActiveEffectHandle? activeModifierEffectHandle = entity.EffectsManager.ApplyEffect(modifierTagEffect); - + entity.EffectsManager.UpdateEffects(secondUpdatePeriod); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", thirdExpectedResults); diff --git a/Forge.Tests/Samples/QuickStartTests.cs b/Forge.Tests/Samples/QuickStartTests.cs index 76ccfbd..d8a1911 100644 --- a/Forge.Tests/Samples/QuickStartTests.cs +++ b/Forge.Tests/Samples/QuickStartTests.cs @@ -121,7 +121,11 @@ public void Buff_effect_with_duration() // Create a strength buff effect that lasts for 10 seconds var strengthBuffEffectData = new EffectData( "Strength Potion", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), // 10 seconds duration + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f))), // 10 seconds duration new[] { new Modifier( "PlayerAttributeSet.Strength", @@ -227,7 +231,11 @@ public void Periodic_effect_example() // Create a poison effect that ticks every 2 seconds for 10 seconds var poisonEffectData = new EffectData( "Poison", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f))), new[] { new Modifier( "PlayerAttributeSet.Health", @@ -270,7 +278,11 @@ public void Stacking_poison_effect_example() // Create a poison effect that stacks up to 3 times var stackingPoisonEffectData = new EffectData( "Stacking Poison", - new DurationData(DurationType.HasDuration, new ScalableFloat(6.0f)), // Each stack lasts 6 seconds + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(6.0f))), // Each stack lasts 6 seconds new[] { new Modifier( "PlayerAttributeSet.Health", @@ -335,7 +347,11 @@ public void Unique_effect_example() // Define the unique effect data var uniqueEffectData = new EffectData( "Unique Buff", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), // Lasts 10 seconds + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f))), // Lasts 10 seconds new[] { new Modifier( "PlayerAttributeSet.Strength", @@ -395,7 +411,11 @@ public void Adding_a_temporary_tag_with_an_effect() // Create a "Stunned" effect that adds a tag and reduces speed to 0 var stunEffectData = new EffectData( "Stunned", - new DurationData(DurationType.HasDuration, new ScalableFloat(3.0f)), // 3 seconds duration + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(3.0f))), // 3 seconds duration new[] { new Modifier( "PlayerAttributeSet.Speed", @@ -484,7 +504,11 @@ public void Advanced_custom_components() // Create a "Stunned" effect that adds a tag and reduces speed to 0 var stunEffectData = new EffectData( "Stunned", - new DurationData(DurationType.HasDuration, new ScalableFloat(3.0f)), // 3 seconds duration + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(3.0f))), // 3 seconds duration new[] { new Modifier( "PlayerAttributeSet.Speed", @@ -625,7 +649,11 @@ public void Triggering_a_cue_through_effects() // Define a burning effect that includes the fire damage cue var burningEffectData = new EffectData( "Burning", - new DurationData(DurationType.HasDuration, new ScalableFloat(5.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(5.0f))), new[] { new Modifier( "PlayerAttributeSet.Health", diff --git a/Forge/Effects/ActiveEffect.cs b/Forge/Effects/ActiveEffect.cs index 913f572..d1c9235 100644 --- a/Forge/Effects/ActiveEffect.cs +++ b/Forge/Effects/ActiveEffect.cs @@ -184,18 +184,18 @@ internal bool AddStack(Effect effect, int stacks = 1) return false; } - Effect evaluatedEffect = EffectEvaluatedData.Effect; + Effect evaluatedEffect = Effect; if (stackingData.OwnerDenialPolicy.HasValue) { if (stackingData.OwnerDenialPolicy.Value == StackOwnerDenialPolicy.DenyIfDifferent && - EffectEvaluatedData.Effect.Ownership.Owner != effect.Ownership.Owner) + Effect.Ownership.Owner != effect.Ownership.Owner) { return false; } if (stackingData.OwnerOverridePolicy == StackOwnerOverridePolicy.Override && - EffectEvaluatedData.Effect.Ownership.Owner != effect.Ownership.Owner) + Effect.Ownership.Owner != effect.Ownership.Owner) { evaluatedEffect = effect; hasChanges = true; @@ -216,7 +216,7 @@ internal bool AddStack(Effect effect, int stacks = 1) } } - // It can be a successfull application and still not increase stack count. + // It can be a successful application and still not increase stack count. // In some cases we can even skip re-application. if (resetStacks) { @@ -276,7 +276,7 @@ internal void RemoveStack() EffectEvaluatedData.Target.EffectsManager.OnActiveEffectUnapplied_InternalCall(this); StackCount--; - ReapplyEffect(EffectEvaluatedData.Effect, isStackingCall: true); + ReapplyEffect(Effect, isStackingCall: true); } internal void Update(double deltaTime) @@ -383,12 +383,11 @@ private void ReapplyEffect(Effect effect, int? level = null, bool isStackingCall { Unapply(true); - EffectEvaluatedData = - new EffectEvaluatedData( - effect, - EffectEvaluatedData.Target, - StackCount, - level); + EffectEvaluatedData = new EffectEvaluatedData( + effect, + EffectEvaluatedData.Target, + StackCount, + level); Apply(reApplication: true); @@ -396,7 +395,7 @@ private void ReapplyEffect(Effect effect, int? level = null, bool isStackingCall EffectEvaluatedData effectEvaluatedData = EffectEvaluatedData; - if (!EffectEvaluatedData.Effect.EffectData.SuppressStackingCues || !isStackingCall) + if (!Effect.EffectData.SuppressStackingCues || !isStackingCall) { EffectEvaluatedData.Target.EffectsManager.TriggerCuesUpdate_InternalCall(in effectEvaluatedData); } @@ -444,15 +443,44 @@ private void Execute() ExecutionCount++; } + private void UpdateEffectEvaluation() + { + if (!EffectData.DurationData.DurationMagnitude.HasValue) + { + ReapplyEffect(Effect); + return; + } + + var updatedDuration = EffectData.DurationData.DurationMagnitude.Value.GetMagnitude( + Effect, + EffectEvaluatedData.Target, + Effect.Level); + + if (EffectEvaluatedData.Duration > updatedDuration + Epsilon + || EffectEvaluatedData.Duration < updatedDuration - Epsilon) + { + RemainingDuration += updatedDuration - EffectEvaluatedData.Duration; + } + + if (RemainingDuration <= 0 + && EffectData.DurationData.DurationType == DurationType.HasDuration + && StackCount == 1) + { + Unapply(); + EffectEvaluatedData.Target.EffectsManager.RemoveActiveEffect_InternalCall(this); + return; + } + + ReapplyEffect(Effect); + } + private void Attribute_OnValueChanged(EntityAttribute attribute, int change) { - // This could be optimized by re-evaluating only the modifiers with the attribute that changed. - ReapplyEffect(EffectEvaluatedData.Effect); + UpdateEffectEvaluation(); } private void Effect_OnLevelChanged(int obj) { - // This one has to re-calculate everything that uses ScalableFloats. - ReapplyEffect(EffectEvaluatedData.Effect); + UpdateEffectEvaluation(); } } diff --git a/Forge/Effects/Components/IEffectComponent.cs b/Forge/Effects/Components/IEffectComponent.cs index 2805296..3beba1a 100644 --- a/Forge/Effects/Components/IEffectComponent.cs +++ b/Forge/Effects/Components/IEffectComponent.cs @@ -13,7 +13,7 @@ public interface IEffectComponent /// /// A custom validation method for validating whether a effect can be applied or not. /// - /// The target of the gampleplay effect. + /// The target of the gameplay effect. /// The effect instance. /// if the effect can be applied; otherwise. /// @@ -54,7 +54,7 @@ void OnPostActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedData a /// contains the number of stacks just before it's removed, so it's never going to be zero. /// /// - /// Note that only effects with duration can be unappled. + /// Note that only effects with duration can be unapplied. /// /// The target whose the active effect is being removed. /// The evaluated data for the active effect being removed.> @@ -82,11 +82,11 @@ void OnActiveEffectChanged(IForgeEntity target, in ActiveEffectEvaluatedData act /// Executes and implements extra functionality for when a effect is applied to a target. /// /// - /// Note that a effect is considered to be applied both when it's intially added and when a new stack is + /// Note that a effect is considered to be applied both when it's initially added and when a new stack is /// successfully applied. All effects, including instant effects, are considered to be applied and will trigger this /// method. /// - /// The target of the gampleplay effect. + /// The target of the gameplay effect. /// The evaluated data for the effect being applied. void OnEffectApplied(IForgeEntity target, in EffectEvaluatedData effectEvaluatedData) { @@ -99,7 +99,7 @@ void OnEffectApplied(IForgeEntity target, in EffectEvaluatedData effectEvaluated /// /// Note that only instant and periodic effects can be executed on a target. /// - /// The target of the gampleplay effect. + /// The target of the gameplay effect. /// The evaluated data for the effect being applied. void OnEffectExecuted(IForgeEntity target, in EffectEvaluatedData effectEvaluatedData) { diff --git a/Forge/Effects/Duration/DurationData.cs b/Forge/Effects/Duration/DurationData.cs index 4fc586e..7213772 100644 --- a/Forge/Effects/Duration/DurationData.cs +++ b/Forge/Effects/Duration/DurationData.cs @@ -8,6 +8,7 @@ namespace Gamesmiths.Forge.Effects.Duration; /// Duration data for a effect. /// /// The type of duration for the effect. -/// The duration for this effect in case it's of type . +/// The duration for this effect in case it's of type +/// . /// -public readonly record struct DurationData(DurationType DurationType, ScalableFloat? Duration = null); +public readonly record struct DurationData(DurationType DurationType, ModifierMagnitude? DurationMagnitude = null); diff --git a/Forge/Effects/EffectData.cs b/Forge/Effects/EffectData.cs index a7dfe92..135405f 100644 --- a/Forge/Effects/EffectData.cs +++ b/Forge/Effects/EffectData.cs @@ -132,7 +132,7 @@ private void ValidateData() "Periodic effects can't be set as instant."); Validation.Assert( - !(DurationData.DurationType != DurationType.HasDuration && DurationData.Duration.HasValue), + !(DurationData.DurationType != DurationType.HasDuration && DurationData.DurationMagnitude.HasValue), $"Can't set duration if {nameof(DurationType)} is set to {DurationData.DurationType}."); Validation.Assert( diff --git a/Forge/Effects/EffectEvaluatedData.cs b/Forge/Effects/EffectEvaluatedData.cs index 6b9a1fc..3090053 100644 --- a/Forge/Effects/EffectEvaluatedData.cs +++ b/Forge/Effects/EffectEvaluatedData.cs @@ -64,14 +64,14 @@ public readonly record struct EffectEvaluatedData public EntityAttribute[] AttributesToCapture { get; } /// - /// Getsan array of custom cue parameters. + /// Gets an array of custom cue parameters. /// public Dictionary? CustomCueParameters { get; } /// /// Initializes a new instance of the struct. /// - /// The taget effect of this evaluated data. + /// The target effect of this evaluated data. /// The target of this evaluated data. /// The stack for this evaluated data. /// The level for this evaluated data. @@ -105,12 +105,12 @@ public EffectEvaluatedData( private float EvaluateDuration(DurationData durationData) { - if (!durationData.Duration.HasValue) + if (!durationData.DurationMagnitude.HasValue) { return 0; } - return durationData.Duration.Value.GetValue(Level); + return durationData.DurationMagnitude.Value.GetMagnitude(Effect, Target, Level); } private float EvaluatePeriod(PeriodicData? periodicData) @@ -169,12 +169,19 @@ private EntityAttribute[] EvaluateAttributesToCapture() foreach (ModifierMagnitude modifierMagnitude in Effect.EffectData.Modifiers.Select(x => x.Magnitude)) { - if (!IsModifierSnapshop(modifierMagnitude)) + if (!IsModifierSnapshot(modifierMagnitude)) { attributesToCapture.AddRange(CaptureModifierBackingAttribute(modifierMagnitude)); } } + if (Effect.EffectData.DurationData.DurationType == DurationType.HasDuration + && Effect.EffectData.DurationData.DurationMagnitude.HasValue) + { + attributesToCapture.AddRange( + CaptureModifierBackingAttribute(Effect.EffectData.DurationData.DurationMagnitude.Value)); + } + foreach (CustomExecution execution in Effect.EffectData.CustomExecutions) { foreach (AttributeCaptureDefinition attributeCaptureDefinition in execution.AttributesToCapture) @@ -209,7 +216,7 @@ private float EvaluateModifierMagnitude(ModifierMagnitude modifierMagnitude) return modifierMagnitude.GetMagnitude(Effect, Target, Level) * stackMultiplier; } - private bool IsModifierSnapshop(ModifierMagnitude modifierMagnitude) + private bool IsModifierSnapshot(ModifierMagnitude modifierMagnitude) { if (Effect.EffectData.DurationData.DurationType == DurationType.Instant) { diff --git a/Forge/Effects/EffectsManager.cs b/Forge/Effects/EffectsManager.cs index f79f2b1..9dd41fe 100644 --- a/Forge/Effects/EffectsManager.cs +++ b/Forge/Effects/EffectsManager.cs @@ -193,6 +193,11 @@ internal void TriggerCuesUpdate_InternalCall(in EffectEvaluatedData effectEvalua _cuesManager.UpdateCues(in effectEvaluatedData); } + internal void RemoveActiveEffect_InternalCall(ActiveEffect effect) + { + RemoveActiveEffect(effect, false); + } + private static bool MatchesStackPolicy(ActiveEffect existingEffect, Effect newEffect) { Validation.Assert( From 3cff5b5365fb86369bbc9d9455356446946b3a6e Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 28 Oct 2025 21:25:37 -0300 Subject: [PATCH 18/87] Added snapshot configuration for SetByCaller float --- Forge.Tests/Cues/CueTests.cs | 4 +- Forge.Tests/Effects/EffectsTests.cs | 48 +++++++++++++++++-- Forge/Effects/ActiveEffect.cs | 43 ++++++++++++++++- Forge/Effects/Effect.cs | 15 +++++- Forge/Effects/EffectData.cs | 16 +++---- .../Effects/Magnitudes/AttributeBasedFloat.cs | 10 ++-- Forge/Effects/Magnitudes/SetByCallerFloat.cs | 3 +- 7 files changed, 118 insertions(+), 21 deletions(-) diff --git a/Forge.Tests/Cues/CueTests.cs b/Forge.Tests/Cues/CueTests.cs index a84557b..8784add 100644 --- a/Forge.Tests/Cues/CueTests.cs +++ b/Forge.Tests/Cues/CueTests.cs @@ -2407,7 +2407,7 @@ private static EffectData CreateInfiniteEffectData( "Infinite Effect", new DurationData(DurationType.Infinite), modifiers, - snapshopLevel: snapshotLevel, + snapshotLevel: snapshotLevel, requireModifierSuccessToTriggerCue: requireModifierSuccessToTriggerCue, cues: cues); } @@ -2461,7 +2461,7 @@ private static EffectData CreateDurationStackableEffectData( LevelOverridePolicy: LevelComparison.Lower | LevelComparison.Equal | LevelComparison.Higher, LevelOverrideStackCountPolicy: StackLevelOverrideStackCountPolicy.IncreaseStacks, ApplicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication), - snapshopLevel: false, + snapshotLevel: false, requireModifierSuccessToTriggerCue: requireModifierSuccessToTriggerCue, suppressStackingCues: suppressStackingCues, cues: cues); diff --git a/Forge.Tests/Effects/EffectsTests.cs b/Forge.Tests/Effects/EffectsTests.cs index 7ab1eec..4b7fa5e 100644 --- a/Forge.Tests/Effects/EffectsTests.cs +++ b/Forge.Tests/Effects/EffectsTests.cs @@ -552,7 +552,7 @@ public void Non_snapshot_level_effect_updates_value_on_level_up( new CurveKey(2, modifierLevel2Multiplier), ])))) ], - snapshopLevel: false); + snapshotLevel: false); var effect = new Effect( effectData, @@ -616,7 +616,7 @@ public void Non_snapshot_level_periodic_effect_updates_scalable_float_values_on_ ])), true, PeriodInhibitionRemovedPolicy.NeverReset), - snapshopLevel: false); + snapshotLevel: false); var effect = new Effect( effectData, @@ -673,7 +673,7 @@ public void Periodic_effect_with_invalid_period_throws_exception( ])), true, PeriodInhibitionRemovedPolicy.NeverReset), - snapshopLevel: false); + snapshotLevel: false); var effect = new Effect( effectData, @@ -3502,6 +3502,48 @@ public void Attribute_based_duration_finishes_with_negative_attribute_changes() TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); } + [Fact] + [Trait("Periodic", null)] + public void Set_by_caller_magnitude_updates_periodic_application_value() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var setByCallerTag = Tag.RequestTag(_tagsManager, "tag"); + + var effectData = new EffectData( + "Level Up", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.SetByCaller, + setByCallerFloat: new SetByCallerFloat(setByCallerTag, false))) + ], + periodicData: new PeriodicData(new ScalableFloat(1f), true, PeriodInhibitionRemovedPolicy.NeverReset), + snapshotLevel: false); + + var effect = new Effect( + effectData, + new EffectOwnership( + new TestEntity(_tagsManager, _cuesManager), + owner)); + + effect.SetSetByCallerMagnitude(setByCallerTag, 1); + + target.EffectsManager.ApplyEffect(effect); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [2, 2, 0, 0]); + + effect.SetSetByCallerMagnitude(setByCallerTag, 2); + + target.EffectsManager.UpdateEffects(1f); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [4, 4, 0, 0]); + } + private sealed class DurationFromSourceAttributeCalculator : CustomModifierMagnitudeCalculator { private readonly AttributeCaptureDefinition _sourceAttr; diff --git a/Forge/Effects/ActiveEffect.cs b/Forge/Effects/ActiveEffect.cs index d1c9235..dd0b2db 100644 --- a/Forge/Effects/ActiveEffect.cs +++ b/Forge/Effects/ActiveEffect.cs @@ -3,9 +3,11 @@ using Gamesmiths.Forge.Attributes; using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Effects.Duration; +using Gamesmiths.Forge.Effects.Magnitudes; using Gamesmiths.Forge.Effects.Modifiers; using Gamesmiths.Forge.Effects.Periodic; using Gamesmiths.Forge.Effects.Stacking; +using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Effects; @@ -16,6 +18,8 @@ internal sealed class ActiveEffect { private const double Epsilon = 0.00001; + private readonly HashSet _nonSnapshotSetByCallerTags; + private double _internalTime; internal ActiveEffectHandle Handle { get; } @@ -54,6 +58,27 @@ internal ActiveEffect(Effect effect, IForgeEntity target) } EffectEvaluatedData = new EffectEvaluatedData(effect, target, StackCount); + + _nonSnapshotSetByCallerTags = []; + + ModifierMagnitude? durationMagnitude = effect.EffectData.DurationData.DurationMagnitude; + if (durationMagnitude.HasValue + && durationMagnitude.Value.MagnitudeCalculationType == MagnitudeCalculationType.SetByCaller + && !durationMagnitude.Value.SetByCallerFloat!.Value.Snapshot) + { + _nonSnapshotSetByCallerTags.Add( + effect.EffectData.DurationData.DurationMagnitude!.Value.SetByCallerFloat!.Value.Tag); + } + + for (var i = 0; i < EffectEvaluatedData.Effect.EffectData.Modifiers.Length; i++) + { + SetByCallerFloat? setByCallerFloat = + EffectEvaluatedData.Effect.EffectData.Modifiers[i].Magnitude.SetByCallerFloat; + if (setByCallerFloat.HasValue && !setByCallerFloat.Value.Snapshot) + { + _nonSnapshotSetByCallerTags.Add(setByCallerFloat.Value.Tag); + } + } } internal void Apply(bool reApplication = false, bool inhibited = false) @@ -65,7 +90,7 @@ internal void Apply(bool reApplication = false, bool inhibited = false) IsInhibited = inhibited; RemainingDuration = EffectEvaluatedData.Duration; - if (!EffectData.SnapshopLevel) + if (!EffectData.SnapshotLevel) { Effect.OnLevelChanged += Effect_OnLevelChanged; } @@ -74,6 +99,8 @@ internal void Apply(bool reApplication = false, bool inhibited = false) { attribute.OnValueChanged += Attribute_OnValueChanged; } + + Effect.OnSetByCallerFloatChanged += Effect_OnSetByCallerFloatChanged; } if (EffectData.PeriodicData.HasValue) @@ -111,10 +138,12 @@ internal void Unapply(bool reApplication = false) attribute.OnValueChanged -= Attribute_OnValueChanged; } - if (!EffectData.SnapshopLevel) + if (!EffectData.SnapshotLevel) { Effect.OnLevelChanged -= Effect_OnLevelChanged; } + + Effect.OnSetByCallerFloatChanged -= Effect_OnSetByCallerFloatChanged; } } @@ -483,4 +512,14 @@ private void Effect_OnLevelChanged(int obj) { UpdateEffectEvaluation(); } + + private void Effect_OnSetByCallerFloatChanged(Tag identifierTag, float magnitude) + { + if (!_nonSnapshotSetByCallerTags.Contains(identifierTag)) + { + return; + } + + UpdateEffectEvaluation(); + } } diff --git a/Forge/Effects/Effect.cs b/Forge/Effects/Effect.cs index 1784c8c..914b182 100644 --- a/Forge/Effects/Effect.cs +++ b/Forge/Effects/Effect.cs @@ -22,13 +22,18 @@ public class Effect(EffectData effectData, EffectOwnership ownership, int level /// public event Action? OnLevelChanged; + /// + /// Event triggered when a magnitude changes. + /// + public event Action? OnSetByCallerFloatChanged; + /// /// Gets the configuration data for this effect. /// public EffectData EffectData { get; } = effectData; /// - /// Gets information about the owership and source of this effect. + /// Gets information about the ownership and source of this effect. /// public EffectOwnership Ownership { get; } = ownership; @@ -68,7 +73,15 @@ public void SetLevel(int level) /// The magnitude to be set for the given tag. public void SetSetByCallerMagnitude(Tag identifierTag, float magnitude) { + if (DataTag.ContainsKey(identifierTag)) + { + DataTag[identifierTag] = magnitude; + OnSetByCallerFloatChanged?.Invoke(identifierTag, magnitude); + return; + } + DataTag.Add(identifierTag, magnitude); + OnSetByCallerFloatChanged?.Invoke(identifierTag, magnitude); } internal static void Execute(in EffectEvaluatedData effectEvaluatedData) diff --git a/Forge/Effects/EffectData.cs b/Forge/Effects/EffectData.cs index 135405f..3d59106 100644 --- a/Forge/Effects/EffectData.cs +++ b/Forge/Effects/EffectData.cs @@ -54,12 +54,12 @@ public readonly record struct EffectData public PeriodicData? PeriodicData { get; } /// - /// Gets a value indicating whether this effect snapshots the level at the momment of creation. + /// Gets a value indicating whether this effect snapshots the level at the moment of creation. /// - public bool SnapshopLevel { get; } + public bool SnapshotLevel { get; } /// - /// Gets the list of effect components that further customize this effect behaviour. + /// Gets the list of effect components that further customize this effect behavior. /// public IEffectComponent[] EffectComponents { get; } @@ -86,10 +86,10 @@ public readonly record struct EffectData /// The list of modifiers for this effect. /// The stacking data for this effect, if it's stackable. /// The periodic data for this effect, if it's periodic. - /// Whether or not this effect snapshots the level at the momment of creation. + /// Whether or not this effect snapshots the level at the moment of creation. /// /// The list of effects components for this effect. - /// Wheter or not trigger cues only when modifiers are successfully + /// Whether or not trigger cues only when modifiers are successfully /// applied. /// Whether or not to trigger cues when applying stacks. /// The list of custom executions for this effect. @@ -100,7 +100,7 @@ public EffectData( Modifier[]? modifiers = null, StackingData? stackingData = null, PeriodicData? periodicData = null, - bool snapshopLevel = true, + bool snapshotLevel = true, IEffectComponent[]? effectComponents = null, bool requireModifierSuccessToTriggerCue = false, bool suppressStackingCues = false, @@ -112,7 +112,7 @@ public EffectData( Modifiers = modifiers ?? []; StackingData = stackingData; PeriodicData = periodicData; - SnapshopLevel = snapshopLevel; + SnapshotLevel = snapshotLevel; EffectComponents = effectComponents ?? []; RequireModifierSuccessToTriggerCue = requireModifierSuccessToTriggerCue; SuppressStackingCues = suppressStackingCues; @@ -219,7 +219,7 @@ private void ValidateData() $"Effects set as {DurationType.Instant} and {MagnitudeCalculationType.AttributeBased} cannot be set as non Snapshot."); Validation.Assert( - !(DurationData.DurationType == DurationType.Instant && !SnapshopLevel), + !(DurationData.DurationType == DurationType.Instant && !SnapshotLevel), $"Effects set as {DurationType.Instant} cannot be set as non Snapshot for Level."); Validation.Assert( diff --git a/Forge/Effects/Magnitudes/AttributeBasedFloat.cs b/Forge/Effects/Magnitudes/AttributeBasedFloat.cs index d023a67..de92d41 100644 --- a/Forge/Effects/Magnitudes/AttributeBasedFloat.cs +++ b/Forge/Effects/Magnitudes/AttributeBasedFloat.cs @@ -17,9 +17,11 @@ namespace Gamesmiths.Forge.Effects.Magnitudes; /// /// Information about which and attribute should be captured and how. /// How the magnitude is going to be extracted from the given attribute. -/// Value to be multiplied with the calculcuated magnitude. -/// Value to be added to the magnitude before multiplying the coeficient. -/// Value to be added to the magnitude after multiplying the coeficient. +/// Value to be multiplied with the calculated magnitude. +/// Value to be added to the magnitude before multiplying the coefficient. +/// +/// Value to be added to the magnitude after multiplying the coefficient. +/// /// In case == /// a final channel for the calculation /// must be provided. @@ -38,7 +40,7 @@ public readonly record struct AttributeBasedFloat( /// Calculates the final magnitude based on the AttributeBasedFloat configurations. /// /// The source effect that will be used to capture source attributes from. - /// The target enity that will be used to capture source attributes from. + /// The target entity that will be used to capture source attributes from. /// Level to use in the magnitude calculation. /// The calculated magnitude for this . public readonly float CalculateMagnitude(Effect effect, IForgeEntity target, int level) diff --git a/Forge/Effects/Magnitudes/SetByCallerFloat.cs b/Forge/Effects/Magnitudes/SetByCallerFloat.cs index 22f2b83..e543ef0 100644 --- a/Forge/Effects/Magnitudes/SetByCallerFloat.cs +++ b/Forge/Effects/Magnitudes/SetByCallerFloat.cs @@ -8,7 +8,8 @@ namespace Gamesmiths.Forge.Effects.Magnitudes; /// A set by caller float is a magnitude used for allowing the caller to set a specific value before applying an effect. /// /// The tag used to identify this custom magnitude. +/// Whether this magnitude should be snapshotted when the effect is applied. /// /// A is used for mapping different possible custom values. /// -public readonly record struct SetByCallerFloat(Tag Tag); +public readonly record struct SetByCallerFloat(Tag Tag, bool Snapshot = true); From 641faaa6b9998c2c23629bc46b0c8c6e6497f036 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 28 Oct 2025 23:15:38 -0300 Subject: [PATCH 19/87] Fixed magnitude calculation for invalid attributes --- Forge.Tests/Effects/EffectsTests.cs | 20 +++---- .../Effects/Magnitudes/AttributeBasedFloat.cs | 58 +++++++++---------- 2 files changed, 38 insertions(+), 40 deletions(-) diff --git a/Forge.Tests/Effects/EffectsTests.cs b/Forge.Tests/Effects/EffectsTests.cs index 4b7fa5e..7123777 100644 --- a/Forge.Tests/Effects/EffectsTests.cs +++ b/Forge.Tests/Effects/EffectsTests.cs @@ -65,7 +65,7 @@ public void Instant_effect_modifies_attribute_base_value( [InlineData("TestAttributeSet.Attribute90", "TestAttributeSet.Attribute5", -1, 5, 2, 82)] [InlineData("TestAttributeSet.Attribute3", "TestAttributeSet.Attribute5", 1.5f, 2.2f, 3.5f, 17)] [InlineData("Invalid.Attribute", "Invalid.Attribute", 2, 1, 2, 0)] - [InlineData("TestAttributeSet.Attribute1", "Invalid.Attribute", 2, 1, 2, 1)] + [InlineData("TestAttributeSet.Attribute1", "Invalid.Attribute", 2, 1, 2, 5)] [InlineData("Invalid.Attribute", "TestAttributeSet.Attribute1", 2, 1, 2, 0)] public void Attribute_based_effect_modifies_values_based_on_source_attribute( string targetAttribute, @@ -111,7 +111,7 @@ public void Attribute_based_effect_modifies_values_based_on_source_attribute( [InlineData("TestAttributeSet.Attribute3", "TestAttributeSet.Attribute5", 1.5f, 2.2f, 3.5f, 4)] [InlineData("TestAttributeSet.Attribute90", "TestAttributeSet.Attribute5", -1, 5, 2, 94)] [InlineData("Invalid.Attribute", "Invalid.Attribute", 2, 1, 2, 0)] - [InlineData("TestAttributeSet.Attribute1", "Invalid.Attribute", 2, 1, 2, 1)] + [InlineData("TestAttributeSet.Attribute1", "Invalid.Attribute", 2, 1, 2, 5)] [InlineData("Invalid.Attribute", "TestAttributeSet.Attribute1", 2, 1, 2, 0)] public void Attribute_based_effect_with_curve_modifies_values_based_on_curve_lookup( string targetAttribute, @@ -1237,17 +1237,17 @@ public void Snapshot_periodic_effect_modifies_base_attribute_with_same_value_eve 1f, 2f, 1f, - 1, + 5, 1f, - 1, + 9, 2f, 0, 1f, - 1, + 13, 0, - 1, + 13, 1f, - 1)] + 17)] [InlineData( "Invalid.Attribute", "TestAttributeSet.Attribute2", @@ -1421,12 +1421,12 @@ public void Non_snapshot_periodic_effect_with_attribute_based_magnitude_should_u 2f, 1f, 2f, - new int[] { 1, 1, 0, 0 }, + new int[] { 5, 1, 4, 0 }, 2f, 0, - new int[] { 1, 1, 0, 0 }, + new int[] { 5, 1, 4, 0 }, 0, - new int[] { 1, 1, 0, 0 })] + new int[] { 5, 1, 4, 0 })] [InlineData( "Invalid.Attribute", "TestAttributeSet.Attribute2", diff --git a/Forge/Effects/Magnitudes/AttributeBasedFloat.cs b/Forge/Effects/Magnitudes/AttributeBasedFloat.cs index de92d41..fead0c2 100644 --- a/Forge/Effects/Magnitudes/AttributeBasedFloat.cs +++ b/Forge/Effects/Magnitudes/AttributeBasedFloat.cs @@ -70,46 +70,44 @@ public readonly float CalculateMagnitude(Effect effect, IForgeEntity target, int break; } - if (attribute is null) - { - return 0f; - } - float magnitude = 0; - switch (AttributeCalculationType) + if (attribute is not null) { - case AttributeCalculationType.CurrentValue: - magnitude = attribute.CurrentValue; - break; + switch (AttributeCalculationType) + { + case AttributeCalculationType.CurrentValue: + magnitude = attribute.CurrentValue; + break; - case AttributeCalculationType.BaseValue: - magnitude = attribute.BaseValue; - break; + case AttributeCalculationType.BaseValue: + magnitude = attribute.BaseValue; + break; - case AttributeCalculationType.Modifier: - magnitude = attribute.Modifier; - break; + case AttributeCalculationType.Modifier: + magnitude = attribute.Modifier; + break; - case AttributeCalculationType.Overflow: - magnitude = attribute.Overflow; - break; + case AttributeCalculationType.Overflow: + magnitude = attribute.Overflow; + break; - case AttributeCalculationType.ValidModifier: - magnitude = attribute.ValidModifier; - break; + case AttributeCalculationType.ValidModifier: + magnitude = attribute.ValidModifier; + break; - case AttributeCalculationType.Min: - magnitude = attribute.Min; - break; + case AttributeCalculationType.Min: + magnitude = attribute.Min; + break; - case AttributeCalculationType.Max: - magnitude = attribute.Max; - break; + case AttributeCalculationType.Max: + magnitude = attribute.Max; + break; - case AttributeCalculationType.MagnitudeEvaluatedUpToChannel: - magnitude = attribute.CalculateMagnitudeUpToChannel(FinalChannel); - break; + case AttributeCalculationType.MagnitudeEvaluatedUpToChannel: + magnitude = attribute.CalculateMagnitudeUpToChannel(FinalChannel); + break; + } } var finalMagnitude = (Coefficient.GetValue(level) * (PreMultiplyAdditiveValue.GetValue(level) + magnitude)) From 877215d469e4b171043434e5a1f0e22ff1cc79d4 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 30 Oct 2025 23:29:14 -0300 Subject: [PATCH 20/87] Fixed support for snapshot AttributeBasedFloat --- Forge.Tests/Effects/EffectsTests.cs | 137 ++++++++++++++++++ Forge/Effects/ActiveEffect.cs | 15 +- Forge/Effects/AttributeSnapshotKey.cs | 19 +++ Forge/Effects/EffectEvaluatedData.cs | 62 ++++++-- .../Effects/Magnitudes/AttributeBasedFloat.cs | 108 +++++++------- .../Magnitudes/AttributeCaptureDefinition.cs | 2 +- Forge/Effects/Magnitudes/ModifierMagnitude.cs | 17 ++- 7 files changed, 274 insertions(+), 86 deletions(-) create mode 100644 Forge/Effects/AttributeSnapshotKey.cs diff --git a/Forge.Tests/Effects/EffectsTests.cs b/Forge.Tests/Effects/EffectsTests.cs index 7123777..b43d7bc 100644 --- a/Forge.Tests/Effects/EffectsTests.cs +++ b/Forge.Tests/Effects/EffectsTests.cs @@ -3544,6 +3544,143 @@ public void Set_by_caller_magnitude_updates_periodic_application_value() TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [4, 4, 0, 0]); } + [Fact] + [Trait("Periodic", null)] + public void Snapshot_attribute_based_magnitude_does_not_update_modifiers_when_attribute_updates() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var effectData = new EffectData( + "Buff", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.AttributeBased, + attributeBasedFloat: new AttributeBasedFloat( + new AttributeCaptureDefinition("TestAttributeSet.Attribute2", AttributeCaptureSource.Source, true), + AttributeCalculationType.BaseValue, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))) + ], + snapshotLevel: false); + + var effect = new Effect( + effectData, + new EffectOwnership(owner, new TestEntity(_tagsManager, _cuesManager))); + + target.EffectsManager.ApplyEffect(effect); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [3, 1, 2, 0]); + + var effectData2 = new EffectData( + "Buff2", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute2", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(2))) + ]); + + var effect2 = new Effect( + effectData2, + new EffectOwnership(owner, new TestEntity(_tagsManager, _cuesManager))); + + owner.EffectsManager.ApplyEffect(effect2); + effect.LevelUp(); + + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute2", [4, 4, 0, 0]); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [3, 1, 2, 0]); + + owner.EffectsManager.ApplyEffect(effect2); + effect.LevelUp(); + + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute2", [6, 6, 0, 0]); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [3, 1, 2, 0]); + } + + [Fact] + [Trait("Periodic", null)] + public void Only_one_modifier_update_modifiers_when_attribute_updates() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var effectData = new EffectData( + "Buff", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.AttributeBased, + attributeBasedFloat: new AttributeBasedFloat( + new AttributeCaptureDefinition("TestAttributeSet.Attribute2", AttributeCaptureSource.Source, true), + AttributeCalculationType.BaseValue, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))), + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.AttributeBased, + attributeBasedFloat: new AttributeBasedFloat( + new AttributeCaptureDefinition("TestAttributeSet.Attribute2", AttributeCaptureSource.Source, false), + AttributeCalculationType.BaseValue, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))) + ], + snapshotLevel: false); + + var effect = new Effect( + effectData, + new EffectOwnership(owner, new TestEntity(_tagsManager, _cuesManager))); + + target.EffectsManager.ApplyEffect(effect); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [5, 1, 4, 0]); + + var effectData2 = new EffectData( + "Buff2", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute2", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(2))) + ]); + + var effect2 = new Effect( + effectData2, + new EffectOwnership(owner, new TestEntity(_tagsManager, _cuesManager))); + + owner.EffectsManager.ApplyEffect(effect2); + + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute2", [4, 4, 0, 0]); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [7, 1, 6, 0]); + + owner.EffectsManager.ApplyEffect(effect2); + + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute2", [6, 6, 0, 0]); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [9, 1, 8, 0]); + } + private sealed class DurationFromSourceAttributeCalculator : CustomModifierMagnitudeCalculator { private readonly AttributeCaptureDefinition _sourceAttr; diff --git a/Forge/Effects/ActiveEffect.cs b/Forge/Effects/ActiveEffect.cs index dd0b2db..d7a39d8 100644 --- a/Forge/Effects/ActiveEffect.cs +++ b/Forge/Effects/ActiveEffect.cs @@ -24,7 +24,7 @@ internal sealed class ActiveEffect internal ActiveEffectHandle Handle { get; } - internal EffectEvaluatedData EffectEvaluatedData { get; private set; } + internal EffectEvaluatedData EffectEvaluatedData { get; } internal bool IsInhibited { get; private set; } @@ -50,7 +50,7 @@ internal ActiveEffect(Effect effect, IForgeEntity target) if (effect.EffectData.StackingData.HasValue) { - StackCount = effect.EffectData.StackingData.Value.InitialStack.GetValue(EffectEvaluatedData.Level); + StackCount = effect.EffectData.StackingData.Value.InitialStack.GetValue(effect.Level); } else { @@ -412,11 +412,7 @@ private void ReapplyEffect(Effect effect, int? level = null, bool isStackingCall { Unapply(true); - EffectEvaluatedData = new EffectEvaluatedData( - effect, - EffectEvaluatedData.Target, - StackCount, - level); + EffectEvaluatedData.ReEvaluate(effect, StackCount, level); Apply(reApplication: true); @@ -480,10 +476,7 @@ private void UpdateEffectEvaluation() return; } - var updatedDuration = EffectData.DurationData.DurationMagnitude.Value.GetMagnitude( - Effect, - EffectEvaluatedData.Target, - Effect.Level); + var updatedDuration = EffectEvaluatedData.EvaluateDuration(EffectData.DurationData); if (EffectEvaluatedData.Duration > updatedDuration + Epsilon || EffectEvaluatedData.Duration < updatedDuration - Epsilon) diff --git a/Forge/Effects/AttributeSnapshotKey.cs b/Forge/Effects/AttributeSnapshotKey.cs new file mode 100644 index 0000000..53601dd --- /dev/null +++ b/Forge/Effects/AttributeSnapshotKey.cs @@ -0,0 +1,19 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Effects.Magnitudes; + +namespace Gamesmiths.Forge.Effects; + +/// +/// Key used for identifying captured attribute snapshots. +/// +/// The attribute being captured. +/// The source from which the attribute is being captured. +/// The type of calculation used for capturing the attribute. +/// The final channel to which the attribute is being captured. +public readonly record struct AttributeSnapshotKey( + StringKey Attribute, + AttributeCaptureSource Source, + AttributeCalculationType CalculationType, + int FinalChannel); diff --git a/Forge/Effects/EffectEvaluatedData.cs b/Forge/Effects/EffectEvaluatedData.cs index 3090053..566c23a 100644 --- a/Forge/Effects/EffectEvaluatedData.cs +++ b/Forge/Effects/EffectEvaluatedData.cs @@ -18,15 +18,19 @@ namespace Gamesmiths.Forge.Effects; /// /// Optimizes performance by avoiding repeated complex calculations and serves as data for event arguments. /// -public readonly record struct EffectEvaluatedData +public sealed class EffectEvaluatedData { private const string InvalidPeriodicDataException = "Evaluated period must be greater than zero. A non-positive" + " value would cause the effect to loop indefinitely."; + private readonly Dictionary _snapshotAttributes; + + private readonly int _snapshotLevel; + /// /// Gets the effect for this evaluated data. /// - public Effect Effect { get; } + public Effect Effect { get; private set; } /// /// Gets the target used for the evaluation of this effect. @@ -36,27 +40,27 @@ public readonly record struct EffectEvaluatedData /// /// Gets the stack count of the effect at the moment of the evaluation. /// - public int Stack { get; } + public int Stack { get; private set; } /// /// Gets the level of the effect at the moment of the evaluation. /// - public int Level { get; } + public int Level { get; private set; } /// /// Gets the duration of the effect at the moment of the evaluation. /// - public float Duration { get; } + public float Duration { get; private set; } /// /// Gets the period of the effect at the moment of the evaluation. /// - public float Period { get; } + public float Period { get; private set; } /// /// Gets the evaluated data for the modifiers of the effect. /// - public ModifierEvaluatedData[] ModifiersEvaluatedData { get; } + public ModifierEvaluatedData[] ModifiersEvaluatedData { get; private set; } /// /// Gets an array of the attributes to be captured by an active effect. @@ -69,7 +73,7 @@ public readonly record struct EffectEvaluatedData public Dictionary? CustomCueParameters { get; } /// - /// Initializes a new instance of the struct. + /// Initializes a new instance of the class. /// /// The target effect of this evaluated data. /// The target of this evaluated data. @@ -86,6 +90,13 @@ public EffectEvaluatedData( Stack = stack; Level = level ?? effect.Level; + _snapshotAttributes = []; + + if (effect.EffectData.SnapshotLevel) + { + _snapshotLevel = Level; + } + Duration = EvaluateDuration(effect.EffectData.DurationData); Period = EvaluatePeriod(effect.EffectData.PeriodicData); @@ -103,14 +114,32 @@ public EffectEvaluatedData( AttributesToCapture = EvaluateAttributesToCapture(); } - private float EvaluateDuration(DurationData durationData) + internal void ReEvaluate(Effect effect, int stack = 1, int? level = null) + { + Effect = effect; + Stack = stack; + Level = level ?? effect.Level; + + if (level is null && effect.EffectData.SnapshotLevel) + { + Level = _snapshotLevel; + } + + Duration = EvaluateDuration(effect.EffectData.DurationData); + Period = EvaluatePeriod(effect.EffectData.PeriodicData); + + // Modifiers should be evaluated after duration and period because it requires those already evaluated. + ModifiersEvaluatedData = EvaluateModifiers(); + } + + internal float EvaluateDuration(DurationData durationData) { if (!durationData.DurationMagnitude.HasValue) { return 0; } - return durationData.DurationMagnitude.Value.GetMagnitude(Effect, Target, Level); + return durationData.DurationMagnitude.Value.GetMagnitude(Effect, Target, Level, _snapshotAttributes); } private float EvaluatePeriod(PeriodicData? periodicData) @@ -127,7 +156,7 @@ private float EvaluatePeriod(PeriodicData? periodicData) throw new ArgumentOutOfRangeException(nameof(periodicData), InvalidPeriodicDataException); } - return periodicData.Value.Period.GetValue(Level); + return evaluatedDuration; } private ModifierEvaluatedData[] EvaluateModifiers() @@ -142,11 +171,14 @@ private ModifierEvaluatedData[] EvaluateModifiers() continue; } + var baseMagnitude = modifier.Magnitude.GetMagnitude(Effect, Target, Level, _snapshotAttributes); + var finalMagnitude = ApplyStackPolicy(baseMagnitude); + modifiersEvaluatedData.Add( new ModifierEvaluatedData( Target.Attributes[modifier.Attribute], modifier.Operation, - EvaluateModifierMagnitude(modifier.Magnitude), + finalMagnitude, modifier.Channel)); } @@ -204,16 +236,16 @@ private EntityAttribute[] EvaluateAttributesToCapture() return [.. attributesToCapture]; } - private float EvaluateModifierMagnitude(ModifierMagnitude modifierMagnitude) + private float ApplyStackPolicy(float baseMagnitude) { - float stackMultiplier = Stack; + var stackMultiplier = Stack; if (Effect.EffectData.StackingData.HasValue && Effect.EffectData.StackingData.Value.MagnitudePolicy == StackMagnitudePolicy.DontStack) { stackMultiplier = 1; } - return modifierMagnitude.GetMagnitude(Effect, Target, Level) * stackMultiplier; + return baseMagnitude * stackMultiplier; } private bool IsModifierSnapshot(ModifierMagnitude modifierMagnitude) diff --git a/Forge/Effects/Magnitudes/AttributeBasedFloat.cs b/Forge/Effects/Magnitudes/AttributeBasedFloat.cs index fead0c2..6fba014 100644 --- a/Forge/Effects/Magnitudes/AttributeBasedFloat.cs +++ b/Forge/Effects/Magnitudes/AttributeBasedFloat.cs @@ -42,82 +42,84 @@ public readonly record struct AttributeBasedFloat( /// The source effect that will be used to capture source attributes from. /// The target entity that will be used to capture source attributes from. /// Level to use in the magnitude calculation. + /// The dictionary containing already captured snapshot attributes for this effect. + /// /// The calculated magnitude for this . - public readonly float CalculateMagnitude(Effect effect, IForgeEntity target, int level) + public readonly float CalculateMagnitude( + Effect effect, + IForgeEntity target, + int level, + Dictionary snapshotAttributes) { - EntityAttribute? attribute = null; + float magnitude = 0; switch (BackingAttribute.Source) { case AttributeCaptureSource.Source: - - if (effect.Ownership.Owner?.Attributes.ContainsAttribute(BackingAttribute.Attribute) != true) - { - break; - } - - attribute = effect.Ownership.Owner.Attributes[BackingAttribute.Attribute]; + magnitude = CaptureAttributeSnapshotAware(effect.Ownership.Owner, snapshotAttributes); break; case AttributeCaptureSource.Target: - - if (!target.Attributes.ContainsAttribute(BackingAttribute.Attribute)) - { - break; - } - - attribute = target.Attributes[BackingAttribute.Attribute]; + magnitude = CaptureAttributeSnapshotAware(target, snapshotAttributes); break; } - float magnitude = 0; + var finalMagnitude = (Coefficient.GetValue(level) * (PreMultiplyAdditiveValue.GetValue(level) + magnitude)) + + PostMultiplyAdditiveValue.GetValue(level); - if (attribute is not null) + if (LookupCurve is not null) { - switch (AttributeCalculationType) - { - case AttributeCalculationType.CurrentValue: - magnitude = attribute.CurrentValue; - break; - - case AttributeCalculationType.BaseValue: - magnitude = attribute.BaseValue; - break; - - case AttributeCalculationType.Modifier: - magnitude = attribute.Modifier; - break; - - case AttributeCalculationType.Overflow: - magnitude = attribute.Overflow; - break; + finalMagnitude = LookupCurve.Evaluate(finalMagnitude); + } - case AttributeCalculationType.ValidModifier: - magnitude = attribute.ValidModifier; - break; + return finalMagnitude; + } - case AttributeCalculationType.Min: - magnitude = attribute.Min; - break; + private float CaptureAttributeSnapshotAware( + IForgeEntity? sourceEntity, + Dictionary snapshotAttributes) + { + if (sourceEntity?.Attributes.ContainsAttribute(BackingAttribute.Attribute) != true) + { + return 0f; + } - case AttributeCalculationType.Max: - magnitude = attribute.Max; - break; + EntityAttribute attribute = sourceEntity.Attributes[BackingAttribute.Attribute]; - case AttributeCalculationType.MagnitudeEvaluatedUpToChannel: - magnitude = attribute.CalculateMagnitudeUpToChannel(FinalChannel); - break; - } + if (!BackingAttribute.Snapshot) + { + return CaptureNow(attribute); } - var finalMagnitude = (Coefficient.GetValue(level) * (PreMultiplyAdditiveValue.GetValue(level) + magnitude)) - + PostMultiplyAdditiveValue.GetValue(level); + var key = new AttributeSnapshotKey( + BackingAttribute.Attribute, + BackingAttribute.Source, + AttributeCalculationType, + FinalChannel); - if (LookupCurve is not null) + if (snapshotAttributes.TryGetValue(key, out var cachedValue)) { - finalMagnitude = LookupCurve.Evaluate(finalMagnitude); + return cachedValue; } - return finalMagnitude; + var currentValue = CaptureNow(attribute); + snapshotAttributes[key] = currentValue; + return currentValue; + } + + private float CaptureNow(EntityAttribute attribute) + { + return AttributeCalculationType switch + { + AttributeCalculationType.CurrentValue => attribute.CurrentValue, + AttributeCalculationType.BaseValue => attribute.BaseValue, + AttributeCalculationType.Modifier => attribute.Modifier, + AttributeCalculationType.Overflow => attribute.Overflow, + AttributeCalculationType.ValidModifier => attribute.ValidModifier, + AttributeCalculationType.Min => attribute.Min, + AttributeCalculationType.Max => attribute.Max, + AttributeCalculationType.MagnitudeEvaluatedUpToChannel => attribute.CalculateMagnitudeUpToChannel(FinalChannel), + _ => 0f, + }; } } diff --git a/Forge/Effects/Magnitudes/AttributeCaptureDefinition.cs b/Forge/Effects/Magnitudes/AttributeCaptureDefinition.cs index 33f4680..28e249b 100644 --- a/Forge/Effects/Magnitudes/AttributeCaptureDefinition.cs +++ b/Forge/Effects/Magnitudes/AttributeCaptureDefinition.cs @@ -7,7 +7,7 @@ namespace Gamesmiths.Forge.Effects.Magnitudes; /// -/// Set of data that definies how an attribute is going to be captured. +/// Set of data that defines how an attribute is going to be captured. /// /// Which attribute to capture. /// From what target to capture the attribute from. diff --git a/Forge/Effects/Magnitudes/ModifierMagnitude.cs b/Forge/Effects/Magnitudes/ModifierMagnitude.cs index 410d42b..5b398e9 100644 --- a/Forge/Effects/Magnitudes/ModifierMagnitude.cs +++ b/Forge/Effects/Magnitudes/ModifierMagnitude.cs @@ -96,10 +96,15 @@ public ModifierMagnitude( /// /// The effect to calculate the magnitude for. /// The target which might be used for the magnitude calculation. - /// An optional custom level used for magnitude calculation. Will use the effect's level if not - /// provided. + /// The level to use in the magnitude calculation. + /// The dictionary containing already captured snapshot attributes for this effect. + /// /// The evaluated magnitude. - public readonly float GetMagnitude(Effect effect, IForgeEntity target, int? level = null) + public readonly float GetMagnitude( + Effect effect, + IForgeEntity target, + int level, + Dictionary snapshotAttributes) { switch (MagnitudeCalculationType) { @@ -107,19 +112,19 @@ public readonly float GetMagnitude(Effect effect, IForgeEntity target, int? leve Validation.Assert( ScalableFloatMagnitude.HasValue, $"{nameof(ScalableFloatMagnitude)} should always have a value at this point."); - return ScalableFloatMagnitude.Value.GetValue(level ?? effect.Level); + return ScalableFloatMagnitude.Value.GetValue(level); case MagnitudeCalculationType.AttributeBased: Validation.Assert( AttributeBasedFloat.HasValue, $"{nameof(AttributeBasedFloat)} should always have a value at this point."); - return AttributeBasedFloat.Value.CalculateMagnitude(effect, target, level ?? effect.Level); + return AttributeBasedFloat.Value.CalculateMagnitude(effect, target, level, snapshotAttributes); case MagnitudeCalculationType.CustomCalculatorClass: Validation.Assert( CustomCalculationBasedFloat.HasValue, $"{nameof(CustomCalculationBasedFloat)} should always have a value at this point."); - return CustomCalculationBasedFloat.Value.CalculateMagnitude(effect, target, level ?? effect.Level); + return CustomCalculationBasedFloat.Value.CalculateMagnitude(effect, target, level); case MagnitudeCalculationType.SetByCaller: Validation.Assert( From 2548f0708ff9de4c4063e2b84c641c38a3d52e01 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 1 Nov 2025 18:22:01 -0300 Subject: [PATCH 21/87] Fixed SetByCaller snapshot --- Forge.Tests/Effects/EffectsTests.cs | 43 +++++++++++++++++++ Forge/Effects/ActiveEffect.cs | 12 +++++- Forge/Effects/EffectEvaluatedData.cs | 17 +++++--- Forge/Effects/Magnitudes/ModifierMagnitude.cs | 17 +++++++- 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/Forge.Tests/Effects/EffectsTests.cs b/Forge.Tests/Effects/EffectsTests.cs index b43d7bc..3a892a5 100644 --- a/Forge.Tests/Effects/EffectsTests.cs +++ b/Forge.Tests/Effects/EffectsTests.cs @@ -3681,6 +3681,49 @@ public void Only_one_modifier_update_modifiers_when_attribute_updates() TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [9, 1, 8, 0]); } + [Fact] + [Trait("Periodic", null)] + public void Set_by_caller_magnitude_does_not_update_periodic_application_value() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var setByCallerTag = Tag.RequestTag(_tagsManager, "tag"); + + var effectData = new EffectData( + "Level Up", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.SetByCaller, + setByCallerFloat: new SetByCallerFloat(setByCallerTag, true))) + ], + periodicData: new PeriodicData(new ScalableFloat(1f), true, PeriodInhibitionRemovedPolicy.NeverReset), + snapshotLevel: false); + + var effect = new Effect( + effectData, + new EffectOwnership( + new TestEntity(_tagsManager, _cuesManager), + owner)); + + effect.SetSetByCallerMagnitude(setByCallerTag, 1); + + target.EffectsManager.ApplyEffect(effect); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [2, 2, 0, 0]); + + effect.SetSetByCallerMagnitude(setByCallerTag, 2); + effect.LevelUp(); + + target.EffectsManager.UpdateEffects(1f); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [3, 3, 0, 0]); + } + private sealed class DurationFromSourceAttributeCalculator : CustomModifierMagnitudeCalculator { private readonly AttributeCaptureDefinition _sourceAttr; diff --git a/Forge/Effects/ActiveEffect.cs b/Forge/Effects/ActiveEffect.cs index d7a39d8..c6d886b 100644 --- a/Forge/Effects/ActiveEffect.cs +++ b/Forge/Effects/ActiveEffect.cs @@ -498,11 +498,21 @@ private void UpdateEffectEvaluation() private void Attribute_OnValueChanged(EntityAttribute attribute, int change) { + if (!EffectEvaluatedData.AttributesToCapture.Contains(attribute)) + { + return; + } + UpdateEffectEvaluation(); } - private void Effect_OnLevelChanged(int obj) + private void Effect_OnLevelChanged(int newLevel) { + if (EffectData.SnapshotLevel) + { + return; + } + UpdateEffectEvaluation(); } diff --git a/Forge/Effects/EffectEvaluatedData.cs b/Forge/Effects/EffectEvaluatedData.cs index 566c23a..261e067 100644 --- a/Forge/Effects/EffectEvaluatedData.cs +++ b/Forge/Effects/EffectEvaluatedData.cs @@ -9,6 +9,7 @@ using Gamesmiths.Forge.Effects.Modifiers; using Gamesmiths.Forge.Effects.Periodic; using Gamesmiths.Forge.Effects.Stacking; +using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Effects; @@ -23,7 +24,9 @@ public sealed class EffectEvaluatedData private const string InvalidPeriodicDataException = "Evaluated period must be greater than zero. A non-positive" + " value would cause the effect to loop indefinitely."; - private readonly Dictionary _snapshotAttributes; + private readonly Dictionary _snapshotAttributes = []; + + private readonly Dictionary _snapshotSetByCallers = []; private readonly int _snapshotLevel; @@ -90,8 +93,6 @@ public EffectEvaluatedData( Stack = stack; Level = level ?? effect.Level; - _snapshotAttributes = []; - if (effect.EffectData.SnapshotLevel) { _snapshotLevel = Level; @@ -139,7 +140,8 @@ internal float EvaluateDuration(DurationData durationData) return 0; } - return durationData.DurationMagnitude.Value.GetMagnitude(Effect, Target, Level, _snapshotAttributes); + return durationData.DurationMagnitude.Value.GetMagnitude( + Effect, Target, Level, _snapshotAttributes, _snapshotSetByCallers); } private float EvaluatePeriod(PeriodicData? periodicData) @@ -171,7 +173,8 @@ private ModifierEvaluatedData[] EvaluateModifiers() continue; } - var baseMagnitude = modifier.Magnitude.GetMagnitude(Effect, Target, Level, _snapshotAttributes); + var baseMagnitude = modifier.Magnitude.GetMagnitude( + Effect, Target, Level, _snapshotAttributes, _snapshotSetByCallers); var finalMagnitude = ApplyStackPolicy(baseMagnitude); modifiersEvaluatedData.Add( @@ -223,7 +226,9 @@ private EntityAttribute[] EvaluateAttributesToCapture() IForgeEntity? attributeSource = attributeCaptureDefinition.Source == AttributeCaptureSource.Source ? Effect.Ownership.Source : Target; - if (!attributeCaptureDefinition.TryGetAttribute(attributeSource, out EntityAttribute? attributeToCapture)) + if (!attributeCaptureDefinition.TryGetAttribute( + attributeSource, + out EntityAttribute? attributeToCapture)) { continue; } diff --git a/Forge/Effects/Magnitudes/ModifierMagnitude.cs b/Forge/Effects/Magnitudes/ModifierMagnitude.cs index 5b398e9..3e2a6bf 100644 --- a/Forge/Effects/Magnitudes/ModifierMagnitude.cs +++ b/Forge/Effects/Magnitudes/ModifierMagnitude.cs @@ -1,6 +1,7 @@ // Copyright © Gamesmiths Guild. using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Effects.Magnitudes; @@ -99,12 +100,15 @@ public ModifierMagnitude( /// The level to use in the magnitude calculation. /// The dictionary containing already captured snapshot attributes for this effect. /// + /// The dictionary containing already captured snapshot SetByCaller for this + /// effect. /// The evaluated magnitude. public readonly float GetMagnitude( Effect effect, IForgeEntity target, int level, - Dictionary snapshotAttributes) + Dictionary snapshotAttributes, + Dictionary snapshotSetByCallerTags) { switch (MagnitudeCalculationType) { @@ -130,6 +134,17 @@ public readonly float GetMagnitude( Validation.Assert( SetByCallerFloat.HasValue, $"{nameof(SetByCallerFloat)} should always have a value at this point."); + + if (SetByCallerFloat.Value.Snapshot) + { + if (snapshotSetByCallerTags.TryGetValue(SetByCallerFloat.Value.Tag, out var snapshotValue)) + { + return snapshotValue; + } + + snapshotSetByCallerTags.Add(SetByCallerFloat.Value.Tag, effect.DataTag[SetByCallerFloat.Value.Tag]); + } + return effect.DataTag[SetByCallerFloat.Value.Tag]; default: From f4bfae9ac2f4b35b8f2e66278a7a1ca17d6f3e6c Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 2 Nov 2025 16:20:56 -0300 Subject: [PATCH 22/87] Fixed snapshot behaviour for CustomCalculators --- Forge.Tests/Cues/CueTests.cs | 11 +- .../Effects/CustomCalculatorsEffectsTests.cs | 187 +++++++++++++++++- Forge.Tests/Effects/EffectsTests.cs | 7 +- .../Helpers/CustomTestExecutionClass.cs | 27 ++- Forge.Tests/Samples/QuickStartTests.cs | 32 ++- Forge/Effects/Calculator/CustomCalculator.cs | 67 +++++-- Forge/Effects/Calculator/CustomExecution.cs | 6 +- .../CustomModifierMagnitudeCalculator.cs | 6 +- Forge/Effects/EffectEvaluatedData.cs | 18 +- .../Effects/Magnitudes/AttributeBasedFloat.cs | 7 +- .../Magnitudes/CustomCalculationBasedFloat.cs | 11 +- Forge/Effects/Magnitudes/ModifierMagnitude.cs | 19 +- 12 files changed, 320 insertions(+), 78 deletions(-) diff --git a/Forge.Tests/Cues/CueTests.cs b/Forge.Tests/Cues/CueTests.cs index 8784add..d63f3d7 100644 --- a/Forge.Tests/Cues/CueTests.cs +++ b/Forge.Tests/Cues/CueTests.cs @@ -2314,7 +2314,7 @@ public void Custom_executions_sets_custom_cues_parameters_correctly() var owner = new TestEntity(_tagsManager, _cuesManager); var target = new TestEntity(_tagsManager, _cuesManager); - var customCalculatorClass = new CustomTestExecutionClass(); + var customCalculatorClass = new CustomTestExecutionClass(false); var effectData = new EffectData( "Test Effect", @@ -2595,10 +2595,15 @@ public CustomMagnitudeCalculator(StringKey attribute, AttributeCaptureSource cap _exponent = exponent; } - public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target) + public override float CalculateBaseMagnitude( + Effect effect, + IForgeEntity target, + EffectEvaluatedData effectEvaluatedData) { CustomCueParameters.Add("test", _exponent); - return (float)Math.Pow(CaptureAttributeMagnitude(Attribute1, effect, target), _exponent); + return (float)Math.Pow( + CaptureAttributeMagnitude(Attribute1, effect, target, effectEvaluatedData), + _exponent); } } } diff --git a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs index 3f473e9..b3ab0c1 100644 --- a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs +++ b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs @@ -44,6 +44,7 @@ public void Custom_calculator_class_magnitude_modifies_attribute_accordingly( var customCalculatorClass = new CustomMagnitudeCalculator( customMagnitudeCalculatorAttribute, AttributeCaptureSource.Source, + false, customMagnitudeCalculatorExponent); var effectData = new EffectData( @@ -98,6 +99,7 @@ public void Custom_calculator_class_magnitude_with_curve_modifies_attribute_acco var customCalculatorClass = new CustomMagnitudeCalculator( customMagnitudeCalculatorAttribute, AttributeCaptureSource.Source, + false, customMagnitudeCalculatorExponent); var effectData = new EffectData( @@ -263,6 +265,7 @@ public void Custom_calculator_class_non_snapshot_modifies_attribute_accordingly( var customCalculatorClass = new CustomMagnitudeCalculator( customMagnitudeCalculatorAttribute, captureSource, + false, customMagnitudeCalculatorExponent); var effectData = new EffectData( @@ -327,7 +330,7 @@ public void Custom_executions_modifies_attribute_accordingly() var owner = new TestEntity(_tagsManager, _cuesManager); var target = new TestEntity(_tagsManager, _cuesManager); - var customCalculatorClass = new CustomTestExecutionClass(); + var customCalculatorClass = new CustomTestExecutionClass(false); var effectData = new EffectData( "Test Effect", @@ -366,7 +369,7 @@ public void Custom_executions_modifies_update_with_non_snapshot_attributes() var owner = new TestEntity(_tagsManager, _cuesManager); var target = new TestEntity(_tagsManager, _cuesManager); - var customCalculatorClass = new CustomTestExecutionClass(); + var customCalculatorClass = new CustomTestExecutionClass(false); var effectData = new EffectData( "Test Effect", @@ -452,7 +455,7 @@ public void Custom_execution_without_valid_attributes_applies_with_no_attribute_ var owner = new NoAttributesEntity(_tagsManager, _cuesManager); var target = new NoAttributesEntity(_tagsManager, _cuesManager); - var customCalculatorClass = new CustomTestExecutionClass(); + var customCalculatorClass = new CustomTestExecutionClass(false); var effectData = new EffectData( "Test Effect", @@ -482,7 +485,7 @@ public void Custom_execution_without_valid_owner_attributes_applies_with_no_attr var owner = new NoAttributesEntity(_tagsManager, _cuesManager); var target = new TestEntity(_tagsManager, _cuesManager); - var customCalculatorClass = new CustomTestExecutionClass(); + var customCalculatorClass = new CustomTestExecutionClass(false); var effectData = new EffectData( "Test Effect", @@ -518,7 +521,7 @@ public void Custom_execution_without_valid_target_attributes_applies_with_no_att var owner = new TestEntity(_tagsManager, _cuesManager); var target = new NoAttributesEntity(_tagsManager, _cuesManager); - var customCalculatorClass = new CustomTestExecutionClass(); + var customCalculatorClass = new CustomTestExecutionClass(false); var effectData = new EffectData( "Test Effect", @@ -553,6 +556,7 @@ public void Custom_calculator_class_with_invalid_ownership_applies_with_no_attri var customCalculatorClass = new CustomMagnitudeCalculator( "TestAttributeSet.Attribute1", AttributeCaptureSource.Source, + false, 1); var effectData = new EffectData( @@ -586,7 +590,7 @@ public void Custom_executions_with_invalid_ownership_applies_with_no_attribute_c { var target = new TestEntity(_tagsManager, _cuesManager); - var customCalculatorClass = new CustomTestExecutionClass(); + var customCalculatorClass = new CustomTestExecutionClass(false); var effectData = new EffectData( "Test Effect", @@ -659,6 +663,7 @@ public void Custom_calculator_class_magnitude_captures_magnitude_correctly( var customCalculatorClass = new CustomMagnitudeCalculator( customMagnitudeCalculatorAttribute, AttributeCaptureSource.Source, + false, 1, attributeCalculationType); @@ -701,6 +706,161 @@ public void Custom_calculator_class_magnitude_captures_magnitude_correctly( TestUtils.TestAttribute(target, targetAttribute, expectedResults); } + [Fact] + [Trait("Snapshot", null)] + public void Custom_calculator_class_snapshot_does_not_update_value_with_effect_level_up() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customCalculatorClass = new CustomMagnitudeCalculator( + "TestAttributeSet.Attribute1", + AttributeCaptureSource.Source, + true, + 1); + + var effectData = new EffectData( + "Test Effect", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.CustomCalculatorClass, + customCalculationBasedFloat: new CustomCalculationBasedFloat( + customCalculatorClass, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))) + ], + snapshotLevel: false); + + var effectData2 = new EffectData( + "Backing Attribute Effect", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(1))) + ]); + + var effect = new Effect( + effectData, + new EffectOwnership( + owner, + owner)); + + var effect2 = new Effect( + effectData2, + new EffectOwnership( + owner, + owner)); + + target.EffectsManager.ApplyEffect(effect); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [2, 1, 1, 0]); + + owner.EffectsManager.ApplyEffect(effect2); + effect.LevelUp(); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [2, 1, 1, 0]); + } + + [Fact] + [Trait("Snapshot", null)] + public void Custom_executions_does_not_update_with_snapshot_attributes_when_effect_level_up() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customCalculatorClass = new CustomTestExecutionClass(true); + + var effectData = new EffectData( + "Test Effect", + new DurationData(DurationType.Infinite), + snapshotLevel: false, + customExecutions: + [ + customCalculatorClass + ]); + + var effectData2 = new EffectData( + "Backing Attribute Effect", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute3", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(1))) + ]); + + var effectData3 = new EffectData( + "Backing Attribute Effect", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute5", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(2))) + ]); + + var effect = new Effect( + effectData, + new EffectOwnership( + owner, + owner)); + + var effect2 = new Effect( + effectData2, + new EffectOwnership( + owner, + owner)); + + var effect3 = new Effect( + effectData3, + new EffectOwnership( + owner, + owner)); + + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [89, 90, -1, 0]); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 2, 8, 0]); + + ActiveEffectHandle? effectHandler1 = owner.EffectsManager.ApplyEffect(effect2); + effect.LevelUp(); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 2, 8, 0]); + + ActiveEffectHandle? effectHandler2 = owner.EffectsManager.ApplyEffect(effect3); + effect.LevelUp(); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 2, 8, 0]); + + owner.EffectsManager.UnapplyEffect(effectHandler2!); + effect.LevelUp(); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 2, 8, 0]); + + owner.EffectsManager.UnapplyEffect(effectHandler1!); + effect.LevelUp(); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 2, 8, 0]); + } + private sealed class CustomMagnitudeCalculator : CustomModifierMagnitudeCalculator { private readonly float _exponent; @@ -711,10 +871,11 @@ private sealed class CustomMagnitudeCalculator : CustomModifierMagnitudeCalculat public CustomMagnitudeCalculator( StringKey attribute, AttributeCaptureSource captureSource, + bool snapshot, float exponent, AttributeCalculationType attributeCalculationType = AttributeCalculationType.CurrentValue) { - Attribute1 = new AttributeCaptureDefinition(attribute, captureSource, false); + Attribute1 = new AttributeCaptureDefinition(attribute, captureSource, snapshot); AttributesToCapture.Add(Attribute1); @@ -722,9 +883,17 @@ public CustomMagnitudeCalculator( _attributeCalculationType = attributeCalculationType; } - public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target) + public override float CalculateBaseMagnitude( + Effect effect, + IForgeEntity target, + EffectEvaluatedData effectEvaluatedData) { - var capturedMagnitude = CaptureAttributeMagnitude(Attribute1, effect, target, _attributeCalculationType); + var capturedMagnitude = CaptureAttributeMagnitude( + Attribute1, + effect, + target, + effectEvaluatedData, + _attributeCalculationType); return (float)Math.Pow(capturedMagnitude, _exponent); } diff --git a/Forge.Tests/Effects/EffectsTests.cs b/Forge.Tests/Effects/EffectsTests.cs index 3a892a5..c308836 100644 --- a/Forge.Tests/Effects/EffectsTests.cs +++ b/Forge.Tests/Effects/EffectsTests.cs @@ -3738,9 +3738,12 @@ public DurationFromSourceAttributeCalculator() AttributesToCapture.Add(_sourceAttr); } - public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target) + public override float CalculateBaseMagnitude( + Effect effect, + IForgeEntity target, + EffectEvaluatedData effectEvaluatedData) { - var value = CaptureAttributeMagnitude(_sourceAttr, effect, target); + var value = CaptureAttributeMagnitude(_sourceAttr, effect, target, effectEvaluatedData); return value * 0.5f; // 2 * 0.5 = 1.0 } } diff --git a/Forge.Tests/Helpers/CustomTestExecutionClass.cs b/Forge.Tests/Helpers/CustomTestExecutionClass.cs index ce05db9..cefa459 100644 --- a/Forge.Tests/Helpers/CustomTestExecutionClass.cs +++ b/Forge.Tests/Helpers/CustomTestExecutionClass.cs @@ -23,16 +23,16 @@ public class CustomTestExecutionClass : CustomExecution public AttributeCaptureDefinition TargetAttribute2 { get; } - public CustomTestExecutionClass() + public CustomTestExecutionClass(bool snapshot) { SourceAttribute1 = new AttributeCaptureDefinition( "TestAttributeSet.Attribute3", AttributeCaptureSource.Source, - false); + snapshot); SourceAttribute2 = new AttributeCaptureDefinition( "TestAttributeSet.Attribute5", AttributeCaptureSource.Source, - false); + snapshot); SourceAttribute3 = new AttributeCaptureDefinition( "TestAttributeSet.Attribute90", AttributeCaptureSource.Source, @@ -40,11 +40,11 @@ public CustomTestExecutionClass() TargetAttribute1 = new AttributeCaptureDefinition( "TestAttributeSet.Attribute1", AttributeCaptureSource.Target, - false); + true); TargetAttribute2 = new AttributeCaptureDefinition( "TestAttributeSet.Attribute2", AttributeCaptureSource.Target, - false); + true); AttributesToCapture.Add(SourceAttribute1); AttributesToCapture.Add(SourceAttribute2); @@ -55,12 +55,23 @@ public CustomTestExecutionClass() CustomCueParameters.Add("custom.parameter", 0); } - public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target) + public override ModifierEvaluatedData[] EvaluateExecution( + Effect effect, + IForgeEntity target, + EffectEvaluatedData effectEvaluatedData) { var result = new List(); - var sourceAttribute1value = CaptureAttributeMagnitude(SourceAttribute1, effect, effect.Ownership.Source); - var sourceAttribute2value = CaptureAttributeMagnitude(SourceAttribute2, effect, effect.Ownership.Source); + var sourceAttribute1value = CaptureAttributeMagnitude( + SourceAttribute1, + effect, + effect.Ownership.Source, + effectEvaluatedData); + var sourceAttribute2value = CaptureAttributeMagnitude( + SourceAttribute2, + effect, + effect.Ownership.Source, + effectEvaluatedData); if (TargetAttribute1.TryGetAttribute(target, out EntityAttribute? targetAttribute1)) { diff --git a/Forge.Tests/Samples/QuickStartTests.cs b/Forge.Tests/Samples/QuickStartTests.cs index d8a1911..a800dea 100644 --- a/Forge.Tests/Samples/QuickStartTests.cs +++ b/Forge.Tests/Samples/QuickStartTests.cs @@ -814,10 +814,13 @@ public StrengthDamageCalculator() AttributesToCapture.Add(SpeedAttribute); } - public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target) + public override float CalculateBaseMagnitude( + Effect effect, + IForgeEntity target, + EffectEvaluatedData effectEvaluatedData) { - int strength = CaptureAttributeMagnitude(StrengthAttribute, effect, target); - int speed = CaptureAttributeMagnitude(SpeedAttribute, effect, target); + int strength = CaptureAttributeMagnitude(StrengthAttribute, effect, target, effectEvaluatedData); + int speed = CaptureAttributeMagnitude(SpeedAttribute, effect, target, effectEvaluatedData); // Base damage plus 50% of strength float damage = (speed * 2) + (strength * 0.5f); @@ -857,14 +860,29 @@ public HealthDrainExecution() AttributesToCapture.Add(SourceStrength); } - public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target) + public override ModifierEvaluatedData[] EvaluateExecution( + Effect effect, + IForgeEntity target, + EffectEvaluatedData effectEvaluatedData) { var results = new List(); // Get attribute values - int targetHealth = CaptureAttributeMagnitude(TargetHealth, effect, target); - int sourceHealth = CaptureAttributeMagnitude(SourceHealth, effect, effect.Ownership.Owner); - int sourceStrength = CaptureAttributeMagnitude(SourceStrength, effect, effect.Ownership.Owner); + int targetHealth = CaptureAttributeMagnitude( + TargetHealth, + effect, + target, + effectEvaluatedData); + int sourceHealth = CaptureAttributeMagnitude( + SourceHealth, + effect, + effect.Ownership.Owner, + effectEvaluatedData); + int sourceStrength = CaptureAttributeMagnitude( + SourceStrength, + effect, + effect.Ownership.Owner, + effectEvaluatedData); // Calculate health drain amount based on source strength float drainAmount = sourceStrength * 0.5f; diff --git a/Forge/Effects/Calculator/CustomCalculator.cs b/Forge/Effects/Calculator/CustomCalculator.cs index 31014ac..1e3578a 100644 --- a/Forge/Effects/Calculator/CustomCalculator.cs +++ b/Forge/Effects/Calculator/CustomCalculator.cs @@ -27,6 +27,7 @@ public abstract class CustomCalculator /// Definition for the attribute to be captured. /// The effect which makes use of this custom calculator. /// The target of the effect. + /// The evaluated data for the effect. /// Which type of calculation to use to capture the magnitude. /// In case == /// a final channel for the calculation @@ -36,37 +37,63 @@ protected static int CaptureAttributeMagnitude( AttributeCaptureDefinition capturedAttribute, Effect effect, IForgeEntity? target, + EffectEvaluatedData effectEvaluatedData, AttributeCalculationType calculationType = AttributeCalculationType.CurrentValue, int finalChannel = 0) { - switch (capturedAttribute.Source) + IForgeEntity? captureTarget = capturedAttribute.Source switch { - case AttributeCaptureSource.Source: + AttributeCaptureSource.Source => effect.Ownership.Owner, + AttributeCaptureSource.Target => target, + _ => null, + }; - if (effect.Ownership.Owner?.Attributes.ContainsAttribute(capturedAttribute.Attribute) != true) - { - return 0; - } + if (captureTarget is null) + { + return 0; + } - return CaptureMagnitudeValue( - effect.Ownership.Owner.Attributes[capturedAttribute.Attribute], + return (int)CaptureAttributeSnapshotAware( + capturedAttribute, calculationType, - finalChannel); + finalChannel, + captureTarget, + effectEvaluatedData); + } - case AttributeCaptureSource.Target: + private static float CaptureAttributeSnapshotAware( + AttributeCaptureDefinition capturedAttribute, + AttributeCalculationType calculationType, + int finalChannel, + IForgeEntity? sourceEntity, + EffectEvaluatedData effectEvaluatedData) + { + if (sourceEntity?.Attributes.ContainsAttribute(capturedAttribute.Attribute) != true) + { + return 0f; + } - if (target?.Attributes.ContainsAttribute(capturedAttribute.Attribute) != true) - { - return 0; - } + EntityAttribute attribute = sourceEntity.Attributes[capturedAttribute.Attribute]; - return CaptureMagnitudeValue( - target.Attributes[capturedAttribute.Attribute], - calculationType, - finalChannel); + if (!capturedAttribute.Snapshot) + { + return CaptureMagnitudeValue(attribute, calculationType, finalChannel); + } + + var key = new AttributeSnapshotKey( + capturedAttribute.Attribute, + capturedAttribute.Source, + calculationType, + finalChannel); + + if (effectEvaluatedData.SnapshotAttributes.TryGetValue(key, out var cachedValue)) + { + return cachedValue; } - return 0; + var currentValue = CaptureMagnitudeValue(attribute, calculationType, finalChannel); + effectEvaluatedData.SnapshotAttributes[key] = currentValue; + return currentValue; } private static int CaptureMagnitudeValue( @@ -85,7 +112,7 @@ private static int CaptureMagnitudeValue( AttributeCalculationType.Max => attribute.Max, AttributeCalculationType.MagnitudeEvaluatedUpToChannel => (int)attribute.CalculateMagnitudeUpToChannel(finalChannel), - _ => throw new ArgumentOutOfRangeException(nameof(calculationType), calculationType, null), + _ => 0, }; } } diff --git a/Forge/Effects/Calculator/CustomExecution.cs b/Forge/Effects/Calculator/CustomExecution.cs index 5432800..2587913 100644 --- a/Forge/Effects/Calculator/CustomExecution.cs +++ b/Forge/Effects/Calculator/CustomExecution.cs @@ -14,6 +14,8 @@ public abstract class CustomExecution : CustomCalculator /// /// The effect to be used as context for the calculation. /// The target entity to be used as context for the calculation. - /// An array of evaluated datas for each modified attribute. - public abstract ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target); + /// The evaluated data for the effect. + /// An array of evaluated data for each modified attribute. + public abstract ModifierEvaluatedData[] EvaluateExecution( + Effect effect, IForgeEntity target, EffectEvaluatedData effectEvaluatedData); } diff --git a/Forge/Effects/Calculator/CustomModifierMagnitudeCalculator.cs b/Forge/Effects/Calculator/CustomModifierMagnitudeCalculator.cs index 90280a1..eec390f 100644 --- a/Forge/Effects/Calculator/CustomModifierMagnitudeCalculator.cs +++ b/Forge/Effects/Calculator/CustomModifierMagnitudeCalculator.cs @@ -14,6 +14,10 @@ public abstract class CustomModifierMagnitudeCalculator : CustomCalculator /// /// The effect to be used as context for the calculation. /// The target entity to be used as context for the calculation. + /// The evaluated data for the effect. /// The custom calculated base magnitude. - public abstract float CalculateBaseMagnitude(Effect effect, IForgeEntity target); + public abstract float CalculateBaseMagnitude( + Effect effect, + IForgeEntity target, + EffectEvaluatedData effectEvaluatedData); } diff --git a/Forge/Effects/EffectEvaluatedData.cs b/Forge/Effects/EffectEvaluatedData.cs index 261e067..ba8398d 100644 --- a/Forge/Effects/EffectEvaluatedData.cs +++ b/Forge/Effects/EffectEvaluatedData.cs @@ -14,7 +14,7 @@ namespace Gamesmiths.Forge.Effects; /// -/// Represents the precomputed static data for a effect that has been applied. +/// Represents the precomputed data for a effect that has been applied. /// /// /// Optimizes performance by avoiding repeated complex calculations and serves as data for event arguments. @@ -24,10 +24,6 @@ public sealed class EffectEvaluatedData private const string InvalidPeriodicDataException = "Evaluated period must be greater than zero. A non-positive" + " value would cause the effect to loop indefinitely."; - private readonly Dictionary _snapshotAttributes = []; - - private readonly Dictionary _snapshotSetByCallers = []; - private readonly int _snapshotLevel; /// @@ -75,6 +71,10 @@ public sealed class EffectEvaluatedData /// public Dictionary? CustomCueParameters { get; } + internal Dictionary SnapshotAttributes { get; } = []; + + internal Dictionary SnapshotSetByCallers { get; } = []; + /// /// Initializes a new instance of the class. /// @@ -140,8 +140,7 @@ internal float EvaluateDuration(DurationData durationData) return 0; } - return durationData.DurationMagnitude.Value.GetMagnitude( - Effect, Target, Level, _snapshotAttributes, _snapshotSetByCallers); + return durationData.DurationMagnitude.Value.GetMagnitude(Effect, Target, Level, this); } private float EvaluatePeriod(PeriodicData? periodicData) @@ -173,8 +172,7 @@ private ModifierEvaluatedData[] EvaluateModifiers() continue; } - var baseMagnitude = modifier.Magnitude.GetMagnitude( - Effect, Target, Level, _snapshotAttributes, _snapshotSetByCallers); + var baseMagnitude = modifier.Magnitude.GetMagnitude(Effect, Target, Level, this); var finalMagnitude = ApplyStackPolicy(baseMagnitude); modifiersEvaluatedData.Add( @@ -192,7 +190,7 @@ private ModifierEvaluatedData[] EvaluateModifiers() continue; } - modifiersEvaluatedData.AddRange(execution.EvaluateExecution(Effect, Target)); + modifiersEvaluatedData.AddRange(execution.EvaluateExecution(Effect, Target, this)); } return [.. modifiersEvaluatedData]; diff --git a/Forge/Effects/Magnitudes/AttributeBasedFloat.cs b/Forge/Effects/Magnitudes/AttributeBasedFloat.cs index 6fba014..3b767ad 100644 --- a/Forge/Effects/Magnitudes/AttributeBasedFloat.cs +++ b/Forge/Effects/Magnitudes/AttributeBasedFloat.cs @@ -107,7 +107,7 @@ private float CaptureAttributeSnapshotAware( return currentValue; } - private float CaptureNow(EntityAttribute attribute) + private int CaptureNow(EntityAttribute attribute) { return AttributeCalculationType switch { @@ -118,8 +118,9 @@ private float CaptureNow(EntityAttribute attribute) AttributeCalculationType.ValidModifier => attribute.ValidModifier, AttributeCalculationType.Min => attribute.Min, AttributeCalculationType.Max => attribute.Max, - AttributeCalculationType.MagnitudeEvaluatedUpToChannel => attribute.CalculateMagnitudeUpToChannel(FinalChannel), - _ => 0f, + AttributeCalculationType.MagnitudeEvaluatedUpToChannel => + (int)attribute.CalculateMagnitudeUpToChannel(FinalChannel), + _ => 0, }; } } diff --git a/Forge/Effects/Magnitudes/CustomCalculationBasedFloat.cs b/Forge/Effects/Magnitudes/CustomCalculationBasedFloat.cs index b3f840d..849ea22 100644 --- a/Forge/Effects/Magnitudes/CustomCalculationBasedFloat.cs +++ b/Forge/Effects/Magnitudes/CustomCalculationBasedFloat.cs @@ -32,12 +32,17 @@ public readonly record struct CustomCalculationBasedFloat( /// Calculates the final magnitude based on the CustomCalculationBasedFloat configurations. /// /// The source effect that will be used for calculating this magnitude. - /// The target of the effect to be used for calcuilating this magnitude. + /// The target of the effect to be used for calculating this magnitude. /// Level to use in the final magnitude calculation. + /// The evaluated data for the effect. /// The calculated magnitude for this . - public float CalculateMagnitude(in Effect effect, IForgeEntity target, int level) + public float CalculateMagnitude( + in Effect effect, + IForgeEntity target, + int level, + EffectEvaluatedData effectEvaluatedData) { - var baseMagnitude = MagnitudeCalculatorClass.CalculateBaseMagnitude(effect, target); + var baseMagnitude = MagnitudeCalculatorClass.CalculateBaseMagnitude(effect, target, effectEvaluatedData); var finalMagnitude = (Coefficient.GetValue(level) * (PreMultiplyAdditiveValue.GetValue(level) + baseMagnitude)) + PostMultiplyAdditiveValue.GetValue(level); diff --git a/Forge/Effects/Magnitudes/ModifierMagnitude.cs b/Forge/Effects/Magnitudes/ModifierMagnitude.cs index 3e2a6bf..587d64f 100644 --- a/Forge/Effects/Magnitudes/ModifierMagnitude.cs +++ b/Forge/Effects/Magnitudes/ModifierMagnitude.cs @@ -98,17 +98,13 @@ public ModifierMagnitude( /// The effect to calculate the magnitude for. /// The target which might be used for the magnitude calculation. /// The level to use in the magnitude calculation. - /// The dictionary containing already captured snapshot attributes for this effect. - /// - /// The dictionary containing already captured snapshot SetByCaller for this - /// effect. + /// The evaluated data for the effect. /// The evaluated magnitude. public readonly float GetMagnitude( Effect effect, IForgeEntity target, int level, - Dictionary snapshotAttributes, - Dictionary snapshotSetByCallerTags) + EffectEvaluatedData effectEvaluatedData) { switch (MagnitudeCalculationType) { @@ -122,13 +118,14 @@ public readonly float GetMagnitude( Validation.Assert( AttributeBasedFloat.HasValue, $"{nameof(AttributeBasedFloat)} should always have a value at this point."); - return AttributeBasedFloat.Value.CalculateMagnitude(effect, target, level, snapshotAttributes); + return AttributeBasedFloat.Value.CalculateMagnitude( + effect, target, level, effectEvaluatedData.SnapshotAttributes); case MagnitudeCalculationType.CustomCalculatorClass: Validation.Assert( CustomCalculationBasedFloat.HasValue, $"{nameof(CustomCalculationBasedFloat)} should always have a value at this point."); - return CustomCalculationBasedFloat.Value.CalculateMagnitude(effect, target, level); + return CustomCalculationBasedFloat.Value.CalculateMagnitude(effect, target, level, effectEvaluatedData); case MagnitudeCalculationType.SetByCaller: Validation.Assert( @@ -137,12 +134,14 @@ public readonly float GetMagnitude( if (SetByCallerFloat.Value.Snapshot) { - if (snapshotSetByCallerTags.TryGetValue(SetByCallerFloat.Value.Tag, out var snapshotValue)) + if (effectEvaluatedData.SnapshotSetByCallers.TryGetValue( + SetByCallerFloat.Value.Tag, out var snapshotValue)) { return snapshotValue; } - snapshotSetByCallerTags.Add(SetByCallerFloat.Value.Tag, effect.DataTag[SetByCallerFloat.Value.Tag]); + effectEvaluatedData.SnapshotSetByCallers.Add( + SetByCallerFloat.Value.Tag, effect.DataTag[SetByCallerFloat.Value.Tag]); } return effect.DataTag[SetByCallerFloat.Value.Tag]; From 172e2323ad29128285815df231969c9d3f08ea24 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 2 Nov 2025 17:20:11 -0300 Subject: [PATCH 23/87] Fixed custom cue parameters re-evaluation --- Forge.Tests/Cues/CueTests.cs | 105 ++++++++++++++++++++++++++- Forge/Effects/EffectEvaluatedData.cs | 4 +- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/Forge.Tests/Cues/CueTests.cs b/Forge.Tests/Cues/CueTests.cs index d63f3d7..c7d45cd 100644 --- a/Forge.Tests/Cues/CueTests.cs +++ b/Forge.Tests/Cues/CueTests.cs @@ -2384,6 +2384,109 @@ public void Effect_triggers_multiple_cues_with_expected_results() ]); } + [Fact] + [Trait("Custom cues", null)] + public void Custom_calculator_class_sets_custom_update_cues_parameters_correctly() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customCalculatorClass = new CustomMagnitudeCalculator( + "TestAttributeSet.Attribute1", + AttributeCaptureSource.Source, + 1); + + var effectData = new EffectData( + "Level Up", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.CustomCalculatorClass, + customCalculationBasedFloat: new CustomCalculationBasedFloat( + customCalculatorClass, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))) + ], + snapshotLevel: false, + cues: [new CueData( + Tag.RequestTag(_tagsManager, "Test.Cue1").GetSingleTagContainer(), + 0, + 10, + CueMagnitudeType.EffectLevel)]); + + var effect = new Effect( + effectData, + new EffectOwnership( + new TestEntity(_tagsManager, _cuesManager), + owner)); + + target.EffectsManager.ApplyEffect(effect); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [2, 1, 1, 0]); + + _testCues[0].ApplyData.CustomParameters.Should().NotBeNull(); + _testCues[0].ApplyData.CustomParameters.Should().Contain("test", 1); + _testCues[0].UpdateData.CustomParameters.Should().BeNull(); + + effect.LevelUp(); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [2, 1, 1, 0]); + + _testCues[0].UpdateData.CustomParameters.Should().NotBeNull(); + _testCues[0].UpdateData.CustomParameters.Should().Contain("test", 1); + } + + [Fact] + [Trait("Custom cues", null)] + public void Custom_executions_sets_custom_update_cues_parameters_correctly() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customCalculatorClass = new CustomTestExecutionClass(false); + + var effectData = new EffectData( + "Test Effect", + new DurationData(DurationType.Infinite), + snapshotLevel: false, + customExecutions: [customCalculatorClass], + cues: [new CueData(Tag.RequestTag(_tagsManager, "Test.Cue1").GetSingleTagContainer(), 0, 10, CueMagnitudeType.EffectLevel)]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + ResetCues(); + + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [89, 90, -1, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 2, 8, 0]); + + _testCues[0].ApplyData.Count.Should().Be(1); + _testCues[0].ApplyData.CustomParameters.Should().NotBeNull(); + + // 1 * (3 + 5) = 8 + _testCues[0].ApplyData.CustomParameters.Should().Contain("custom.parameter", 8); + + target.EffectsManager.ApplyEffect(effect); + + // 2 * (3 + 5) = 16 + _testCues[0].ApplyData.CustomParameters.Should().Contain("custom.parameter", 16); + effect.LevelUp(); + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [88, 90, -2, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [31, 1, 30, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [18, 2, 16, 0]); + + _testCues[0].UpdateData.Count.Should().Be(2); + _testCues[0].UpdateData.CustomParameters.Should().NotBeNull(); + + // Updated twice because we have two instances of the effect, so 4 * (3 + 5) = 32 + _testCues[0].UpdateData.CustomParameters.Should().Contain("custom.parameter", 32); + } + private static EffectData CreateInstantEffectData( Modifier[] modifiers, bool requireModifierSuccessToTriggerCue, @@ -2600,7 +2703,7 @@ public override float CalculateBaseMagnitude( IForgeEntity target, EffectEvaluatedData effectEvaluatedData) { - CustomCueParameters.Add("test", _exponent); + CustomCueParameters["test"] = _exponent; return (float)Math.Pow( CaptureAttributeMagnitude(Attribute1, effect, target, effectEvaluatedData), _exponent); diff --git a/Forge/Effects/EffectEvaluatedData.cs b/Forge/Effects/EffectEvaluatedData.cs index ba8398d..8fdb52c 100644 --- a/Forge/Effects/EffectEvaluatedData.cs +++ b/Forge/Effects/EffectEvaluatedData.cs @@ -69,7 +69,7 @@ public sealed class EffectEvaluatedData /// /// Gets an array of custom cue parameters. /// - public Dictionary? CustomCueParameters { get; } + public Dictionary? CustomCueParameters { get; private set; } internal Dictionary SnapshotAttributes { get; } = []; @@ -131,6 +131,8 @@ internal void ReEvaluate(Effect effect, int stack = 1, int? level = null) // Modifiers should be evaluated after duration and period because it requires those already evaluated. ModifiersEvaluatedData = EvaluateModifiers(); + + CustomCueParameters = EvaluateCustomCueParameters(); } internal float EvaluateDuration(DurationData durationData) From 57f992380a612312ba226fa92767876c340817a6 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 2 Nov 2025 20:05:10 -0300 Subject: [PATCH 24/87] Ability Cooldown --- Forge.Tests/Abilities/AbilitiesTests.cs | 98 ++++++++++++++----- .../Effects/CustomCalculatorsEffectsTests.cs | 2 +- Forge.Tests/Helpers/TestEntity.cs | 2 +- Forge.Tests/Samples/QuickStartTests.cs | 2 +- Forge/Abilities/Ability.cs | 75 +++++++++++++- Forge/Abilities/AbilityData.cs | 3 +- Forge/Abilities/AbilityHandle.cs | 16 ++- Forge/Core/EntityAbilities.cs | 18 ++-- .../Components/ModifierTagsEffectComponent.cs | 6 +- Forge/Effects/Effect.cs | 43 ++++++-- Forge/Tags/TagContainer.cs | 8 +- 11 files changed, 219 insertions(+), 54 deletions(-) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index f9f011a..52f7d27 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -26,7 +26,7 @@ public void Ability_is_granted_successfully() { TestEntity entity = new(_tagsManager, _cuesManager); - AbilityData abilityData = CreateAbiltyData( + AbilityData abilityData = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -52,7 +52,7 @@ public void Removed_ability_is_deactivated_immediately() { TestEntity entity = new(_tagsManager, _cuesManager); - AbilityData abilityData = CreateAbiltyData( + AbilityData abilityData = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -84,7 +84,7 @@ public void Ability_is_only_removed_after_being_deactivated() { TestEntity entity = new(_tagsManager, _cuesManager); - AbilityData abilityData = CreateAbiltyData( + AbilityData abilityData = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -122,7 +122,7 @@ public void Ability_gets_inhibited_temporarily_while_granting_effect_is_inhibite { TestEntity entity = new(_tagsManager, _cuesManager); - AbilityData abilityData = CreateAbiltyData( + AbilityData abilityData = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -172,7 +172,7 @@ public void Granted_ability_is_not_removed_when_deactivation_policy_is_ignore() { TestEntity entity = new(_tagsManager, _cuesManager); - AbilityData abilityData = CreateAbiltyData( + AbilityData abilityData = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -210,7 +210,7 @@ public void Ability_granted_by_multiple_effects_is_removed_only_when_all_grantin { TestEntity entity = new(_tagsManager, _cuesManager); - AbilityData abilityData = CreateAbiltyData( + AbilityData abilityData = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -250,7 +250,7 @@ public void Ability_is_not_granted_if_target_has_blocking_tags() { TestEntity entity = new(_tagsManager, _cuesManager); - AbilityData abilityData = CreateAbiltyData( + AbilityData abilityData = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -291,7 +291,7 @@ public void Ability_granted_by_instant_effect_is_permanent() { TestEntity entity = new(_tagsManager, _cuesManager); - AbilityData abilityData = CreateAbiltyData( + AbilityData abilityData = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -331,7 +331,7 @@ public void Ability_granted_by_late_instant_effect_is_permanent() { TestEntity entity = new(_tagsManager, _cuesManager); - AbilityData abilityData = CreateAbiltyData( + AbilityData abilityData = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -371,7 +371,7 @@ public void Ability_granted_by_instant_effect_is_not_inhibited() { TestEntity entity = new(_tagsManager, _cuesManager); - AbilityData abilityData = CreateAbiltyData( + AbilityData abilityData = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -414,7 +414,7 @@ public void Ability_granted_by_late_instant_effect_is_not_inhibited() { TestEntity entity = new(_tagsManager, _cuesManager); - AbilityData abilityData = CreateAbiltyData( + AbilityData abilityData = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -460,7 +460,7 @@ public void Ability_is_inhibited_only_when_all_granting_effects_are_inhibited() { TestEntity entity = new(_tagsManager, _cuesManager); - AbilityData abilityData = CreateAbiltyData( + AbilityData abilityData = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -514,7 +514,7 @@ public void Inhibited_ability_becomes_active_if_new_non_inhibited_source_is_adde { TestEntity entity = new(_tagsManager, _cuesManager); - AbilityData abilityData = CreateAbiltyData( + AbilityData abilityData = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -556,7 +556,7 @@ public void Inhibition_policy_RemoveOnEnd_inhibits_after_deactivation() { TestEntity entity = new(_tagsManager, _cuesManager); - AbilityData abilityData = CreateAbiltyData( + AbilityData abilityData = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -601,7 +601,7 @@ public void Inhibition_policy_Ignore_prevents_inhibition() { TestEntity entity = new(_tagsManager, _cuesManager); - AbilityData abilityData = CreateAbiltyData( + AbilityData abilityData = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -643,7 +643,7 @@ public void Effect_inhibited_at_application_grant_inhibited_abilities() { TestEntity entity = new(_tagsManager, _cuesManager); - AbilityData abilityData = CreateAbiltyData( + AbilityData abilityData = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -673,7 +673,7 @@ public void Ability_level_is_set_correctly() { TestEntity entity = new(_tagsManager, _cuesManager); - AbilityData abilityData = CreateAbiltyData( + AbilityData abilityData = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -702,7 +702,7 @@ public void Ability_level_scales_with_curve() new CurveKey(3, 5f), // Effect level 3 -> Ability level 5 ]); - AbilityData abilityData = CreateAbiltyData( + AbilityData abilityData = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -726,7 +726,7 @@ public void Ability_level_override_policy_works_correctly() { TestEntity entity = new(_tagsManager, _cuesManager); - AbilityData abilityData = CreateAbiltyData( + AbilityData abilityData = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -781,7 +781,7 @@ public void Abilities_with_different_sources_are_separate_instances() TestEntity entity2 = new(_tagsManager, _cuesManager); // Create two different AbilityData instances (differ by name) - AbilityData abilityData1 = CreateAbiltyData( + AbilityData abilityData1 = CreateAbilityData( "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", @@ -816,26 +816,72 @@ public void Abilities_with_different_sources_are_separate_instances() abilityHandle2!.IsActive.Should().BeFalse(); } - private static AbilityData CreateAbiltyData( + [Fact] + [Trait("Cooldown", null)] + public void Ability_wont_activate_while_cooldown_is_active() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + var activated = abilityHandle!.Activate(); + + abilityHandle.IsActive.Should().BeTrue(); + activated.Should().BeTrue(); + + abilityHandle.CommitAbility(); + + activated = abilityHandle!.Activate(); + + activated.Should().BeFalse(); + + entity.EffectsManager.UpdateEffects(2f); + + activated = abilityHandle!.Activate(); + + activated.Should().BeFalse(); + + entity.EffectsManager.UpdateEffects(1f); + + activated = abilityHandle!.Activate(); + + activated.Should().BeTrue(); + } + + private AbilityData CreateAbilityData( string abilityName, ScalableFloat cooldownDuration, string costAttribute, - ScalableFloat costAmmount) + ScalableFloat costAmount) { - var costEffectData = new EffectData( + var cooldownEffectData = new EffectData( "Fireball Cooldown", new DurationData( DurationType.HasDuration, - new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, cooldownDuration))); + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, cooldownDuration)), + effectComponents: + [ + new ModifierTagsEffectComponent(new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["simple.tag"]))) + ]); - var cooldownEffectData = new EffectData( + var costEffectData = new EffectData( "Fireball Cost", new DurationData(DurationType.Instant), [ new Modifier( costAttribute, ModifierOperation.FlatBonus, - new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, costAmmount)) + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, costAmount)) ]); return new( diff --git a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs index b3ab0c1..b8dbd47 100644 --- a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs +++ b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs @@ -914,7 +914,7 @@ public NoAttributesEntity(TagsManager tagsManager, CuesManager cuesManager) EffectsManager = new(this, cuesManager); Attributes = new(); Tags = new(new TagContainer(tagsManager)); - Abilities = new(); + Abilities = new(this); } } } diff --git a/Forge.Tests/Helpers/TestEntity.cs b/Forge.Tests/Helpers/TestEntity.cs index 7705084..5e2dd3e 100644 --- a/Forge.Tests/Helpers/TestEntity.cs +++ b/Forge.Tests/Helpers/TestEntity.cs @@ -32,6 +32,6 @@ public TestEntity(TagsManager tagsManager, CuesManager cuesManager) EffectsManager = new(this, cuesManager); Attributes = new(PlayerAttributeSet); Tags = new(originalTags); - Abilities = new(); + Abilities = new(this); } } diff --git a/Forge.Tests/Samples/QuickStartTests.cs b/Forge.Tests/Samples/QuickStartTests.cs index a800dea..cb3eecb 100644 --- a/Forge.Tests/Samples/QuickStartTests.cs +++ b/Forge.Tests/Samples/QuickStartTests.cs @@ -766,7 +766,7 @@ public Player(TagsManager tagsManager, CuesManager cuesManager) Attributes = new EntityAttributes(new PlayerAttributeSet()); Tags = new EntityTags(baseTags); EffectsManager = new EffectsManager(this, cuesManager); - Abilities = new(); + Abilities = new(this); } } diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 98b84c1..57033a8 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -1,6 +1,7 @@ // Copyright © Gamesmiths Guild. using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Effects; namespace Gamesmiths.Forge.Abilities; @@ -9,10 +10,17 @@ namespace Gamesmiths.Forge.Abilities; /// internal class Ability { + private readonly Effect? _cooldownEffect; + private int _activeCount; internal event Action? OnAbilityDeactivated; + /// + /// Gets the owner of this ability. + /// + public IForgeEntity Owner { get; } + /// /// Gets the ability data for this ability. /// @@ -47,6 +55,7 @@ internal class Ability /// /// Initializes a new instance of the class. /// + /// The entity that owns this ability. /// The data defining this ability. /// The level of the ability. /// The policy that determines when this granted ability should be removed. @@ -55,12 +64,14 @@ internal class Ability /// inhibited. /// The entity that granted us this ability. internal Ability( + IForgeEntity owner, AbilityData abilityData, int level, AbilityDeactivationPolicy grantedAbilityRemovalPolicy = AbilityDeactivationPolicy.CancelImmediately, AbilityDeactivationPolicy grantedAbilityInhibitionPolicy = AbilityDeactivationPolicy.CancelImmediately, IForgeEntity? sourceEntity = null) { + Owner = owner; AbilityData = abilityData; Level = level; GrantedAbilityRemovalPolicy = grantedAbilityRemovalPolicy; @@ -70,6 +81,14 @@ internal Ability( _activeCount = 0; IsInhibited = false; + if (abilityData.CooldownEffect is not null) + { + _cooldownEffect = new Effect( + abilityData.CooldownEffect.Value, + new EffectOwnership(owner, sourceEntity), + level); + } + Handle = new AbilityHandle(this); } @@ -88,7 +107,61 @@ internal void Activate() Console.WriteLine($"Ability {AbilityData.Name} activated. Active count: {_activeCount}"); } - internal void Deactivate() + // TODO: Might need to return reasons why it can't be activated, including relevant tags. + internal bool CanActivate() + { + if (IsInhibited) + { + return false; + } + + // Check instance. + + // Check cooldown. + if (_cooldownEffect?.CachedGrantedTags is not null + && Owner.Tags.CombinedTags.HasAny(_cooldownEffect.CachedGrantedTags)) + { + return false; + } + + // Check resources. + + // Check tags condition. + + return true; + } + + internal bool TryActivateAbility() + { + if (CanActivate()) + { + Activate(); + return true; + } + + return false; + } + + internal void CommitAbility() + { + CommitCooldown(); + } + + internal void CommitCooldown() + { + if (_cooldownEffect is not null) + { + Owner.EffectsManager.ApplyEffect(_cooldownEffect); + } + } + + internal void CancelAbility() + { + // TODO: Set flags for cancellation. + End(); + } + + internal void End() { OnAbilityDeactivated?.Invoke(this); diff --git a/Forge/Abilities/AbilityData.cs b/Forge/Abilities/AbilityData.cs index 8fd341b..82cb818 100644 --- a/Forge/Abilities/AbilityData.cs +++ b/Forge/Abilities/AbilityData.cs @@ -37,7 +37,8 @@ namespace Gamesmiths.Forge.Abilities; /// Tags that, if present on the source, will block the ability from being activated. /// /// Tags required on the target to activate the ability. -/// Tags that, if present on the target, will block the ability from being. +/// Tags that, if present on the target, will block the ability from being activated. +/// public readonly record struct AbilityData( string Name, EffectData? CostEffect = null, diff --git a/Forge/Abilities/AbilityHandle.cs b/Forge/Abilities/AbilityHandle.cs index 92dbd94..f15c348 100644 --- a/Forge/Abilities/AbilityHandle.cs +++ b/Forge/Abilities/AbilityHandle.cs @@ -32,9 +32,11 @@ internal AbilityHandle(Ability ability) /// /// Activates the ability associated with this handle. /// - public void Activate() + /// Return if the ability was successfully activated; + /// otherwise, . + public bool Activate() { - Ability?.Activate(); + return Ability?.TryActivateAbility() ?? false; } /// @@ -42,7 +44,15 @@ public void Activate() /// public void End() { - Ability?.Deactivate(); + Ability?.End(); + } + + /// + /// Commits the ability cooldown and cost. + /// + public void CommitAbility() + { + Ability?.CommitAbility(); } internal void Free() diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index 0ef2a20..bacae63 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -9,12 +9,18 @@ namespace Gamesmiths.Forge.Core; /// /// Manager for handling an entity's abilities. /// -public class EntityAbilities +/// The owner of this manager. +public class EntityAbilities(IForgeEntity owner) { private readonly Dictionary?> _grantSources = []; private readonly Dictionary?> _inhibitSources = []; + /// + /// Gets the owner of this effects manager. + /// + public IForgeEntity Owner { get; } = owner; + /// /// Gets the set of abilities currently granted to the entity. /// @@ -76,7 +82,7 @@ internal void GrantAbilityPermanently( return; } - var newAbility = new Ability(abilityData, abilityLevel, removalPolicy, inhibitionPolicy, sourceEntity); + var newAbility = new Ability(owner, abilityData, abilityLevel, removalPolicy, inhibitionPolicy, sourceEntity); GrantedAbilities.Add(newAbility.Handle); } @@ -101,7 +107,7 @@ internal AbilityHandle GrantAbility( Validation.Assert( inhibitSources is not null, - "InhibitAbilityBasedOnPolicy inhibitSources should not be null if grant grantSources are not null."); + "inhibitSources should not be null if grant grantSources are not null."); // Ability already granted, just add the new source to the mapping. grantSources.Add(sourceActiveEffectHandle); @@ -128,7 +134,7 @@ internal AbilityHandle GrantAbility( return existingAbility.Handle; } - var newAbility = new Ability(abilityData, abilityLevel, removalPolicy, inhibitionPolicy, sourceEntity); + var newAbility = new Ability(owner, abilityData, abilityLevel, removalPolicy, inhibitionPolicy, sourceEntity); GrantedAbilities.Add(newAbility.Handle); _grantSources[newAbility] = [sourceActiveEffectHandle]; @@ -180,7 +186,7 @@ internal void RemoveGrantedAbility(Ability? abilityToRemove, ActiveEffectHandle case AbilityDeactivationPolicy.CancelImmediately: if (abilityToRemove.IsActive) { - abilityToRemove.Deactivate(); + abilityToRemove.End(); } RemoveAbility(abilityToRemove); @@ -238,7 +244,7 @@ private void InhibitAbilityBasedOnPolicy(Ability abilityToInhibit) case AbilityDeactivationPolicy.CancelImmediately: if (abilityToInhibit.IsActive) { - abilityToInhibit.Deactivate(); + abilityToInhibit.End(); } InhibitAbility(abilityToInhibit); diff --git a/Forge/Effects/Components/ModifierTagsEffectComponent.cs b/Forge/Effects/Components/ModifierTagsEffectComponent.cs index a483ea8..561b974 100644 --- a/Forge/Effects/Components/ModifierTagsEffectComponent.cs +++ b/Forge/Effects/Components/ModifierTagsEffectComponent.cs @@ -15,14 +15,14 @@ namespace Gamesmiths.Forge.Effects.Components; /// Which tags to be added as modifier tags. public class ModifierTagsEffectComponent(TagContainer tagsToAdd) : IEffectComponent { - private readonly TagContainer _tagsToAdd = tagsToAdd; + internal TagContainer TagsToAdd { get; } = tagsToAdd; /// public bool OnActiveEffectAdded( IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) { - target.Tags.AddModifierTags(_tagsToAdd); + target.Tags.AddModifierTags(TagsToAdd); return true; } @@ -37,6 +37,6 @@ public void OnActiveEffectUnapplied( return; } - target.Tags.RemoveModifierTags(_tagsToAdd); + target.Tags.RemoveModifierTags(TagsToAdd); } } diff --git a/Forge/Effects/Effect.cs b/Forge/Effects/Effect.cs index 914b182..6affe24 100644 --- a/Forge/Effects/Effect.cs +++ b/Forge/Effects/Effect.cs @@ -12,10 +12,7 @@ namespace Gamesmiths.Forge.Effects; /// A runtime version of a used to apply the effects with level and ownership /// information. /// -/// The configuration data for this effect. -/// The ownership info for this effect. -/// The initial level for this effect. -public class Effect(EffectData effectData, EffectOwnership ownership, int level = 1) +public class Effect { /// /// Event triggered when the level of this effect changes. @@ -30,23 +27,55 @@ public class Effect(EffectData effectData, EffectOwnership ownership, int level /// /// Gets the configuration data for this effect. /// - public EffectData EffectData { get; } = effectData; + public EffectData EffectData { get; } /// /// Gets information about the ownership and source of this effect. /// - public EffectOwnership Ownership { get; } = ownership; + public EffectOwnership Ownership { get; } /// /// Gets the current level o this effect. /// - public int Level { get; private set; } = level; + public int Level { get; private set; } /// /// Gets the mapping for custom magnitudes. /// public Dictionary DataTag { get; } = []; + /// + /// Gets the cached granted tags from this effect, if any. + /// + public TagContainer? CachedGrantedTags { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The configuration data for this effect. + /// The ownership info for this effect. + /// The initial level for this effect. + public Effect(EffectData effectData, EffectOwnership ownership, int level = 1) + { + EffectData = effectData; + Ownership = ownership; + Level = level; + + foreach (IEffectComponent component in EffectData.EffectComponents) + { + if (component is ModifierTagsEffectComponent modifierTagsComponent) + { + if (CachedGrantedTags is null) + { + CachedGrantedTags = new TagContainer(modifierTagsComponent.TagsToAdd); + continue; + } + + CachedGrantedTags.AppendTags(modifierTagsComponent.TagsToAdd); + } + } + } + /// /// Level up this effect by exactly one level. /// diff --git a/Forge/Tags/TagContainer.cs b/Forge/Tags/TagContainer.cs index 2f00ded..46193c2 100644 --- a/Forge/Tags/TagContainer.cs +++ b/Forge/Tags/TagContainer.cs @@ -12,7 +12,7 @@ namespace Gamesmiths.Forge.Tags; /// -/// A represets a collection of s, containing tags added +/// A represents a collection of s, containing tags added /// explicitly and implicitly through their parent-child tag hierarchy. /// public sealed class TagContainer : IEnumerable, IEquatable @@ -117,7 +117,7 @@ public TagContainer(TagsManager tagsManager, HashSet sourceTags) /// /// The manager responsible for tag lookup and net index handling. /// The to be serialized. - /// The serialized stream for this caontainer. + /// The serialized stream for this container. /// if successfully serialized; otherwise. /// Throws if there are more tags than the configured max size. public static bool NetSerialize( @@ -147,7 +147,7 @@ public static bool NetSerialize( // Containers at this point should always have a designated manager. Validation.Assert( container.TagsManager is not null, - $"Container isn't properly registred in a {typeof(TagsManager)}."); + $"Container isn't properly registered in a {typeof(TagsManager)}."); if (tagsManager != container.TagsManager) { @@ -170,7 +170,7 @@ public static bool NetSerialize( { Tag.NetSerialize(tagsManager, tag, out var index); - // Read net index from buffer. This is just a practical example, use a BitStream reader here isntead. + // Read net index from buffer. This is just a practical example, use a BitStream reader here instead. var netIndex = new ushort[] { index }; var netIndexStream = new byte[2]; Buffer.BlockCopy(netIndex, 0, netIndexStream, 0, 2); From 0aa13dd63926148f48a9e62d9d92085c9ccf5350 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 2 Nov 2025 21:22:13 -0300 Subject: [PATCH 25/87] Ability Cost --- Forge.Tests/Abilities/AbilitiesTests.cs | 88 +++++++++++++------ Forge.Tests/Cues/CueTests.cs | 2 +- .../Effects/CustomCalculatorsEffectsTests.cs | 2 +- Forge.Tests/Effects/EffectsTests.cs | 2 +- Forge.Tests/Samples/QuickStartTests.cs | 6 +- Forge/Abilities/Ability.cs | 24 +++++ Forge/Abilities/AbilityHandle.cs | 16 ++++ Forge/Effects/Calculator/CustomCalculator.cs | 6 +- .../CustomModifierMagnitudeCalculator.cs | 2 +- Forge/Effects/EffectsManager.cs | 14 +++ .../Effects/Magnitudes/AttributeBasedFloat.cs | 6 +- .../Magnitudes/CustomCalculationBasedFloat.cs | 2 +- Forge/Effects/Magnitudes/ModifierMagnitude.cs | 7 +- Forge/Effects/Modifiers/Modifier.cs | 35 +++++++- 14 files changed, 165 insertions(+), 47 deletions(-) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index 52f7d27..db42fa9 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -839,7 +839,7 @@ public void Ability_wont_activate_while_cooldown_is_active() abilityHandle.IsActive.Should().BeTrue(); activated.Should().BeTrue(); - abilityHandle.CommitAbility(); + abilityHandle.CommitCooldown(); activated = abilityHandle!.Activate(); @@ -858,36 +858,36 @@ public void Ability_wont_activate_while_cooldown_is_active() activated.Should().BeTrue(); } - private AbilityData CreateAbilityData( - string abilityName, - ScalableFloat cooldownDuration, - string costAttribute, - ScalableFloat costAmount) + [Theory] + [Trait("Cost", null)] + [InlineData(5)] + [InlineData(-50)] + public void Ability_wont_activate_if_cant_afford_cost(int cost) { - var cooldownEffectData = new EffectData( - "Fireball Cooldown", - new DurationData( - DurationType.HasDuration, - new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, cooldownDuration)), - effectComponents: - [ - new ModifierTagsEffectComponent(new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["simple.tag"]))) - ]); + TestEntity entity = new(_tagsManager, _cuesManager); - var costEffectData = new EffectData( - "Fireball Cost", - new DurationData(DurationType.Instant), - [ - new Modifier( - costAttribute, - ModifierOperation.FlatBonus, - new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, costAmount)) - ]); + AbilityData abilityData = CreateAbilityData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(cost)); - return new( - abilityName, - costEffectData, - cooldownEffectData); + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + var activated = abilityHandle!.Activate(); + + abilityHandle.IsActive.Should().BeTrue(); + activated.Should().BeTrue(); + + abilityHandle.CommitCost(); + + activated = abilityHandle!.Activate(); + + activated.Should().BeFalse(); } private static AbilityHandle? SetupAbility( @@ -965,4 +965,36 @@ private static Effect CreateAbilityApplierEffect( return entity.EffectsManager.ApplyEffect(tagEffect); } + + private AbilityData CreateAbilityData( + string abilityName, + ScalableFloat cooldownDuration, + string costAttribute, + ScalableFloat costAmount) + { + var cooldownEffectData = new EffectData( + "Fireball Cooldown", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, cooldownDuration)), + effectComponents: + [ + new ModifierTagsEffectComponent(new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["simple.tag"]))) + ]); + + var costEffectData = new EffectData( + "Fireball Cost", + new DurationData(DurationType.Instant), + [ + new Modifier( + costAttribute, + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, costAmount)) + ]); + + return new( + abilityName, + costEffectData, + cooldownEffectData); + } } diff --git a/Forge.Tests/Cues/CueTests.cs b/Forge.Tests/Cues/CueTests.cs index c7d45cd..ce5a67e 100644 --- a/Forge.Tests/Cues/CueTests.cs +++ b/Forge.Tests/Cues/CueTests.cs @@ -2701,7 +2701,7 @@ public CustomMagnitudeCalculator(StringKey attribute, AttributeCaptureSource cap public override float CalculateBaseMagnitude( Effect effect, IForgeEntity target, - EffectEvaluatedData effectEvaluatedData) + EffectEvaluatedData? effectEvaluatedData) { CustomCueParameters["test"] = _exponent; return (float)Math.Pow( diff --git a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs index b8dbd47..deb97bb 100644 --- a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs +++ b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs @@ -886,7 +886,7 @@ public CustomMagnitudeCalculator( public override float CalculateBaseMagnitude( Effect effect, IForgeEntity target, - EffectEvaluatedData effectEvaluatedData) + EffectEvaluatedData? effectEvaluatedData) { var capturedMagnitude = CaptureAttributeMagnitude( Attribute1, diff --git a/Forge.Tests/Effects/EffectsTests.cs b/Forge.Tests/Effects/EffectsTests.cs index c308836..1c4e496 100644 --- a/Forge.Tests/Effects/EffectsTests.cs +++ b/Forge.Tests/Effects/EffectsTests.cs @@ -3741,7 +3741,7 @@ public DurationFromSourceAttributeCalculator() public override float CalculateBaseMagnitude( Effect effect, IForgeEntity target, - EffectEvaluatedData effectEvaluatedData) + EffectEvaluatedData? effectEvaluatedData) { var value = CaptureAttributeMagnitude(_sourceAttr, effect, target, effectEvaluatedData); return value * 0.5f; // 2 * 0.5 = 1.0 diff --git a/Forge.Tests/Samples/QuickStartTests.cs b/Forge.Tests/Samples/QuickStartTests.cs index cb3eecb..4a020a3 100644 --- a/Forge.Tests/Samples/QuickStartTests.cs +++ b/Forge.Tests/Samples/QuickStartTests.cs @@ -815,9 +815,9 @@ public StrengthDamageCalculator() } public override float CalculateBaseMagnitude( - Effect effect, - IForgeEntity target, - EffectEvaluatedData effectEvaluatedData) + Effect effect, + IForgeEntity target, + EffectEvaluatedData? effectEvaluatedData) { int strength = CaptureAttributeMagnitude(StrengthAttribute, effect, target, effectEvaluatedData); int speed = CaptureAttributeMagnitude(SpeedAttribute, effect, target, effectEvaluatedData); diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 57033a8..08c32a7 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -12,6 +12,8 @@ internal class Ability { private readonly Effect? _cooldownEffect; + private readonly Effect? _costEffect; + private int _activeCount; internal event Action? OnAbilityDeactivated; @@ -89,6 +91,14 @@ internal Ability( level); } + if (abilityData.CostEffect is not null) + { + _costEffect = new Effect( + abilityData.CostEffect.Value, + new EffectOwnership(owner, sourceEntity), + level); + } + Handle = new AbilityHandle(this); } @@ -125,6 +135,11 @@ internal bool CanActivate() } // Check resources. + if (_costEffect is not null + && !Owner.EffectsManager.CanApplyEffect(_costEffect, Level)) + { + return false; + } // Check tags condition. @@ -145,6 +160,7 @@ internal bool TryActivateAbility() internal void CommitAbility() { CommitCooldown(); + CommitCost(); } internal void CommitCooldown() @@ -155,6 +171,14 @@ internal void CommitCooldown() } } + internal void CommitCost() + { + if (_costEffect is not null) + { + Owner.EffectsManager.ApplyEffect(_costEffect); + } + } + internal void CancelAbility() { // TODO: Set flags for cancellation. diff --git a/Forge/Abilities/AbilityHandle.cs b/Forge/Abilities/AbilityHandle.cs index f15c348..2ede991 100644 --- a/Forge/Abilities/AbilityHandle.cs +++ b/Forge/Abilities/AbilityHandle.cs @@ -55,6 +55,22 @@ public void CommitAbility() Ability?.CommitAbility(); } + /// + /// Commits the ability cooldown. + /// + public void CommitCooldown() + { + Ability?.CommitCooldown(); + } + + /// + /// Commits the ability cost. + /// + public void CommitCost() + { + Ability?.CommitCost(); + } + internal void Free() { Ability = null; diff --git a/Forge/Effects/Calculator/CustomCalculator.cs b/Forge/Effects/Calculator/CustomCalculator.cs index 1e3578a..1ed719f 100644 --- a/Forge/Effects/Calculator/CustomCalculator.cs +++ b/Forge/Effects/Calculator/CustomCalculator.cs @@ -37,7 +37,7 @@ protected static int CaptureAttributeMagnitude( AttributeCaptureDefinition capturedAttribute, Effect effect, IForgeEntity? target, - EffectEvaluatedData effectEvaluatedData, + EffectEvaluatedData? effectEvaluatedData, AttributeCalculationType calculationType = AttributeCalculationType.CurrentValue, int finalChannel = 0) { @@ -66,7 +66,7 @@ private static float CaptureAttributeSnapshotAware( AttributeCalculationType calculationType, int finalChannel, IForgeEntity? sourceEntity, - EffectEvaluatedData effectEvaluatedData) + EffectEvaluatedData? effectEvaluatedData) { if (sourceEntity?.Attributes.ContainsAttribute(capturedAttribute.Attribute) != true) { @@ -75,7 +75,7 @@ private static float CaptureAttributeSnapshotAware( EntityAttribute attribute = sourceEntity.Attributes[capturedAttribute.Attribute]; - if (!capturedAttribute.Snapshot) + if (!capturedAttribute.Snapshot || effectEvaluatedData is null) { return CaptureMagnitudeValue(attribute, calculationType, finalChannel); } diff --git a/Forge/Effects/Calculator/CustomModifierMagnitudeCalculator.cs b/Forge/Effects/Calculator/CustomModifierMagnitudeCalculator.cs index eec390f..8ac981c 100644 --- a/Forge/Effects/Calculator/CustomModifierMagnitudeCalculator.cs +++ b/Forge/Effects/Calculator/CustomModifierMagnitudeCalculator.cs @@ -19,5 +19,5 @@ public abstract class CustomModifierMagnitudeCalculator : CustomCalculator public abstract float CalculateBaseMagnitude( Effect effect, IForgeEntity target, - EffectEvaluatedData effectEvaluatedData); + EffectEvaluatedData? effectEvaluatedData); } diff --git a/Forge/Effects/EffectsManager.cs b/Forge/Effects/EffectsManager.cs index 9dd41fe..c48e12b 100644 --- a/Forge/Effects/EffectsManager.cs +++ b/Forge/Effects/EffectsManager.cs @@ -4,6 +4,7 @@ using Gamesmiths.Forge.Cues; using Gamesmiths.Forge.Effects.Components; using Gamesmiths.Forge.Effects.Duration; +using Gamesmiths.Forge.Effects.Modifiers; using Gamesmiths.Forge.Effects.Stacking; namespace Gamesmiths.Forge.Effects; @@ -198,6 +199,19 @@ internal void RemoveActiveEffect_InternalCall(ActiveEffect effect) RemoveActiveEffect(effect, false); } + internal bool CanApplyEffect(Effect costEffect, int level) + { + foreach (Modifier modifier in costEffect.EffectData.Modifiers) + { + if (!modifier.CanApply(costEffect, Owner, level)) + { + return false; + } + } + + return true; + } + private static bool MatchesStackPolicy(ActiveEffect existingEffect, Effect newEffect) { Validation.Assert( diff --git a/Forge/Effects/Magnitudes/AttributeBasedFloat.cs b/Forge/Effects/Magnitudes/AttributeBasedFloat.cs index 3b767ad..d175ea8 100644 --- a/Forge/Effects/Magnitudes/AttributeBasedFloat.cs +++ b/Forge/Effects/Magnitudes/AttributeBasedFloat.cs @@ -49,7 +49,7 @@ public readonly float CalculateMagnitude( Effect effect, IForgeEntity target, int level, - Dictionary snapshotAttributes) + Dictionary? snapshotAttributes) { float magnitude = 0; @@ -77,7 +77,7 @@ public readonly float CalculateMagnitude( private float CaptureAttributeSnapshotAware( IForgeEntity? sourceEntity, - Dictionary snapshotAttributes) + Dictionary? snapshotAttributes) { if (sourceEntity?.Attributes.ContainsAttribute(BackingAttribute.Attribute) != true) { @@ -86,7 +86,7 @@ private float CaptureAttributeSnapshotAware( EntityAttribute attribute = sourceEntity.Attributes[BackingAttribute.Attribute]; - if (!BackingAttribute.Snapshot) + if (!BackingAttribute.Snapshot || snapshotAttributes is null) { return CaptureNow(attribute); } diff --git a/Forge/Effects/Magnitudes/CustomCalculationBasedFloat.cs b/Forge/Effects/Magnitudes/CustomCalculationBasedFloat.cs index 849ea22..be59d4e 100644 --- a/Forge/Effects/Magnitudes/CustomCalculationBasedFloat.cs +++ b/Forge/Effects/Magnitudes/CustomCalculationBasedFloat.cs @@ -40,7 +40,7 @@ public float CalculateMagnitude( in Effect effect, IForgeEntity target, int level, - EffectEvaluatedData effectEvaluatedData) + EffectEvaluatedData? effectEvaluatedData) { var baseMagnitude = MagnitudeCalculatorClass.CalculateBaseMagnitude(effect, target, effectEvaluatedData); diff --git a/Forge/Effects/Magnitudes/ModifierMagnitude.cs b/Forge/Effects/Magnitudes/ModifierMagnitude.cs index 587d64f..855e421 100644 --- a/Forge/Effects/Magnitudes/ModifierMagnitude.cs +++ b/Forge/Effects/Magnitudes/ModifierMagnitude.cs @@ -1,7 +1,6 @@ // Copyright © Gamesmiths Guild. using Gamesmiths.Forge.Core; -using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Effects.Magnitudes; @@ -104,7 +103,7 @@ public readonly float GetMagnitude( Effect effect, IForgeEntity target, int level, - EffectEvaluatedData effectEvaluatedData) + EffectEvaluatedData? effectEvaluatedData = null) { switch (MagnitudeCalculationType) { @@ -119,7 +118,7 @@ public readonly float GetMagnitude( AttributeBasedFloat.HasValue, $"{nameof(AttributeBasedFloat)} should always have a value at this point."); return AttributeBasedFloat.Value.CalculateMagnitude( - effect, target, level, effectEvaluatedData.SnapshotAttributes); + effect, target, level, effectEvaluatedData?.SnapshotAttributes); case MagnitudeCalculationType.CustomCalculatorClass: Validation.Assert( @@ -132,7 +131,7 @@ public readonly float GetMagnitude( SetByCallerFloat.HasValue, $"{nameof(SetByCallerFloat)} should always have a value at this point."); - if (SetByCallerFloat.Value.Snapshot) + if (SetByCallerFloat.Value.Snapshot && effectEvaluatedData is not null) { if (effectEvaluatedData.SnapshotSetByCallers.TryGetValue( SetByCallerFloat.Value.Tag, out var snapshotValue)) diff --git a/Forge/Effects/Modifiers/Modifier.cs b/Forge/Effects/Modifiers/Modifier.cs index b063baa..67f5456 100644 --- a/Forge/Effects/Modifiers/Modifier.cs +++ b/Forge/Effects/Modifiers/Modifier.cs @@ -1,5 +1,6 @@ // Copyright © Gamesmiths Guild. +using Gamesmiths.Forge.Attributes; using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Effects.Magnitudes; @@ -16,4 +17,36 @@ public readonly record struct Modifier( StringKey Attribute, ModifierOperation Operation, ModifierMagnitude Magnitude, - int Channel = 0); + int Channel = 0) +{ + /// + /// Checks whether this modifier can be applied to the given target entity at the specified level. + /// + /// The source effect of this modifier. + /// The target entity to check against. + /// The level to be used for magnitude calculation. + /// if the modifier can be applied; otherwise, . + public bool CanApply(Effect effect, IForgeEntity target, int level) + { + if (!target.Attributes.ContainsAttribute(Attribute)) + { + return false; + } + + var magnitude = Magnitude.GetMagnitude(effect, target, level); + + EntityAttribute attribute = target.Attributes[Attribute]; + + if (magnitude < 0) + { + return magnitude >= attribute.Min - attribute.CurrentValue; + } + + if (magnitude > 0) + { + return magnitude <= attribute.Max - attribute.CurrentValue; + } + + return true; + } +} From 44ec7d3aba2215a86a2ba9504af2a71aa3cb720a Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 2 Nov 2025 23:38:55 -0300 Subject: [PATCH 26/87] Fixed some typos --- Forge/Abilities/AbilityData.cs | 6 +++--- .../{AbilitTriggerData.cs => AbilityTriggerData.cs} | 2 +- Forge/Core/EntityAbilities.cs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) rename Forge/Abilities/{AbilitTriggerData.cs => AbilityTriggerData.cs} (91%) diff --git a/Forge/Abilities/AbilityData.cs b/Forge/Abilities/AbilityData.cs index 82cb818..23898c7 100644 --- a/Forge/Abilities/AbilityData.cs +++ b/Forge/Abilities/AbilityData.cs @@ -16,14 +16,14 @@ namespace Gamesmiths.Forge.Abilities; /// /// The name of the ability. /// The effect that represents the cost of using the ability called when the ability is -/// commited. +/// committed. /// The effect that represents the cooldown of the ability. /// Tags associated with the ability for categorization and filtering. /// The instancing policy for the ability, determining how instances are created and /// managed. /// Flag indicating whether an instanced ability can be re-triggered while it is /// Still active. If on, it will stop and re-trigger the ability. -/// The trigger data associated with the ability, defining how and when the ability can +/// The trigger data associated with the ability, defining how and when the ability can /// be executed. /// Abilities with any of these tags will be canceled when this ability is /// executed. @@ -46,7 +46,7 @@ public readonly record struct AbilityData( TagContainer? AbilityTags = null, AbilityInstancingPolicy InstancingPolicy = AbilityInstancingPolicy.PerEntity, bool RetriggerInstancedAbility = false, - AbilitTriggerData? AbilitTriggerData = null, + AbilityTriggerData? AbilityTriggerData = null, TagContainer? CancelAbilitiesWithTag = null, TagContainer? BlockAbilitiesWithTag = null, TagContainer? ActivationOwnedTags = null, diff --git a/Forge/Abilities/AbilitTriggerData.cs b/Forge/Abilities/AbilityTriggerData.cs similarity index 91% rename from Forge/Abilities/AbilitTriggerData.cs rename to Forge/Abilities/AbilityTriggerData.cs index 7838514..71612e9 100644 --- a/Forge/Abilities/AbilitTriggerData.cs +++ b/Forge/Abilities/AbilityTriggerData.cs @@ -10,6 +10,6 @@ namespace Gamesmiths.Forge.Abilities; /// The tag identifying the specific trigger. This value is used to categorize or distinguish /// the trigger. /// The source of the trigger, indicating where or how the trigger originated. -public readonly record struct AbilitTriggerData( +public readonly record struct AbilityTriggerData( Tag TriggerTag, AbitityTriggerSource TriggerSource); diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index bacae63..ec87d19 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -82,7 +82,7 @@ internal void GrantAbilityPermanently( return; } - var newAbility = new Ability(owner, abilityData, abilityLevel, removalPolicy, inhibitionPolicy, sourceEntity); + var newAbility = new Ability(Owner, abilityData, abilityLevel, removalPolicy, inhibitionPolicy, sourceEntity); GrantedAbilities.Add(newAbility.Handle); } @@ -134,7 +134,7 @@ internal AbilityHandle GrantAbility( return existingAbility.Handle; } - var newAbility = new Ability(owner, abilityData, abilityLevel, removalPolicy, inhibitionPolicy, sourceEntity); + var newAbility = new Ability(Owner, abilityData, abilityLevel, removalPolicy, inhibitionPolicy, sourceEntity); GrantedAbilities.Add(newAbility.Handle); _grantSources[newAbility] = [sourceActiveEffectHandle]; From 543abd669348d65ccaaf71b00f9970639502389b Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 2 Nov 2025 23:39:16 -0300 Subject: [PATCH 27/87] Owner, source and target tag requirements --- Forge.Tests/Abilities/AbilitiesTests.cs | 290 +++++++++++++++++++++++- Forge/Abilities/Ability.cs | 99 +++++++- Forge/Abilities/AbilityHandle.cs | 7 +- 3 files changed, 381 insertions(+), 15 deletions(-) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index db42fa9..d97f605 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -890,6 +890,274 @@ public void Ability_wont_activate_if_cant_afford_cost(int cost) activated.Should().BeFalse(); } + [Fact] + [Trait("OwnerTag requirements", null)] + public void Ability_wont_activate_when_owner_missing_required_tag() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + activationRequiredTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + var activated = abilityHandle!.Activate(); + + abilityHandle.IsActive.Should().BeFalse(); + activated.Should().BeFalse(); + } + + [Fact] + [Trait("OwnerTag requirements", null)] + public void Ability_wont_activate_when_owner_has_blocked_tag() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + activationBlockedTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["color.green"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + var activated = abilityHandle!.Activate(); + + abilityHandle.IsActive.Should().BeFalse(); + activated.Should().BeFalse(); + } + + [Fact] + [Trait("SourceTag requirements", null)] + public void Ability_wont_activate_when_source_missing_required_tag() + { + TestEntity entity = new(_tagsManager, _cuesManager); + TestEntity source = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + sourceRequiredTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + sourceEntity: source); + + var activated = abilityHandle!.Activate(); + + abilityHandle.IsActive.Should().BeFalse(); + activated.Should().BeFalse(); + } + + [Fact] + [Trait("SourceTag requirements", null)] + public void Ability_wont_activate_when_source_has_blocked_tag() + { + TestEntity entity = new(_tagsManager, _cuesManager); + TestEntity source = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + sourceBlockedTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["color.green"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + sourceEntity: source); + + var activated = abilityHandle!.Activate(); + + abilityHandle.IsActive.Should().BeFalse(); + activated.Should().BeFalse(); + } + + [Fact] + [Trait("SourceTag requirements", null)] + public void Ability_wont_activate_when_source_is_missing_but_has_required_tags() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + sourceRequiredTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + sourceEntity: null); + + var activated = abilityHandle!.Activate(); + + abilityHandle.IsActive.Should().BeFalse(); + activated.Should().BeFalse(); + } + + [Fact] + [Trait("SourceTag requirements", null)] + public void Ability_activates_when_source_is_missing_and_has_blocked_tags() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + sourceBlockedTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["color.green"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + sourceEntity: null); + + var activated = abilityHandle!.Activate(); + + abilityHandle.IsActive.Should().BeTrue(); + activated.Should().BeTrue(); + } + + [Fact] + [Trait("TargetTag requirements", null)] + public void Ability_wont_activate_when_target_missing_required_tag() + { + TestEntity entity = new(_tagsManager, _cuesManager); + TestEntity target = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + targetRequiredTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + var activated = abilityHandle!.Activate(target); + + abilityHandle.IsActive.Should().BeFalse(); + activated.Should().BeFalse(); + } + + [Fact] + [Trait("TargetTag requirements", null)] + public void Ability_wont_activate_when_target_has_blocked_tag() + { + TestEntity entity = new(_tagsManager, _cuesManager); + TestEntity target = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + targetBlockedTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["color.green"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + var activated = abilityHandle!.Activate(target); + + abilityHandle.IsActive.Should().BeFalse(); + activated.Should().BeFalse(); + } + + [Fact] + [Trait("TargetTag requirements", null)] + public void Ability_wont_activate_when_target_is_missing_but_has_required_tags() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + sourceRequiredTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + var activated = abilityHandle!.Activate(); + + abilityHandle.IsActive.Should().BeFalse(); + activated.Should().BeFalse(); + } + + [Fact] + [Trait("TargetTag requirements", null)] + public void Ability_activates_when_target_is_missing_and_has_blocked_tags() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + sourceBlockedTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["color.green"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + var activated = abilityHandle!.Activate(); + + abilityHandle.IsActive.Should().BeTrue(); + activated.Should().BeTrue(); + } + private static AbilityHandle? SetupAbility( TestEntity targetEntity, AbilityData abilityData, @@ -970,7 +1238,16 @@ private AbilityData CreateAbilityData( string abilityName, ScalableFloat cooldownDuration, string costAttribute, - ScalableFloat costAmount) + ScalableFloat costAmount, + TagContainer? cancelAbilitiesWithTag = null, + TagContainer? blockAbilitiesWithTag = null, + TagContainer? activationOwnedTags = null, + TagContainer? activationRequiredTags = null, + TagContainer? activationBlockedTags = null, + TagContainer? sourceRequiredTags = null, + TagContainer? sourceBlockedTags = null, + TagContainer? targetRequiredTags = null, + TagContainer? targetBlockedTags = null) { var cooldownEffectData = new EffectData( "Fireball Cooldown", @@ -995,6 +1272,15 @@ private AbilityData CreateAbilityData( return new( abilityName, costEffectData, - cooldownEffectData); + cooldownEffectData, + CancelAbilitiesWithTag: cancelAbilitiesWithTag, + BlockAbilitiesWithTag: blockAbilitiesWithTag, + ActivationOwnedTags: activationOwnedTags, + ActivationRequiredTags: activationRequiredTags, + ActivationBlockedTags: activationBlockedTags, + SourceRequiredTags: sourceRequiredTags, + SourceBlockedTags: sourceBlockedTags, + TargetRequiredTags: targetRequiredTags, + TargetBlockedTags: targetBlockedTags); } } diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 08c32a7..03b11f5 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -2,6 +2,9 @@ using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Effects.Components; +using Gamesmiths.Forge.Effects.Duration; +using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Abilities; @@ -14,6 +17,10 @@ internal class Ability private readonly Effect? _costEffect; + private readonly Effect? _activationOwnedTagsEffect; + + private ActiveEffectHandle? _activationOwnedTagsEffectHandle; + private int _activeCount; internal event Action? OnAbilityDeactivated; @@ -60,7 +67,8 @@ internal class Ability /// The entity that owns this ability. /// The data defining this ability. /// The level of the ability. - /// The policy that determines when this granted ability should be removed. + /// The policy that determines when this granted ability should be + /// removed. /// /// The policy that determines how this ability behaves when it is /// inhibited. @@ -99,6 +107,23 @@ internal Ability( level); } + if (abilityData.ActivationOwnedTags is not null) + { + _activationOwnedTagsEffect = new Effect( + new EffectData( + name: $"{abilityData.Name}_ActivationOwnedTagsEffect", + new DurationData + { + DurationType = DurationType.Infinite, + }, + effectComponents: + [ + new ModifierTagsEffectComponent(abilityData.ActivationOwnedTags) + ]), + new EffectOwnership(owner, sourceEntity), + level); + } + Handle = new AbilityHandle(this); } @@ -113,12 +138,20 @@ internal void Activate() return; } + if (_activationOwnedTagsEffect is not null) + { + _activationOwnedTagsEffectHandle = Owner.EffectsManager.ApplyEffect(_activationOwnedTagsEffect); + } + + //AbilityData.CancelAbilitiesWithTag + //AbilityData.BlockAbilitiesWithTag + _activeCount++; Console.WriteLine($"Ability {AbilityData.Name} activated. Active count: {_activeCount}"); } // TODO: Might need to return reasons why it can't be activated, including relevant tags. - internal bool CanActivate() + internal bool CanActivate(IForgeEntity? abilityTarget) { if (IsInhibited) { @@ -142,13 +175,39 @@ internal bool CanActivate() } // Check tags condition. + TagContainer ownerTags = Owner.Tags.CombinedTags; + TagContainer? sourceTags = SourceEntity?.Tags.CombinedTags; + TagContainer? targetTags = abilityTarget?.Tags.CombinedTags; + + // Owner tags. + if (FailsRequiredTags(AbilityData.ActivationRequiredTags, ownerTags) + || HasBlockedTags(AbilityData.ActivationBlockedTags, ownerTags)) + { + return false; + } + + // Source tags. + if (FailsRequiredTags(AbilityData.SourceRequiredTags, sourceTags) + || HasBlockedTags(AbilityData.SourceBlockedTags, sourceTags)) + { + return false; + } + + // Target tags. + if (FailsRequiredTags(AbilityData.TargetRequiredTags, targetTags) + || HasBlockedTags(AbilityData.TargetBlockedTags, targetTags)) + { + return false; + } + + // Check ability tags against BlockAbilitiesWithTag return true; } - internal bool TryActivateAbility() + internal bool TryActivateAbility(IForgeEntity? abilityTarget) { - if (CanActivate()) + if (CanActivate(abilityTarget)) { Activate(); return true; @@ -187,16 +246,34 @@ internal void CancelAbility() internal void End() { - OnAbilityDeactivated?.Invoke(this); - - if (_activeCount > 0) + if (_activeCount <= 0) { - _activeCount--; - Console.WriteLine($"Ability {AbilityData.Name} deactivated. Active count: {_activeCount}"); + Console.WriteLine($"Ability {AbilityData.Name} is not active."); + return; } - else + + if (_activationOwnedTagsEffectHandle is not null) { - Console.WriteLine($"Ability {AbilityData.Name} is not active."); + Owner.EffectsManager.UnapplyEffect(_activationOwnedTagsEffectHandle); + _activationOwnedTagsEffectHandle.Free(); } + + // Unblock abilities with tags. + //AbilityData.BlockAbilitiesWithTag + + _activeCount--; + + OnAbilityDeactivated?.Invoke(this); + Console.WriteLine($"Ability {AbilityData.Name} deactivated. Active count: {_activeCount}"); + } + + private static bool FailsRequiredTags(TagContainer? required, TagContainer? present) + { + return required is not null && (present?.HasAll(required) != true); + } + + private static bool HasBlockedTags(TagContainer? blocked, TagContainer? present) + { + return blocked is not null && (present?.HasAny(blocked) == true); } } diff --git a/Forge/Abilities/AbilityHandle.cs b/Forge/Abilities/AbilityHandle.cs index 2ede991..c9be1bf 100644 --- a/Forge/Abilities/AbilityHandle.cs +++ b/Forge/Abilities/AbilityHandle.cs @@ -1,5 +1,7 @@ // Copyright © Gamesmiths Guild. +using Gamesmiths.Forge.Core; + namespace Gamesmiths.Forge.Abilities; /// @@ -32,11 +34,12 @@ internal AbilityHandle(Ability ability) /// /// Activates the ability associated with this handle. /// + /// The target entity for the ability activation. /// Return if the ability was successfully activated; /// otherwise, . - public bool Activate() + public bool Activate(IForgeEntity? target = null) { - return Ability?.TryActivateAbility() ?? false; + return Ability?.TryActivateAbility(target) ?? false; } /// From 78e670149b0ed035f588f391d15b76abc0e2e113 Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 3 Nov 2025 09:37:06 -0300 Subject: [PATCH 28/87] Added BlockedAbilityTags funcionality --- Forge.Tests/Abilities/AbilitiesTests.cs | 75 ++++++++++++++++++++++++- Forge/Abilities/Ability.cs | 27 ++++++++- Forge/Core/EntityAbilities.cs | 9 +++ Forge/Core/IForgeEntity.cs | 2 +- 4 files changed, 108 insertions(+), 5 deletions(-) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index d97f605..0b29bc4 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -1158,6 +1158,77 @@ public void Ability_activates_when_target_is_missing_and_has_blocked_tags() activated.Should().BeTrue(); } + [Fact] + [Trait("BlockAbilitiesWithTag", null)] + public void Ability_activation_blocks_other_abilities_with_blocked_tags() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData blockerAbilityData = CreateAbilityData( + "Blocker ability", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + blockAbilitiesWithTag: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"]))); + + AbilityData unblockedAbilityData = CreateAbilityData( + "Unblocked ability", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + abilityTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["color.blue"]))); + + AbilityData blockedAbilityData = CreateAbilityData( + "Blocked ability", + new ScalableFloat(3f), + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + abilityTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"]))); + + AbilityHandle? blockerAbilityHandle = SetupAbility( + entity, + blockerAbilityData, + new ScalableInt(1), + out _); + + AbilityHandle? unblockedAbilityHandle = SetupAbility( + entity, + unblockedAbilityData, + new ScalableInt(1), + out _); + + AbilityHandle? blockedAbilityHandle = SetupAbility( + entity, + blockedAbilityData, + new ScalableInt(1), + out _); + + var activated = blockerAbilityHandle!.Activate(); + + blockerAbilityHandle.IsActive.Should().BeTrue(); + activated.Should().BeTrue(); + + activated = unblockedAbilityHandle!.Activate(); + + unblockedAbilityHandle.IsActive.Should().BeTrue(); + activated.Should().BeTrue(); + + activated = blockedAbilityHandle!.Activate(); + + blockedAbilityHandle.IsActive.Should().BeFalse(); + activated.Should().BeFalse(); + + blockerAbilityHandle!.End(); + + activated = blockedAbilityHandle!.Activate(); + + blockedAbilityHandle.IsActive.Should().BeTrue(); + activated.Should().BeTrue(); + } + private static AbilityHandle? SetupAbility( TestEntity targetEntity, AbilityData abilityData, @@ -1179,7 +1250,7 @@ public void Ability_activates_when_target_is_missing_and_has_blocked_tags() levelOverridePolicy); Effect grantAbilityEffect = CreateAbilityApplierEffect( - "Grant Fireball", + "Grant Ability Effect", grantAbilityConfig, sourceEntity, durationData, @@ -1239,6 +1310,7 @@ private AbilityData CreateAbilityData( ScalableFloat cooldownDuration, string costAttribute, ScalableFloat costAmount, + TagContainer? abilityTags = null, TagContainer? cancelAbilitiesWithTag = null, TagContainer? blockAbilitiesWithTag = null, TagContainer? activationOwnedTags = null, @@ -1273,6 +1345,7 @@ private AbilityData CreateAbilityData( abilityName, costEffectData, cooldownEffectData, + AbilityTags: abilityTags, CancelAbilitiesWithTag: cancelAbilitiesWithTag, BlockAbilitiesWithTag: blockAbilitiesWithTag, ActivationOwnedTags: activationOwnedTags, diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 03b11f5..6937990 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -19,6 +19,8 @@ internal class Ability private readonly Effect? _activationOwnedTagsEffect; + private readonly TagContainer? _abilityTags; + private ActiveEffectHandle? _activationOwnedTagsEffectHandle; private int _activeCount; @@ -124,6 +126,11 @@ internal Ability( level); } + if (abilityData.AbilityTags is not null) + { + _abilityTags = abilityData.AbilityTags; + } + Handle = new AbilityHandle(this); } @@ -143,8 +150,15 @@ internal void Activate() _activationOwnedTagsEffectHandle = Owner.EffectsManager.ApplyEffect(_activationOwnedTagsEffect); } - //AbilityData.CancelAbilitiesWithTag - //AbilityData.BlockAbilitiesWithTag + if (AbilityData.CancelAbilitiesWithTag is not null) + { + //AbilityData.CancelAbilitiesWithTag + } + + if (AbilityData.BlockAbilitiesWithTag is not null) + { + Owner.Abilities.BlockedAbilityTags.AddModifierTags(AbilityData.BlockAbilitiesWithTag); + } _activeCount++; Console.WriteLine($"Ability {AbilityData.Name} activated. Active count: {_activeCount}"); @@ -201,6 +215,10 @@ internal bool CanActivate(IForgeEntity? abilityTarget) } // Check ability tags against BlockAbilitiesWithTag + if (_abilityTags?.HasAny(Owner.Abilities.BlockedAbilityTags.CombinedTags) == true) + { + return false; + } return true; } @@ -259,7 +277,10 @@ internal void End() } // Unblock abilities with tags. - //AbilityData.BlockAbilitiesWithTag + if (AbilityData.BlockAbilitiesWithTag is not null) + { + Owner.Abilities.BlockedAbilityTags.RemoveModifierTags(AbilityData.BlockAbilitiesWithTag); + } _activeCount--; diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index ec87d19..8853e4e 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -3,12 +3,16 @@ using System.Diagnostics.CodeAnalysis; using Gamesmiths.Forge.Abilities; using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Core; /// /// Manager for handling an entity's abilities. /// +/// +/// Initializes a new instance of the class. +/// /// The owner of this manager. public class EntityAbilities(IForgeEntity owner) { @@ -26,6 +30,11 @@ public class EntityAbilities(IForgeEntity owner) /// public HashSet GrantedAbilities { get; } = []; + /// + /// Gets the tags that block abilities from being used. + /// + public EntityTags BlockedAbilityTags { get; } = new EntityTags(new TagContainer(owner.Tags.BaseTags.TagsManager)); + /// /// Tries to get a granted ability from its data. /// diff --git a/Forge/Core/IForgeEntity.cs b/Forge/Core/IForgeEntity.cs index 48bb96e..a43572f 100644 --- a/Forge/Core/IForgeEntity.cs +++ b/Forge/Core/IForgeEntity.cs @@ -25,7 +25,7 @@ public interface IForgeEntity EffectsManager EffectsManager { get; } /// - /// Gets the abitilies manager for this entity. + /// Gets the abilities manager for this entity. /// EntityAbilities Abilities { get; } } From c767f6b6adc76582252998c823df3b4e45da17e7 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 4 Nov 2025 23:18:07 -0300 Subject: [PATCH 29/87] Ability instancing and cancelation --- Forge.Tests/Abilities/AbilitiesTests.cs | 554 ++++++++++++++++++++++++ Forge/Abilities/Ability.cs | 118 ++--- Forge/Abilities/AbilityHandle.cs | 8 + Forge/Abilities/AbilityInstance.cs | 74 ++++ Forge/Core/EntityAbilities.cs | 30 ++ 5 files changed, 735 insertions(+), 49 deletions(-) create mode 100644 Forge/Abilities/AbilityInstance.cs diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index 0b29bc4..f9d3999 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -840,6 +840,7 @@ public void Ability_wont_activate_while_cooldown_is_active() activated.Should().BeTrue(); abilityHandle.CommitCooldown(); + abilityHandle.End(); activated = abilityHandle!.Activate(); @@ -1229,6 +1230,555 @@ public void Ability_activation_blocks_other_abilities_with_blocked_tags() activated.Should().BeTrue(); } + [Fact] + [Trait("Instancing", null)] + public void Ability_PerEntity_no_retrigger_activated_twice_does_not_start_second_instance() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "PerEntity_NoRetrigger", + new ScalableFloat(3f), + "TestAttributeSet.Attribute1", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerEntity, + retriggerInstancedAbility: false); + + AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); + handle.Should().NotBeNull(); + + var first = handle!.Activate(); + var second = handle!.Activate(); + + first.Should().BeTrue(); + + // No retrigger, single instance. + second.Should().BeFalse(); + handle.IsActive.Should().BeTrue(); + + handle.End(); + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Instancing", null)] + public void Ability_PerEntity_retrigger_restarts_instance_and_fires_deactivated_once() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "PerEntity_Retrigger", + new ScalableFloat(3f), + "TestAttributeSet.Attribute2", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerEntity, + retriggerInstancedAbility: true); + + AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); + handle.Should().NotBeNull(); + + var first = handle!.Activate(); + var second = handle!.Activate(); + + first.Should().BeTrue(); + + // Retrigger replaces the running instance. + second.Should().BeTrue(); + + // One End should fully deactivate because retrigger replaced the instance instead of stacking. + handle.End(); + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Instancing", null)] + public void Ability_per_execution_multiple_activations_create_multiple_instances() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "PerExecution", + new ScalableFloat(3f), + "TestAttributeSet.Attribute3", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerExecution); + + AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); + handle.Should().NotBeNull(); + + var a = handle!.Activate(); + var b = handle!.Activate(); + var c = handle!.Activate(); + + a.Should().BeTrue(); + b.Should().BeTrue(); + c.Should().BeTrue(); + + handle.IsActive.Should().BeTrue(); + + // End most recent instance only; still active until all are ended. + handle.End(); + handle.IsActive.Should().BeTrue(); + + handle.End(); + handle.IsActive.Should().BeTrue(); + + handle.End(); + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Instancing", null)] + public void Ability_End_ends_most_recent_instance_only() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "EndsMostRecent", + new ScalableFloat(3f), + "TestAttributeSet.Attribute5", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerExecution); + + AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); + handle.Should().NotBeNull(); + + handle!.Activate(); + handle!.Activate(); + + handle.IsActive.Should().BeTrue(); + + // One End should not fully deactivate if multiple instances exist. + handle.End(); + handle.IsActive.Should().BeTrue(); + + // Second End ends the remaining instance. + handle.End(); + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Instancing", null)] + public void Ability_CancelAbility_cancels_all_active_instances() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "CancelAllInstances", + new ScalableFloat(3f), + "TestAttributeSet.Attribute5", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerExecution); + + AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); + handle.Should().NotBeNull(); + + handle!.Activate(); + handle!.Activate(); + handle!.Activate(); + + handle.Cancel(); + + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("CancelAbilitiesWithTag", null)] + public void CancelAbilitiesWithTag_cancels_all_matching_active_abilities_on_activation() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var cancelTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])); + + AbilityData canceller = CreateAbilityData( + "Canceller", + new ScalableFloat(3f), + "TestAttributeSet.Attribute3", + new ScalableFloat(-1), + cancelAbilitiesWithTag: cancelTags); + + AbilityData victim = CreateAbilityData( + "Victim", + new ScalableFloat(3f), + "TestAttributeSet.Attribute5", + new ScalableFloat(-1), + abilityTags: new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"]))); + + AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); + AbilityHandle? victimHandle = SetupAbility(entity, victim, new ScalableInt(1), out _); + + victimHandle!.Activate().Should().BeTrue(); + victimHandle.IsActive.Should().BeTrue(); + + cancellerHandle!.Activate().Should().BeTrue(); + + victimHandle.IsActive.Should().BeFalse(); + cancellerHandle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("CancelAbilitiesWithTag", null)] + public void CancelAbilitiesWithTag_does_not_cancel_unrelated_abilities() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var cancelTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])); + + AbilityData canceller = CreateAbilityData( + "Canceller2", + new ScalableFloat(3f), + "TestAttributeSet.Attribute3", + new ScalableFloat(-1), + cancelAbilitiesWithTag: cancelTags); + + AbilityData unrelated = CreateAbilityData( + "Unrelated", + new ScalableFloat(3f), + "TestAttributeSet.Attribute5", + new ScalableFloat(-1), + abilityTags: new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.blue"]))); + + AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); + AbilityHandle? unrelatedHandle = SetupAbility(entity, unrelated, new ScalableInt(1), out _); + + unrelatedHandle!.Activate().Should().BeTrue(); + unrelatedHandle.IsActive.Should().BeTrue(); + + cancellerHandle!.Activate().Should().BeTrue(); + + unrelatedHandle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("CancelAbilitiesWithTag", null)] + public void CancelAbilitiesWithTag_cancels_all_instances_of_matching_abilities() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var cancelTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])); + + AbilityData canceller = CreateAbilityData( + "Canceller3", + new ScalableFloat(3f), + "TestAttributeSet.Attribute1", + new ScalableFloat(-1), + cancelAbilitiesWithTag: cancelTags); + + AbilityData victim = CreateAbilityData( + "VictimMulti", + new ScalableFloat(3f), + "TestAttributeSet.Attribute2", + new ScalableFloat(-1), + abilityTags: new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])), + instancingPolicy: AbilityInstancingPolicy.PerExecution); + + AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); + AbilityHandle? victimHandle = SetupAbility(entity, victim, new ScalableInt(1), out _); + + victimHandle!.Activate(); + victimHandle!.Activate(); + victimHandle.IsActive.Should().BeTrue(); + + cancellerHandle!.Activate().Should().BeTrue(); + + victimHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("CancelAbilitiesWithTag", null)] + public void CancelAbilitiesWithTag_does_not_cancel_self_on_activation() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var redTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])); + + AbilityData selfCanceller = CreateAbilityData( + "SelfCanceller", + new ScalableFloat(3f), + "TestAttributeSet.Attribute1", + new ScalableFloat(-1), + abilityTags: redTags, + cancelAbilitiesWithTag: redTags); + + AbilityHandle? handle = SetupAbility(entity, selfCanceller, new ScalableInt(1), out _); + + handle!.Activate().Should().BeTrue(); + handle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("BlockAbilitiesWithTag", null)] + public void Blocked_ability_tags_are_removed_only_after_last_instance_ends() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var redTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])); + + AbilityData blocker = CreateAbilityData( + "BlockerMulti", + new ScalableFloat(3f), + "TestAttributeSet.Attribute1", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerExecution, + blockAbilitiesWithTag: redTags); + + AbilityData blocked = CreateAbilityData( + "BlockedRed", + new ScalableFloat(3f), + "TestAttributeSet.Attribute5", + new ScalableFloat(-1), + abilityTags: redTags); + + AbilityHandle? blockerHandle = SetupAbility(entity, blocker, new ScalableInt(1), out _); + AbilityHandle? blockedHandle = SetupAbility(entity, blocked, new ScalableInt(1), out _); + + blockerHandle!.Activate().Should().BeTrue(); + blockerHandle!.Activate().Should().BeTrue(); + + // While any blocker instance active, blocked ability cannot activate. + blockedHandle!.Activate().Should().BeFalse(); + + // End one blocker instance; still blocked. + blockerHandle.End(); + blockedHandle.Activate().Should().BeFalse(); + + // End last blocker instance; now unblocked. + blockerHandle.End(); + blockedHandle.Activate().Should().BeTrue(); + } + + [Fact] + [Trait("ActivationOwnedTags", null)] + public void Activation_owned_tags_are_applied_on_activation_and_removed_on_end() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var ownedTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"])); + + AbilityData abilityWithOwned = CreateAbilityData( + "OwnedTagsAbility", + new ScalableFloat(3f), + "TestAttributeSet.Attribute5", + new ScalableFloat(-1), + activationOwnedTags: ownedTags); + + AbilityHandle? handle = SetupAbility(entity, abilityWithOwned, new ScalableInt(1), out _); + + handle!.Activate().Should().BeTrue(); + entity.Tags.CombinedTags.HasAll(ownedTags).Should().BeTrue(); + + handle.End(); + entity.Tags.CombinedTags.HasAny(ownedTags).Should().BeFalse(); + } + + [Fact] + [Trait("ActivationOwnedTags", null)] + public void Activation_owned_tags_are_applied_on_activation_and_removed_after_last_instance_ends() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var ownedTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"])); + + AbilityData abilityWithOwned = CreateAbilityData( + "OwnedTagsAbility", + new ScalableFloat(3f), + "TestAttributeSet.Attribute5", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerExecution, + activationOwnedTags: ownedTags); + + AbilityHandle? handle = SetupAbility(entity, abilityWithOwned, new ScalableInt(1), out _); + + handle!.Activate().Should().BeTrue(); + handle!.Activate().Should().BeTrue(); + handle!.Activate().Should().BeTrue(); + entity.Tags.CombinedTags.HasAll(ownedTags).Should().BeTrue(); + + handle.End(); + entity.Tags.CombinedTags.HasAll(ownedTags).Should().BeTrue(); + + handle.End(); + handle.End(); + entity.Tags.CombinedTags.HasAny(ownedTags).Should().BeFalse(); + } + + [Fact] + [Trait("ActivationOwnedTags", null)] + public void Activation_owned_tags_enable_other_ability_only_while_active() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var buffTag = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"])); + + AbilityData giver = CreateAbilityData( + "Giver", + new ScalableFloat(3f), + "TestAttributeSet.Attribute1", + new ScalableFloat(-1), + activationOwnedTags: buffTag); + + AbilityData requiresBuff = CreateAbilityData( + "NeedsBuff", + new ScalableFloat(3f), + "TestAttributeSet.Attribute5", + new ScalableFloat(-1), + activationRequiredTags: buffTag); + + AbilityHandle? giverHandle = SetupAbility(entity, giver, new ScalableInt(1), out _); + AbilityHandle? needsHandle = SetupAbility(entity, requiresBuff, new ScalableInt(1), out _); + + // Cannot activate without buff. + needsHandle!.Activate().Should().BeFalse(); + + // Gain buff, then can activate. + giverHandle!.Activate().Should().BeTrue(); + needsHandle.Activate().Should().BeTrue(); + + // Lose buff, then cannot activate again. + giverHandle.End(); + needsHandle.End(); + needsHandle.Activate().Should().BeFalse(); + } + + [Fact] + [Trait("Bookkeeping", null)] + public void OnAbilityDeactivated_is_fired_once_per_instance_end() + { + // Proxy via RemoveOnEnd semantics: ability is only removed after each instance ends once. + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "RemoveOnEndProxy", + new ScalableFloat(3f), + "TestAttributeSet.Attribute1", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerExecution); + + AbilityHandle? handle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? grantHandle, + AbilityDeactivationPolicy.RemoveOnEnd, + AbilityDeactivationPolicy.RemoveOnEnd); + + handle.Should().NotBeNull(); + grantHandle.Should().NotBeNull(); + + // Activate twice to simulate two instances. + handle!.Activate().Should().BeTrue(); + handle!.Activate().Should().BeTrue(); + + // Remove grant; ability should not be removed until all instances end. + entity.EffectsManager.UnapplyEffect(grantHandle!); + + // Still present because policy is RemoveOnEnd and still active. + entity.Abilities.GrantedAbilities.Should().Contain(handle); + + // End one instance; still granted, one more end needed. + handle.End(); + entity.Abilities.GrantedAbilities.Should().Contain(handle); + + // End last instance; now removed. + handle.End(); + entity.Abilities.GrantedAbilities.Should().NotContain(handle); + } + + [Fact] + [Trait("Instancing", null)] + public void Persistent_instance_reference_is_cleared_on_end() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "PersistentCleared", + new ScalableFloat(3f), + "TestAttributeSet.Attribute3", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerEntity); + + AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); + handle.Should().NotBeNull(); + + handle!.Activate().Should().BeTrue(); + handle.End(); + handle.IsActive.Should().BeFalse(); + + // Should be able to activate again, implying the persistent instance was cleared. + handle.Activate().Should().BeTrue(); + } + + [Fact] + [Trait("CancelAbilitiesWithTag", null)] + public void CancelAbilitiesWithTag_with_no_active_abilities_does_nothing() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var cancelTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])); + + AbilityData canceller = CreateAbilityData( + "Canceller", + new ScalableFloat(3f), + "TestAttributeSet.Attribute2", + new ScalableFloat(-1), + cancelAbilitiesWithTag: cancelTags); + + AbilityData victim = CreateAbilityData( + "Victim", + new ScalableFloat(3f), + "TestAttributeSet.Attribute3", + new ScalableFloat(-1), + abilityTags: new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"]))); + + AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); + AbilityHandle? victimHandle = SetupAbility(entity, victim, new ScalableInt(1), out _); + + // Victim is granted but inactive. + victimHandle!.IsActive.Should().BeFalse(); + + // Activating canceller should not affect inactive victim. + cancellerHandle!.Activate().Should().BeTrue(); + victimHandle.IsActive.Should().BeFalse(); + entity.Abilities.GrantedAbilities.Should().Contain(victimHandle); + } + + [Fact] + [Trait("CancelAbilitiesWithTag", null)] + public void CancelAbilitiesWithTag_executes_before_applying_blocking_or_activation_tags() + { + // Approximated: ensure canceller activates and cancels victim reliably. + TestEntity entity = new(_tagsManager, _cuesManager); + + var redTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])); + var blockBlue = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.blue"])); + + AbilityData canceller = CreateAbilityData( + "CancellerOrder", + new ScalableFloat(3f), + "TestAttributeSet.Attribute1", + new ScalableFloat(-1), + cancelAbilitiesWithTag: redTags, + blockAbilitiesWithTag: blockBlue, + activationOwnedTags: new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"]))); + + AbilityData victim = CreateAbilityData( + "VictimOrder", + new ScalableFloat(3f), + "TestAttributeSet.Attribute2", + new ScalableFloat(-1), + abilityTags: redTags); + + AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); + AbilityHandle? victimHandle = SetupAbility(entity, victim, new ScalableInt(1), out _); + + victimHandle!.Activate().Should().BeTrue(); + cancellerHandle!.Activate().Should().BeTrue(); + + // Victim must be canceled; canceller remains active. + victimHandle.IsActive.Should().BeFalse(); + cancellerHandle.IsActive.Should().BeTrue(); + } + private static AbilityHandle? SetupAbility( TestEntity targetEntity, AbilityData abilityData, @@ -1311,6 +1861,8 @@ private AbilityData CreateAbilityData( string costAttribute, ScalableFloat costAmount, TagContainer? abilityTags = null, + AbilityInstancingPolicy instancingPolicy = AbilityInstancingPolicy.PerEntity, + bool retriggerInstancedAbility = false, TagContainer? cancelAbilitiesWithTag = null, TagContainer? blockAbilitiesWithTag = null, TagContainer? activationOwnedTags = null, @@ -1346,6 +1898,8 @@ private AbilityData CreateAbilityData( costEffectData, cooldownEffectData, AbilityTags: abilityTags, + InstancingPolicy: instancingPolicy, + RetriggerInstancedAbility: retriggerInstancedAbility, CancelAbilitiesWithTag: cancelAbilitiesWithTag, BlockAbilitiesWithTag: blockAbilitiesWithTag, ActivationOwnedTags: activationOwnedTags, diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 6937990..0641e61 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -2,8 +2,6 @@ using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Effects; -using Gamesmiths.Forge.Effects.Components; -using Gamesmiths.Forge.Effects.Duration; using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Abilities; @@ -17,13 +15,11 @@ internal class Ability private readonly Effect? _costEffect; - private readonly Effect? _activationOwnedTagsEffect; - private readonly TagContainer? _abilityTags; - private ActiveEffectHandle? _activationOwnedTagsEffectHandle; + private readonly List _activeInstances = []; - private int _activeCount; + private AbilityInstance? _persistentInstance; internal event Action? OnAbilityDeactivated; @@ -61,7 +57,7 @@ internal class Ability internal bool IsInhibited { get; set; } - internal bool IsActive => _activeCount > 0; + internal bool IsActive => _activeInstances.Count > 0; /// /// Initializes a new instance of the class. @@ -90,7 +86,6 @@ internal Ability( GrantedAbilityInhibitionPolicy = grantedAbilityInhibitionPolicy; SourceEntity = sourceEntity; - _activeCount = 0; IsInhibited = false; if (abilityData.CooldownEffect is not null) @@ -109,23 +104,6 @@ internal Ability( level); } - if (abilityData.ActivationOwnedTags is not null) - { - _activationOwnedTagsEffect = new Effect( - new EffectData( - name: $"{abilityData.Name}_ActivationOwnedTagsEffect", - new DurationData - { - DurationType = DurationType.Infinite, - }, - effectComponents: - [ - new ModifierTagsEffectComponent(abilityData.ActivationOwnedTags) - ]), - new EffectOwnership(owner, sourceEntity), - level); - } - if (abilityData.AbilityTags is not null) { _abilityTags = abilityData.AbilityTags; @@ -135,7 +113,7 @@ internal Ability( } /// - /// Activates the ability and increments the active count. + /// Activates the ability by creating and starting an instance based on the instancing policy. /// internal void Activate() { @@ -145,23 +123,37 @@ internal void Activate() return; } - if (_activationOwnedTagsEffect is not null) - { - _activationOwnedTagsEffectHandle = Owner.EffectsManager.ApplyEffect(_activationOwnedTagsEffect); - } - + // Cancel conflicting abilities before we start this one. if (AbilityData.CancelAbilitiesWithTag is not null) { - //AbilityData.CancelAbilitiesWithTag + Owner.Abilities.CancelAbilitiesWithTag(AbilityData.CancelAbilitiesWithTag); } - if (AbilityData.BlockAbilitiesWithTag is not null) + if (AbilityData.InstancingPolicy == AbilityInstancingPolicy.PerEntity) { - Owner.Abilities.BlockedAbilityTags.AddModifierTags(AbilityData.BlockAbilitiesWithTag); + if (_persistentInstance?.IsActive == true) + { + if (!AbilityData.RetriggerInstancedAbility) + { + Console.WriteLine($"Ability {AbilityData.Name} already active (PerEntity)."); + return; + } + + _persistentInstance.Cancel(); + _persistentInstance = null; + } + + _persistentInstance ??= new AbilityInstance(this); + _activeInstances.Add(_persistentInstance); + _persistentInstance.Start(); + Console.WriteLine($"Ability {AbilityData.Name} activated. Active count: {_activeInstances.Count}"); + return; } - _activeCount++; - Console.WriteLine($"Ability {AbilityData.Name} activated. Active count: {_activeCount}"); + var instance = new AbilityInstance(this); + _activeInstances.Add(instance); + instance.Start(); + Console.WriteLine($"Ability {AbilityData.Name} activated. Active count: {_activeInstances.Count}"); } // TODO: Might need to return reasons why it can't be activated, including relevant tags. @@ -172,7 +164,13 @@ internal bool CanActivate(IForgeEntity? abilityTarget) return false; } - // Check instance. + // Check instance policy for non re-triggerable persistent instance. + if (AbilityData.InstancingPolicy == AbilityInstancingPolicy.PerEntity + && !AbilityData.RetriggerInstancedAbility + && _persistentInstance?.IsActive == true) + { + return false; + } // Check cooldown. if (_cooldownEffect?.CachedGrantedTags is not null @@ -258,34 +256,56 @@ internal void CommitCost() internal void CancelAbility() { - // TODO: Set flags for cancellation. - End(); + // Cancel all active instances. + CancelAllInstances(); } internal void End() { - if (_activeCount <= 0) + // End the most recent active instance, if any. + if (_activeInstances.Count == 0) { Console.WriteLine($"Ability {AbilityData.Name} is not active."); return; } - if (_activationOwnedTagsEffectHandle is not null) + AbilityInstance last = _activeInstances[^1]; + last.End(); + } + + internal void CancelAllInstances() + { + if (_activeInstances.Count == 0) { - Owner.EffectsManager.UnapplyEffect(_activationOwnedTagsEffectHandle); - _activationOwnedTagsEffectHandle.Free(); + return; } - // Unblock abilities with tags. - if (AbilityData.BlockAbilitiesWithTag is not null) + // Copy to avoid modification during iteration. + foreach (AbilityInstance instance in _activeInstances.ToArray()) { - Owner.Abilities.BlockedAbilityTags.RemoveModifierTags(AbilityData.BlockAbilitiesWithTag); + instance.Cancel(); } + } - _activeCount--; + internal void OnInstanceStarted(AbilityInstance instance) + { + Console.WriteLine($"Ability {AbilityData.Name} started ({instance.IsActive})."); + } - OnAbilityDeactivated?.Invoke(this); - Console.WriteLine($"Ability {AbilityData.Name} deactivated. Active count: {_activeCount}"); + internal void OnInstanceEnded(AbilityInstance instance) + { + _activeInstances.Remove(instance); + + if (_persistentInstance == instance) + { + _persistentInstance = null; + } + + if (_activeInstances.Count == 0) + { + OnAbilityDeactivated?.Invoke(this); + Console.WriteLine($"Ability {AbilityData.Name} deactivated. Active count: {_activeInstances.Count}"); + } } private static bool FailsRequiredTags(TagContainer? required, TagContainer? present) diff --git a/Forge/Abilities/AbilityHandle.cs b/Forge/Abilities/AbilityHandle.cs index c9be1bf..74d44b8 100644 --- a/Forge/Abilities/AbilityHandle.cs +++ b/Forge/Abilities/AbilityHandle.cs @@ -50,6 +50,14 @@ public void End() Ability?.End(); } + /// + /// Cancels all instances of the ability associated with this handle. + /// + public void Cancel() + { + Ability?.CancelAbility(); + } + /// /// Commits the ability cooldown and cost. /// diff --git a/Forge/Abilities/AbilityInstance.cs b/Forge/Abilities/AbilityInstance.cs new file mode 100644 index 0000000..87d785b --- /dev/null +++ b/Forge/Abilities/AbilityInstance.cs @@ -0,0 +1,74 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Represents a single activation/execution of an Ability. +/// Responsible for per-activation state (activation-owned tags, blocking tags). +/// +internal sealed class AbilityInstance +{ + private readonly Ability _ability; + + internal bool IsActive { get; private set; } + + internal AbilityInstance(Ability ability) + { + _ability = ability; + } + + internal void Start() + { + if (IsActive) + { + return; + } + + // Apply activation-owned tags. + if (_ability.AbilityData.ActivationOwnedTags is not null) + { + _ability.Owner.Tags.AddModifierTags(_ability.AbilityData.ActivationOwnedTags); + } + + // Block abilities with tags while this instance is active. + TagContainer? blockTags = _ability.AbilityData.BlockAbilitiesWithTag; + if (blockTags is not null) + { + _ability.Owner.Abilities.BlockedAbilityTags.AddModifierTags(blockTags); + } + + IsActive = true; + _ability.OnInstanceStarted(this); + } + + internal void End() + { + if (!IsActive) + { + return; + } + + // Remove activation-owned tags. + if (_ability.AbilityData.ActivationOwnedTags is not null) + { + _ability.Owner.Tags.RemoveModifierTags(_ability.AbilityData.ActivationOwnedTags); + } + + // Unblock abilities with tags for this instance. + TagContainer? blockTags = _ability.AbilityData.BlockAbilitiesWithTag; + if (blockTags is not null) + { + _ability.Owner.Abilities.BlockedAbilityTags.RemoveModifierTags(blockTags); + } + + IsActive = false; + _ability.OnInstanceEnded(this); + } + + internal void Cancel() + { + End(); + } +} diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index 8853e4e..60e67a6 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -59,6 +59,36 @@ public bool TryGetAbility( return false; } + /// + /// Cancels all active abilities whose AbilityTags overlap the provided tags. + /// For PerEntity abilities, cancels the single active instance. + /// For per-execution abilities, cancels all active instances. + /// + /// Tags that identify abilities to cancel. + public void CancelAbilitiesWithTag(TagContainer tagsToCancel) + { + if (tagsToCancel is null) + { + return; + } + + // Enumerate snapshot to avoid modification during cancel. + foreach (AbilityHandle? handle in GrantedAbilities.ToArray()) + { + Ability? ability = handle?.Ability; + if (ability is null) + { + continue; + } + + TagContainer? abilityTags = ability.AbilityData.AbilityTags; + if (abilityTags?.HasAny(tagsToCancel) == true) + { + ability.CancelAllInstances(); + } + } + } + internal void GrantAbilityPermanently( AbilityData abilityData, int abilityLevel, From fd06b2ff90b8fcef6e7affd8506f7d16dffcbe51 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 8 Nov 2025 17:43:32 -0300 Subject: [PATCH 30/87] Added AbilityBehavior support --- Forge.Tests/Abilities/AbilitiesTests.cs | 303 ++++++++++++--------- Forge/Abilities/Ability.cs | 276 ++++++++++--------- Forge/Abilities/AbilityActivationResult.cs | 64 +++++ Forge/Abilities/AbilityBehaviorContext.cs | 56 ++++ Forge/Abilities/AbilityData.cs | 4 +- Forge/Abilities/AbilityHandle.cs | 6 +- Forge/Abilities/AbilityInstance.cs | 6 +- Forge/Abilities/AbilityInstanceHandle.cs | 45 +++ Forge/Abilities/IAbilityBehavior.cs | 21 ++ 9 files changed, 515 insertions(+), 266 deletions(-) create mode 100644 Forge/Abilities/AbilityActivationResult.cs create mode 100644 Forge/Abilities/AbilityBehaviorContext.cs create mode 100644 Forge/Abilities/AbilityInstanceHandle.cs create mode 100644 Forge/Abilities/IAbilityBehavior.cs diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index f9d3999..a859934 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -41,8 +41,8 @@ public void Ability_is_granted_successfully() abilityHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(); - + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); abilityHandle.IsActive.Should().BeTrue(); } @@ -68,8 +68,8 @@ public void Removed_ability_is_deactivated_immediately() effectHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(); - + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); abilityHandle.IsActive.Should().BeTrue(); entity.EffectsManager.UnapplyEffect(effectHandle!); @@ -102,8 +102,8 @@ public void Ability_is_only_removed_after_being_deactivated() effectHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(); - + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); abilityHandle.IsActive.Should().BeTrue(); entity.EffectsManager.UnapplyEffect(effectHandle!); @@ -143,16 +143,16 @@ public void Ability_gets_inhibited_temporarily_while_granting_effect_is_inhibite abilityHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(); - + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); abilityHandle.IsActive.Should().BeTrue(); ActiveEffectHandle? tagEffectHandle = CreateAndApplyTagEffect(entity, ignoreTags!); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle.Activate(); - + abilityHandle.Activate(out activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedInhibition); abilityHandle.IsActive.Should().BeFalse(); abilityHandle.IsInhibited.Should().BeTrue(); @@ -160,8 +160,8 @@ public void Ability_gets_inhibited_temporarily_while_granting_effect_is_inhibite entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle.Activate(); - + abilityHandle.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); abilityHandle.IsActive.Should().BeTrue(); abilityHandle.IsInhibited.Should().BeFalse(); } @@ -190,8 +190,8 @@ public void Granted_ability_is_not_removed_when_deactivation_policy_is_ignore() effectHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(); - + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); abilityHandle.IsActive.Should().BeTrue(); entity.EffectsManager.UnapplyEffect(effectHandle!); @@ -491,7 +491,8 @@ public void Ability_is_inhibited_only_when_all_granting_effects_are_inhibited() abilityHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(); + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); abilityHandle.IsActive.Should().BeTrue(); // Inhibit the first effect. @@ -577,7 +578,8 @@ public void Inhibition_policy_RemoveOnEnd_inhibits_after_deactivation() abilityHandle.Should().NotBeNull(); - abilityHandle!.Activate(); + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); abilityHandle.IsActive.Should().BeTrue(); // Inhibit the granting effect. @@ -622,7 +624,8 @@ public void Inhibition_policy_Ignore_prevents_inhibition() abilityHandle.Should().NotBeNull(); - abilityHandle!.Activate(); + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); // Inhibit the granting effect. CreateAndApplyTagEffect(entity, ignoreTags!); @@ -811,7 +814,8 @@ public void Abilities_with_different_sources_are_separate_instances() entity1.Abilities.GrantedAbilities.Should().HaveCount(2); // Activate one and ensure the other is not affected - abilityHandle1!.Activate(); + abilityHandle1!.Activate(out AbilityActivationResult activationResult1).Should().BeTrue(); + activationResult1.Should().Be(AbilityActivationResult.Success); abilityHandle1.IsActive.Should().BeTrue(); abilityHandle2!.IsActive.Should().BeFalse(); } @@ -834,29 +838,25 @@ public void Ability_wont_activate_while_cooldown_is_active() new ScalableInt(1), out _); - var activated = abilityHandle!.Activate(); - + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); abilityHandle.IsActive.Should().BeTrue(); - activated.Should().BeTrue(); abilityHandle.CommitCooldown(); abilityHandle.End(); - activated = abilityHandle!.Activate(); - - activated.Should().BeFalse(); + abilityHandle!.Activate(out activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedCooldown); entity.EffectsManager.UpdateEffects(2f); - activated = abilityHandle!.Activate(); - - activated.Should().BeFalse(); + abilityHandle!.Activate(out activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedCooldown); entity.EffectsManager.UpdateEffects(1f); - activated = abilityHandle!.Activate(); - - activated.Should().BeTrue(); + abilityHandle!.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); } [Theory] @@ -871,7 +871,8 @@ public void Ability_wont_activate_if_cant_afford_cost(int cost) "Fireball", new ScalableFloat(3f), "TestAttributeSet.Attribute90", - new ScalableFloat(cost)); + new ScalableFloat(cost), + retriggerInstancedAbility: true); AbilityHandle? abilityHandle = SetupAbility( entity, @@ -879,16 +880,14 @@ public void Ability_wont_activate_if_cant_afford_cost(int cost) new ScalableInt(1), out _); - var activated = abilityHandle!.Activate(); - + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); abilityHandle.IsActive.Should().BeTrue(); - activated.Should().BeTrue(); abilityHandle.CommitCost(); - activated = abilityHandle!.Activate(); - - activated.Should().BeFalse(); + abilityHandle!.Activate(out activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedInsufficientResources); } [Fact] @@ -911,10 +910,9 @@ public void Ability_wont_activate_when_owner_missing_required_tag() new ScalableInt(1), out _); - var activated = abilityHandle!.Activate(); - + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedOwnerTagRequirements); abilityHandle.IsActive.Should().BeFalse(); - activated.Should().BeFalse(); } [Fact] @@ -937,10 +935,9 @@ public void Ability_wont_activate_when_owner_has_blocked_tag() new ScalableInt(1), out _); - var activated = abilityHandle!.Activate(); - + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedOwnerTagRequirements); abilityHandle.IsActive.Should().BeFalse(); - activated.Should().BeFalse(); } [Fact] @@ -965,10 +962,9 @@ public void Ability_wont_activate_when_source_missing_required_tag() out _, sourceEntity: source); - var activated = abilityHandle!.Activate(); - + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedSourceTagRequirements); abilityHandle.IsActive.Should().BeFalse(); - activated.Should().BeFalse(); } [Fact] @@ -993,10 +989,9 @@ public void Ability_wont_activate_when_source_has_blocked_tag() out _, sourceEntity: source); - var activated = abilityHandle!.Activate(); - + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedSourceTagRequirements); abilityHandle.IsActive.Should().BeFalse(); - activated.Should().BeFalse(); } [Fact] @@ -1020,10 +1015,9 @@ public void Ability_wont_activate_when_source_is_missing_but_has_required_tags() out _, sourceEntity: null); - var activated = abilityHandle!.Activate(); - + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedSourceTagRequirements); abilityHandle.IsActive.Should().BeFalse(); - activated.Should().BeFalse(); } [Fact] @@ -1047,10 +1041,9 @@ public void Ability_activates_when_source_is_missing_and_has_blocked_tags() out _, sourceEntity: null); - var activated = abilityHandle!.Activate(); - + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); abilityHandle.IsActive.Should().BeTrue(); - activated.Should().BeTrue(); } [Fact] @@ -1074,10 +1067,9 @@ public void Ability_wont_activate_when_target_missing_required_tag() new ScalableInt(1), out _); - var activated = abilityHandle!.Activate(target); - + abilityHandle!.Activate(out AbilityActivationResult activationResult, target).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedTargetTagRequirements); abilityHandle.IsActive.Should().BeFalse(); - activated.Should().BeFalse(); } [Fact] @@ -1101,10 +1093,9 @@ public void Ability_wont_activate_when_target_has_blocked_tag() new ScalableInt(1), out _); - var activated = abilityHandle!.Activate(target); - + abilityHandle!.Activate(out AbilityActivationResult activationResult, target).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedTargetTagRequirements); abilityHandle.IsActive.Should().BeFalse(); - activated.Should().BeFalse(); } [Fact] @@ -1118,7 +1109,7 @@ public void Ability_wont_activate_when_target_is_missing_but_has_required_tags() new ScalableFloat(3f), "TestAttributeSet.Attribute90", new ScalableFloat(-1), - sourceRequiredTags: new TagContainer( + targetRequiredTags: new TagContainer( _tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"]))); AbilityHandle? abilityHandle = SetupAbility( @@ -1127,10 +1118,9 @@ public void Ability_wont_activate_when_target_is_missing_but_has_required_tags() new ScalableInt(1), out _); - var activated = abilityHandle!.Activate(); - + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedTargetTagRequirements); abilityHandle.IsActive.Should().BeFalse(); - activated.Should().BeFalse(); } [Fact] @@ -1144,7 +1134,7 @@ public void Ability_activates_when_target_is_missing_and_has_blocked_tags() new ScalableFloat(3f), "TestAttributeSet.Attribute90", new ScalableFloat(-1), - sourceBlockedTags: new TagContainer( + targetBlockedTags: new TagContainer( _tagsManager, TestUtils.StringToTag(_tagsManager, ["color.green"]))); AbilityHandle? abilityHandle = SetupAbility( @@ -1153,10 +1143,9 @@ public void Ability_activates_when_target_is_missing_and_has_blocked_tags() new ScalableInt(1), out _); - var activated = abilityHandle!.Activate(); - + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); abilityHandle.IsActive.Should().BeTrue(); - activated.Should().BeTrue(); } [Fact] @@ -1207,27 +1196,23 @@ public void Ability_activation_blocks_other_abilities_with_blocked_tags() new ScalableInt(1), out _); - var activated = blockerAbilityHandle!.Activate(); - + blockerAbilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); blockerAbilityHandle.IsActive.Should().BeTrue(); - activated.Should().BeTrue(); - - activated = unblockedAbilityHandle!.Activate(); + unblockedAbilityHandle!.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); unblockedAbilityHandle.IsActive.Should().BeTrue(); - activated.Should().BeTrue(); - - activated = blockedAbilityHandle!.Activate(); + blockedAbilityHandle!.Activate(out activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedBlockedByTags); blockedAbilityHandle.IsActive.Should().BeFalse(); - activated.Should().BeFalse(); blockerAbilityHandle!.End(); - activated = blockedAbilityHandle!.Activate(); - + blockedAbilityHandle!.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); blockedAbilityHandle.IsActive.Should().BeTrue(); - activated.Should().BeTrue(); } [Fact] @@ -1247,13 +1232,13 @@ public void Ability_PerEntity_no_retrigger_activated_twice_does_not_start_second AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); handle.Should().NotBeNull(); - var first = handle!.Activate(); - var second = handle!.Activate(); - - first.Should().BeTrue(); + handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + handle.IsActive.Should().BeTrue(); // No retrigger, single instance. - second.Should().BeFalse(); + handle!.Activate(out activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedPersistentInstanceActive); handle.IsActive.Should().BeTrue(); handle.End(); @@ -1277,13 +1262,14 @@ public void Ability_PerEntity_retrigger_restarts_instance_and_fires_deactivated_ AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); handle.Should().NotBeNull(); - var first = handle!.Activate(); - var second = handle!.Activate(); - - first.Should().BeTrue(); + handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + handle.IsActive.Should().BeTrue(); // Retrigger replaces the running instance. - second.Should().BeTrue(); + handle!.Activate(out AbilityActivationResult activationResult2).Should().BeTrue(); + activationResult2.Should().Be(AbilityActivationResult.Success); + handle.IsActive.Should().BeTrue(); // One End should fully deactivate because retrigger replaced the instance instead of stacking. handle.End(); @@ -1306,14 +1292,16 @@ public void Ability_per_execution_multiple_activations_create_multiple_instances AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); handle.Should().NotBeNull(); - var a = handle!.Activate(); - var b = handle!.Activate(); - var c = handle!.Activate(); + handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + handle.IsActive.Should().BeTrue(); - a.Should().BeTrue(); - b.Should().BeTrue(); - c.Should().BeTrue(); + handle!.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + handle.IsActive.Should().BeTrue(); + handle!.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); handle.IsActive.Should().BeTrue(); // End most recent instance only; still active until all are ended. @@ -1343,9 +1331,12 @@ public void Ability_End_ends_most_recent_instance_only() AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); handle.Should().NotBeNull(); - handle!.Activate(); - handle!.Activate(); + handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + handle.IsActive.Should().BeTrue(); + handle!.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); handle.IsActive.Should().BeTrue(); // One End should not fully deactivate if multiple instances exist. @@ -1373,9 +1364,17 @@ public void Ability_CancelAbility_cancels_all_active_instances() AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); handle.Should().NotBeNull(); - handle!.Activate(); - handle!.Activate(); - handle!.Activate(); + handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + handle.IsActive.Should().BeTrue(); + + handle!.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + handle.IsActive.Should().BeTrue(); + + handle!.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + handle.IsActive.Should().BeTrue(); handle.Cancel(); @@ -1407,13 +1406,15 @@ public void CancelAbilitiesWithTag_cancels_all_matching_active_abilities_on_acti AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); AbilityHandle? victimHandle = SetupAbility(entity, victim, new ScalableInt(1), out _); - victimHandle!.Activate().Should().BeTrue(); + victimHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); victimHandle.IsActive.Should().BeTrue(); - cancellerHandle!.Activate().Should().BeTrue(); + cancellerHandle!.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + cancellerHandle.IsActive.Should().BeTrue(); victimHandle.IsActive.Should().BeFalse(); - cancellerHandle.IsActive.Should().BeTrue(); } [Fact] @@ -1441,10 +1442,13 @@ public void CancelAbilitiesWithTag_does_not_cancel_unrelated_abilities() AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); AbilityHandle? unrelatedHandle = SetupAbility(entity, unrelated, new ScalableInt(1), out _); - unrelatedHandle!.Activate().Should().BeTrue(); + unrelatedHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); unrelatedHandle.IsActive.Should().BeTrue(); - cancellerHandle!.Activate().Should().BeTrue(); + cancellerHandle!.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + cancellerHandle.IsActive.Should().BeTrue(); unrelatedHandle.IsActive.Should().BeTrue(); } @@ -1475,11 +1479,13 @@ public void CancelAbilitiesWithTag_cancels_all_instances_of_matching_abilities() AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); AbilityHandle? victimHandle = SetupAbility(entity, victim, new ScalableInt(1), out _); - victimHandle!.Activate(); - victimHandle!.Activate(); + victimHandle!.Activate(out AbilityActivationResult activationResultA).Should().BeTrue(); + activationResultA.Should().Be(AbilityActivationResult.Success); victimHandle.IsActive.Should().BeTrue(); - cancellerHandle!.Activate().Should().BeTrue(); + cancellerHandle!.Activate(out AbilityActivationResult activationResultB).Should().BeTrue(); + activationResultB.Should().Be(AbilityActivationResult.Success); + cancellerHandle.IsActive.Should().BeTrue(); victimHandle.IsActive.Should().BeFalse(); } @@ -1502,7 +1508,8 @@ public void CancelAbilitiesWithTag_does_not_cancel_self_on_activation() AbilityHandle? handle = SetupAbility(entity, selfCanceller, new ScalableInt(1), out _); - handle!.Activate().Should().BeTrue(); + handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); handle.IsActive.Should().BeTrue(); } @@ -1532,19 +1539,30 @@ public void Blocked_ability_tags_are_removed_only_after_last_instance_ends() AbilityHandle? blockerHandle = SetupAbility(entity, blocker, new ScalableInt(1), out _); AbilityHandle? blockedHandle = SetupAbility(entity, blocked, new ScalableInt(1), out _); - blockerHandle!.Activate().Should().BeTrue(); - blockerHandle!.Activate().Should().BeTrue(); + blockerHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + blockerHandle.IsActive.Should().BeTrue(); + + blockerHandle!.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + blockerHandle.IsActive.Should().BeTrue(); // While any blocker instance active, blocked ability cannot activate. - blockedHandle!.Activate().Should().BeFalse(); + blockedHandle!.Activate(out activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedBlockedByTags); + blockedHandle.IsActive.Should().BeFalse(); // End one blocker instance; still blocked. blockerHandle.End(); - blockedHandle.Activate().Should().BeFalse(); + blockedHandle.Activate(out activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedBlockedByTags); + blockedHandle.IsActive.Should().BeFalse(); // End last blocker instance; now unblocked. blockerHandle.End(); - blockedHandle.Activate().Should().BeTrue(); + blockedHandle.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + blockedHandle.IsActive.Should().BeTrue(); } [Fact] @@ -1564,8 +1582,10 @@ public void Activation_owned_tags_are_applied_on_activation_and_removed_on_end() AbilityHandle? handle = SetupAbility(entity, abilityWithOwned, new ScalableInt(1), out _); - handle!.Activate().Should().BeTrue(); + handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); entity.Tags.CombinedTags.HasAll(ownedTags).Should().BeTrue(); + handle.IsActive.Should().BeTrue(); handle.End(); entity.Tags.CombinedTags.HasAny(ownedTags).Should().BeFalse(); @@ -1589,9 +1609,18 @@ public void Activation_owned_tags_are_applied_on_activation_and_removed_after_la AbilityHandle? handle = SetupAbility(entity, abilityWithOwned, new ScalableInt(1), out _); - handle!.Activate().Should().BeTrue(); - handle!.Activate().Should().BeTrue(); - handle!.Activate().Should().BeTrue(); + handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + handle.IsActive.Should().BeTrue(); + + handle!.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + handle.IsActive.Should().BeTrue(); + + handle!.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + handle.IsActive.Should().BeTrue(); + entity.Tags.CombinedTags.HasAll(ownedTags).Should().BeTrue(); handle.End(); @@ -1628,16 +1657,21 @@ public void Activation_owned_tags_enable_other_ability_only_while_active() AbilityHandle? needsHandle = SetupAbility(entity, requiresBuff, new ScalableInt(1), out _); // Cannot activate without buff. - needsHandle!.Activate().Should().BeFalse(); + needsHandle!.Activate(out AbilityActivationResult activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedOwnerTagRequirements); + needsHandle.IsActive.Should().BeFalse(); // Gain buff, then can activate. - giverHandle!.Activate().Should().BeTrue(); - needsHandle.Activate().Should().BeTrue(); + giverHandle!.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + needsHandle.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); // Lose buff, then cannot activate again. giverHandle.End(); needsHandle.End(); - needsHandle.Activate().Should().BeFalse(); + needsHandle.Activate(out activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedOwnerTagRequirements); } [Fact] @@ -1666,8 +1700,10 @@ public void OnAbilityDeactivated_is_fired_once_per_instance_end() grantHandle.Should().NotBeNull(); // Activate twice to simulate two instances. - handle!.Activate().Should().BeTrue(); - handle!.Activate().Should().BeTrue(); + handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); // Remove grant; ability should not be removed until all instances end. entity.EffectsManager.UnapplyEffect(grantHandle!); @@ -1700,12 +1736,14 @@ public void Persistent_instance_reference_is_cleared_on_end() AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); handle.Should().NotBeNull(); - handle!.Activate().Should().BeTrue(); + handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); handle.End(); handle.IsActive.Should().BeFalse(); // Should be able to activate again, implying the persistent instance was cleared. - handle.Activate().Should().BeTrue(); + handle.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); } [Fact] @@ -1737,7 +1775,8 @@ public void CancelAbilitiesWithTag_with_no_active_abilities_does_nothing() victimHandle!.IsActive.Should().BeFalse(); // Activating canceller should not affect inactive victim. - cancellerHandle!.Activate().Should().BeTrue(); + cancellerHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); victimHandle.IsActive.Should().BeFalse(); entity.Abilities.GrantedAbilities.Should().Contain(victimHandle); } @@ -1771,8 +1810,10 @@ public void CancelAbilitiesWithTag_executes_before_applying_blocking_or_activati AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); AbilityHandle? victimHandle = SetupAbility(entity, victim, new ScalableInt(1), out _); - victimHandle!.Activate().Should().BeTrue(); - cancellerHandle!.Activate().Should().BeTrue(); + victimHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + cancellerHandle!.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); // Victim must be canceled; canceller remains active. victimHandle.IsActive.Should().BeFalse(); diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 0641e61..c663490 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -11,6 +11,8 @@ namespace Gamesmiths.Forge.Abilities; /// internal class Ability { + private record struct BehaviorBinding(IAbilityBehavior Behavior, AbilityBehaviorContext Context); + private readonly Effect? _cooldownEffect; private readonly Effect? _costEffect; @@ -19,6 +21,8 @@ internal class Ability private readonly List _activeInstances = []; + private readonly Dictionary _behaviors = []; + private AbilityInstance? _persistentInstance; internal event Action? OnAbilityDeactivated; @@ -28,29 +32,14 @@ internal class Ability /// public IForgeEntity Owner { get; } - /// - /// Gets the ability data for this ability. - /// internal AbilityData AbilityData { get; } - /// - /// Gets or sets the current level o this ability. - /// internal int Level { get; set; } - /// - /// Gets the policy that determines when this granted ability should be removed. - /// internal AbilityDeactivationPolicy GrantedAbilityRemovalPolicy { get; } - /// - /// Gets the policy that determines how this ability behaves when it is inhibited. - /// internal AbilityDeactivationPolicy GrantedAbilityInhibitionPolicy { get; } - /// - /// Gets the entity that is the source of this ability. - /// internal IForgeEntity? SourceEntity { get; } internal AbilityHandle Handle { get; } @@ -85,7 +74,6 @@ internal Ability( GrantedAbilityRemovalPolicy = grantedAbilityRemovalPolicy; GrantedAbilityInhibitionPolicy = grantedAbilityInhibitionPolicy; SourceEntity = sourceEntity; - IsInhibited = false; if (abilityData.CooldownEffect is not null) @@ -112,120 +100,11 @@ internal Ability( Handle = new AbilityHandle(this); } - /// - /// Activates the ability by creating and starting an instance based on the instancing policy. - /// - internal void Activate() + internal bool TryActivateAbility(IForgeEntity? abilityTarget, out AbilityActivationResult activationResult) { - if (IsInhibited) - { - Console.WriteLine($"Ability {AbilityData.Name} is inhibited and cannot be activated."); - return; - } - - // Cancel conflicting abilities before we start this one. - if (AbilityData.CancelAbilitiesWithTag is not null) + if (CanActivate(abilityTarget, out activationResult)) { - Owner.Abilities.CancelAbilitiesWithTag(AbilityData.CancelAbilitiesWithTag); - } - - if (AbilityData.InstancingPolicy == AbilityInstancingPolicy.PerEntity) - { - if (_persistentInstance?.IsActive == true) - { - if (!AbilityData.RetriggerInstancedAbility) - { - Console.WriteLine($"Ability {AbilityData.Name} already active (PerEntity)."); - return; - } - - _persistentInstance.Cancel(); - _persistentInstance = null; - } - - _persistentInstance ??= new AbilityInstance(this); - _activeInstances.Add(_persistentInstance); - _persistentInstance.Start(); - Console.WriteLine($"Ability {AbilityData.Name} activated. Active count: {_activeInstances.Count}"); - return; - } - - var instance = new AbilityInstance(this); - _activeInstances.Add(instance); - instance.Start(); - Console.WriteLine($"Ability {AbilityData.Name} activated. Active count: {_activeInstances.Count}"); - } - - // TODO: Might need to return reasons why it can't be activated, including relevant tags. - internal bool CanActivate(IForgeEntity? abilityTarget) - { - if (IsInhibited) - { - return false; - } - - // Check instance policy for non re-triggerable persistent instance. - if (AbilityData.InstancingPolicy == AbilityInstancingPolicy.PerEntity - && !AbilityData.RetriggerInstancedAbility - && _persistentInstance?.IsActive == true) - { - return false; - } - - // Check cooldown. - if (_cooldownEffect?.CachedGrantedTags is not null - && Owner.Tags.CombinedTags.HasAny(_cooldownEffect.CachedGrantedTags)) - { - return false; - } - - // Check resources. - if (_costEffect is not null - && !Owner.EffectsManager.CanApplyEffect(_costEffect, Level)) - { - return false; - } - - // Check tags condition. - TagContainer ownerTags = Owner.Tags.CombinedTags; - TagContainer? sourceTags = SourceEntity?.Tags.CombinedTags; - TagContainer? targetTags = abilityTarget?.Tags.CombinedTags; - - // Owner tags. - if (FailsRequiredTags(AbilityData.ActivationRequiredTags, ownerTags) - || HasBlockedTags(AbilityData.ActivationBlockedTags, ownerTags)) - { - return false; - } - - // Source tags. - if (FailsRequiredTags(AbilityData.SourceRequiredTags, sourceTags) - || HasBlockedTags(AbilityData.SourceBlockedTags, sourceTags)) - { - return false; - } - - // Target tags. - if (FailsRequiredTags(AbilityData.TargetRequiredTags, targetTags) - || HasBlockedTags(AbilityData.TargetBlockedTags, targetTags)) - { - return false; - } - - // Check ability tags against BlockAbilitiesWithTag - if (_abilityTags?.HasAny(Owner.Abilities.BlockedAbilityTags.CombinedTags) == true) - { - return false; - } - - return true; - } - - internal bool TryActivateAbility(IForgeEntity? abilityTarget) - { - if (CanActivate(abilityTarget)) - { - Activate(); + Activate(abilityTarget); return true; } @@ -256,7 +135,6 @@ internal void CommitCost() internal void CancelAbility() { - // Cancel all active instances. CancelAllInstances(); } @@ -289,7 +167,29 @@ internal void CancelAllInstances() internal void OnInstanceStarted(AbilityInstance instance) { - Console.WriteLine($"Ability {AbilityData.Name} started ({instance.IsActive})."); + if (AbilityData.BehaviorFactory is null) + { + return; + } + + IAbilityBehavior? behavior = AbilityData.BehaviorFactory.Invoke(); + if (behavior is null) + { + return; + } + + var context = new AbilityBehaviorContext(this, instance); + _behaviors[instance] = new BehaviorBinding(behavior, context); + + try + { + behavior.OnStarted(context); + } + catch (Exception ex) + { + Console.WriteLine($"Ability behavior threw on start: {ex}"); + instance.Cancel(); + } } internal void OnInstanceEnded(AbilityInstance instance) @@ -301,10 +201,21 @@ internal void OnInstanceEnded(AbilityInstance instance) _persistentInstance = null; } + if (_behaviors.Remove(instance, out BehaviorBinding binding)) + { + try + { + binding.Behavior.OnEnded(binding.Context); + } + catch (Exception ex) + { + Console.WriteLine($"Ability behavior threw on end: {ex}"); + } + } + if (_activeInstances.Count == 0) { OnAbilityDeactivated?.Invoke(this); - Console.WriteLine($"Ability {AbilityData.Name} deactivated. Active count: {_activeInstances.Count}"); } } @@ -317,4 +228,107 @@ private static bool HasBlockedTags(TagContainer? blocked, TagContainer? present) { return blocked is not null && (present?.HasAny(blocked) == true); } + + private bool CanActivate(IForgeEntity? abilityTarget, out AbilityActivationResult activationResult) + { + if (IsInhibited) + { + activationResult = AbilityActivationResult.FailedInhibition; + return false; + } + + // Check instance policy for non re-triggerable persistent instance. + if (AbilityData.InstancingPolicy == AbilityInstancingPolicy.PerEntity + && !AbilityData.RetriggerInstancedAbility + && _persistentInstance?.IsActive == true) + { + activationResult = AbilityActivationResult.FailedPersistentInstanceActive; + return false; + } + + // Check cooldown. + if (_cooldownEffect?.CachedGrantedTags is not null + && Owner.Tags.CombinedTags.HasAny(_cooldownEffect.CachedGrantedTags)) + { + activationResult = AbilityActivationResult.FailedCooldown; + return false; + } + + // Check resources. + if (_costEffect is not null + && !Owner.EffectsManager.CanApplyEffect(_costEffect, Level)) + { + activationResult = AbilityActivationResult.FailedInsufficientResources; + return false; + } + + // Check tags condition. + TagContainer ownerTags = Owner.Tags.CombinedTags; + TagContainer? sourceTags = SourceEntity?.Tags.CombinedTags; + TagContainer? targetTags = abilityTarget?.Tags.CombinedTags; + + // Owner tags. + if (FailsRequiredTags(AbilityData.ActivationRequiredTags, ownerTags) + || HasBlockedTags(AbilityData.ActivationBlockedTags, ownerTags)) + { + activationResult = AbilityActivationResult.FailedOwnerTagRequirements; + return false; + } + + // Source tags. + if (FailsRequiredTags(AbilityData.SourceRequiredTags, sourceTags) + || HasBlockedTags(AbilityData.SourceBlockedTags, sourceTags)) + { + activationResult = AbilityActivationResult.FailedSourceTagRequirements; + return false; + } + + // Target tags. + if (FailsRequiredTags(AbilityData.TargetRequiredTags, targetTags) + || HasBlockedTags(AbilityData.TargetBlockedTags, targetTags)) + { + activationResult = AbilityActivationResult.FailedTargetTagRequirements; + return false; + } + + // Check ability tags against BlockAbilitiesWithTag + if (_abilityTags?.HasAny(Owner.Abilities.BlockedAbilityTags.CombinedTags) == true) + { + activationResult = AbilityActivationResult.FailedBlockedByTags; + return false; + } + + activationResult = AbilityActivationResult.Success; + return true; + } + + private void Activate(IForgeEntity? abilityTarget) + { + // Cancel conflicting abilities before we start this one. + if (AbilityData.CancelAbilitiesWithTag is not null) + { + Owner.Abilities.CancelAbilitiesWithTag(AbilityData.CancelAbilitiesWithTag); + } + + if (AbilityData.InstancingPolicy == AbilityInstancingPolicy.PerEntity) + { + if (_persistentInstance?.IsActive == true) + { + Validation.Assert( + !AbilityData.RetriggerInstancedAbility, "Should not reach here due to CanActivate check."); + + _persistentInstance.Cancel(); + _persistentInstance = null; + } + + _persistentInstance ??= new AbilityInstance(this, abilityTarget); + _activeInstances.Add(_persistentInstance); + _persistentInstance.Start(); + return; + } + + var instance = new AbilityInstance(this, abilityTarget); + _activeInstances.Add(instance); + instance.Start(); + } } diff --git a/Forge/Abilities/AbilityActivationResult.cs b/Forge/Abilities/AbilityActivationResult.cs new file mode 100644 index 0000000..f17dc2f --- /dev/null +++ b/Forge/Abilities/AbilityActivationResult.cs @@ -0,0 +1,64 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Represents the result of an attempt to activate an ability. +/// +/// +/// This enumeration provides detailed outcomes for ability activation attempts, allowing the caller to determine the +/// specific reason for success or failure. Use this result to handle activation logic appropriately based on the +/// returned value. +/// +public enum AbilityActivationResult +{ + /// + /// Successfully activated the ability. + /// + Success = 0, + + /// + /// Failed to activate the ability due to an invalid handler. + /// + FailedInvalidHandler = 1, + + /// + /// Failed to activate the ability because it is currently inhibited. + /// + FailedInhibition = 2, + + /// + /// Failed to activate the ability because a persistent instance is already active. + /// + FailedPersistentInstanceActive = 3, + + /// + /// Failed to activate the ability because it is on cooldown. + /// + FailedCooldown = 4, + + /// + /// Failed to activate the ability due to insufficient resources. + /// + FailedInsufficientResources = 5, + + /// + /// Failed to activate the ability due to unmet tag requirements. + /// + FailedOwnerTagRequirements = 6, + + /// + /// Failed to activate the ability due to unmet source tag requirements. + /// + FailedSourceTagRequirements = 7, + + /// + /// Failed to activate the ability due to unmet target tag requirements. + /// + FailedTargetTagRequirements = 8, + + /// + /// Failed to activate the ability due to being blocked by tags. + /// + FailedBlockedByTags = 9, +} diff --git a/Forge/Abilities/AbilityBehaviorContext.cs b/Forge/Abilities/AbilityBehaviorContext.cs new file mode 100644 index 0000000..21cf66d --- /dev/null +++ b/Forge/Abilities/AbilityBehaviorContext.cs @@ -0,0 +1,56 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Runtime context for a single ability activation. Provides data and helpers for user behaviors. +/// +public sealed class AbilityBehaviorContext +{ + /// + /// Gets the owner of this ability. + /// + public IForgeEntity Owner { get; } + + /// + /// Gets the source entity that granted this ability. + /// + public IForgeEntity? Source { get; } + + /// + /// Gets the target entity of this ability instance. + /// + public IForgeEntity? Target => Instance.Target; + + /// + /// Gets the level of the ability at the time of execution. + /// + public int Level => Ability.Level; + + /// + /// Gets the handle to the ability being executed. + /// + /// + /// Ability-level control (affects the granted ability as a whole: commit, cancel all, end last). + /// + public AbilityHandle Ability { get; } + + /// + /// Gets the public instance handle for per-instance operations. + /// + /// + /// This instance control (affects only this execution: end/cancel). + /// + public AbilityInstanceHandle Instance { get; } + + internal AbilityBehaviorContext(Ability ability, AbilityInstance instance) + { + Ability = ability.Handle; + Instance = new AbilityInstanceHandle(instance); + + Owner = ability.Owner; + Source = ability.SourceEntity; + } +} diff --git a/Forge/Abilities/AbilityData.cs b/Forge/Abilities/AbilityData.cs index 23898c7..79a7acc 100644 --- a/Forge/Abilities/AbilityData.cs +++ b/Forge/Abilities/AbilityData.cs @@ -39,6 +39,7 @@ namespace Gamesmiths.Forge.Abilities; /// Tags required on the target to activate the ability. /// Tags that, if present on the target, will block the ability from being activated. /// +/// The factory function to create custom ability behavior instances. public readonly record struct AbilityData( string Name, EffectData? CostEffect = null, @@ -55,4 +56,5 @@ public readonly record struct AbilityData( TagContainer? SourceRequiredTags = null, TagContainer? SourceBlockedTags = null, TagContainer? TargetRequiredTags = null, - TagContainer? TargetBlockedTags = null); + TagContainer? TargetBlockedTags = null, + Func? BehaviorFactory = null); diff --git a/Forge/Abilities/AbilityHandle.cs b/Forge/Abilities/AbilityHandle.cs index 74d44b8..dc2272a 100644 --- a/Forge/Abilities/AbilityHandle.cs +++ b/Forge/Abilities/AbilityHandle.cs @@ -34,12 +34,14 @@ internal AbilityHandle(Ability ability) /// /// Activates the ability associated with this handle. /// + /// The result of the ability activation attempt. /// The target entity for the ability activation. /// Return if the ability was successfully activated; /// otherwise, . - public bool Activate(IForgeEntity? target = null) + public bool Activate(out AbilityActivationResult activationResult, IForgeEntity? target = null) { - return Ability?.TryActivateAbility(target) ?? false; + activationResult = AbilityActivationResult.FailedInvalidHandler; + return Ability?.TryActivateAbility(target, out activationResult) ?? false; } /// diff --git a/Forge/Abilities/AbilityInstance.cs b/Forge/Abilities/AbilityInstance.cs index 87d785b..9795804 100644 --- a/Forge/Abilities/AbilityInstance.cs +++ b/Forge/Abilities/AbilityInstance.cs @@ -1,5 +1,6 @@ // Copyright © Gamesmiths Guild. +using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Abilities; @@ -14,9 +15,12 @@ internal sealed class AbilityInstance internal bool IsActive { get; private set; } - internal AbilityInstance(Ability ability) + internal IForgeEntity? Target { get; } + + internal AbilityInstance(Ability ability, IForgeEntity? target) { _ability = ability; + Target = target; } internal void Start() diff --git a/Forge/Abilities/AbilityInstanceHandle.cs b/Forge/Abilities/AbilityInstanceHandle.cs new file mode 100644 index 0000000..18f866a --- /dev/null +++ b/Forge/Abilities/AbilityInstanceHandle.cs @@ -0,0 +1,45 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Slim handle for controlling a single active ability instance (end / cancel). +/// Additional context (owner, source, level, commits) lives in . +/// +public sealed class AbilityInstanceHandle +{ + private readonly AbilityInstance _instance; + + /// + /// Gets the target entity of this ability instance. + /// + public IForgeEntity? Target => _instance.Target; + + /// + /// Gets a value indicating whether this ability instance is currently active. + /// + public bool IsActive => _instance.IsActive; + + internal AbilityInstanceHandle(AbilityInstance instance) + { + _instance = instance; + } + + /// + /// Ends the ability instance. + /// + public void End() + { + _instance.End(); + } + + /// + /// Cancels the ability instance. + /// + public void Cancel() + { + _instance.Cancel(); + } +} diff --git a/Forge/Abilities/IAbilityBehavior.cs b/Forge/Abilities/IAbilityBehavior.cs new file mode 100644 index 0000000..01fcdb1 --- /dev/null +++ b/Forge/Abilities/IAbilityBehavior.cs @@ -0,0 +1,21 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Interface for defining custom behavior when an ability instance starts and ends. +/// +public interface IAbilityBehavior +{ + /// + /// Called when an ability instance has started. + /// + /// The context for the started ability instance. + void OnStarted(AbilityBehaviorContext context); + + /// + /// Called when an ability instance has ended. + /// + /// The context for the ended ability instance. + void OnEnded(AbilityBehaviorContext context); +} From be52e8c4ca4d8b0ae9baeacbc10bd9d1f66324df Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 8 Nov 2025 19:29:53 -0300 Subject: [PATCH 31/87] Added AbilityBehavior tests --- Forge.Tests/Abilities/AbilityBehaviorTests.cs | 374 ++++++++++++++++++ Forge/Abilities/AbilityBehaviorContext.cs | 22 +- 2 files changed, 382 insertions(+), 14 deletions(-) create mode 100644 Forge.Tests/Abilities/AbilityBehaviorTests.cs diff --git a/Forge.Tests/Abilities/AbilityBehaviorTests.cs b/Forge.Tests/Abilities/AbilityBehaviorTests.cs new file mode 100644 index 0000000..bfe7f17 --- /dev/null +++ b/Forge.Tests/Abilities/AbilityBehaviorTests.cs @@ -0,0 +1,374 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Abilities; +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Cues; +using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Effects.Components; +using Gamesmiths.Forge.Effects.Duration; +using Gamesmiths.Forge.Effects.Magnitudes; +using Gamesmiths.Forge.Effects.Modifiers; +using Gamesmiths.Forge.Tags; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Abilities; + +public class AbilityBehaviorTests(TagsAndCuesFixture fixture) : IClassFixture +{ + private readonly TagsManager _tags = fixture.TagsManager; + private readonly CuesManager _cues = fixture.CuesManager; + + [Fact] + [Trait("Behavior", "Lifecycle")] + public void Behavior_OnStarted_and_OnEnded_are_invoked_per_instance() + { + var entity = new TestEntity(_tags, _cues); + var behavior = new TrackingBehavior(); + AbilityData data = CreateAbilityData("Tracked", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationResult result).Should().BeTrue(); + result.Should().Be(AbilityActivationResult.Success); + behavior.StartCount.Should().Be(1); + behavior.EndCount.Should().Be(0); + + handle.End(); + behavior.StartCount.Should().Be(1); + behavior.EndCount.Should().Be(1); + } + + [Fact] + [Trait("Behavior", "Multiple Instances")] + public void PerExecution_creates_distinct_behavior_instances() + { + var entity = new TestEntity(_tags, _cues); + var behaviors = new List(); + AbilityData data = CreateAbilityData( + "Multi", + behaviorFactory: () => + { + var trackingBehavior = new TrackingBehavior(); + behaviors.Add(trackingBehavior); + return trackingBehavior; + }, + instancingPolicy: AbilityInstancingPolicy.PerExecution); + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out _).Should().BeTrue(); + handle.Activate(out _).Should().BeTrue(); + handle.Activate(out _).Should().BeTrue(); + behaviors.Should().HaveCount(3); + behaviors.Sum(x => x.StartCount).Should().Be(3); + behaviors.Sum(x => x.EndCount).Should().Be(0); + + handle.End(); + behaviors[^1].EndCount.Should().Be(1); + behaviors[^2].EndCount.Should().Be(0); + behaviors[^3].EndCount.Should().Be(0); + } + + [Fact] + [Trait("Behavior", "Retrigger")] + public void PerEntity_retrigger_invokes_previous_OnEnded_before_new_OnStarted() + { + var entity = new TestEntity(_tags, _cues); + var endedBeforeNew = false; + TrackingBehavior? previous = null; + + AbilityData data = CreateAbilityData( + "Retriggered", + behaviorFactory: () => + { + var behavior = new TrackingBehavior(() => + { + if (previous?.EndCount == 1) + { + endedBeforeNew = true; + } + }); + + previous ??= behavior; + return behavior; + }, + instancingPolicy: AbilityInstancingPolicy.PerEntity, + retriggerInstancedAbility: true); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out _).Should().BeTrue(); + + // Second activation retriggers: ability should call previous.OnEnded() before new.OnStarted(). + handle.Activate(out _).Should().BeTrue(); + + endedBeforeNew.Should().BeTrue("the previous instance should have ended before the new one started"); + } + + [Fact] + [Trait("Behavior", "Context")] + public void Context_provides_expected_values() + { + var source = new TestEntity(_tags, _cues); + var target = new TestEntity(_tags, _cues); + AbilityBehaviorContext? captured = null; + var behavior = new CallbackBehavior(x => captured = x); + + AbilityData data = CreateAbilityData("ContextTest", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(target, data, sourceEntity: source); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationResult result, target).Should().BeTrue(); + result.Should().Be(AbilityActivationResult.Success); + captured.Should().NotBeNull(); + captured!.Owner.Should().Be(target); + captured.Source.Should().Be(source); + captured.Target.Should().Be(target); + captured.Level.Should().Be(handle.Level); + captured.AbilityHandle.Should().Be(handle); + captured.InstanceHandle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("Behavior", "EndInsideStart")] + public void Behavior_can_end_instance_during_OnStarted() + { + var entity = new TestEntity(_tags, _cues); + var behavior = new CallbackBehavior(x => x.InstanceHandle.End()); + + AbilityData data = CreateAbilityData("EndInsideStart", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationResult result).Should().BeTrue(); + result.Should().Be(AbilityActivationResult.Success); + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Behavior", "CommitAbility")] + public void Behavior_commits_cooldown_and_cost_on_start() + { + var entity = new TestEntity(_tags, _cues); + var behavior = new CallbackBehavior(x => x.AbilityHandle.CommitAbility()); + + AbilityData data = CreateAbilityData( + "CommitOnStart", + behaviorFactory: () => behavior, + instancingPolicy: AbilityInstancingPolicy.PerExecution, + cooldownSeconds: 2f, + costMagnitude: -5f); + var baseBefore = entity.Attributes["TestAttributeSet.Attribute90"].BaseValue; + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationResult result).Should().BeTrue(); + result.Should().Be(AbilityActivationResult.Success); + + entity.Attributes["TestAttributeSet.Attribute90"].BaseValue.Should().Be(baseBefore - 5); + + // Attempt re-activate during cooldown should fail. + handle.Activate(out result).Should().BeFalse(); + result.Should().Be(AbilityActivationResult.FailedCooldown); + + // Advance time until cooldown expires. + entity.EffectsManager.UpdateEffects(2f); + handle.Activate(out result).Should().BeTrue(); + result.Should().Be(AbilityActivationResult.Success); + } + + [Fact] + [Trait("Behavior", "ExceptionStart")] + public void Exception_in_OnStarted_cancels_instance_and_does_not_crash() + { + var entity = new TestEntity(_tags, _cues); + var behavior = new ExceptionBehaviorOnStart(); + + AbilityData data = CreateAbilityData("ThrowStart", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + // Activation returns success (instance created then canceled). + handle!.Activate(out AbilityActivationResult result).Should().BeTrue(); + handle.IsActive.Should().BeFalse(); + behavior.StartAttempts.Should().Be(1); + } + + [Fact] + [Trait("Behavior", "ExceptionEnd")] + public void Exception_in_OnEnded_does_not_prevent_deactivation() + { + var entity = new TestEntity(_tags, _cues); + var behavior = new ExceptionBehaviorOnEnd(); + + AbilityData data = CreateAbilityData("ThrowEnd", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out _).Should().BeTrue(); + handle.IsActive.Should().BeTrue(); + + handle.End(); + behavior.EndAttempts.Should().Be(1); + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Behavior", "NullFactoryReturn")] + public void Null_behavior_instance_is_ignored() + { + var entity = new TestEntity(_tags, _cues); + AbilityData data = CreateAbilityData("NullBehavior", behaviorFactory: () => + { +#pragma warning disable CS8603 // Possible null reference return. + return null; +#pragma warning restore CS8603 // Possible null reference return. + }); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out _).Should().BeTrue(); + handle.IsActive.Should().BeTrue(); + + handle.End(); + handle.IsActive.Should().BeFalse(); + } + + private static AbilityHandle? Grant( + TestEntity target, + AbilityData data, + IForgeEntity? sourceEntity = null) + { + var grantConfig = new GrantAbilityConfig( + data, + new ScalableInt(1), + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.CancelImmediately, + LevelComparison.Higher); + + Effect grantEffect = CreateGrantEffect("Grant", grantConfig, sourceEntity); + _ = target.EffectsManager.ApplyEffect(grantEffect); + target.Abilities.TryGetAbility(data, out AbilityHandle? handle, sourceEntity); + return handle; + } + + private static Effect CreateGrantEffect( + string name, + GrantAbilityConfig config, + IForgeEntity? sourceEntity) + { + var data = new EffectData( + name, + new DurationData(DurationType.Infinite), + effectComponents: [new GrantAbilityEffectComponent([config])]); + + return new Effect(data, new EffectOwnership(null, sourceEntity)); + } + + private AbilityData CreateAbilityData( + string name, + IAbilityBehavior? behavior = null, + Func? behaviorFactory = null, + AbilityInstancingPolicy instancingPolicy = AbilityInstancingPolicy.PerEntity, + bool retriggerInstancedAbility = false, + float cooldownSeconds = 3f, + float costMagnitude = -1f) + { + var cooldownEffectData = new EffectData( + $"{name} Cooldown", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + scalableFloatMagnitude: new ScalableFloat(cooldownSeconds))), + effectComponents: [new ModifierTagsEffectComponent(new TagContainer(_tags, TestUtils.StringToTag(_tags, ["simple.tag"])))]); + + var costEffectData = new EffectData( + $"{name} Cost", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute90", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + scalableFloatMagnitude: new ScalableFloat(costMagnitude))) + ]); + + return new AbilityData( + name, + costEffectData, + cooldownEffectData, + InstancingPolicy: instancingPolicy, + RetriggerInstancedAbility: retriggerInstancedAbility, + BehaviorFactory: behaviorFactory ?? (() => behavior!)); + } + + private sealed class TrackingBehavior(Action? onStartExtra = null) : IAbilityBehavior + { + private readonly Action? _onStartExtra = onStartExtra; + + public int StartCount { get; private set; } + + public int EndCount { get; private set; } + + public void OnStarted(AbilityBehaviorContext context) + { + StartCount++; + _onStartExtra?.Invoke(); + } + + public void OnEnded(AbilityBehaviorContext context) + { + EndCount++; + } + } + + private sealed class CallbackBehavior(Action callback) : IAbilityBehavior + { + public void OnStarted(AbilityBehaviorContext context) + { + callback(context); + } + + public void OnEnded(AbilityBehaviorContext context) + { + // No-op + } + } + + private sealed class ExceptionBehaviorOnStart : IAbilityBehavior + { + public int StartAttempts { get; private set; } + + public void OnStarted(AbilityBehaviorContext context) + { + StartAttempts++; + throw new InvalidOperationException("Start failure"); + } + + public void OnEnded(AbilityBehaviorContext context) + { + // No-op + } + } + + private sealed class ExceptionBehaviorOnEnd : IAbilityBehavior + { + public int EndAttempts { get; private set; } + + public void OnStarted(AbilityBehaviorContext context) + { + // No-op + } + + public void OnEnded(AbilityBehaviorContext context) + { + EndAttempts++; + throw new InvalidOperationException("End failure"); + } + } +} diff --git a/Forge/Abilities/AbilityBehaviorContext.cs b/Forge/Abilities/AbilityBehaviorContext.cs index 21cf66d..459af70 100644 --- a/Forge/Abilities/AbilityBehaviorContext.cs +++ b/Forge/Abilities/AbilityBehaviorContext.cs @@ -22,33 +22,27 @@ public sealed class AbilityBehaviorContext /// /// Gets the target entity of this ability instance. /// - public IForgeEntity? Target => Instance.Target; + public IForgeEntity? Target => InstanceHandle.Target; /// /// Gets the level of the ability at the time of execution. /// - public int Level => Ability.Level; + public int Level => AbilityHandle.Level; /// - /// Gets the handle to the ability being executed. + /// Gets the handle to the ability being executed (ability-level operations). /// - /// - /// Ability-level control (affects the granted ability as a whole: commit, cancel all, end last). - /// - public AbilityHandle Ability { get; } + public AbilityHandle AbilityHandle { get; } /// - /// Gets the public instance handle for per-instance operations. + /// Gets the per-instance handle for this execution (end/cancel this instance). /// - /// - /// This instance control (affects only this execution: end/cancel). - /// - public AbilityInstanceHandle Instance { get; } + public AbilityInstanceHandle InstanceHandle { get; } internal AbilityBehaviorContext(Ability ability, AbilityInstance instance) { - Ability = ability.Handle; - Instance = new AbilityInstanceHandle(instance); + AbilityHandle = ability.Handle; + InstanceHandle = new AbilityInstanceHandle(instance); Owner = ability.Owner; Source = ability.SourceEntity; From 47f74ded742255ede39870e1687e638e1f7275bb Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 10 Nov 2025 21:16:41 -0300 Subject: [PATCH 32/87] Support for multiple cooldown effects --- Forge.Tests/Abilities/AbilitiesTests.cs | 244 +++++++++++++----- Forge.Tests/Abilities/AbilityBehaviorTests.cs | 8 +- Forge/Abilities/Ability.cs | 37 ++- Forge/Abilities/AbilityData.cs | 4 +- 4 files changed, 208 insertions(+), 85 deletions(-) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index a859934..0607abc 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -28,7 +28,8 @@ public void Ability_is_granted_successfully() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -54,7 +55,8 @@ public void Removed_ability_is_deactivated_immediately() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -86,7 +88,8 @@ public void Ability_is_only_removed_after_being_deactivated() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -124,7 +127,8 @@ public void Ability_gets_inhibited_temporarily_while_granting_effect_is_inhibite AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -174,7 +178,8 @@ public void Granted_ability_is_not_removed_when_deactivation_policy_is_ignore() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -212,7 +217,8 @@ public void Ability_granted_by_multiple_effects_is_removed_only_when_all_grantin AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -252,7 +258,8 @@ public void Ability_is_not_granted_if_target_has_blocking_tags() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -293,7 +300,8 @@ public void Ability_granted_by_instant_effect_is_permanent() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -333,7 +341,8 @@ public void Ability_granted_by_late_instant_effect_is_permanent() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -373,7 +382,8 @@ public void Ability_granted_by_instant_effect_is_not_inhibited() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -416,7 +426,8 @@ public void Ability_granted_by_late_instant_effect_is_not_inhibited() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -462,7 +473,8 @@ public void Ability_is_inhibited_only_when_all_granting_effects_are_inhibited() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -517,7 +529,8 @@ public void Inhibited_ability_becomes_active_if_new_non_inhibited_source_is_adde AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -559,7 +572,8 @@ public void Inhibition_policy_RemoveOnEnd_inhibits_after_deactivation() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -605,7 +619,8 @@ public void Inhibition_policy_Ignore_prevents_inhibition() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -648,7 +663,8 @@ public void Effect_inhibited_at_application_grant_inhibited_abilities() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -678,7 +694,8 @@ public void Ability_level_is_set_correctly() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -707,7 +724,8 @@ public void Ability_level_scales_with_curve() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -731,7 +749,8 @@ public void Ability_level_override_policy_works_correctly() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -786,7 +805,8 @@ public void Abilities_with_different_sources_are_separate_instances() // Create two different AbilityData instances (differ by name) AbilityData abilityData1 = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -828,7 +848,48 @@ public void Ability_wont_activate_while_cooldown_is_active() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle.IsActive.Should().BeTrue(); + + abilityHandle.CommitCooldown(); + abilityHandle.End(); + + abilityHandle!.Activate(out activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedCooldown); + + entity.EffectsManager.UpdateEffects(2f); + + abilityHandle!.Activate(out activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedCooldown); + + entity.EffectsManager.UpdateEffects(1f); + + abilityHandle!.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + } + + [Fact] + [Trait("Cooldown", null)] + public void Ability_wont_activate_until_last_cooldown_effect_is_removed() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f), new ScalableFloat(1f)], + ["simple.tag", "tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); @@ -869,7 +930,8 @@ public void Ability_wont_activate_if_cant_afford_cost(int cost) AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(cost), retriggerInstancedAbility: true); @@ -898,7 +960,8 @@ public void Ability_wont_activate_when_owner_missing_required_tag() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1), activationRequiredTags: new TagContainer( @@ -923,7 +986,8 @@ public void Ability_wont_activate_when_owner_has_blocked_tag() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1), activationBlockedTags: new TagContainer( @@ -949,7 +1013,8 @@ public void Ability_wont_activate_when_source_missing_required_tag() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1), sourceRequiredTags: new TagContainer( @@ -976,7 +1041,8 @@ public void Ability_wont_activate_when_source_has_blocked_tag() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1), sourceBlockedTags: new TagContainer( @@ -1002,7 +1068,8 @@ public void Ability_wont_activate_when_source_is_missing_but_has_required_tags() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1), sourceRequiredTags: new TagContainer( @@ -1028,7 +1095,8 @@ public void Ability_activates_when_source_is_missing_and_has_blocked_tags() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1), sourceBlockedTags: new TagContainer( @@ -1055,7 +1123,8 @@ public void Ability_wont_activate_when_target_missing_required_tag() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1), targetRequiredTags: new TagContainer( @@ -1081,7 +1150,8 @@ public void Ability_wont_activate_when_target_has_blocked_tag() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1), targetBlockedTags: new TagContainer( @@ -1106,7 +1176,8 @@ public void Ability_wont_activate_when_target_is_missing_but_has_required_tags() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1), targetRequiredTags: new TagContainer( @@ -1131,7 +1202,8 @@ public void Ability_activates_when_target_is_missing_and_has_blocked_tags() AbilityData abilityData = CreateAbilityData( "Fireball", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1), targetBlockedTags: new TagContainer( @@ -1156,7 +1228,8 @@ public void Ability_activation_blocks_other_abilities_with_blocked_tags() AbilityData blockerAbilityData = CreateAbilityData( "Blocker ability", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1), blockAbilitiesWithTag: new TagContainer( @@ -1164,7 +1237,8 @@ public void Ability_activation_blocks_other_abilities_with_blocked_tags() AbilityData unblockedAbilityData = CreateAbilityData( "Unblocked ability", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1), abilityTags: new TagContainer( @@ -1172,7 +1246,8 @@ public void Ability_activation_blocks_other_abilities_with_blocked_tags() AbilityData blockedAbilityData = CreateAbilityData( "Blocked ability", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1), abilityTags: new TagContainer( @@ -1223,7 +1298,8 @@ public void Ability_PerEntity_no_retrigger_activated_twice_does_not_start_second AbilityData abilityData = CreateAbilityData( "PerEntity_NoRetrigger", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute1", new ScalableFloat(-1), instancingPolicy: AbilityInstancingPolicy.PerEntity, @@ -1253,7 +1329,8 @@ public void Ability_PerEntity_retrigger_restarts_instance_and_fires_deactivated_ AbilityData abilityData = CreateAbilityData( "PerEntity_Retrigger", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute2", new ScalableFloat(-1), instancingPolicy: AbilityInstancingPolicy.PerEntity, @@ -1284,7 +1361,8 @@ public void Ability_per_execution_multiple_activations_create_multiple_instances AbilityData abilityData = CreateAbilityData( "PerExecution", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute3", new ScalableFloat(-1), instancingPolicy: AbilityInstancingPolicy.PerExecution); @@ -1323,7 +1401,8 @@ public void Ability_End_ends_most_recent_instance_only() AbilityData abilityData = CreateAbilityData( "EndsMostRecent", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute5", new ScalableFloat(-1), instancingPolicy: AbilityInstancingPolicy.PerExecution); @@ -1356,7 +1435,8 @@ public void Ability_CancelAbility_cancels_all_active_instances() AbilityData abilityData = CreateAbilityData( "CancelAllInstances", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute5", new ScalableFloat(-1), instancingPolicy: AbilityInstancingPolicy.PerExecution); @@ -1391,14 +1471,16 @@ public void CancelAbilitiesWithTag_cancels_all_matching_active_abilities_on_acti AbilityData canceller = CreateAbilityData( "Canceller", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute3", new ScalableFloat(-1), cancelAbilitiesWithTag: cancelTags); AbilityData victim = CreateAbilityData( "Victim", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute5", new ScalableFloat(-1), abilityTags: new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"]))); @@ -1427,14 +1509,16 @@ public void CancelAbilitiesWithTag_does_not_cancel_unrelated_abilities() AbilityData canceller = CreateAbilityData( "Canceller2", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute3", new ScalableFloat(-1), cancelAbilitiesWithTag: cancelTags); AbilityData unrelated = CreateAbilityData( "Unrelated", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute5", new ScalableFloat(-1), abilityTags: new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.blue"]))); @@ -1463,14 +1547,16 @@ public void CancelAbilitiesWithTag_cancels_all_instances_of_matching_abilities() AbilityData canceller = CreateAbilityData( "Canceller3", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute1", new ScalableFloat(-1), cancelAbilitiesWithTag: cancelTags); AbilityData victim = CreateAbilityData( "VictimMulti", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute2", new ScalableFloat(-1), abilityTags: new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])), @@ -1500,7 +1586,8 @@ public void CancelAbilitiesWithTag_does_not_cancel_self_on_activation() AbilityData selfCanceller = CreateAbilityData( "SelfCanceller", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute1", new ScalableFloat(-1), abilityTags: redTags, @@ -1523,7 +1610,8 @@ public void Blocked_ability_tags_are_removed_only_after_last_instance_ends() AbilityData blocker = CreateAbilityData( "BlockerMulti", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute1", new ScalableFloat(-1), instancingPolicy: AbilityInstancingPolicy.PerExecution, @@ -1531,7 +1619,8 @@ public void Blocked_ability_tags_are_removed_only_after_last_instance_ends() AbilityData blocked = CreateAbilityData( "BlockedRed", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute5", new ScalableFloat(-1), abilityTags: redTags); @@ -1575,7 +1664,8 @@ public void Activation_owned_tags_are_applied_on_activation_and_removed_on_end() AbilityData abilityWithOwned = CreateAbilityData( "OwnedTagsAbility", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute5", new ScalableFloat(-1), activationOwnedTags: ownedTags); @@ -1601,7 +1691,8 @@ public void Activation_owned_tags_are_applied_on_activation_and_removed_after_la AbilityData abilityWithOwned = CreateAbilityData( "OwnedTagsAbility", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute5", new ScalableFloat(-1), instancingPolicy: AbilityInstancingPolicy.PerExecution, @@ -1641,14 +1732,16 @@ public void Activation_owned_tags_enable_other_ability_only_while_active() AbilityData giver = CreateAbilityData( "Giver", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute1", new ScalableFloat(-1), activationOwnedTags: buffTag); AbilityData requiresBuff = CreateAbilityData( "NeedsBuff", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute5", new ScalableFloat(-1), activationRequiredTags: buffTag); @@ -1683,7 +1776,8 @@ public void OnAbilityDeactivated_is_fired_once_per_instance_end() AbilityData abilityData = CreateAbilityData( "RemoveOnEndProxy", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute1", new ScalableFloat(-1), instancingPolicy: AbilityInstancingPolicy.PerExecution); @@ -1728,7 +1822,8 @@ public void Persistent_instance_reference_is_cleared_on_end() AbilityData abilityData = CreateAbilityData( "PersistentCleared", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute3", new ScalableFloat(-1), instancingPolicy: AbilityInstancingPolicy.PerEntity); @@ -1756,14 +1851,16 @@ public void CancelAbilitiesWithTag_with_no_active_abilities_does_nothing() AbilityData canceller = CreateAbilityData( "Canceller", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute2", new ScalableFloat(-1), cancelAbilitiesWithTag: cancelTags); AbilityData victim = CreateAbilityData( "Victim", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute3", new ScalableFloat(-1), abilityTags: new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"]))); @@ -1793,7 +1890,8 @@ public void CancelAbilitiesWithTag_executes_before_applying_blocking_or_activati AbilityData canceller = CreateAbilityData( "CancellerOrder", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute1", new ScalableFloat(-1), cancelAbilitiesWithTag: redTags, @@ -1802,7 +1900,8 @@ public void CancelAbilitiesWithTag_executes_before_applying_blocking_or_activati AbilityData victim = CreateAbilityData( "VictimOrder", - new ScalableFloat(3f), + [new ScalableFloat(3f)], + ["simple.tag"], "TestAttributeSet.Attribute2", new ScalableFloat(-1), abilityTags: redTags); @@ -1898,7 +1997,8 @@ private static Effect CreateAbilityApplierEffect( private AbilityData CreateAbilityData( string abilityName, - ScalableFloat cooldownDuration, + ScalableFloat[] cooldownDurations, + string[] cooldownTags, string costAttribute, ScalableFloat costAmount, TagContainer? abilityTags = null, @@ -1914,15 +2014,21 @@ private AbilityData CreateAbilityData( TagContainer? targetRequiredTags = null, TagContainer? targetBlockedTags = null) { - var cooldownEffectData = new EffectData( - "Fireball Cooldown", - new DurationData( - DurationType.HasDuration, - new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, cooldownDuration)), - effectComponents: - [ - new ModifierTagsEffectComponent(new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["simple.tag"]))) - ]); + var cooldownEffectData = new EffectData[cooldownDurations.Length]; + + for (var i = 0; i < cooldownDurations.Length; i++) + { + cooldownEffectData[i] = new EffectData( + "Fireball Cooldown", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, cooldownDurations[i])), + effectComponents: + [ + new ModifierTagsEffectComponent( + new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, [cooldownTags[i]]))) + ]); + } var costEffectData = new EffectData( "Fireball Cost", diff --git a/Forge.Tests/Abilities/AbilityBehaviorTests.cs b/Forge.Tests/Abilities/AbilityBehaviorTests.cs index bfe7f17..206699b 100644 --- a/Forge.Tests/Abilities/AbilityBehaviorTests.cs +++ b/Forge.Tests/Abilities/AbilityBehaviorTests.cs @@ -277,14 +277,18 @@ private AbilityData CreateAbilityData( float cooldownSeconds = 3f, float costMagnitude = -1f) { - var cooldownEffectData = new EffectData( + EffectData[] cooldownEffectData = [new EffectData( $"{name} Cooldown", new DurationData( DurationType.HasDuration, new ModifierMagnitude( MagnitudeCalculationType.ScalableFloat, scalableFloatMagnitude: new ScalableFloat(cooldownSeconds))), - effectComponents: [new ModifierTagsEffectComponent(new TagContainer(_tags, TestUtils.StringToTag(_tags, ["simple.tag"])))]); + effectComponents: + [ + new ModifierTagsEffectComponent( + new TagContainer(_tags, TestUtils.StringToTag(_tags, ["simple.tag"]))) + ])]; var costEffectData = new EffectData( $"{name} Cost", diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index c663490..dba7ba9 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -13,7 +13,7 @@ internal class Ability { private record struct BehaviorBinding(IAbilityBehavior Behavior, AbilityBehaviorContext Context); - private readonly Effect? _cooldownEffect; + private readonly Effect[]? _cooldownEffects; private readonly Effect? _costEffect; @@ -76,12 +76,17 @@ internal Ability( SourceEntity = sourceEntity; IsInhibited = false; - if (abilityData.CooldownEffect is not null) + if (abilityData.CooldownEffects is not null) { - _cooldownEffect = new Effect( - abilityData.CooldownEffect.Value, - new EffectOwnership(owner, sourceEntity), - level); + _cooldownEffects = new Effect[abilityData.CooldownEffects.Length]; + + for (var i = 0; i < abilityData.CooldownEffects.Length; i++) + { + _cooldownEffects[i] = new Effect( + abilityData.CooldownEffects[i], + new EffectOwnership(owner, sourceEntity), + level); + } } if (abilityData.CostEffect is not null) @@ -119,9 +124,12 @@ internal void CommitAbility() internal void CommitCooldown() { - if (_cooldownEffect is not null) + if (_cooldownEffects is not null) { - Owner.EffectsManager.ApplyEffect(_cooldownEffect); + foreach (Effect effect in _cooldownEffects) + { + Owner.EffectsManager.ApplyEffect(effect); + } } } @@ -247,11 +255,16 @@ private bool CanActivate(IForgeEntity? abilityTarget, out AbilityActivationResul } // Check cooldown. - if (_cooldownEffect?.CachedGrantedTags is not null - && Owner.Tags.CombinedTags.HasAny(_cooldownEffect.CachedGrantedTags)) + if (_cooldownEffects is not null) { - activationResult = AbilityActivationResult.FailedCooldown; - return false; + foreach (Effect effect in _cooldownEffects) + { + if (effect?.CachedGrantedTags is not null && Owner.Tags.CombinedTags.HasAny(effect.CachedGrantedTags)) + { + activationResult = AbilityActivationResult.FailedCooldown; + return false; + } + } } // Check resources. diff --git a/Forge/Abilities/AbilityData.cs b/Forge/Abilities/AbilityData.cs index 79a7acc..a173772 100644 --- a/Forge/Abilities/AbilityData.cs +++ b/Forge/Abilities/AbilityData.cs @@ -17,7 +17,7 @@ namespace Gamesmiths.Forge.Abilities; /// The name of the ability. /// The effect that represents the cost of using the ability called when the ability is /// committed. -/// The effect that represents the cooldown of the ability. +/// A list of effects that represents the cooldowns of the ability. /// Tags associated with the ability for categorization and filtering. /// The instancing policy for the ability, determining how instances are created and /// managed. @@ -43,7 +43,7 @@ namespace Gamesmiths.Forge.Abilities; public readonly record struct AbilityData( string Name, EffectData? CostEffect = null, - EffectData? CooldownEffect = null, + EffectData[]? CooldownEffects = null, TagContainer? AbilityTags = null, AbilityInstancingPolicy InstancingPolicy = AbilityInstancingPolicy.PerEntity, bool RetriggerInstancedAbility = false, From f2af09610e85703e4bb8ba4bae31f8ad081de0a9 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 12 Nov 2025 23:52:42 -0300 Subject: [PATCH 33/87] Fixed Cancel and End behaviors --- Forge.Tests/Abilities/AbilitiesTests.cs | 104 +++++------ Forge.Tests/Abilities/AbilityBehaviorTests.cs | 161 ++++++++++++++---- Forge/Abilities/AbilityHandle.cs | 42 ++++- Forge/Abilities/AbilityInstanceHandle.cs | 8 - 4 files changed, 202 insertions(+), 113 deletions(-) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index 0607abc..66fffc2 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -113,7 +113,7 @@ [new ScalableFloat(3f)], entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle.End(); + abilityHandle.Cancel(); entity.Abilities.GrantedAbilities.Should().BeEmpty(); abilityHandle.IsActive.Should().BeFalse(); @@ -203,7 +203,7 @@ [new ScalableFloat(3f)], entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle.End(); + abilityHandle.Cancel(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); abilityHandle.IsActive.Should().BeFalse(); @@ -604,7 +604,7 @@ [new ScalableFloat(3f)], abilityHandle.IsActive.Should().BeTrue(); // End the ability. - abilityHandle.End(); + abilityHandle.Cancel(); // Now that it's no longer active, it should become inhibited. abilityHandle.IsActive.Should().BeFalse(); @@ -649,7 +649,7 @@ [new ScalableFloat(3f)], abilityHandle.IsInhibited.Should().BeFalse(); abilityHandle.IsActive.Should().BeTrue(); - abilityHandle.End(); + abilityHandle.Cancel(); abilityHandle.IsInhibited.Should().BeFalse(); abilityHandle.IsActive.Should().BeFalse(); @@ -864,7 +864,7 @@ [new ScalableFloat(3f)], abilityHandle.IsActive.Should().BeTrue(); abilityHandle.CommitCooldown(); - abilityHandle.End(); + abilityHandle.Cancel(); abilityHandle!.Activate(out activationResult).Should().BeFalse(); activationResult.Should().Be(AbilityActivationResult.FailedCooldown); @@ -904,7 +904,7 @@ public void Ability_wont_activate_until_last_cooldown_effect_is_removed() abilityHandle.IsActive.Should().BeTrue(); abilityHandle.CommitCooldown(); - abilityHandle.End(); + abilityHandle.Cancel(); abilityHandle!.Activate(out activationResult).Should().BeFalse(); activationResult.Should().Be(AbilityActivationResult.FailedCooldown); @@ -1283,7 +1283,7 @@ [new ScalableFloat(3f)], activationResult.Should().Be(AbilityActivationResult.FailedBlockedByTags); blockedAbilityHandle.IsActive.Should().BeFalse(); - blockerAbilityHandle!.End(); + blockerAbilityHandle!.Cancel(); blockedAbilityHandle!.Activate(out activationResult).Should().BeTrue(); activationResult.Should().Be(AbilityActivationResult.Success); @@ -1317,7 +1317,7 @@ [new ScalableFloat(3f)], activationResult.Should().Be(AbilityActivationResult.FailedPersistentInstanceActive); handle.IsActive.Should().BeTrue(); - handle.End(); + handle.Cancel(); handle.IsActive.Should().BeFalse(); } @@ -1349,7 +1349,7 @@ [new ScalableFloat(3f)], handle.IsActive.Should().BeTrue(); // One End should fully deactivate because retrigger replaced the instance instead of stacking. - handle.End(); + handle.Cancel(); handle.IsActive.Should().BeFalse(); } @@ -1382,20 +1382,14 @@ [new ScalableFloat(3f)], activationResult.Should().Be(AbilityActivationResult.Success); handle.IsActive.Should().BeTrue(); - // End most recent instance only; still active until all are ended. - handle.End(); - handle.IsActive.Should().BeTrue(); - - handle.End(); - handle.IsActive.Should().BeTrue(); - - handle.End(); + // Cancel ends all instances. + handle.Cancel(); handle.IsActive.Should().BeFalse(); } [Fact] [Trait("Instancing", null)] - public void Ability_End_ends_most_recent_instance_only() + public void Ability_Cancel_ends_all_instances() { TestEntity entity = new(_tagsManager, _cuesManager); @@ -1418,12 +1412,8 @@ [new ScalableFloat(3f)], activationResult.Should().Be(AbilityActivationResult.Success); handle.IsActive.Should().BeTrue(); - // One End should not fully deactivate if multiple instances exist. - handle.End(); - handle.IsActive.Should().BeTrue(); - - // Second End ends the remaining instance. - handle.End(); + // One Cancel should fully deactivate if multiple instances exist. + handle.Cancel(); handle.IsActive.Should().BeFalse(); } @@ -1602,7 +1592,7 @@ [new ScalableFloat(3f)], [Fact] [Trait("BlockAbilitiesWithTag", null)] - public void Blocked_ability_tags_are_removed_only_after_last_instance_ends() + public void Blocked_ability_tags_are_removed_after_all_instance_ends() { TestEntity entity = new(_tagsManager, _cuesManager); @@ -1641,14 +1631,8 @@ [new ScalableFloat(3f)], activationResult.Should().Be(AbilityActivationResult.FailedBlockedByTags); blockedHandle.IsActive.Should().BeFalse(); - // End one blocker instance; still blocked. - blockerHandle.End(); - blockedHandle.Activate(out activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedBlockedByTags); - blockedHandle.IsActive.Should().BeFalse(); - - // End last blocker instance; now unblocked. - blockerHandle.End(); + // End all blocker instances. + blockerHandle.Cancel(); blockedHandle.Activate(out activationResult).Should().BeTrue(); activationResult.Should().Be(AbilityActivationResult.Success); blockedHandle.IsActive.Should().BeTrue(); @@ -1656,7 +1640,7 @@ [new ScalableFloat(3f)], [Fact] [Trait("ActivationOwnedTags", null)] - public void Activation_owned_tags_are_applied_on_activation_and_removed_on_end() + public void Activation_owned_tags_are_applied_on_activation_and_removed_on_Cancel() { TestEntity entity = new(_tagsManager, _cuesManager); @@ -1677,13 +1661,13 @@ [new ScalableFloat(3f)], entity.Tags.CombinedTags.HasAll(ownedTags).Should().BeTrue(); handle.IsActive.Should().BeTrue(); - handle.End(); + handle.Cancel(); entity.Tags.CombinedTags.HasAny(ownedTags).Should().BeFalse(); } [Fact] [Trait("ActivationOwnedTags", null)] - public void Activation_owned_tags_are_applied_on_activation_and_removed_after_last_instance_ends() + public void Activation_owned_tags_are_applied_on_activation_and_removed_when_all_instances_ends() { TestEntity entity = new(_tagsManager, _cuesManager); @@ -1714,11 +1698,7 @@ [new ScalableFloat(3f)], entity.Tags.CombinedTags.HasAll(ownedTags).Should().BeTrue(); - handle.End(); - entity.Tags.CombinedTags.HasAll(ownedTags).Should().BeTrue(); - - handle.End(); - handle.End(); + handle.Cancel(); entity.Tags.CombinedTags.HasAny(ownedTags).Should().BeFalse(); } @@ -1761,15 +1741,15 @@ [new ScalableFloat(3f)], activationResult.Should().Be(AbilityActivationResult.Success); // Lose buff, then cannot activate again. - giverHandle.End(); - needsHandle.End(); + giverHandle.Cancel(); + needsHandle.Cancel(); needsHandle.Activate(out activationResult).Should().BeFalse(); activationResult.Should().Be(AbilityActivationResult.FailedOwnerTagRequirements); } [Fact] [Trait("Bookkeeping", null)] - public void OnAbilityDeactivated_is_fired_once_per_instance_end() + public void Granted_ability_is_removed_when_all_instances_end() { // Proxy via RemoveOnEnd semantics: ability is only removed after each instance ends once. TestEntity entity = new(_tagsManager, _cuesManager); @@ -1805,18 +1785,14 @@ [new ScalableFloat(3f)], // Still present because policy is RemoveOnEnd and still active. entity.Abilities.GrantedAbilities.Should().Contain(handle); - // End one instance; still granted, one more end needed. - handle.End(); - entity.Abilities.GrantedAbilities.Should().Contain(handle); - - // End last instance; now removed. - handle.End(); + // End all instances, remove grant. + handle.Cancel(); entity.Abilities.GrantedAbilities.Should().NotContain(handle); } [Fact] [Trait("Instancing", null)] - public void Persistent_instance_reference_is_cleared_on_end() + public void Persistent_instance_reference_is_cleared_on_Cancel() { TestEntity entity = new(_tagsManager, _cuesManager); @@ -1833,7 +1809,7 @@ [new ScalableFloat(3f)], handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); activationResult.Should().Be(AbilityActivationResult.Success); - handle.End(); + handle.Cancel(); handle.IsActive.Should().BeFalse(); // Should be able to activate again, implying the persistent instance was cleared. @@ -2044,17 +2020,17 @@ private AbilityData CreateAbilityData( abilityName, costEffectData, cooldownEffectData, - AbilityTags: abilityTags, - InstancingPolicy: instancingPolicy, - RetriggerInstancedAbility: retriggerInstancedAbility, - CancelAbilitiesWithTag: cancelAbilitiesWithTag, - BlockAbilitiesWithTag: blockAbilitiesWithTag, - ActivationOwnedTags: activationOwnedTags, - ActivationRequiredTags: activationRequiredTags, - ActivationBlockedTags: activationBlockedTags, - SourceRequiredTags: sourceRequiredTags, - SourceBlockedTags: sourceBlockedTags, - TargetRequiredTags: targetRequiredTags, - TargetBlockedTags: targetBlockedTags); + abilityTags: abilityTags, + instancingPolicy: instancingPolicy, + retriggerInstancedAbility: retriggerInstancedAbility, + cancelAbilitiesWithTag: cancelAbilitiesWithTag, + blockAbilitiesWithTag: blockAbilitiesWithTag, + activationOwnedTags: activationOwnedTags, + activationRequiredTags: activationRequiredTags, + activationBlockedTags: activationBlockedTags, + sourceRequiredTags: sourceRequiredTags, + sourceBlockedTags: sourceBlockedTags, + targetRequiredTags: targetRequiredTags, + targetBlockedTags: targetBlockedTags); } } diff --git a/Forge.Tests/Abilities/AbilityBehaviorTests.cs b/Forge.Tests/Abilities/AbilityBehaviorTests.cs index 206699b..1af6dfd 100644 --- a/Forge.Tests/Abilities/AbilityBehaviorTests.cs +++ b/Forge.Tests/Abilities/AbilityBehaviorTests.cs @@ -16,14 +16,14 @@ namespace Gamesmiths.Forge.Tests.Abilities; public class AbilityBehaviorTests(TagsAndCuesFixture fixture) : IClassFixture { - private readonly TagsManager _tags = fixture.TagsManager; - private readonly CuesManager _cues = fixture.CuesManager; + private readonly TagsManager _tagsManager = fixture.TagsManager; + private readonly CuesManager _cuesManager = fixture.CuesManager; [Fact] - [Trait("Behavior", "Lifecycle")] + [Trait("Lifecycle", null)] public void Behavior_OnStarted_and_OnEnded_are_invoked_per_instance() { - var entity = new TestEntity(_tags, _cues); + var entity = new TestEntity(_tagsManager, _cuesManager); var behavior = new TrackingBehavior(); AbilityData data = CreateAbilityData("Tracked", behaviorFactory: () => behavior); AbilityHandle? handle = Grant(entity, data); @@ -34,16 +34,16 @@ public void Behavior_OnStarted_and_OnEnded_are_invoked_per_instance() behavior.StartCount.Should().Be(1); behavior.EndCount.Should().Be(0); - handle.End(); + handle.Cancel(); behavior.StartCount.Should().Be(1); behavior.EndCount.Should().Be(1); } [Fact] - [Trait("Behavior", "Multiple Instances")] + [Trait("Multiple Instances", null)] public void PerExecution_creates_distinct_behavior_instances() { - var entity = new TestEntity(_tags, _cues); + var entity = new TestEntity(_tagsManager, _cuesManager); var behaviors = new List(); AbilityData data = CreateAbilityData( "Multi", @@ -64,17 +64,99 @@ public void PerExecution_creates_distinct_behavior_instances() behaviors.Sum(x => x.StartCount).Should().Be(3); behaviors.Sum(x => x.EndCount).Should().Be(0); - handle.End(); - behaviors[^1].EndCount.Should().Be(1); - behaviors[^2].EndCount.Should().Be(0); - behaviors[^3].EndCount.Should().Be(0); + handle.Cancel(); + behaviors.Sum(x => x.EndCount).Should().Be(3); } [Fact] - [Trait("Behavior", "Retrigger")] + [Trait("Multiple Instances", null)] + public void Blocked_ability_tags_are_removed_only_after_last_instance_ends() + { + TestEntity entity = new(_tagsManager, _cuesManager); + var behaviors = new List(); + + var redTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])); + + AbilityData blocker = CreateAbilityData( + "BlockerMulti", + behaviorFactory: () => + { + var trackingBehavior = new TrackingBehavior(); + behaviors.Add(trackingBehavior); + return trackingBehavior; + }, + instancingPolicy: AbilityInstancingPolicy.PerExecution, + blockAbilitiesWithTag: redTags); + + AbilityData blocked = CreateAbilityData( + "BlockedRed", + abilityTags: redTags); + + AbilityHandle? blockerHandle = Grant(entity, blocker); + AbilityHandle? blockedHandle = Grant(entity, blocked); + + blockerHandle!.Activate(out _).Should().BeTrue(); + blockerHandle!.Activate(out _).Should().BeTrue(); + + // While any blocker instance active, blocked ability cannot activate. + blockedHandle!.Activate(out AbilityActivationResult activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedBlockedByTags); + blockedHandle.IsActive.Should().BeFalse(); + + // End one blocker instance; still blocked. + behaviors[0].End(); + blockedHandle.Activate(out activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedBlockedByTags); + blockedHandle.IsActive.Should().BeFalse(); + + // End last blocker instance; now unblocked. + behaviors[1].End(); + blockedHandle.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + blockedHandle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("Multiple Instances", null)] + public void Activation_owned_tags_are_applied_on_activation_and_removed_after_last_instance_ends() + { + TestEntity entity = new(_tagsManager, _cuesManager); + var behaviors = new List(); + + var ownedTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"])); + + AbilityData abilityWithOwned = CreateAbilityData( + "OwnedTagsAbility", + behaviorFactory: () => + { + var trackingBehavior = new TrackingBehavior(); + behaviors.Add(trackingBehavior); + return trackingBehavior; + }, + instancingPolicy: AbilityInstancingPolicy.PerExecution, + activationOwnedTags: ownedTags); + + AbilityHandle? handle = Grant(entity, abilityWithOwned); + + handle!.Activate(out _).Should().BeTrue(); + handle!.Activate(out _).Should().BeTrue(); + handle!.Activate(out _).Should().BeTrue(); + + entity.Tags.CombinedTags.HasAll(ownedTags).Should().BeTrue(); + + behaviors[0].End(); + entity.Tags.CombinedTags.HasAll(ownedTags).Should().BeTrue(); + + behaviors[1].End(); + behaviors[2].End(); + entity.Tags.CombinedTags.HasAny(ownedTags).Should().BeFalse(); + } + + [Fact] + [Trait("Retrigger", null)] public void PerEntity_retrigger_invokes_previous_OnEnded_before_new_OnStarted() { - var entity = new TestEntity(_tags, _cues); + var entity = new TestEntity(_tagsManager, _cuesManager); var endedBeforeNew = false; TrackingBehavior? previous = null; @@ -108,11 +190,11 @@ public void PerEntity_retrigger_invokes_previous_OnEnded_before_new_OnStarted() } [Fact] - [Trait("Behavior", "Context")] + [Trait("Context", null)] public void Context_provides_expected_values() { - var source = new TestEntity(_tags, _cues); - var target = new TestEntity(_tags, _cues); + var source = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); AbilityBehaviorContext? captured = null; var behavior = new CallbackBehavior(x => captured = x); @@ -132,10 +214,10 @@ public void Context_provides_expected_values() } [Fact] - [Trait("Behavior", "EndInsideStart")] + [Trait("EndInsideStart", null)] public void Behavior_can_end_instance_during_OnStarted() { - var entity = new TestEntity(_tags, _cues); + var entity = new TestEntity(_tagsManager, _cuesManager); var behavior = new CallbackBehavior(x => x.InstanceHandle.End()); AbilityData data = CreateAbilityData("EndInsideStart", behaviorFactory: () => behavior); @@ -148,10 +230,10 @@ public void Behavior_can_end_instance_during_OnStarted() } [Fact] - [Trait("Behavior", "CommitAbility")] + [Trait("CommitAbility", null)] public void Behavior_commits_cooldown_and_cost_on_start() { - var entity = new TestEntity(_tags, _cues); + var entity = new TestEntity(_tagsManager, _cuesManager); var behavior = new CallbackBehavior(x => x.AbilityHandle.CommitAbility()); AbilityData data = CreateAbilityData( @@ -180,10 +262,10 @@ public void Behavior_commits_cooldown_and_cost_on_start() } [Fact] - [Trait("Behavior", "ExceptionStart")] + [Trait("ExceptionStart", null)] public void Exception_in_OnStarted_cancels_instance_and_does_not_crash() { - var entity = new TestEntity(_tags, _cues); + var entity = new TestEntity(_tagsManager, _cuesManager); var behavior = new ExceptionBehaviorOnStart(); AbilityData data = CreateAbilityData("ThrowStart", behaviorFactory: () => behavior); @@ -197,10 +279,10 @@ public void Exception_in_OnStarted_cancels_instance_and_does_not_crash() } [Fact] - [Trait("Behavior", "ExceptionEnd")] + [Trait("ExceptionEnd", null)] public void Exception_in_OnEnded_does_not_prevent_deactivation() { - var entity = new TestEntity(_tags, _cues); + var entity = new TestEntity(_tagsManager, _cuesManager); var behavior = new ExceptionBehaviorOnEnd(); AbilityData data = CreateAbilityData("ThrowEnd", behaviorFactory: () => behavior); @@ -210,16 +292,16 @@ public void Exception_in_OnEnded_does_not_prevent_deactivation() handle!.Activate(out _).Should().BeTrue(); handle.IsActive.Should().BeTrue(); - handle.End(); + handle.Cancel(); behavior.EndAttempts.Should().Be(1); handle.IsActive.Should().BeFalse(); } [Fact] - [Trait("Behavior", "NullFactoryReturn")] + [Trait("NullFactoryReturn", null)] public void Null_behavior_instance_is_ignored() { - var entity = new TestEntity(_tags, _cues); + var entity = new TestEntity(_tagsManager, _cuesManager); AbilityData data = CreateAbilityData("NullBehavior", behaviorFactory: () => { #pragma warning disable CS8603 // Possible null reference return. @@ -233,7 +315,7 @@ public void Null_behavior_instance_is_ignored() handle!.Activate(out _).Should().BeTrue(); handle.IsActive.Should().BeTrue(); - handle.End(); + handle.Cancel(); handle.IsActive.Should().BeFalse(); } @@ -275,7 +357,10 @@ private AbilityData CreateAbilityData( AbilityInstancingPolicy instancingPolicy = AbilityInstancingPolicy.PerEntity, bool retriggerInstancedAbility = false, float cooldownSeconds = 3f, - float costMagnitude = -1f) + float costMagnitude = -1f, + TagContainer? abilityTags = null, + TagContainer? blockAbilitiesWithTag = null, + TagContainer? activationOwnedTags = null) { EffectData[] cooldownEffectData = [new EffectData( $"{name} Cooldown", @@ -287,7 +372,7 @@ private AbilityData CreateAbilityData( effectComponents: [ new ModifierTagsEffectComponent( - new TagContainer(_tags, TestUtils.StringToTag(_tags, ["simple.tag"]))) + new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["simple.tag"]))) ])]; var costEffectData = new EffectData( @@ -306,14 +391,18 @@ private AbilityData CreateAbilityData( name, costEffectData, cooldownEffectData, - InstancingPolicy: instancingPolicy, - RetriggerInstancedAbility: retriggerInstancedAbility, - BehaviorFactory: behaviorFactory ?? (() => behavior!)); + abilityTags: abilityTags, + instancingPolicy: instancingPolicy, + retriggerInstancedAbility: retriggerInstancedAbility, + blockAbilitiesWithTag: blockAbilitiesWithTag, + activationOwnedTags: activationOwnedTags, + behaviorFactory: behaviorFactory ?? (() => behavior!)); } private sealed class TrackingBehavior(Action? onStartExtra = null) : IAbilityBehavior { private readonly Action? _onStartExtra = onStartExtra; + private AbilityBehaviorContext? _context; public int StartCount { get; private set; } @@ -321,6 +410,7 @@ private sealed class TrackingBehavior(Action? onStartExtra = null) : IAbilityBeh public void OnStarted(AbilityBehaviorContext context) { + _context = context; StartCount++; _onStartExtra?.Invoke(); } @@ -329,6 +419,11 @@ public void OnEnded(AbilityBehaviorContext context) { EndCount++; } + + public void End() + { + _context.InstanceHandle.End(); + } } private sealed class CallbackBehavior(Action callback) : IAbilityBehavior diff --git a/Forge/Abilities/AbilityHandle.cs b/Forge/Abilities/AbilityHandle.cs index dc2272a..26f0950 100644 --- a/Forge/Abilities/AbilityHandle.cs +++ b/Forge/Abilities/AbilityHandle.cs @@ -1,6 +1,7 @@ // Copyright © Gamesmiths Guild. using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Abilities; @@ -44,14 +45,6 @@ public bool Activate(out AbilityActivationResult activationResult, IForgeEntity? return Ability?.TryActivateAbility(target, out activationResult) ?? false; } - /// - /// End the ability associated with this handle. - /// - public void End() - { - Ability?.End(); - } - /// /// Cancels all instances of the ability associated with this handle. /// @@ -84,6 +77,39 @@ public void CommitCost() Ability?.CommitCost(); } + /// + /// Checks if the ability can be activated for the given target. + /// + /// The result of the ability activation check. + /// Optional target entity for the ability activation check. + /// Returns if the ability can be activated; otherwise, . + /// + public bool CanActivate(out AbilityActivationResult activationResult, IForgeEntity? abilityTarget = null) + { + activationResult = AbilityActivationResult.FailedInvalidHandler; + return Ability?.CanActivate(abilityTarget, out activationResult) ?? false; + } + + /// + /// Gets the cooldown data for the ability associated with this handle. + /// + /// A list of cooldown data. Returns if the ability is invalid. + public CooldownData[]? GetCooldownData() + { + return Ability?.GetCooldownData(); + } + + /// + /// Gets the remaining cooldown time for a specific tag. + /// + /// The tag to check for remaining cooldown time. + /// The remaining cooldown time in seconds. Returns 0 if there is no cooldown or the ability is invalid. + /// + public float GetRemainingCooldownTime(Tag tag) + { + return Ability?.GetRemainingCooldownTime(tag) ?? 0f; + } + internal void Free() { Ability = null; diff --git a/Forge/Abilities/AbilityInstanceHandle.cs b/Forge/Abilities/AbilityInstanceHandle.cs index 18f866a..57715b6 100644 --- a/Forge/Abilities/AbilityInstanceHandle.cs +++ b/Forge/Abilities/AbilityInstanceHandle.cs @@ -34,12 +34,4 @@ public void End() { _instance.End(); } - - /// - /// Cancels the ability instance. - /// - public void Cancel() - { - _instance.Cancel(); - } } From d241b69476e415fb2ef5d338292151c5677ff8ce Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 13 Nov 2025 11:02:27 -0300 Subject: [PATCH 34/87] Added cooldown retrieval methods --- Forge.Tests/Abilities/AbilitiesTests.cs | 115 ++++++++++++++++++ Forge.Tests/Abilities/AbilityBehaviorTests.cs | 2 +- Forge/Abilities/Ability.cs | 96 +++++++++++++-- Forge/Abilities/CooldownData.cs | 13 ++ 4 files changed, 212 insertions(+), 14 deletions(-) create mode 100644 Forge/Abilities/CooldownData.cs diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index 66fffc2..bfa6a58 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -920,6 +920,121 @@ public void Ability_wont_activate_until_last_cooldown_effect_is_removed() activationResult.Should().Be(AbilityActivationResult.Success); } + [Fact] + [Trait("Cooldown", null)] + public void GetCooldownData_and_GetRemainingCooldownTime_return_correct_values() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f), new ScalableFloat(1f)], + ["simple.tag", "tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle.IsActive.Should().BeTrue(); + + CooldownData[]? cooldownData = abilityHandle.GetCooldownData()!; + cooldownData.Should().HaveCount(2); + + var simpleTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var tag = Tag.RequestTag(_tagsManager, "tag"); + + cooldownData[0].TotalTime.Should().Be(3f); + cooldownData[0].RemainingTime.Should().Be(0f); + cooldownData[0].CooldownTags.Should().Contain(simpleTag); + + cooldownData[1].TotalTime.Should().Be(1f); + cooldownData[1].RemainingTime.Should().Be(0f); + cooldownData[1].CooldownTags.Should().Contain(tag); + + abilityHandle.GetRemainingCooldownTime(simpleTag).Should().Be(0f); + abilityHandle.GetRemainingCooldownTime(tag).Should().Be(0f); + + abilityHandle.CommitCooldown(); + abilityHandle.Cancel(); + + abilityHandle!.Activate(out activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedCooldown); + + cooldownData = abilityHandle.GetCooldownData()!; + cooldownData.Should().HaveCount(2); + + cooldownData[0].TotalTime.Should().Be(3f); + cooldownData[0].RemainingTime.Should().Be(3f); + cooldownData[0].CooldownTags.Should().Contain(simpleTag); + + cooldownData[1].TotalTime.Should().Be(1f); + cooldownData[1].RemainingTime.Should().Be(1f); + cooldownData[1].CooldownTags.Should().Contain(tag); + + abilityHandle.GetRemainingCooldownTime(simpleTag).Should().Be(3f); + abilityHandle.GetRemainingCooldownTime(tag).Should().Be(1f); + + entity.EffectsManager.UpdateEffects(0.5f); + + abilityHandle!.Activate(out activationResult).Should().BeFalse(); + activationResult.Should().Be(AbilityActivationResult.FailedCooldown); + + cooldownData = abilityHandle.GetCooldownData()!; + cooldownData.Should().HaveCount(2); + + cooldownData[0].TotalTime.Should().Be(3f); + cooldownData[0].RemainingTime.Should().Be(2.5f); + cooldownData[0].CooldownTags.Should().Contain(simpleTag); + + cooldownData[1].TotalTime.Should().Be(1f); + cooldownData[1].RemainingTime.Should().Be(0.5f); + cooldownData[1].CooldownTags.Should().Contain(tag); + + abilityHandle.GetRemainingCooldownTime(simpleTag).Should().Be(2.5f); + abilityHandle.GetRemainingCooldownTime(tag).Should().Be(0.5f); + + entity.EffectsManager.UpdateEffects(1f); + + cooldownData = abilityHandle.GetCooldownData()!; + cooldownData.Should().HaveCount(2); + + cooldownData[0].TotalTime.Should().Be(3f); + cooldownData[0].RemainingTime.Should().Be(1.5f); + cooldownData[0].CooldownTags.Should().Contain(simpleTag); + + cooldownData[1].TotalTime.Should().Be(1f); + cooldownData[1].RemainingTime.Should().Be(0f); + cooldownData[1].CooldownTags.Should().Contain(tag); + + abilityHandle.GetRemainingCooldownTime(simpleTag).Should().Be(1.5f); + abilityHandle.GetRemainingCooldownTime(tag).Should().Be(0f); + + entity.EffectsManager.UpdateEffects(2f); + + cooldownData = abilityHandle.GetCooldownData()!; + cooldownData.Should().HaveCount(2); + + cooldownData[0].TotalTime.Should().Be(3f); + cooldownData[0].RemainingTime.Should().Be(0f); + cooldownData[0].CooldownTags.Should().Contain(simpleTag); + + cooldownData[1].TotalTime.Should().Be(1f); + cooldownData[1].RemainingTime.Should().Be(0f); + cooldownData[1].CooldownTags.Should().Contain(tag); + + abilityHandle.GetRemainingCooldownTime(simpleTag).Should().Be(0f); + abilityHandle.GetRemainingCooldownTime(tag).Should().Be(0f); + + abilityHandle!.Activate(out activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + } + [Theory] [Trait("Cost", null)] [InlineData(5)] diff --git a/Forge.Tests/Abilities/AbilityBehaviorTests.cs b/Forge.Tests/Abilities/AbilityBehaviorTests.cs index 1af6dfd..f7a6810 100644 --- a/Forge.Tests/Abilities/AbilityBehaviorTests.cs +++ b/Forge.Tests/Abilities/AbilityBehaviorTests.cs @@ -422,7 +422,7 @@ public void OnEnded(AbilityBehaviorContext context) public void End() { - _context.InstanceHandle.End(); + _context?.InstanceHandle.End(); } } diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index dba7ba9..280b0eb 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -2,6 +2,7 @@ using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Effects.Components; using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Abilities; @@ -15,6 +16,8 @@ private record struct BehaviorBinding(IAbilityBehavior Behavior, AbilityBehavior private readonly Effect[]? _cooldownEffects; + private readonly ActiveEffectHandle?[]? _activeCooldownHandles; + private readonly Effect? _costEffect; private readonly TagContainer? _abilityTags; @@ -79,6 +82,7 @@ internal Ability( if (abilityData.CooldownEffects is not null) { _cooldownEffects = new Effect[abilityData.CooldownEffects.Length]; + _activeCooldownHandles = new ActiveEffectHandle[abilityData.CooldownEffects.Length]; for (var i = 0; i < abilityData.CooldownEffects.Length; i++) { @@ -126,9 +130,15 @@ internal void CommitCooldown() { if (_cooldownEffects is not null) { - foreach (Effect effect in _cooldownEffects) + Validation.Assert( + _activeCooldownHandles is not null + && _activeCooldownHandles.Length == _cooldownEffects.Length, + "Active cooldown handles array should have been properly initialized."); + + for (var i = 0; i < _cooldownEffects.Length; i++) { - Owner.EffectsManager.ApplyEffect(effect); + Effect effect = _cooldownEffects[i]; + _activeCooldownHandles[i] = Owner.EffectsManager.ApplyEffect(effect); } } } @@ -227,17 +237,7 @@ internal void OnInstanceEnded(AbilityInstance instance) } } - private static bool FailsRequiredTags(TagContainer? required, TagContainer? present) - { - return required is not null && (present?.HasAll(required) != true); - } - - private static bool HasBlockedTags(TagContainer? blocked, TagContainer? present) - { - return blocked is not null && (present?.HasAny(blocked) == true); - } - - private bool CanActivate(IForgeEntity? abilityTarget, out AbilityActivationResult activationResult) + internal bool CanActivate(IForgeEntity? abilityTarget, out AbilityActivationResult activationResult) { if (IsInhibited) { @@ -315,6 +315,76 @@ private bool CanActivate(IForgeEntity? abilityTarget, out AbilityActivationResul return true; } + internal CooldownData[] GetCooldownData() + { + var cooldownData = new CooldownData[_activeCooldownHandles?.Length ?? 0]; + + if (_activeCooldownHandles is not null) + { + for (var i = 0; i < _activeCooldownHandles.Length; i++) + { + ActiveEffectHandle? effectHandle = _activeCooldownHandles[i]; + if (effectHandle?.ActiveEffect is not null) + { + ActiveEffect activeEffect = effectHandle.ActiveEffect; + + TagContainer cooldownTags = activeEffect.Effect.CachedGrantedTags!; + var totalTime = activeEffect.EffectEvaluatedData.Duration; + var remainingTime = (float)activeEffect.RemainingDuration; + + cooldownData[i] = new CooldownData(cooldownTags, totalTime, remainingTime); + } + else + { + EffectData effectData = _cooldownEffects![i].EffectData; + + ModifierTagsEffectComponent modifierTagsComponent = + effectData.EffectComponents.OfType().First(); + TagContainer cooldownTags = modifierTagsComponent.TagsToAdd; + + var totalTime = effectData.DurationData.DurationMagnitude!.Value.GetMagnitude( + _cooldownEffects[i], Owner, Level); + + cooldownData[i] = new CooldownData(cooldownTags, totalTime, 0f); + } + } + } + + return cooldownData; + } + + internal float GetRemainingCooldownTime(Tag tag) + { + if (_activeCooldownHandles is not null) + { + for (var i = 0; i < _activeCooldownHandles.Length; i++) + { + ActiveEffectHandle? effectHandle = _activeCooldownHandles[i]; + if (effectHandle?.ActiveEffect is not null) + { + ActiveEffect activeEffect = effectHandle.ActiveEffect; + TagContainer cooldownTags = activeEffect.Effect.CachedGrantedTags!; + if (cooldownTags.HasTag(tag)) + { + return (float)activeEffect.RemainingDuration; + } + } + } + } + + return 0f; + } + + private static bool FailsRequiredTags(TagContainer? required, TagContainer? present) + { + return required is not null && (present?.HasAll(required) != true); + } + + private static bool HasBlockedTags(TagContainer? blocked, TagContainer? present) + { + return blocked is not null && (present?.HasAny(blocked) == true); + } + private void Activate(IForgeEntity? abilityTarget) { // Cancel conflicting abilities before we start this one. diff --git a/Forge/Abilities/CooldownData.cs b/Forge/Abilities/CooldownData.cs new file mode 100644 index 0000000..96c31b0 --- /dev/null +++ b/Forge/Abilities/CooldownData.cs @@ -0,0 +1,13 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Contains information about an ability's cooldown state. +/// +/// Tags associated with the cooldown. +/// The total duration of the cooldown. +/// The remaining time left on the cooldown. +public record struct CooldownData(TagContainer CooldownTags, float TotalTime, float RemainingTime); From f8de7cc5067f43c01e1ba5f9919c72b2cc103454 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 13 Nov 2025 11:02:40 -0300 Subject: [PATCH 35/87] Added ability data validation --- Forge/Abilities/AbilityData.cs | 241 +++++++++++++++++++++++++++------ 1 file changed, 197 insertions(+), 44 deletions(-) diff --git a/Forge/Abilities/AbilityData.cs b/Forge/Abilities/AbilityData.cs index a173772..46b6b17 100644 --- a/Forge/Abilities/AbilityData.cs +++ b/Forge/Abilities/AbilityData.cs @@ -1,6 +1,9 @@ // Copyright © Gamesmiths Guild. +using System.Reflection.Emit; +using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Effects.Components; using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Abilities; @@ -14,47 +17,197 @@ namespace Gamesmiths.Forge.Abilities; /// instancing and retriggering abilities. Use this type to define the behavior and constraints of abilities in a /// consistent and extensible manner. /// -/// The name of the ability. -/// The effect that represents the cost of using the ability called when the ability is -/// committed. -/// A list of effects that represents the cooldowns of the ability. -/// Tags associated with the ability for categorization and filtering. -/// The instancing policy for the ability, determining how instances are created and -/// managed. -/// Flag indicating whether an instanced ability can be re-triggered while it is -/// Still active. If on, it will stop and re-trigger the ability. -/// The trigger data associated with the ability, defining how and when the ability can -/// be executed. -/// Abilities with any of these tags will be canceled when this ability is -/// executed. -/// Abilities with any of these tags will be blocked from being executed while this -/// ability is active. -/// Tags that will be applied to the owner when the ability is activated. -/// Tags required on the owner to activate the ability. -/// Tags that, if present on the owner, will block the ability from being activated. -/// -/// Tags required on the source to activate the ability. -/// Tags that, if present on the source, will block the ability from being activated. -/// -/// Tags required on the target to activate the ability. -/// Tags that, if present on the target, will block the ability from being activated. -/// -/// The factory function to create custom ability behavior instances. -public readonly record struct AbilityData( - string Name, - EffectData? CostEffect = null, - EffectData[]? CooldownEffects = null, - TagContainer? AbilityTags = null, - AbilityInstancingPolicy InstancingPolicy = AbilityInstancingPolicy.PerEntity, - bool RetriggerInstancedAbility = false, - AbilityTriggerData? AbilityTriggerData = null, - TagContainer? CancelAbilitiesWithTag = null, - TagContainer? BlockAbilitiesWithTag = null, - TagContainer? ActivationOwnedTags = null, - TagContainer? ActivationRequiredTags = null, - TagContainer? ActivationBlockedTags = null, - TagContainer? SourceRequiredTags = null, - TagContainer? SourceBlockedTags = null, - TagContainer? TargetRequiredTags = null, - TagContainer? TargetBlockedTags = null, - Func? BehaviorFactory = null); +public readonly record struct AbilityData +{ + /// + /// Gets the name of the ability. + /// + public string Name { get; } + + /// + /// Gets the effect that represents the cost of using the ability called when the ability is committed. + /// + public EffectData? CostEffect { get; } + + /// + /// Gets a list of effects that represents the cooldowns of the ability. + /// + public EffectData[]? CooldownEffects { get; } + + /// + /// Gets tags associated with the ability for categorization and filtering. + /// + public TagContainer? AbilityTags { get; } + + /// + /// Gets the instancing policy for the ability, determining how instances are created and managed. + /// + public AbilityInstancingPolicy InstancingPolicy { get; } + + /// + /// Gets a value indicating whether an instanced ability can be re-triggered while it is still active. If on, it + /// will stop and re-trigger the ability. + /// + public bool RetriggerInstancedAbility { get; } + + /// + /// Gets the trigger data associated with the ability, defining how and when the ability can be executed. + /// + public AbilityTriggerData? AbilityTriggerData { get; } + + /// + /// Gets the tags that, if present on other abilities, will cause those abilities to be canceled when this ability + /// is activated. + /// + public TagContainer? CancelAbilitiesWithTag { get; } + + /// + /// Gets the tags that, if present on other abilities, will block those abilities from being activated while this + /// ability is active. + /// + public TagContainer? BlockAbilitiesWithTag { get; } + + /// + /// Gets tags that will be applied to the owner when the ability is activated. + /// + public TagContainer? ActivationOwnedTags { get; } + + /// + /// Gets tags required on the owner to activate the ability. + /// + public TagContainer? ActivationRequiredTags { get; } + + /// + /// Gets tags that, if present on the owner, will block the ability from being activated. + /// + public TagContainer? ActivationBlockedTags { get; } + + /// + /// Gets tags required on the source to activate the ability. + /// + public TagContainer? SourceRequiredTags { get; } + + /// + /// Gets tags that, if present on the source, will block the ability from being activated. + /// + public TagContainer? SourceBlockedTags { get; } + + /// + /// Gets tags required on the target to activate the ability. + /// + public TagContainer? TargetRequiredTags { get; } + + /// + /// Gets tags that, if present on the target, will block the ability from being activated. + /// + public TagContainer? TargetBlockedTags { get; } + + /// + /// Gets the factory function to create custom ability behavior instances. + /// + public Func? BehaviorFactory { get; } + + /// + /// Initializes a new instance of the structure. + /// + /// The name of the ability. + /// The effect that represents the cost of using the ability called when the ability is + /// committed. + /// A list of effects that represents the cooldowns of the ability. + /// Tags associated with the ability for categorization and filtering. + /// The instancing policy for the ability, determining how instances are created and + /// managed. + /// Flag indicating whether an instanced ability can be re-triggered while it is + /// Still active. If on, it will stop and re-trigger the ability. + /// The trigger data associated with the ability, defining how and when the ability can + /// be executed. + /// Abilities with any of these tags will be canceled when this ability is + /// executed. + /// Abilities with any of these tags will be blocked from being executed while this + /// ability is active. + /// Tags that will be applied to the owner when the ability is activated. + /// Tags required on the owner to activate the ability. + /// Tags that, if present on the owner, will block the ability from being activated. + /// + /// Tags required on the source to activate the ability. + /// Tags that, if present on the source, will block the ability from being activated. + /// + /// Tags required on the target to activate the ability. + /// Tags that, if present on the target, will block the ability from being activated. + /// + /// The factory function to create custom ability behavior instances. + public AbilityData( + string name, + EffectData? costEffect = null, + EffectData[]? cooldownEffects = null, + TagContainer? abilityTags = null, + AbilityInstancingPolicy instancingPolicy = AbilityInstancingPolicy.PerEntity, + bool retriggerInstancedAbility = false, + AbilityTriggerData? abilityTriggerData = null, + TagContainer? cancelAbilitiesWithTag = null, + TagContainer? blockAbilitiesWithTag = null, + TagContainer? activationOwnedTags = null, + TagContainer? activationRequiredTags = null, + TagContainer? activationBlockedTags = null, + TagContainer? sourceRequiredTags = null, + TagContainer? sourceBlockedTags = null, + TagContainer? targetRequiredTags = null, + TagContainer? targetBlockedTags = null, + Func? behaviorFactory = null) + { + Name = name; + CostEffect = costEffect; + CooldownEffects = cooldownEffects; + AbilityTags = abilityTags; + InstancingPolicy = instancingPolicy; + RetriggerInstancedAbility = retriggerInstancedAbility; + AbilityTriggerData = abilityTriggerData; + CancelAbilitiesWithTag = cancelAbilitiesWithTag; + BlockAbilitiesWithTag = blockAbilitiesWithTag; + ActivationOwnedTags = activationOwnedTags; + ActivationRequiredTags = activationRequiredTags; + ActivationBlockedTags = activationBlockedTags; + SourceRequiredTags = sourceRequiredTags; + SourceBlockedTags = sourceBlockedTags; + TargetRequiredTags = targetRequiredTags; + TargetBlockedTags = targetBlockedTags; + BehaviorFactory = behaviorFactory; + + if (Validation.Enabled) + { + ValidateData(); + } + } + + private void ValidateData() + { + if (CooldownEffects is not null) + { + for (var i = 0; i < CooldownEffects.Length; i++) + { + Validation.Assert( + Array.TrueForAll( + CooldownEffects, + x => x.DurationData.DurationType == Effects.Duration.DurationType.HasDuration), + "Cooldown effects should have a duration."); + + Validation.Assert( + Array.Exists( + CooldownEffects[i].EffectComponents, + x => x is ModifierTagsEffectComponent y && !y.TagsToAdd.IsEmpty), + "Cooldown effects should have modifier tags."); + } + } + + if (CostEffect is not null) + { + Validation.Assert( + CostEffect.Value.DurationData.DurationType == Effects.Duration.DurationType.Instant, + "Cost effects should be instant."); + } + + Validation.Assert( + RetriggerInstancedAbility && InstancingPolicy == AbilityInstancingPolicy.PerEntity, + "RetriggerInstancedAbility is only used when InstancingPolicy is PerEntity."); + } +} From aefc298da27d11436bbef1ceeb38bba17bfc0b09 Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 17 Nov 2025 00:19:03 -0300 Subject: [PATCH 36/87] Added cost retrieval methods --- Forge.Tests/Abilities/AbilitiesTests.cs | 87 ++++++++++++++++ .../Helpers/CustomTestExecutionClass.cs | 2 +- Forge/Abilities/Ability.cs | 99 +++++++++++++++++++ Forge/Abilities/AbilityData.cs | 3 +- Forge/Abilities/AbilityHandle.cs | 20 ++++ Forge/Abilities/CostData.cs | 12 +++ Forge/Attributes/AttributeSet.cs | 5 +- Forge/Attributes/EntityAttribute.cs | 8 ++ Forge/Effects/Calculator/CustomExecution.cs | 34 ++++++- Forge/Effects/EffectEvaluatedData.cs | 39 +------- Forge/Tags/Tag.cs | 4 +- 11 files changed, 268 insertions(+), 45 deletions(-) create mode 100644 Forge/Abilities/CostData.cs diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index bfa6a58..eef0bb5 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -2010,6 +2010,93 @@ [new ScalableFloat(3f)], cancellerHandle.IsActive.Should().BeTrue(); } + [Theory] + [InlineData(ModifierOperation.FlatBonus, -1f, -1, 89)] + [InlineData(ModifierOperation.FlatBonus, 1f, 1, 91)] + [InlineData(ModifierOperation.PercentBonus, -0.1f, -9, 81)] + [InlineData(ModifierOperation.PercentBonus, 0.1f, 9, 99)] + [InlineData(ModifierOperation.Override, 1f, -89, 1)] + [InlineData(ModifierOperation.Override, 98f, 8, 98)] + [Trait("Ability cost", null)] + public void Cost_effect_reports_configured_cost_and_applies_on_commit( + ModifierOperation operation, float magnitude, int expectedCost, int finalValue) + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var costEffectData = new EffectData( + "Fireball Cost", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute90", + operation, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(magnitude))) + ]); + + AbilityData abilityData = new("Fireball", costEffectData); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + CostData[]? costData = abilityHandle!.GetCostData(); + costData.Should().ContainSingle(); + costData![0].Attribute.Should().Be("TestAttributeSet.Attribute90"); + costData![0].Cost.Should().Be(expectedCost); + + abilityHandle.GetCostForAttribute("TestAttributeSet.Attribute90").Should().Be(expectedCost); + + abilityHandle.CommitCost(); + entity.Attributes["TestAttributeSet.Attribute90"].CurrentValue.Should().Be(finalValue); + } + + [Fact] + [Trait("Ability cost", null)] + public void Cost_effect_with_multiple_modifiers_reports_configured_cost_and_applies_on_commit() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var costEffectData = new EffectData( + "Fireball Cost", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute90", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(1f))), + new Modifier( + "TestAttributeSet.Attribute90", + ModifierOperation.PercentBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-0.1f))) + ]); + + AbilityData abilityData = new("Fireball", costEffectData); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + CostData[]? costData = abilityHandle!.GetCostData(); + costData.Should().ContainSingle(); + costData![0].Attribute.Should().Be("TestAttributeSet.Attribute90"); + costData![0].Cost.Should().Be(-9); + + abilityHandle.GetCostForAttribute("TestAttributeSet.Attribute90").Should().Be(-9); + + abilityHandle.CommitCost(); + entity.Attributes["TestAttributeSet.Attribute90"].CurrentValue.Should().Be(81); + } + private static AbilityHandle? SetupAbility( TestEntity targetEntity, AbilityData abilityData, diff --git a/Forge.Tests/Helpers/CustomTestExecutionClass.cs b/Forge.Tests/Helpers/CustomTestExecutionClass.cs index cefa459..afed1d8 100644 --- a/Forge.Tests/Helpers/CustomTestExecutionClass.cs +++ b/Forge.Tests/Helpers/CustomTestExecutionClass.cs @@ -58,7 +58,7 @@ public CustomTestExecutionClass(bool snapshot) public override ModifierEvaluatedData[] EvaluateExecution( Effect effect, IForgeEntity target, - EffectEvaluatedData effectEvaluatedData) + EffectEvaluatedData? effectEvaluatedData) { var result = new List(); diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 280b0eb..06aa9e9 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -2,7 +2,9 @@ using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Effects.Calculator; using Gamesmiths.Forge.Effects.Components; +using Gamesmiths.Forge.Effects.Modifiers; using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Abilities; @@ -375,6 +377,69 @@ internal float GetRemainingCooldownTime(Tag tag) return 0f; } + internal CostData[]? GetCostData(StringKey? specificAttribute = null) + { + if (_costEffect is null) + { + return null; + } + + ModifierEvaluatedData[] allModifiersEvaluatedData = EvaluateInstantModifiers(_costEffect, specificAttribute); + + Dictionary costByAttribute = []; + + foreach (ModifierEvaluatedData modifierEvaluatedData in allModifiersEvaluatedData) + { + if (!costByAttribute.TryGetValue(modifierEvaluatedData.Attribute.Key, out var value)) + { + value = 0f; + costByAttribute[modifierEvaluatedData.Attribute.Key] = value; + } + + var baseValue = modifierEvaluatedData.Attribute.BaseValue + + value; + + switch (modifierEvaluatedData.ModifierOperation) + { + case ModifierOperation.FlatBonus: + costByAttribute[modifierEvaluatedData.Attribute.Key] += modifierEvaluatedData.Magnitude; + break; + + case ModifierOperation.PercentBonus: + costByAttribute[modifierEvaluatedData.Attribute.Key] += + (int)(baseValue * (1 + modifierEvaluatedData.Magnitude)) - baseValue; + break; + + case ModifierOperation.Override: + costByAttribute[modifierEvaluatedData.Attribute.Key] += + modifierEvaluatedData.Magnitude - baseValue; + break; + } + } + + return [.. costByAttribute.Select(x => new CostData(x.Key, (int)x.Value))]; + } + + internal int GetCostForAttribute(StringKey attributeKey) + { + CostData[]? costData = GetCostData(attributeKey); + + if (costData is null) + { + return 0; + } + + foreach (CostData cost in costData) + { + if (cost.Attribute == attributeKey) + { + return cost.Cost; + } + } + + return 0; + } + private static bool FailsRequiredTags(TagContainer? required, TagContainer? present) { return required is not null && (present?.HasAll(required) != true); @@ -414,4 +479,38 @@ private void Activate(IForgeEntity? abilityTarget) _activeInstances.Add(instance); instance.Start(); } + + private ModifierEvaluatedData[] EvaluateInstantModifiers(Effect effect, StringKey? specificAttribute = null) + { + var modifiersEvaluatedData = new List(effect.EffectData.Modifiers.Length); + + foreach (Modifier modifier in effect.EffectData.Modifiers) + { + // Ignore modifiers for attributes not present in the target. + if (!Owner.Attributes.ContainsAttribute(modifier.Attribute) || + (specificAttribute.HasValue && specificAttribute.Value != modifier.Attribute)) + { + continue; + } + + modifiersEvaluatedData.Add( + new ModifierEvaluatedData( + Owner.Attributes[modifier.Attribute], + modifier.Operation, + modifier.Magnitude.GetMagnitude(effect, Owner, Level, null), + modifier.Channel)); + } + + foreach (CustomExecution execution in effect.EffectData.CustomExecutions) + { + if (CustomExecution.ExecutionHasInvalidAttributeCaptures(execution, effect, Owner)) + { + continue; + } + + modifiersEvaluatedData.AddRange(execution.EvaluateExecution(effect, Owner, null)); + } + + return [.. modifiersEvaluatedData]; + } } diff --git a/Forge/Abilities/AbilityData.cs b/Forge/Abilities/AbilityData.cs index 46b6b17..4070bc4 100644 --- a/Forge/Abilities/AbilityData.cs +++ b/Forge/Abilities/AbilityData.cs @@ -1,6 +1,5 @@ // Copyright © Gamesmiths Guild. -using System.Reflection.Emit; using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Effects; using Gamesmiths.Forge.Effects.Components; @@ -108,7 +107,7 @@ public readonly record struct AbilityData public Func? BehaviorFactory { get; } /// - /// Initializes a new instance of the structure. + /// Initializes a new instance of the structure. /// /// The name of the ability. /// The effect that represents the cost of using the ability called when the ability is diff --git a/Forge/Abilities/AbilityHandle.cs b/Forge/Abilities/AbilityHandle.cs index 26f0950..f8cc833 100644 --- a/Forge/Abilities/AbilityHandle.cs +++ b/Forge/Abilities/AbilityHandle.cs @@ -110,6 +110,26 @@ public float GetRemainingCooldownTime(Tag tag) return Ability?.GetRemainingCooldownTime(tag) ?? 0f; } + /// + /// Gets the cost data for the ability associated with this handle. + /// + /// The cost data. Returns if the ability is invalid or has no cost. + public CostData[]? GetCostData() + { + return Ability?.GetCostData(); + } + + /// + /// Gets the cost for a specific attribute. + /// + /// The attribute key to get the cost for. + /// The cost for the specified attribute. Returns 0 if the ability is invalid or has no cost for the + /// attribute. + public int GetCostForAttribute(StringKey attribute) + { + return Ability?.GetCostForAttribute(attribute) ?? 0; + } + internal void Free() { Ability = null; diff --git a/Forge/Abilities/CostData.cs b/Forge/Abilities/CostData.cs new file mode 100644 index 0000000..dac6e6c --- /dev/null +++ b/Forge/Abilities/CostData.cs @@ -0,0 +1,12 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Contains information about the cost associated with an ability activation. +/// +/// The attribute used to pay the cost. +/// The amount of the cost. +public record struct CostData(StringKey Attribute, int Cost); diff --git a/Forge/Attributes/AttributeSet.cs b/Forge/Attributes/AttributeSet.cs index 790e9eb..e7987ed 100644 --- a/Forge/Attributes/AttributeSet.cs +++ b/Forge/Attributes/AttributeSet.cs @@ -92,8 +92,9 @@ protected EntityAttribute InitializeAttribute( { Validation.Assert(!string.IsNullOrEmpty(attributeName), "attributeName should never be null or empty."); - var attribute = new EntityAttribute(defaultValue, minValue, maxValue, channels); - AttributesMap.Add($"{GetType().Name}.{attributeName}", attribute); + StringKey attributeKey = $"{GetType().Name}.{attributeName}"; + var attribute = new EntityAttribute(attributeKey, defaultValue, minValue, maxValue, channels); + AttributesMap.Add(attributeKey, attribute); attribute.OnValueChanged += AttributeOnValueChanged; return attribute; } diff --git a/Forge/Attributes/EntityAttribute.cs b/Forge/Attributes/EntityAttribute.cs index 0b96250..d411c67 100644 --- a/Forge/Attributes/EntityAttribute.cs +++ b/Forge/Attributes/EntityAttribute.cs @@ -26,6 +26,11 @@ public sealed class EntityAttribute /// public event Action? OnValueChanged; + /// + /// Gets the unique key identifying this attribute. + /// + public StringKey Key { get; internal set; } + /// /// Gets the base value for this attribute. /// @@ -73,11 +78,14 @@ public sealed class EntityAttribute internal int PendingValueChange { get; private set; } internal EntityAttribute( + StringKey key, int defaultValue, int minValue, int maxValue, int channels) { + Key = key; + PendingValueChange = 0; Min = minValue; diff --git a/Forge/Effects/Calculator/CustomExecution.cs b/Forge/Effects/Calculator/CustomExecution.cs index 2587913..9c50fb9 100644 --- a/Forge/Effects/Calculator/CustomExecution.cs +++ b/Forge/Effects/Calculator/CustomExecution.cs @@ -1,6 +1,7 @@ // Copyright © Gamesmiths Guild. using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Effects.Magnitudes; namespace Gamesmiths.Forge.Effects.Calculator; @@ -17,5 +18,36 @@ public abstract class CustomExecution : CustomCalculator /// The evaluated data for the effect. /// An array of evaluated data for each modified attribute. public abstract ModifierEvaluatedData[] EvaluateExecution( - Effect effect, IForgeEntity target, EffectEvaluatedData effectEvaluatedData); + Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData); + + internal static bool ExecutionHasInvalidAttributeCaptures(CustomExecution execution, Effect effect, IForgeEntity target) + { + foreach (AttributeCaptureDefinition capturedAttribute in execution.AttributesToCapture) + { + switch (capturedAttribute.Source) + { + case AttributeCaptureSource.Target: + + if (!target.Attributes.ContainsAttribute(capturedAttribute.Attribute)) + { + return true; + } + + break; + + case AttributeCaptureSource.Source: + + IForgeEntity? sourceEntity = effect.Ownership.Source; + + if (sourceEntity?.Attributes.ContainsAttribute(capturedAttribute.Attribute) != true) + { + return true; + } + + break; + } + } + + return false; + } } diff --git a/Forge/Effects/EffectEvaluatedData.cs b/Forge/Effects/EffectEvaluatedData.cs index 8fdb52c..cd0e066 100644 --- a/Forge/Effects/EffectEvaluatedData.cs +++ b/Forge/Effects/EffectEvaluatedData.cs @@ -100,8 +100,6 @@ public EffectEvaluatedData( Duration = EvaluateDuration(effect.EffectData.DurationData); Period = EvaluatePeriod(effect.EffectData.PeriodicData); - - // Modifiers should be evaluated after duration and period because it requires those already evaluated. ModifiersEvaluatedData = EvaluateModifiers(); CustomCueParameters = EvaluateCustomCueParameters(); @@ -128,8 +126,6 @@ internal void ReEvaluate(Effect effect, int stack = 1, int? level = null) Duration = EvaluateDuration(effect.EffectData.DurationData); Period = EvaluatePeriod(effect.EffectData.PeriodicData); - - // Modifiers should be evaluated after duration and period because it requires those already evaluated. ModifiersEvaluatedData = EvaluateModifiers(); CustomCueParameters = EvaluateCustomCueParameters(); @@ -187,7 +183,7 @@ private ModifierEvaluatedData[] EvaluateModifiers() foreach (CustomExecution execution in Effect.EffectData.CustomExecutions) { - if (ExecutionHasInvalidAttributeCaptures(execution)) + if (CustomExecution.ExecutionHasInvalidAttributeCaptures(execution, Effect, Target)) { continue; } @@ -350,37 +346,6 @@ private bool TryGetBackingAttribute( return attributeSource.TryGetAttribute(attributeSourceOwner, out backingAttribute); } - private bool ExecutionHasInvalidAttributeCaptures(CustomExecution execution) - { - foreach (AttributeCaptureDefinition capturedAttribute in execution.AttributesToCapture) - { - switch (capturedAttribute.Source) - { - case AttributeCaptureSource.Target: - - if (!Target.Attributes.ContainsAttribute(capturedAttribute.Attribute)) - { - return true; - } - - break; - - case AttributeCaptureSource.Source: - - IForgeEntity? sourceEntity = Effect.Ownership.Source; - - if (sourceEntity?.Attributes.ContainsAttribute(capturedAttribute.Attribute) != true) - { - return true; - } - - break; - } - } - - return false; - } - private Dictionary? EvaluateCustomCueParameters() { var customParameters = new Dictionary(); @@ -409,7 +374,7 @@ private bool ExecutionHasInvalidAttributeCaptures(CustomExecution execution) foreach (CustomExecution execution in Effect.EffectData.CustomExecutions) { - if (ExecutionHasInvalidAttributeCaptures(execution)) + if (CustomExecution.ExecutionHasInvalidAttributeCaptures(execution, Effect, Target)) { continue; } diff --git a/Forge/Tags/Tag.cs b/Forge/Tags/Tag.cs index 4fe8ea7..191d4c4 100644 --- a/Forge/Tags/Tag.cs +++ b/Forge/Tags/Tag.cs @@ -56,7 +56,7 @@ public static Tag RequestTag(TagsManager tagsManager, StringKey tagKey, bool err /// Serializes the given into a net index. /// /// - /// TODO: Use a propper BitStream or similar solution in the future. + /// TODO: Use a proper BitStream or similar solution in the future. /// /// The manager responsible for tag lookup and net index handling. /// The to be serialized. @@ -89,7 +89,7 @@ public static bool NetSerialize(TagsManager tagsManager, Tag tag, out ushort net /// Deserializes a from a given net index value. /// /// - /// TODO: Use a propper BitStream or similar solution in the future. + /// TODO: Use a proper BitStream or similar solution in the future. /// /// The manager responsible for tag lookup and net index handling. /// The data stream to be deserialized. From 3924b556c4e1d969f48078a23eb9fcbb6d62bb16 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 22 Nov 2025 23:51:28 -0300 Subject: [PATCH 37/87] Added event for ability Ended --- Forge/Abilities/Ability.cs | 9 ++++----- Forge/Abilities/AbilityEndedData.cs | 12 ++++++++++++ Forge/Abilities/AbilityHandle.cs | 2 +- Forge/Core/EntityAbilities.cs | 7 +++++++ 4 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 Forge/Abilities/AbilityEndedData.cs diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 06aa9e9..cbe65db 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -153,11 +153,6 @@ internal void CommitCost() } } - internal void CancelAbility() - { - CancelAllInstances(); - } - internal void End() { // End the most recent active instance, if any. @@ -169,6 +164,8 @@ internal void End() AbilityInstance last = _activeInstances[^1]; last.End(); + + Owner.Abilities.NotifyAbilityEnded(new AbilityEndedData(Handle, false)); } internal void CancelAllInstances() @@ -183,6 +180,8 @@ internal void CancelAllInstances() { instance.Cancel(); } + + Owner.Abilities.NotifyAbilityEnded(new AbilityEndedData(Handle, true)); } internal void OnInstanceStarted(AbilityInstance instance) diff --git a/Forge/Abilities/AbilityEndedData.cs b/Forge/Abilities/AbilityEndedData.cs new file mode 100644 index 0000000..d628a27 --- /dev/null +++ b/Forge/Abilities/AbilityEndedData.cs @@ -0,0 +1,12 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Represents data associated with the ending of an ability within the Forge framework. +/// +/// The handle of the ability that has ended. +/// Whether the ability was cancelled or ended gracefully or got cancelled. +public readonly record struct AbilityEndedData( + AbilityHandle Ability, + bool WasCanceled); diff --git a/Forge/Abilities/AbilityHandle.cs b/Forge/Abilities/AbilityHandle.cs index f8cc833..6bb1ede 100644 --- a/Forge/Abilities/AbilityHandle.cs +++ b/Forge/Abilities/AbilityHandle.cs @@ -50,7 +50,7 @@ public bool Activate(out AbilityActivationResult activationResult, IForgeEntity? /// public void Cancel() { - Ability?.CancelAbility(); + Ability?.CancelAllInstances(); } /// diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index 60e67a6..b4a236f 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -16,6 +16,8 @@ namespace Gamesmiths.Forge.Core; /// The owner of this manager. public class EntityAbilities(IForgeEntity owner) { + public Action? OnAbilityEnded; + private readonly Dictionary?> _grantSources = []; private readonly Dictionary?> _inhibitSources = []; @@ -273,6 +275,11 @@ internal void InhibitGrantedAbility(Ability? abilityToInhibit, bool inhibit, Act } } + internal void NotifyAbilityEnded(AbilityEndedData abilityEndedData) + { + OnAbilityEnded?.Invoke(abilityEndedData); + } + private void InhibitAbilityBasedOnPolicy(Ability abilityToInhibit) { switch (abilityToInhibit.GrantedAbilityInhibitionPolicy) From bedcc0c8c793470ce6bb09c83587097dbcaf824d Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 23 Nov 2025 00:00:49 -0300 Subject: [PATCH 38/87] Added ability activation through tags --- Forge/Abilities/Ability.cs | 33 +++++++++ Forge/Abilities/AbilityActivationResult.cs | 10 +++ Forge/Core/EntityAbilities.cs | 84 +++++++++++++++++++++- 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index cbe65db..b08d773 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -108,6 +108,19 @@ internal Ability( _abilityTags = abilityData.AbilityTags; } + if (abilityData.AbilityTriggerData is not null) + { + switch (abilityData.AbilityTriggerData.Value.TriggerSource) + { + case AbitityTriggerSource.TagAdded: + owner.Tags.OnTagsChanged += TagAdded_OnTagChanged; + break; + case AbitityTriggerSource.TagPresent: + owner.Tags.OnTagsChanged += TagPresent_OnTagChanged; + break; + } + } + Handle = new AbilityHandle(this); } @@ -512,4 +525,24 @@ private ModifierEvaluatedData[] EvaluateInstantModifiers(Effect effect, StringKe return [.. modifiersEvaluatedData]; } + + private void TagPresent_OnTagChanged(TagContainer container) + { + if (container.HasTag(AbilityData.AbilityTriggerData!.Value.TriggerTag)) + { + Activate(null); + } + else + { + CancelAllInstances(); + } + } + + private void TagAdded_OnTagChanged(TagContainer container) + { + if (container.HasTag(AbilityData.AbilityTriggerData!.Value.TriggerTag)) + { + Activate(null); + } + } } diff --git a/Forge/Abilities/AbilityActivationResult.cs b/Forge/Abilities/AbilityActivationResult.cs index f17dc2f..16bfb3a 100644 --- a/Forge/Abilities/AbilityActivationResult.cs +++ b/Forge/Abilities/AbilityActivationResult.cs @@ -61,4 +61,14 @@ public enum AbilityActivationResult /// Failed to activate the ability due to being blocked by tags. /// FailedBlockedByTags = 9, + + /// + /// Failed to activate the ability because the target tag is not present. + /// + FailedTargetTagNotPresent = 10, + + /// + /// Failed to activate the ability due to invalid tag configuration. + /// + FailedInvalidTagConfiguration = 11, } diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index b4a236f..c07cf02 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -16,12 +16,15 @@ namespace Gamesmiths.Forge.Core; /// The owner of this manager. public class EntityAbilities(IForgeEntity owner) { - public Action? OnAbilityEnded; - private readonly Dictionary?> _grantSources = []; private readonly Dictionary?> _inhibitSources = []; + /// + /// Event invoked when an ability ends. + /// + public event Action? OnAbilityEnded; + /// /// Gets the owner of this effects manager. /// @@ -91,6 +94,83 @@ public void CancelAbilitiesWithTag(TagContainer tagsToCancel) } } + /// + /// Tries to activate all abilities whose AbilityTags overlap the provided tags. + /// + /// Tags that identify abilities to activate. + /// Optional target for the abilities. + /// The result of the ability activation attempt. + /// Returns if any abilities were activated; otherwise, . + /// + public bool TryActivateAbilitiesByTag( + TagContainer tagsToActivate, + IForgeEntity? target, + out AbilityActivationResult activationResult) + { + if (tagsToActivate is null) + { + activationResult = AbilityActivationResult.FailedInvalidTagConfiguration; + return false; + } + + var anyActivated = false; + activationResult = AbilityActivationResult.FailedTargetTagNotPresent; + + // Enumerate snapshot to avoid modification during activation. + foreach (AbilityHandle? handle in GrantedAbilities.ToArray()) + { + Ability? ability = handle?.Ability; + if (ability is null) + { + continue; + } + + TagContainer? abilityTags = ability.AbilityData.AbilityTags; + if (abilityTags?.HasAny(tagsToActivate) == true) + { + anyActivated |= ability.TryActivateAbility(target, out activationResult); + } + } + + return anyActivated; + } + + /// + /// Grants an ability and activates it once. + /// + /// The configuration data of the ability to grant and activate. + /// The level at which to grant the ability. + /// The policy for removing the granted ability. + /// The policy for inhibiting the granted ability. + /// The policy for overriding the level of an existing granted ability. + /// The handle of the active effect that is the source of this granted + /// ability. + /// The source entity of the granted ability, if any. + /// The result of the ability activation attempt. + /// Returns if the ability was successfully activated; otherwise, + /// . + public bool GrantAbilityAndActivateOnce( + AbilityData abilityData, + int abilityLevel, + AbilityDeactivationPolicy removalPolicy, + AbilityDeactivationPolicy inhibitionPolicy, + LevelComparison levelOverridePolicy, + ActiveEffectHandle sourceActiveEffectHandle, + IForgeEntity? sourceEntity, + out AbilityActivationResult activationResult) + { + AbilityHandle abilityHandle = GrantAbility( + abilityData, + abilityLevel, + removalPolicy, + inhibitionPolicy, + levelOverridePolicy, + sourceActiveEffectHandle, + sourceEntity); + + return abilityHandle.Activate(out activationResult, null); + } + internal void GrantAbilityPermanently( AbilityData abilityData, int abilityLevel, From 9d8599c65b23962d22cbac70c8ec5fada0c9220f Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 1 Dec 2025 22:24:15 -0300 Subject: [PATCH 39/87] Added events implementation --- Forge/Abilities/Ability.cs | 10 +- Forge/Core/EntityEvents.cs | 125 +++++++++++++++++++++++++ Forge/Core/IForgeEntity.cs | 5 + Forge/Events/EventData.cs | 69 ++++++++++++++ Forge/Events/EventSubscriptionToken.cs | 9 ++ Forge/Events/IEventBus.cs | 56 +++++++++++ 6 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 Forge/Core/EntityEvents.cs create mode 100644 Forge/Events/EventData.cs create mode 100644 Forge/Events/EventSubscriptionToken.cs create mode 100644 Forge/Events/IEventBus.cs diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index b08d773..6da21a7 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -118,6 +118,12 @@ internal Ability( case AbitityTriggerSource.TagPresent: owner.Tags.OnTagsChanged += TagPresent_OnTagChanged; break; + case AbitityTriggerSource.Event: + owner.Events.Subscribe( + abilityData.AbilityTriggerData.Value.TriggerTag, + x => TryActivateAbility(x.Target, out _), + priority: 0); + break; } } @@ -530,7 +536,7 @@ private void TagPresent_OnTagChanged(TagContainer container) { if (container.HasTag(AbilityData.AbilityTriggerData!.Value.TriggerTag)) { - Activate(null); + TryActivateAbility(null, out _); } else { @@ -542,7 +548,7 @@ private void TagAdded_OnTagChanged(TagContainer container) { if (container.HasTag(AbilityData.AbilityTriggerData!.Value.TriggerTag)) { - Activate(null); + TryActivateAbility(null, out _); } } } diff --git a/Forge/Core/EntityEvents.cs b/Forge/Core/EntityEvents.cs new file mode 100644 index 0000000..b04884f --- /dev/null +++ b/Forge/Core/EntityEvents.cs @@ -0,0 +1,125 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Core; + +/// +/// Per-entity event bus that supports both non-generic and generic (typed) event subscriptions. +/// Subscriptions are ordered by priority (higher priority invoked first). +/// Generic handlers are invoked without boxing. Generic raises do NOT forward to non-generic handlers. +/// +public sealed class EntityEvents : IEventBus +{ + private readonly List _nonGeneric = []; + private readonly Dictionary> _genericByType = []; + + /// + public void Raise(in EventData data) + { + for (var i = 0; i < _nonGeneric.Count; i++) + { + NonGenericSubscription sub = _nonGeneric[i]; + if (!data.EventTags.HasTag(sub.EventTag)) + { + continue; + } + + sub.Handler.Invoke(data); + } + } + + /// + public void Raise(in EventData data) + { + Type key = typeof(TPayload); + if (_genericByType.TryGetValue(key, out List? typedList)) + { + for (var i = 0; i < typedList.Count; i++) + { + GenericSubscription sub = typedList[i]; + if (!data.EventTags.HasTag(sub.EventTag)) + { + continue; + } + + ((Action>)sub.Handler).Invoke(data); + } + } + } + + /// + public EventSubscriptionToken Subscribe(Tag eventTag, Action handler, int priority = 0) + { + var token = new EventSubscriptionToken(Guid.NewGuid()); + _nonGeneric.Add(new NonGenericSubscription(token, eventTag, priority, handler)); + + _nonGeneric.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + return token; + } + + /// + public EventSubscriptionToken Subscribe(Tag eventTag, Action> handler, int priority = 0) + { + var token = new EventSubscriptionToken(Guid.NewGuid()); + Type key = typeof(TPayload); + + if (!_genericByType.TryGetValue(key, out List? list)) + { + list = []; + _genericByType[key] = list; + } + + list.Add(new GenericSubscription(token, eventTag, priority, handler)); + + list.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + return token; + } + + /// + public bool Unsubscribe(EventSubscriptionToken token) + { + var removed = _nonGeneric.RemoveAll(x => x.Token == token) > 0; + + List? keysToRemove = null; + foreach (KeyValuePair> keyValuePair in _genericByType) + { + List list = keyValuePair.Value; + if (list.RemoveAll(x => x.Token == token) > 0) + { + removed = true; + } + + if (list.Count == 0) + { + keysToRemove ??= []; + keysToRemove.Add(keyValuePair.Key); + } + } + + if (keysToRemove is null) + { + return removed; + } + + for (var i = 0; i < keysToRemove.Count; i++) + { + _genericByType.Remove(keysToRemove[i]); + } + + return removed; + } + + private readonly record struct NonGenericSubscription( + EventSubscriptionToken Token, + Tag EventTag, + int Priority, + Action Handler); + + private readonly record struct GenericSubscription( + EventSubscriptionToken Token, + Tag EventTag, + int Priority, + Delegate Handler); +} diff --git a/Forge/Core/IForgeEntity.cs b/Forge/Core/IForgeEntity.cs index a43572f..3fff8a9 100644 --- a/Forge/Core/IForgeEntity.cs +++ b/Forge/Core/IForgeEntity.cs @@ -28,4 +28,9 @@ public interface IForgeEntity /// Gets the abilities manager for this entity. /// EntityAbilities Abilities { get; } + + /// + /// Gets the event bus for this entity. + /// + EntityEvents Events { get; } } diff --git a/Forge/Events/EventData.cs b/Forge/Events/EventData.cs new file mode 100644 index 0000000..a66d7dc --- /dev/null +++ b/Forge/Events/EventData.cs @@ -0,0 +1,69 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Events; + +/// +/// Represents data associated with an event within the Forge framework. +/// +public readonly record struct EventData +{ + /// + /// Gets the tags associated with the event. + /// + public TagContainer EventTags { get; init; } + + /// + /// Gets the source entity that triggered the event. + /// + public IForgeEntity? Source { get; init; } + + /// + /// Gets the target entity of the event. + /// + public IForgeEntity? Target { get; init; } + + /// + /// Gets the magnitude or intensity of the event. + /// + public float EventMagnitude { get; init; } + + /// + /// Gets any additional payload data associated with the event. + /// + public object? Payload { get; init; } +} + +/// +/// Represents data associated with an event within the Forge framework. +/// +/// The type of the payload data associated with the event. +public readonly record struct EventData +{ + /// + /// Gets the tags associated with the event. + /// + public TagContainer EventTags { get; init; } + + /// + /// Gets the source entity that triggered the event. + /// + public IForgeEntity? Source { get; init; } + + /// + /// Gets the target entity of the event. + /// + public IForgeEntity? Target { get; init; } + + /// + /// Gets the magnitude or intensity of the event. + /// + public float EventMagnitude { get; init; } + + /// + /// Gets the additional payload data associated with the event. + /// + public TPayload Payload { get; init; } +} diff --git a/Forge/Events/EventSubscriptionToken.cs b/Forge/Events/EventSubscriptionToken.cs new file mode 100644 index 0000000..89fca52 --- /dev/null +++ b/Forge/Events/EventSubscriptionToken.cs @@ -0,0 +1,9 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Events; + +/// +/// Represents a unique identifier for an event subscription. +/// +/// The unique identifier for the subscription. +public readonly record struct EventSubscriptionToken(Guid Id); diff --git a/Forge/Events/IEventBus.cs b/Forge/Events/IEventBus.cs new file mode 100644 index 0000000..087a93c --- /dev/null +++ b/Forge/Events/IEventBus.cs @@ -0,0 +1,56 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Events; + +/// +/// Interface for an event bus that allows raising and subscribing to events within the Forge framework. +/// +public interface IEventBus +{ + /// + /// Raise a non-generic event. + /// + /// The event data to raise. + void Raise(in EventData data); + + /// + /// Raise a generic event. + /// + /// The type of the payload associated with the event. + /// The event data to raise. + void Raise(in EventData data); + + /// + /// Subscribe using a tag; returns a token for later un-subscription. + /// + /// The event tag to subscribe to. + /// The handler to invoke when the event is raised. + /// The priority of the subscription; higher values indicate higher priority. + /// The subscription token for later un-subscription. + EventSubscriptionToken Subscribe( + Tag eventTag, + Action handler, + int priority = 0); + + /// + /// Subscribe using a tag; returns a token for later un-subscription. + /// + /// The type of the payload associated with the event. + /// The event tag to subscribe to. + /// The handler to invoke when the event is raised. + /// The priority of the subscription; higher values indicate higher priority. + /// The subscription token for later un-subscription. + EventSubscriptionToken Subscribe( + Tag eventTag, + Action> handler, + int priority = 0); + + /// + /// Unsubscribe using the provided token; returns if successful. + /// + /// The subscription token to unsubscribe. + /// if un-subscription was successful; otherwise, . + bool Unsubscribe(EventSubscriptionToken token); +} From 4a8be03fd61f31dcb54e56a5a5291e67c35a3bf3 Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 1 Dec 2025 23:32:54 -0300 Subject: [PATCH 40/87] Added ability trigger tests --- Forge.Tests/Abilities/AbilitiesTests.cs | 128 ++++++++++++++++++ .../Effects/CustomCalculatorsEffectsTests.cs | 4 + Forge.Tests/Helpers/TestEntity.cs | 4 + Forge.Tests/Samples/QuickStartTests.cs | 4 + Forge/Core/IForgeEntity.cs | 3 +- .../EventManager.cs} | 39 ++++-- Forge/Events/IEventBus.cs | 56 -------- 7 files changed, 173 insertions(+), 65 deletions(-) rename Forge/{Core/EntityEvents.cs => Events/EventManager.cs} (63%) delete mode 100644 Forge/Events/IEventBus.cs diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index eef0bb5..43c4405 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -9,6 +9,7 @@ using Gamesmiths.Forge.Effects.Duration; using Gamesmiths.Forge.Effects.Magnitudes; using Gamesmiths.Forge.Effects.Modifiers; +using Gamesmiths.Forge.Events; using Gamesmiths.Forge.Tags; using Gamesmiths.Forge.Tests.Core; using Gamesmiths.Forge.Tests.Helpers; @@ -2097,6 +2098,131 @@ public void Cost_effect_with_multiple_modifiers_reports_configured_cost_and_appl entity.Attributes["TestAttributeSet.Attribute90"].CurrentValue.Should().Be(81); } + [Fact] + [Trait("Event", null)] + public void Ability_gets_activated_by_proper_event_with_tag() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var triggerTag = Tag.RequestTag(_tagsManager, "color.red"); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + abilityTriggerData: new() + { + TriggerTag = triggerTag, + TriggerSource = AbitityTriggerSource.Event, + }); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + var nonActivatingEventData = new EventData + { + EventTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.blue"])), + Source = entity, + Target = entity, + }; + + entity.Events.Raise(in nonActivatingEventData); + abilityHandle!.IsActive.Should().BeFalse(); + + var activatingEventData = new EventData + { + EventTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])), + Source = entity, + Target = entity, + }; + + entity.Events.Raise(in activatingEventData); + abilityHandle.IsActive.Should().BeTrue(); + + abilityHandle.Cancel(); + abilityHandle!.IsActive.Should().BeFalse(); + + entity.Events.Raise(in activatingEventData); + abilityHandle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("Event", null)] + public void Ability_gets_activated_by_tag_addition() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var triggerTag = Tag.RequestTag(_tagsManager, "color.red"); + TagContainer? triggerTagContainer = triggerTag.GetSingleTagContainer(); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + abilityTriggerData: new() + { + TriggerTag = triggerTag, + TriggerSource = AbitityTriggerSource.TagAdded, + }); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.IsActive.Should().BeFalse(); + + CreateAndApplyTagEffect(entity, triggerTagContainer!); + + abilityHandle!.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("Event", null)] + public void Ability_gets_activated_while_tag_present() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var triggerTag = Tag.RequestTag(_tagsManager, "color.red"); + TagContainer? triggerTagContainer = triggerTag.GetSingleTagContainer(); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + abilityTriggerData: new() + { + TriggerTag = triggerTag, + TriggerSource = AbitityTriggerSource.TagPresent, + }); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.IsActive.Should().BeFalse(); + + ActiveEffectHandle? effectHandle = CreateAndApplyTagEffect(entity, triggerTagContainer!); + + abilityHandle!.IsActive.Should().BeTrue(); + + entity.EffectsManager.UnapplyEffect(effectHandle!); + + abilityHandle!.IsActive.Should().BeFalse(); + } + private static AbilityHandle? SetupAbility( TestEntity targetEntity, AbilityData abilityData, @@ -2182,6 +2308,7 @@ private AbilityData CreateAbilityData( TagContainer? abilityTags = null, AbilityInstancingPolicy instancingPolicy = AbilityInstancingPolicy.PerEntity, bool retriggerInstancedAbility = false, + AbilityTriggerData? abilityTriggerData = null, TagContainer? cancelAbilitiesWithTag = null, TagContainer? blockAbilitiesWithTag = null, TagContainer? activationOwnedTags = null, @@ -2225,6 +2352,7 @@ private AbilityData CreateAbilityData( abilityTags: abilityTags, instancingPolicy: instancingPolicy, retriggerInstancedAbility: retriggerInstancedAbility, + abilityTriggerData: abilityTriggerData, cancelAbilitiesWithTag: cancelAbilitiesWithTag, blockAbilitiesWithTag: blockAbilitiesWithTag, activationOwnedTags: activationOwnedTags, diff --git a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs index deb97bb..052d296 100644 --- a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs +++ b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs @@ -8,6 +8,7 @@ using Gamesmiths.Forge.Effects.Duration; using Gamesmiths.Forge.Effects.Magnitudes; using Gamesmiths.Forge.Effects.Modifiers; +using Gamesmiths.Forge.Events; using Gamesmiths.Forge.Tags; using Gamesmiths.Forge.Tests.Core; using Gamesmiths.Forge.Tests.Helpers; @@ -909,12 +910,15 @@ private sealed class NoAttributesEntity : IForgeEntity public EntityAbilities Abilities { get; } + public EventManager Events { get; } + public NoAttributesEntity(TagsManager tagsManager, CuesManager cuesManager) { EffectsManager = new(this, cuesManager); Attributes = new(); Tags = new(new TagContainer(tagsManager)); Abilities = new(this); + Events = new(); } } } diff --git a/Forge.Tests/Helpers/TestEntity.cs b/Forge.Tests/Helpers/TestEntity.cs index 5e2dd3e..e596257 100644 --- a/Forge.Tests/Helpers/TestEntity.cs +++ b/Forge.Tests/Helpers/TestEntity.cs @@ -3,6 +3,7 @@ using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Cues; using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Events; using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Tests.Helpers; @@ -19,6 +20,8 @@ public class TestEntity : IForgeEntity public EntityAbilities Abilities { get; } + public EventManager Events { get; } + public TestEntity(TagsManager tagsManager, CuesManager cuesManager) { PlayerAttributeSet = new TestAttributeSet(); @@ -33,5 +36,6 @@ public TestEntity(TagsManager tagsManager, CuesManager cuesManager) Attributes = new(PlayerAttributeSet); Tags = new(originalTags); Abilities = new(this); + Events = new(); } } diff --git a/Forge.Tests/Samples/QuickStartTests.cs b/Forge.Tests/Samples/QuickStartTests.cs index 4a020a3..e12e50b 100644 --- a/Forge.Tests/Samples/QuickStartTests.cs +++ b/Forge.Tests/Samples/QuickStartTests.cs @@ -17,6 +17,7 @@ using Gamesmiths.Forge.Effects.Modifiers; using Gamesmiths.Forge.Effects.Periodic; using Gamesmiths.Forge.Effects.Stacking; +using Gamesmiths.Forge.Events; using Gamesmiths.Forge.Tags; using Gamesmiths.Forge.Tests; using Gamesmiths.Forge.Tests.Core; @@ -753,6 +754,8 @@ public class Player : IForgeEntity public EffectsManager EffectsManager { get; } public EntityAbilities Abilities { get; } + public EventManager Events { get; } + public Player(TagsManager tagsManager, CuesManager cuesManager) { // Initialize base tags during construction @@ -767,6 +770,7 @@ public Player(TagsManager tagsManager, CuesManager cuesManager) Tags = new EntityTags(baseTags); EffectsManager = new EffectsManager(this, cuesManager); Abilities = new(this); + Events = new(); } } diff --git a/Forge/Core/IForgeEntity.cs b/Forge/Core/IForgeEntity.cs index 3fff8a9..39e306f 100644 --- a/Forge/Core/IForgeEntity.cs +++ b/Forge/Core/IForgeEntity.cs @@ -1,6 +1,7 @@ // Copyright © Gamesmiths Guild. using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Events; namespace Gamesmiths.Forge.Core; @@ -32,5 +33,5 @@ public interface IForgeEntity /// /// Gets the event bus for this entity. /// - EntityEvents Events { get; } + EventManager Events { get; } } diff --git a/Forge/Core/EntityEvents.cs b/Forge/Events/EventManager.cs similarity index 63% rename from Forge/Core/EntityEvents.cs rename to Forge/Events/EventManager.cs index b04884f..55af14f 100644 --- a/Forge/Core/EntityEvents.cs +++ b/Forge/Events/EventManager.cs @@ -1,21 +1,23 @@ // Copyright © Gamesmiths Guild. -using Gamesmiths.Forge.Events; using Gamesmiths.Forge.Tags; -namespace Gamesmiths.Forge.Core; +namespace Gamesmiths.Forge.Events; /// /// Per-entity event bus that supports both non-generic and generic (typed) event subscriptions. /// Subscriptions are ordered by priority (higher priority invoked first). /// Generic handlers are invoked without boxing. Generic raises do NOT forward to non-generic handlers. /// -public sealed class EntityEvents : IEventBus +public sealed class EventManager { private readonly List _nonGeneric = []; private readonly Dictionary> _genericByType = []; - /// + /// + /// Raise a non-generic event. + /// + /// The event data to raise. public void Raise(in EventData data) { for (var i = 0; i < _nonGeneric.Count; i++) @@ -30,7 +32,11 @@ public void Raise(in EventData data) } } - /// + /// + /// Raise a generic event. + /// + /// The type of the payload associated with the event. + /// The event data to raise. public void Raise(in EventData data) { Type key = typeof(TPayload); @@ -49,7 +55,13 @@ public void Raise(in EventData data) } } - /// + /// + /// Subscribe using a tag; returns a token for later un-subscription. + /// + /// The event tag to subscribe to. + /// The handler to invoke when the event is raised. + /// The priority of the subscription; higher values indicate higher priority. + /// The subscription token for later un-subscription. public EventSubscriptionToken Subscribe(Tag eventTag, Action handler, int priority = 0) { var token = new EventSubscriptionToken(Guid.NewGuid()); @@ -59,7 +71,14 @@ public EventSubscriptionToken Subscribe(Tag eventTag, Action handler, return token; } - /// + /// + /// Subscribe using a tag; returns a token for later un-subscription. + /// + /// The type of the payload associated with the event. + /// The event tag to subscribe to. + /// The handler to invoke when the event is raised. + /// The priority of the subscription; higher values indicate higher priority. + /// The subscription token for later un-subscription. public EventSubscriptionToken Subscribe(Tag eventTag, Action> handler, int priority = 0) { var token = new EventSubscriptionToken(Guid.NewGuid()); @@ -77,7 +96,11 @@ public EventSubscriptionToken Subscribe(Tag eventTag, Action + /// + /// Unsubscribe using the provided token; returns if successful. + /// + /// The subscription token to unsubscribe. + /// if un-subscription was successful; otherwise, . public bool Unsubscribe(EventSubscriptionToken token) { var removed = _nonGeneric.RemoveAll(x => x.Token == token) > 0; diff --git a/Forge/Events/IEventBus.cs b/Forge/Events/IEventBus.cs deleted file mode 100644 index 087a93c..0000000 --- a/Forge/Events/IEventBus.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright © Gamesmiths Guild. - -using Gamesmiths.Forge.Tags; - -namespace Gamesmiths.Forge.Events; - -/// -/// Interface for an event bus that allows raising and subscribing to events within the Forge framework. -/// -public interface IEventBus -{ - /// - /// Raise a non-generic event. - /// - /// The event data to raise. - void Raise(in EventData data); - - /// - /// Raise a generic event. - /// - /// The type of the payload associated with the event. - /// The event data to raise. - void Raise(in EventData data); - - /// - /// Subscribe using a tag; returns a token for later un-subscription. - /// - /// The event tag to subscribe to. - /// The handler to invoke when the event is raised. - /// The priority of the subscription; higher values indicate higher priority. - /// The subscription token for later un-subscription. - EventSubscriptionToken Subscribe( - Tag eventTag, - Action handler, - int priority = 0); - - /// - /// Subscribe using a tag; returns a token for later un-subscription. - /// - /// The type of the payload associated with the event. - /// The event tag to subscribe to. - /// The handler to invoke when the event is raised. - /// The priority of the subscription; higher values indicate higher priority. - /// The subscription token for later un-subscription. - EventSubscriptionToken Subscribe( - Tag eventTag, - Action> handler, - int priority = 0); - - /// - /// Unsubscribe using the provided token; returns if successful. - /// - /// The subscription token to unsubscribe. - /// if un-subscription was successful; otherwise, . - bool Unsubscribe(EventSubscriptionToken token); -} From affe7ca73a5057d5696ff033ec318c7c83148845 Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 3 Dec 2025 22:00:02 -0300 Subject: [PATCH 41/87] Added OnAbilityEnded event tests. --- Forge.Tests/Abilities/AbilitiesTests.cs | 36 +++++++++++++++++++ Forge.Tests/Abilities/AbilityBehaviorTests.cs | 28 +++++++++++++++ Forge/Abilities/Ability.cs | 1 + 3 files changed, 65 insertions(+) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index 43c4405..952a8ae 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -2223,6 +2223,42 @@ [new ScalableFloat(3f)], abilityHandle!.IsActive.Should().BeFalse(); } + [Fact] + [Trait("Ability Ended Event", null)] + public void OnAbilityEnded_fires_when_ability_instance_is_canceled() + { + var targetEntity = new TestEntity(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Test Ability", + [], + [], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerEntity); + + AbilityHandle? abilityHandle = SetupAbility( + targetEntity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle.Should().NotBeNull(); + + AbilityEndedData? capturedData = null; + + targetEntity.Abilities.OnAbilityEnded += x => { capturedData = x; }; + + // Activate the ability + abilityHandle!.Activate(out AbilityActivationResult result).Should().BeTrue(); + abilityHandle.Cancel(); + + // Verify event was fired + capturedData.Should().NotBeNull(); + capturedData!.Value.Ability.Should().Be(abilityHandle); + capturedData.Value.WasCanceled.Should().BeTrue(); + } + private static AbilityHandle? SetupAbility( TestEntity targetEntity, AbilityData abilityData, diff --git a/Forge.Tests/Abilities/AbilityBehaviorTests.cs b/Forge.Tests/Abilities/AbilityBehaviorTests.cs index f7a6810..de0e9e8 100644 --- a/Forge.Tests/Abilities/AbilityBehaviorTests.cs +++ b/Forge.Tests/Abilities/AbilityBehaviorTests.cs @@ -319,6 +319,34 @@ public void Null_behavior_instance_is_ignored() handle.IsActive.Should().BeFalse(); } + [Fact] + [Trait("Ability Ended Event", null)] + public void OnAbilityEnded_fires_when_ability_instance_ends() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new TrackingBehavior(); + AbilityData data = CreateAbilityData("Tracked", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + AbilityEndedData? capturedData = null; + entity.Abilities.OnAbilityEnded += x => { capturedData = x; }; + + handle!.Activate(out AbilityActivationResult result).Should().BeTrue(); + result.Should().Be(AbilityActivationResult.Success); + behavior.StartCount.Should().Be(1); + behavior.EndCount.Should().Be(0); + + behavior.End(); + behavior.StartCount.Should().Be(1); + behavior.EndCount.Should().Be(1); + + // Verify event was fired + capturedData.Should().NotBeNull(); + capturedData!.Value.Ability.Should().Be(handle); + capturedData.Value.WasCanceled.Should().BeFalse(); + } + private static AbilityHandle? Grant( TestEntity target, AbilityData data, diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 6da21a7..678fbce 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -254,6 +254,7 @@ internal void OnInstanceEnded(AbilityInstance instance) if (_activeInstances.Count == 0) { OnAbilityDeactivated?.Invoke(this); + Owner.Abilities.NotifyAbilityEnded(new AbilityEndedData(Handle, false)); } } From 1439ce071ddea2e1f0284ff2d646b73068314bba Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 8 Dec 2025 22:16:45 -0300 Subject: [PATCH 42/87] Updated quick-start to better reflect tests --- docs/quick-start.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/quick-start.md b/docs/quick-start.md index fde7537..ad731fe 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -33,7 +33,7 @@ public class PlayerAttributeSet : AttributeSet public PlayerAttributeSet() { // Initialize the attributes with the current, min and max values. - Health = InitializeAttribute(nameof(Health), 100, 0, 100); + Health = InitializeAttribute(nameof(Health), 100, 0, 150); Strength = InitializeAttribute(nameof(Strength), 10, 0, 99); Speed = InitializeAttribute(nameof(Speed), 5, 0, 10); } @@ -271,6 +271,13 @@ var poisonEffectData = new EffectData( // Apply the poison effect var poisonEffect = new Effect(poisonEffectData, new EffectOwnership(player, player)); player.EffectsManager.ApplyEffect(poisonEffect); + +// Simulate 10 seconds of game time +player.EffectsManager.UpdateEffects(10f); + +// After 6 ticks total (including the initial execute), health should be 70 if it started at 100 +int currentHealthAfterPoison = player.Attributes["PlayerAttributeSet.Health"].CurrentValue; // 70 +int baseHealthAfterPoison = player.Attributes["PlayerAttributeSet.Health"].BaseValue; // 70 ``` --- @@ -322,6 +329,12 @@ var stackingPoisonEffect = new Effect(stackingPoisonEffectData, new EffectOwners player.EffectsManager.ApplyEffect(stackingPoisonEffect); player.EffectsManager.ApplyEffect(stackingPoisonEffect); player.EffectsManager.ApplyEffect(stackingPoisonEffect); + +// Simulate 6 seconds of game time +player.EffectsManager.UpdateEffects(6f); + +// After three ticks at -9 per tick, total damage is -27 +var healthAfterStacks = player.Attributes["PlayerAttributeSet.Health"].CurrentValue; // 73 if starting at 100 ``` --- @@ -459,7 +472,7 @@ player.EffectsManager.ApplyEffect(fireAttack); You can extend effects with custom logic using components. -This component applies an additional effect when the target's Health attribute falls below a specified threshold: +This component applies an additional effect when the target's Health attribute falls below a specified threshold at the time of application: ```csharp // Custom component that applies extra effect when Health is below threshold @@ -504,9 +517,10 @@ var thresholdAttackData = new EffectData( ] ); -// Apply the effect +// Apply the effect twice (second application should trigger the stun) var thresholdAttack = new Effect(thresholdAttackData, new EffectOwnership(player, player)); player.EffectsManager.ApplyEffect(thresholdAttack); +player.EffectsManager.ApplyEffect(thresholdAttack); // Check if the stun was applied (will be true if health was 90 or less after damage) bool isStunned = player.Tags.CombinedTags.HasTag(Tag.RequestTag(tagsManager, "status.stunned")); @@ -734,6 +748,7 @@ var burningEffectData = new EffectData( // Apply the burning effect var burningEffect = new Effect(burningEffectData, new EffectOwnership(player, player)); player.EffectsManager.ApplyEffect(burningEffect); +player.EffectsManager.UpdateEffects(5f); ``` --- From e9fb07820c9e574b1b8f2f87dde788fdc68cdf6a Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 8 Dec 2025 22:21:18 -0300 Subject: [PATCH 43/87] Updated quick-start.md with new DurationData changes --- docs/quick-start.md | 70 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/docs/quick-start.md b/docs/quick-start.md index ad731fe..f474693 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -45,6 +45,8 @@ public class Player : IForgeEntity public EntityAttributes Attributes { get; } public EntityTags Tags { get; } public EffectsManager EffectsManager { get; } + public EntityAbilities Abilities { get; } + public EventManager Events { get; } public Player(TagsManager tagsManager, CuesManager cuesManager) { @@ -60,6 +62,8 @@ public class Player : IForgeEntity Attributes = new EntityAttributes(new PlayerAttributeSet()); Tags = new EntityTags(baseTags); EffectsManager = new EffectsManager(this, cuesManager); + Abilities = new(this); + Events = new(); } } @@ -154,7 +158,13 @@ The following effect increases the player's strength by +5 for 10 seconds: // Create a strength buff effect that lasts for 10 seconds var strengthBuffEffectData = new EffectData( "Strength Potion", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), // 10 seconds duration + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f) // 10 seconds duration + ) + ), new[] { new Modifier( "PlayerAttributeSet.Strength", @@ -250,7 +260,13 @@ This poison effect ticks every 2 seconds for 10 seconds, causing -5 damage per t // Create a poison effect that ticks every 2 seconds for 10 seconds var poisonEffectData = new EffectData( "Poison", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f) + ) + ), new[] { new Modifier( "PlayerAttributeSet.Health", @@ -290,7 +306,13 @@ This example shows a poison effect that ticks every 2 seconds for -3 damage per // Create a poison effect that stacks up to 3 times var stackingPoisonEffectData = new EffectData( "Stacking Poison", - new DurationData(DurationType.HasDuration, new ScalableFloat(6.0f)), // Each stack lasts 6 seconds + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(6.0f) // Each stack lasts 6 seconds + ) + ), new[] { new Modifier( "PlayerAttributeSet.Health", @@ -312,7 +334,7 @@ var stackingPoisonEffectData = new EffectData( overflowPolicy: StackOverflowPolicy.DenyApplication, // Deny if max stacks reached magnitudePolicy: StackMagnitudePolicy.Sum, // Total damage increases with stacks expirationPolicy: StackExpirationPolicy.ClearEntireStack, // All stacks expire together - applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication, // Refresh duration on new stack + applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication, stackPolicy: StackPolicy.AggregateBySource, // Aggregate stacks from the same source stackLevelPolicy: StackLevelPolicy.SegregateLevels, // Each stack can have its own level @@ -347,7 +369,13 @@ This example shows an effect that is unique to a target and can be overridden on // Define the unique effect data var uniqueEffectData = new EffectData( "Unique Buff", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), // Lasts 10 seconds + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f) // Lasts 10 seconds + ) + ), new[] { new Modifier( "PlayerAttributeSet.Strength", @@ -368,7 +396,7 @@ var uniqueEffectData = new EffectData( overflowPolicy: StackOverflowPolicy.AllowApplication, // Allow application even if max stacks reached magnitudePolicy: StackMagnitudePolicy.Sum, // Total damage increases with stacks expirationPolicy: StackExpirationPolicy.ClearEntireStack, // All stacks expire together - applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication, // Refresh duration on new stack + applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication, stackPolicy: StackPolicy.AggregateByTarget, // Only one effect per target ownerDenialPolicy: StackOwnerDenialPolicy.AlwaysAllow, // Always allow application regardless of owner ownerOverridePolicy: StackOwnerOverridePolicy.Override, // Override existing effect if applied again @@ -401,7 +429,13 @@ This example shows how to add a temporary "status.stunned" tag to the target whi // Create a "Stunned" effect that adds a tag and reduces speed to 0 var stunEffectData = new EffectData( "Stunned", - new DurationData(DurationType.HasDuration, new ScalableFloat(3.0f)), // 3 seconds duration + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(3.0f) // 3 seconds duration + ) + ), new[] { new Modifier( "PlayerAttributeSet.Speed", @@ -557,10 +591,10 @@ public class StrengthDamageCalculator : CustomModifierMagnitudeCalculator AttributesToCapture.Add(SpeedAttribute); } - public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target) + public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData) { - int strength = CaptureAttributeMagnitude(StrengthAttribute, effect, target); - int speed = CaptureAttributeMagnitude(SpeedAttribute, effect, target); + int strength = CaptureAttributeMagnitude(StrengthAttribute, effect, target, effectEvaluatedData); + int speed = CaptureAttributeMagnitude(SpeedAttribute, effect, target, effectEvaluatedData); // Calculate damage based on strength and speed float damage = (speed * 2) + (strength * 0.5f); @@ -635,14 +669,14 @@ public class HealthDrainExecution : CustomExecution AttributesToCapture.Add(SourceStrength); } - public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target) + public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target, EffectEvaluatedData effectEvaluatedData) { var results = new List(); // Get attribute values - int targetHealth = CaptureAttributeMagnitude(TargetHealth, effect, target); - int sourceHealth = CaptureAttributeMagnitude(SourceHealth, effect, effect.Ownership.Owner); - int sourceStrength = CaptureAttributeMagnitude(SourceStrength, effect, effect.Ownership.Owner); + int targetHealth = CaptureAttributeMagnitude(TargetHealth, effect, target, effectEvaluatedData); + int sourceHealth = CaptureAttributeMagnitude(SourceHealth, effect, effect.Ownership.Owner, effectEvaluatedData); + int sourceStrength = CaptureAttributeMagnitude(SourceStrength, effect, effect.Ownership.Owner, effectEvaluatedData); // Calculate health drain amount based on source strength float drainAmount = sourceStrength * 0.5f; @@ -718,7 +752,13 @@ This example shows how to trigger cues as part of an effect: // Define a burning effect that includes the fire damage cue var burningEffectData = new EffectData( "Burning", - new DurationData(DurationType.HasDuration, new ScalableFloat(5.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(5.0f) + ) + ), new[] { new Modifier( "PlayerAttributeSet.Health", From e6faff075e8cd66bb6689d871e463694c56c7442 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 14 Dec 2025 18:08:43 -0300 Subject: [PATCH 44/87] Update documentation based on new duration changes --- docs/effects/README.md | 25 +- docs/effects/components.md | 24 +- docs/effects/duration.md | 111 ++++++--- docs/effects/periodic.md | 459 ++++++++++++++++++++++++++++++++++++- docs/effects/stacking.md | 55 ++--- docs/tags.md | 8 +- 6 files changed, 591 insertions(+), 91 deletions(-) diff --git a/docs/effects/README.md b/docs/effects/README.md index 6a88cb3..6f27dc5 100644 --- a/docs/effects/README.md +++ b/docs/effects/README.md @@ -282,7 +282,7 @@ var effectData = new EffectData( // Instant effect (execute once and end) var healData = new EffectData( "Instant Heal", - durationData: new DurationData(DurationType.Instant) + durationData: new DurationData(DurationType.Instant), modifiers: [/*...*/], ); @@ -291,7 +291,10 @@ var buffData = new EffectData( "Temporary Buff", durationData: new DurationData( DurationType.HasDuration, - new ScalableFloat(10.0f) // 10 second duration + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f) // 10 second duration + ) ), modifiers: [/*...*/] ); @@ -317,7 +320,13 @@ var curseData = new EffectData( ```csharp var effectData = new EffectData( "Strength Potion", - durationData: new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), + durationData: new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f) + ) + ), modifiers: [ // First modifier - flat strength bonus new Modifier( @@ -369,7 +378,10 @@ var stackingEffectData = new EffectData( "Bleed", durationData: new DurationData( DurationType.HasDuration, - new ScalableFloat(5.0f) + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(5.0f) + ) ), modifiers: [/*...*/], stackingData: new StackingData( @@ -407,7 +419,10 @@ var dotEffectData = new EffectData( "Poison", durationData: new DurationData( DurationType.HasDuration, - new ScalableFloat(8.0f), // 8 second total duration + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(8.0f) // 8 second total duration + ) ), modifiers: [/*...*/], periodicData: new PeriodicData( diff --git a/docs/effects/components.md b/docs/effects/components.md index c51e33a..517b896 100644 --- a/docs/effects/components.md +++ b/docs/effects/components.md @@ -263,7 +263,13 @@ The component specifically uses the `NextSingle()` method, which returns a rando // Create a "Stun" effect with a 25% chance to apply var stunEffectData = new EffectData( "Stun", - new DurationData(DurationType.HasDuration, new ScalableFloat(3.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(3.0f) + ) + ), effectComponents: new[] { new ChanceToApplyEffectComponent( randomProvider, // Your game's random number generator @@ -320,7 +326,13 @@ Usage example: // Create a "Burning" effect that adds the "Status.Burning" tag to the target var burningEffectData = new EffectData( "Burning", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f) + ) + ), new[] { new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-5))) }, @@ -426,7 +438,13 @@ query.Build(new TagQueryExpression(tagsManager) // is removed if target gains the "Fire" tag, and is inhibited if target has the "Cold.Immune" tag var frostEffectData = new EffectData( "Frost", - new DurationData(DurationType.HasDuration, new ScalableFloat(8.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(8.0f) + ) + ), [/*...*/], effectComponents: new[] { new TargetTagRequirementsEffectComponent( diff --git a/docs/effects/duration.md b/docs/effects/duration.md index f9117c3..c2e856c 100644 --- a/docs/effects/duration.md +++ b/docs/effects/duration.md @@ -21,13 +21,13 @@ public enum DurationType : byte ### DurationData -`DurationData` encapsulates the duration configuration for an effect: +`DurationData` encapsulates the duration configuration for an effect. Durations use `ModifierMagnitude`, enabling scalable, attribute-driven, custom-calculated, or set-by-caller values. ```csharp -public readonly struct DurationData(DurationType durationType, ScalableFloat? duration = null) +public readonly record struct DurationData(DurationType durationType, ModifierMagnitude? durationMagnitude = null) { - public DurationType Type { get; } = durationType; - public ScalableFloat? Duration { get; } = duration; + public DurationType DurationType { get; } + public ModifierMagnitude? DurationMagnitude { get; } } ``` @@ -46,10 +46,13 @@ Instant effects apply their changes immediately and then end. They: // Create an instant damage effect var damageEffectData = new EffectData( "Direct Damage", + new DurationData(DurationType.Instant), new[] { - new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-25))) - }, - new DurationData(DurationType.Instant) + new Modifier( + "CombatAttributeSet.CurrentHealth", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-25))) + } ); ``` @@ -67,11 +70,11 @@ Equipment-based buffs are a perfect use case for Infinite effects: // Create an equipment buff that lasts until the item is unequipped var swordBuffEffectData = new EffectData( "Magic Sword Bonus", + new DurationData(DurationType.Infinite), new[] { - new Modifier("CombatAttributeSet.AttackPower", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))), + new Modifier("CombatAttributeSet.AttackPower", ModifierOperation.FlatBonus, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))), new Modifier("CombatAttributeSet.CriticalChance", ModifierOperation.PercentBonus, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(0.05f))) - }, - new DurationData(DurationType.Infinite) + } ); var swordBuffEffect = new Effect(swordBuffEffectData, new EffectOwnership(character, character)); @@ -92,17 +95,40 @@ Duration-based effects automatically expire after a specific amount of time. The - Apply their modifiers for a limited period. - Automatically remove themselves when their duration ends. -- Can use `ScalableFloat` for level-based duration scaling. - Are often used for temporary buffs and debuffs. +- Can use any `ModifierMagnitude`, so durations can scale with level, attributes, custom calculators, or set-by-caller values. +- Re-evaluate while active if the duration depends on non-snapshot inputs (live attribute captures or non-snapshot set-by-caller values). ```csharp // Create a temporary buff that lasts 30 seconds var temporaryBuffEffectData = new EffectData( "Temporary Speed Boost", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + scalableFloatMagnitude: new ScalableFloat(30.0f))), new[] { new Modifier("MovementAttributeSet.Speed", ModifierOperation.PercentBonus, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(0.3f))) - }, - new DurationData(DurationType.HasDuration, new ScalableFloat(30.0f)) + } +); + +// Attribute-driven duration (live capture) +var attributeDurationEffectData = new EffectData( + "Attribute-Based Shield", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.AttributeBased, + attributeBasedFloat: new AttributeBasedFloat( + new AttributeCaptureDefinition("StatAttributeSet.Strength", AttributeCaptureSource.Source, snapshot: false), + AttributeCalculationType.CurrentValue, + coefficient: new ScalableFloat(0.2f), + preMultiplyAdditiveValue: new ScalableFloat(0), + postMultiplyAdditiveValue: new ScalableFloat(5)))), + new[] { + new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.FlatBonus, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(25))) + } ); ``` @@ -149,16 +175,16 @@ When working with durations, several constraints apply to ensure effects behave ```csharp // INVALID - Instant effects can't apply modifier tags new EffectData( - "Invalid Effect", - new DurationData(DurationType.Instant), - [/*...*/], - effectComponents: new[] { new ModifierTagsEffectComponent(new TagContainer()) } // Error + "Invalid Effect", + new DurationData(DurationType.Instant), + [/*...*/], + effectComponents: new[] { new ModifierTagsEffectComponent(new TagContainer()) } // Error ); ``` ### HasDuration Effects Constraints -1. **Duration Required**: `HasDuration` effects must provide a valid `Duration` property. +1. **Duration Required**: `HasDuration` effects must provide a valid `DurationMagnitude`. ```csharp // INVALID - HasDuration requires a duration value new EffectData( @@ -167,41 +193,53 @@ When working with durations, several constraints apply to ensure effects behave [/*...*/] ); - // VALID - Providing required duration + // VALID - Providing required duration magnitude new EffectData( "Valid Effect", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), // Correct - [/*...*/] + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + scalableFloatMagnitude: new ScalableFloat(10.0f))), + [/*...*/] ); ``` -2. **Stacking Requirements**: `HasDuration` effects with stacking must define `ApplicationRefreshPolicy`. +2. **Stacking Requirement**: If stacking is configured, `ApplicationRefreshPolicy` must be defined. ```csharp // VALID - HasDuration with stacking needs ApplicationRefreshPolicy new EffectData( "Valid Effect", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + scalableFloatMagnitude: new ScalableFloat(10.0f))), [/*...*/], stackingData: new StackingData( stackLimit: new ScalableInt(3), initialStack: new ScalableInt(1), // ... other stacking data - applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication + applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication ) ); ``` +3. **Periodic + Stacking**: When both periodic and stacking are present, `ExecuteOnSuccessfulApplication` and `ApplicationResetPeriodPolicy` must be defined in stacking data. + ### General Constraints -1. **Duration Value Requirement**: The `duration` value is only valid for `HasDuration` effects. +1. **DurationMagnitude Scope**: `DurationMagnitude` is only valid when `DurationType` is `HasDuration`. ```csharp - // INVALID - Can't provide duration for non-HasDuration effects + // INVALID - Can't provide duration magniteude for non-HasDuration effects new DurationData( DurationType.Infinite, - new ScalableFloat(10.0f) // Error + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10.0f)) // Invalid ); ``` +2. **Dynamic Re-evaluation**: Active effects re-evaluate duration when non-snapshot inputs used by `DurationMagnitude` change (live attribute captures or non-snapshot set-by-caller values). Remaining duration adjusts accordingly. + ## Best Practices 1. **Choose Appropriate Types**: @@ -209,21 +247,22 @@ When working with durations, several constraints apply to ensure effects behave - Use `HasDuration` for temporary buffs and debuffs. - Use `Infinite` for permanent effects or those requiring manual removal. -2. **Scale Durations with Level**: +2. **Choose Appropriate ModifierMagnitudes**: - Use `ScalableFloat` with curves for level-based duration scaling. - ```csharp - new DurationData( - DurationType.HasDuration, - new ScalableFloat(10.0f, durationCurve) // Scale with effect level - ) - ``` + - Use `AttributeBased` for attribute-driven duration. + - Use `CustomCalculatorClass` for custom logic duration. + - Use `SetByCaller` for duration based on external inputs. + +3. **Snapshot Attributes**: + - Capture only the attributes needed. + - Decide between snapshot and live captures based on whether the duration should update while active. -3. **Handle Effect Removal**: +4. **Handle Effect Removal**: - Always store `ActiveEffectHandle` for `Infinite` effects. - Consider early removal conditions for `HasDuration` effects. - Use `EffectsManager.UnapplyEffect` appropriately. -4. **Consider Performance**: +5. **Consider Performance**: - Minimize the number of long-duration effects active simultaneously. - Use `Instant` effects when appropriate to avoid tracking overhead. - Be cautious with infinitely stacking `HasDuration` effects. diff --git a/docs/effects/periodic.md b/docs/effects/periodic.md index eaf4932..9ece065 100644 --- a/docs/effects/periodic.md +++ b/docs/effects/periodic.md @@ -65,7 +65,13 @@ To create a periodic effect, specify a period duration, whether it executes on a // Create a poison effect that deals damage every 2 seconds var poisonEffectData = new EffectData( "Poison", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), // 10 second duration + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f) // 10 second duration + ) + ), new[] { new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-5))) }, @@ -85,7 +91,13 @@ The `Period` property uses `ScalableFloat`, allowing periods to change based on // Create a healing effect with frequency that scales with level var healingEffectData = new EffectData( "Healing Aura", - new DurationData(DurationType.HasDuration, new ScalableFloat(30.0f)), // 30 second duration + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(30.0f) // 30 second duration + ) + ), new[] { new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) }, @@ -152,7 +164,12 @@ Periodic effects have specific constraints and interactions with other systems: // VALID - HasDuration periodic effect new EffectData( "Valid Effect", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f)) + ), [/*...*/], periodicData: new PeriodicData(new ScalableFloat(1.0f)) ); @@ -179,7 +196,12 @@ When combining periodic effects with [stacking](stacking.md): // VALID - Stacking periodic effect var stackingPeriodicEffect = new EffectData( "Bleeding", - new DurationData(DurationType.HasDuration, new ScalableFloat(8.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(8.0f)) + ), [/*...*/], stackingData: new StackingData( stackLimit: new ScalableInt(3), @@ -206,7 +228,12 @@ var stackingPeriodicEffect = new EffectData( // Burning effect that deals damage every second for 5 seconds var burningEffectData = new EffectData( "Burning", - new DurationData(DurationType.HasDuration, new ScalableFloat(5.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(5.0f)) + ), new[] { new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-8))) }, @@ -224,7 +251,12 @@ var burningEffectData = new EffectData( // Regeneration effect that heals every 2 seconds for 10 seconds var regenerationEffectData = new EffectData( "Regeneration", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f)) + ), new[] { new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(15))) }, @@ -260,7 +292,12 @@ var manaRegenEffectData = new EffectData( // Bleeding effect that stacks up to 3 times, each stack does damage every second var bleedingEffectData = new EffectData( "Bleeding", - new DurationData(DurationType.HasDuration, new ScalableFloat(6.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(6.0f)) + ), new[] { new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-3))) }, @@ -315,3 +352,411 @@ var bleedingEffectData = new EffectData( - In turn-based games, call `entity.EffectsManager.UpdateEffects(1.0f)` at the end of each turn. - Set period to exactly `1.0f` for effects that should trigger once per turn. - Use values like `2.0f` or `3.0f` for effects that trigger every few turns. +``` + +````markdown name=docs/effects/stacking.md url=https://github.com/gamesmiths-guild/forge/blob/37b442b3f13986dcda70ed54c1d2c93e69e01c83/docs/effects/stacking.md +# Effect Stacking + +Effect Stacking in Forge enables [effects](README.md) to accumulate on a target entity, allowing gameplay mechanics like poison stacks, buff/debuff stacks, or other cumulative effects. This powerful system offers extensive control over how effects combine, interact, and expire. + +For a practical guide on using stacking, see the [Quick Start Guide](../quick-start.md). + +## Core Components + +### StackingData + +`StackingData` defines how an effect behaves when multiple instances are applied to the same target: + +```csharp +public readonly struct StackingData( + ScalableInt stackLimit, + ScalableInt initialStack, + StackPolicy stackPolicy, + StackLevelPolicy stackLevelPolicy, + StackMagnitudePolicy magnitudePolicy, + StackOverflowPolicy overflowPolicy, + StackExpirationPolicy expirationPolicy, + StackOwnerDenialPolicy? ownerDenialPolicy = null, + StackOwnerOverridePolicy? ownerOverridePolicy = null, + StackOwnerOverrideStackCountPolicy? ownerOverrideStackCountPolicy = null, + LevelComparison? levelDenialPolicy = null, + LevelComparison? levelOverridePolicy = null, + StackLevelOverrideStackCountPolicy? levelOverrideStackCountPolicy = null, + StackApplicationRefreshPolicy? applicationRefreshPolicy = null, + StackApplicationResetPeriodPolicy? applicationResetPeriodPolicy = null, + bool? executeOnSuccessfulApplication = null) +{ + // Properties to access each parameter... +} +``` + +## Basic Stacking Parameters + +### Stack Limits and Counts + +- **StackLimit**: Maximum number of stacks that can be applied to a target. + ```csharp + public ScalableInt StackLimit { get; } + ``` + +- **InitialStack**: Number of stacks applied when the effect is first applied. + ```csharp + public ScalableInt InitialStack { get; } + ``` + +- **ExecuteOnSuccessfulApplication**: For [periodic effects](periodic.md), determines whether the periodic effect executes when a new stack is applied. + ```csharp + public bool? ExecuteOnSuccessfulApplication { get; } + ``` + +### Overflow Policy + +The `StackOverflowPolicy` controls what happens when a new stack application would exceed the stack limit: + +```csharp +public enum StackOverflowPolicy : byte +{ + AllowApplication = 0, // Apply the effect but maintain the stack limit + DenyApplication = 1 // Reject the application entirely +} +``` + +An "overflow" occurs when an effect has reached its maximum stack count (defined by `StackLimit`) and a new application attempts to add more stacks. The overflow policy determines how this situation is handled: + +- With `AllowApplication`, the new application is processed (refreshing duration, triggering events, etc.) but the stack count remains at the limit. +- With `DenyApplication`, the new application is completely rejected as if it never happened. + +## Key Stacking Policies + +### Stack Aggregation + +The `StackPolicy` determines how stacks are aggregated on a target: + +```csharp +public enum StackPolicy : byte +{ + AggregateBySource = 0, // Each source has its own stack on the target + AggregateByTarget = 1 // Target has only one stack, shared by all sources +} +``` + +### Stack Level Handling + +The `StackLevelPolicy` defines how effects of different levels interact: + +```csharp +public enum StackLevelPolicy : byte +{ + AggregateLevels = 0, // Combine effects of different levels + SegregateLevels = 1 // Keep effects of different levels separate +} +``` + +### Magnitude Policy + +The `StackMagnitudePolicy` controls how effect [magnitudes](modifiers.md) are calculated when stacked: + +```csharp +public enum StackMagnitudePolicy : byte +{ + DontStack = 0, // Each stack uses its original magnitude + Sum = 1 // Sum the magnitudes of all stacks +} +``` + +### Expiration Policy + +The `StackExpirationPolicy` determines what happens when an effect's [duration](duration.md) ends: + +```csharp +public enum StackExpirationPolicy : byte +{ + ClearEntireStack = 0, // Remove all stacks at once + RemoveSingleStackAndRefreshDuration = 1 // Remove one stack, refresh duration +} +``` + +### Owner Control Policies + +When using `StackPolicy.AggregateByTarget`, these policies control how different owners' effects interact: + +- **OwnerDenialPolicy**: Controls whether different owners can apply stacks. + ```csharp + public enum StackOwnerDenialPolicy : byte + { + AlwaysAllow = 0, // Any source can add stacks + DenyIfDifferent = 1 // Only the original source can add stacks + } + ``` + +- **OwnerOverridePolicy**: Controls whether effect ownership changes. + ```csharp + public enum StackOwnerOverridePolicy : byte + { + KeepCurrent = 0, // Original owner is always kept + Override = 1 // New applications change ownership + } + ``` + +- **OwnerOverrideStackCountPolicy**: Controls stack behavior when ownership changes. + ```csharp + public enum StackOwnerOverrideStackCountPolicy : byte + { + IncreaseStacks = 0, // Add to existing stack count + ResetStacks = 1 // Reset stack count to initial value + } + ``` + +### Application Policies + +- **ApplicationRefreshPolicy**: Controls how duration is handled when applying new stacks. + ```csharp + public enum StackApplicationRefreshPolicy : byte + { + RefreshOnSuccessfulApplication = 0, // Reset the duration when a stack is applied + NeverRefresh = 1 // Keep the current duration + } + ``` + +- **ApplicationResetPeriodPolicy**: For periodic effects, controls how the period timer is handled when a new stack is applied. + ```csharp + public enum StackApplicationResetPeriodPolicy : byte + { + ResetOnSuccessfulApplication = 0, // Reset period timer when a stack is applied + NeverReset = 1 // Keep the current period timer + } + ``` + +## Advanced Stacking Control + +### Level Comparison + +`LevelComparison` is a flags enum used to compare effect levels: + +```csharp +[Flags] +public enum LevelComparison : byte +{ + None = 0, + Equal = 1 << 0, // 1 + Higher = 1 << 1, // 2 + Lower = 1 << 2 // 4 +} +``` + +| Flag Combination | Value | Description | +|----------------------------|-------|----------------------------------------------| +| None | 0 | No comparison, ignores all levels | +| Equal | 1 | Only matches equal levels | +| Higher | 2 | Only matches higher levels | +| Lower | 4 | Only matches lower levels | +| Equal \| Higher | 3 | Matches equal or higher levels | +| Equal \| Lower | 5 | Matches equal or lower levels | +| Higher \| Lower | 6 | Matches higher or lower levels (not equal) | +| Equal \| Higher \| Lower | 7 | Matches all levels (rarely useful) | + +When used for: + +- **LevelDenialPolicy**: Denies application if the level relationship matches. +- **LevelOverridePolicy**: Overrides existing stack if the level relationship matches. + +### Level Override Stack Count Policy + +When a level override occurs, this policy controls what happens to the stack count: + +```csharp +public enum StackLevelOverrideStackCountPolicy : byte +{ + IncreaseStacks = 0, // Add to existing stack count + ResetStacks = 1 // Reset stack count to initial value +} +``` + +## Configuring Stacking Effects + +### Basic Stacking Effect + +```csharp +// Simple poison effect that stacks up to 5 times, each stack adds to the damage +var poisonEffectData = new EffectData( + "Poison", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f) + ) + ), + new[] { + new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-5))) + }, + new StackingData( + stackLimit: new ScalableInt(5), + initialStack: new ScalableInt(1), + stackPolicy: StackPolicy.AggregateBySource, + stackLevelPolicy: StackLevelPolicy.SegregateLevels, + magnitudePolicy: StackMagnitudePolicy.Sum, + overflowPolicy: StackOverflowPolicy.DenyApplication, + expirationPolicy: StackExpirationPolicy.ClearEntireStack, + applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication + ) +); +``` + +### Advanced Stacking with Level Control + +```csharp +// Buff that allows higher level applications to override lower ones +var hierarchicalBuffEffect = new EffectData( + "Strength Buff", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(30.0f)) + ), + new[] { + new Modifier("CombatAttributeSet.AttackPower", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) + }, + new StackingData( + stackLimit: new ScalableInt(3), + initialStack: new ScalableInt(1), + stackPolicy: StackPolicy.AggregateByTarget, + stackLevelPolicy: StackLevelPolicy.AggregateLevels, + magnitudePolicy: StackMagnitudePolicy.Sum, + overflowPolicy: StackOverflowPolicy.DenyApplication, + expirationPolicy: StackExpirationPolicy.RemoveSingleStackAndRefreshDuration, + // Control how different owners interact + ownerDenialPolicy: StackOwnerDenialPolicy.AlwaysAllow, + ownerOverridePolicy: StackOwnerOverridePolicy.Override, + ownerOverrideStackCountPolicy: StackOwnerOverrideStackCountPolicy.IncreaseStacks, + // Control how different levels interact + levelDenialPolicy: LevelComparison.None, + levelOverridePolicy: LevelComparison.Higher, + levelOverrideStackCountPolicy: StackLevelOverrideStackCountPolicy.ResetStacks, + applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication + ) +); +``` + +### Stacking with Periodic Effect + +```csharp +// Bleeding effect that ticks every 2 seconds and stacks up to 3 times +var bleedingEffectData = new EffectData( + "Bleeding", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(8.0f)) + ), + new[] { + new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-3))) + }, + new StackingData( + stackLimit: new ScalableInt(3), + initialStack: new ScalableInt(1), + stackPolicy: StackPolicy.AggregateBySource, + stackLevelPolicy: StackLevelPolicy.SegregateLevels, + magnitudePolicy: StackMagnitudePolicy.Sum, + overflowPolicy: StackOverflowPolicy.AllowApplication, + expirationPolicy: StackExpirationPolicy.ClearEntireStack, + applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication, + // Required for periodic effects + applicationResetPeriodPolicy: StackApplicationResetPeriodPolicy.ResetOnSuccessfulApplication, + executeOnSuccessfulApplication: true + ), + new PeriodicData( + period: new ScalableFloat(2.0f), + executeOnApplication: true, + periodInhibitionRemovedPolicy: PeriodInhibitionRemovedPolicy.ResetPeriod + ) +); +``` + +## Constraints and Relationships + +Stacking effects have several constraints and required relationships: + +1. **No Instant Stacking**: Stacks cannot be used with `DurationType.Instant`. + ```csharp + // INVALID - Instant effects can't stack + new EffectData( + "Invalid Effect", + new DurationData(DurationType.Instant), // Error with stacking data + [/*...*/], + new StackingData(/*...*/) + ); + ``` + +2. **Stack Limit and Initial Stack**: The initial stack count must be greater than 0 and less than or equal to the stack limit. + ```csharp + // VALID - Initial stack and limit relationship + new StackingData( + stackLimit: new ScalableInt(5), + initialStack: new ScalableInt(1) + // ... + ); + ``` + +3. **`ApplicationRefreshPolicy` Required**: For `HasDuration` effects with stacking. + ```csharp + // VALID - HasDuration requires ApplicationRefreshPolicy + new StackingData( + // ... + applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication + ); + ``` + +4. **Periodic Integration**: Stacking effects with `PeriodicData` must define: + - `ExecuteOnSuccessfulApplication` + - `ApplicationResetPeriodPolicy` + +5. **`AggregateByTarget` Requirements**: + - Must define `OwnerDenialPolicy`. + - If `OwnerDenialPolicy` is `AlwaysAllow`, must define `OwnerOverridePolicy`. + - If `OwnerOverridePolicy` is `Override`, must define `OwnerOverrideStackCountPolicy`. + +6. **`AggregateLevels` Requirements**: + - Must define `LevelDenialPolicy`. + - Must define `LevelOverridePolicy`. + - If `LevelOverridePolicy` is not `None`, must define `LevelOverrideStackCountPolicy`. + - `LevelDenialPolicy` and `LevelOverridePolicy` cannot have overlapping flags. + +## Best Practices + +1. **Use Clear Stack Limits**: + - Choose appropriate stack limits based on your game's balance. + - Consider using `ScalableInt` for level-based stack limits. + +2. **Choose Magnitude Policy Carefully**: + - `Sum`: Good for additive effects (damage, stat bonuses). + - `DontStack`: Good for status effects where you want duration benefits of stacking but not increased magnitude. + +3. **Consider Stack Expiration**: + - `ClearEntireStack`: Simple but can feel abrupt to players. + - `RemoveSingleStackAndRefreshDuration`: More gradual, better player experience. + +4. **Level Control Strategies**: + - Use `SegregateLevels` for simpler systems. + - Use `AggregateLevels` with careful level policies for more complex behaviors. + +5. **Owner Control**: + - `AggregateBySource`: Simpler, each source gets its own stack. + - `AggregateByTarget`: More complex, but prevents stacking abuse. + +6. **Create Unique Effects**: + - Use `StackPolicy.AggregateByTarget` with `StackLimit` of 1 to ensure only one instance of an effect exists on a target. + - Control replacement behavior with `OwnerDenialPolicy` and `LevelDenialPolicy`. + - Use `LevelOverridePolicy` to allow higher-level versions to replace lower ones. + +7. **Test Edge Cases**: + - Stack limit behavior. + - Stack expiration and duration refresh. + - Interactions with inhibitions. + - Effects from multiple owners and levels. + +8. **Document Your Stacking Rules**: + - Clearly explain to players how stacks work for key abilities. + - Use UI to communicate current stack counts. diff --git a/docs/effects/stacking.md b/docs/effects/stacking.md index b06889a..f98dc79 100644 --- a/docs/effects/stacking.md +++ b/docs/effects/stacking.md @@ -223,7 +223,11 @@ public enum StackLevelOverrideStackCountPolicy : byte // Simple poison effect that stacks up to 5 times, each stack adds to the damage var poisonEffectData = new EffectData( "Poison", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + scalableFloatMagnitude: new ScalableFloat(10.0f))), new[] { new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-5))) }, @@ -246,7 +250,11 @@ var poisonEffectData = new EffectData( // Buff that allows higher level applications to override lower ones var hierarchicalBuffEffect = new EffectData( "Strength Buff", - new DurationData(DurationType.HasDuration, new ScalableFloat(30.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + scalableFloatMagnitude: new ScalableFloat(30.0f))), new[] { new Modifier("CombatAttributeSet.AttackPower", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) }, @@ -277,7 +285,11 @@ var hierarchicalBuffEffect = new EffectData( // Bleeding effect that ticks every 2 seconds and stacks up to 3 times var bleedingEffectData = new EffectData( "Bleeding", - new DurationData(DurationType.HasDuration, new ScalableFloat(8.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + scalableFloatMagnitude: new ScalableFloat(8.0f))), new[] { new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-3))) }, @@ -351,39 +363,4 @@ Stacking effects have several constraints and required relationships: - If `LevelOverridePolicy` is not `None`, must define `LevelOverrideStackCountPolicy`. - `LevelDenialPolicy` and `LevelOverridePolicy` cannot have overlapping flags. -## Best Practices - -1. **Use Clear Stack Limits**: - - Choose appropriate stack limits based on your game's balance. - - Consider using `ScalableInt` for level-based stack limits. - -2. **Choose Magnitude Policy Carefully**: - - `Sum`: Good for additive effects (damage, stat bonuses). - - `DontStack`: Good for status effects where you want duration benefits of stacking but not increased magnitude. - -3. **Consider Stack Expiration**: - - `ClearEntireStack`: Simple but can feel abrupt to players. - - `RemoveSingleStackAndRefreshDuration`: More gradual, better player experience. - -4. **Level Control Strategies**: - - Use `SegregateLevels` for simpler systems. - - Use `AggregateLevels` with careful level policies for more complex behaviors. - -5. **Owner Control**: - - `AggregateBySource`: Simpler, each source gets its own stack. - - `AggregateByTarget`: More complex, but prevents stacking abuse. - -6. **Create Unique Effects**: - - Use `StackPolicy.AggregateByTarget` with `StackLimit` of 1 to ensure only one instance of an effect exists on a target. - - Control replacement behavior with `OwnerDenialPolicy` and `LevelDenialPolicy`. - - Use `LevelOverridePolicy` to allow higher-level versions to replace lower ones. - -7. **Test Edge Cases**: - - Stack limit behavior. - - Stack expiration and duration refresh. - - Interactions with inhibitions. - - Effects from multiple owners and levels. - -8. **Document Your Stacking Rules**: - - Clearly explain to players how stacks work for key abilities. - - Use UI to communicate current stack counts. +7. **Duration Magnitude**: `DurationData` uses `ModifierMagnitude` (ScalableFloat, AttributeBased, CustomCalculatorClass, SetByCaller). For non-snapshot attribute captures or `SetByCaller` values, durations are re-evaluated at runtime. Stack refresh/reset behaviors (e.g., `ApplicationRefreshPolicy` or `RemoveSingleStackAndRefreshDuration`) use the current evaluated duration when they apply. diff --git a/docs/tags.md b/docs/tags.md index ef6350d..4974d40 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -444,7 +444,13 @@ Tag modification typically happens in specific ways: // Create effect that applies a tag var stunEffectData = new EffectData( "Stun Effect", - new DurationData(DurationType.HasDuration, new ScalableFloat(3.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(3.0f) + ) + ), effectComponents: [ new ModifierTagsEffectComponent( tagsManager.RequestTagContainer(["status.stunned"]) From 7b9e345620fa6ff2b02a962e75047e0a1aba2a64 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 14 Dec 2025 23:06:53 -0300 Subject: [PATCH 45/87] Added Events documentation --- README.md | 12 ++-- docs/README.md | 23 +++++++ docs/cues.md | 23 +++++++ docs/events.md | 166 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 docs/events.md diff --git a/README.md b/README.md index 37b7eb3..f757214 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A gameplay framework for developing games using C#. -Forge is an engine-agnostic gameplay framework designed for building robust game systems in C#. Inspired by Unreal Engine's Gameplay Ability System (GAS), Forge provides a centralized and controlled approach to managing attributes, effects, tags, and cues in your games. +Forge is an engine-agnostic gameplay framework designed for building robust game systems in C#. Inspired by Unreal Engine's Gameplay Ability System (GAS), Forge provides a centralized and controlled approach to managing attributes, effects, tags, abilities, events, and cues in your games. The framework eliminates the need to rebuild status systems for every game project by offering a flexible, data-driven architecture that works seamlessly with Unity, Godot, and other C#-compatible engines. With Forge, all attribute changes are handled through effects, ensuring organized and maintainable code even in complex gameplay scenarios. @@ -16,13 +16,15 @@ New to Forge? Check out the [Quick Start Guide](docs/quick-start.md) to build yo ## Architecture Overview -Forge is built around four core systems that work together to provide comprehensive gameplay functionality: +Forge is built around core systems that work together to provide comprehensive gameplay functionality: ### Core Systems - **[Attributes](docs/attributes.md)**: Centralized attribute management with min/max values, channels, and controlled modifications. - **[Effects](docs/effects/README.md)**: Data-driven system for applying temporary or permanent changes to entities. - **[Tags](docs/tags.md)**: Hierarchical tagging system for entity classification and effect targeting. +- **[Abilities](docs/abilities.md)**: Creation, granting, activation, cooldowns, costs, and instancing rules for gameplay abilities. +- **[Events](docs/events.md)**: Gameplay event handling and propagation used for ability triggers and game logic reactions. - **[Cues](docs/cues.md)**: Visual and audio feedback system that bridges gameplay with presentation. ### Entity Integration @@ -32,6 +34,8 @@ Every game object that uses Forge implements the `IForgeEntity` interface, provi - `EntityAttributes` - Manages all attributes and attribute sets. - `EntityTags` - Handles base and modifier tags with automatic inheritance. - `EffectsManager` - Controls effect application, stacking, and lifecycle. +- `EntityAbilities` - Grants and activates abilities, handles cooldowns/costs, and manages instancing. +- `EventManager` - Dispatches and listens to gameplay events for triggers and reactions. ### Advanced Features @@ -55,12 +59,12 @@ Forge supports a variety of gameplay mechanics through specialized subsystems: - **Effects System**: Comprehensive effect application with stacking support. - **Cues System**: Visual feedback system for effect application/removal. - **Custom Calculators**: Flexible logic execution for effects. +- **Abilities System**: Ability granting, activation, instancing, costs, cooldowns, and tag-based requirements. +- **Events System**: Gameplay event handling, tagging, and trigger support. ### Planned Features 🚧 -- **Abilities System**: Complete ability system similar to GAS abilities. - **Multiplayer Support**: Network replication for all systems. -- **Events System**: Gameplay event handling and propagation. ## Installation diff --git a/docs/README.md b/docs/README.md index 326fc6d..9b3d7d2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,8 @@ At the center of Forge is the entity system, represented by `IForgeEntity`. Each - **Attributes**: Numeric values that define an entity's capabilities and state. - **Tags**: Labels that identify entity characteristics and enable queries. - **Effects**: Gameplay effects that can modify attributes and behaviors. +- **Abilities**: Granted and activated gameplay abilities with instancing, costs, cooldowns, and tag requirements. +- **Events**: Gameplay events dispatched and listened to for triggers and reactions. Entities serve as containers for these components, allowing for modular construction of game objects. @@ -48,6 +50,25 @@ Tags are central to many Forge systems, enabling contextual application of effec Effects can be instant or persistent, with various duration types including infinite, timed, and conditional. +### Ability System + +[Abilities](abilities.md) manage creation and use of gameplay abilities: + +- **Granting**: Add abilities to entities, including level scaling and ownership. +- **Activation**: Start ability instances with checks for inhibition and requirements. +- **Costs and Cooldowns**: Apply cost effects and cooldown effects via the effects system. +- **Instancing Policies**: Control per-entity or per-execution instances, retrigger behavior, and cancellation. +- **Tag Rules**: Use tags to require, block, cancel, or inhibit abilities. + +### Event System + +[Events](events.md) deliver gameplay event data for triggers and reactions: + +- **Event Data**: Structured payloads carrying source, target, and parameters. +- **Tagging**: Event tags for filtering and matching listeners. +- **Dispatching**: Raise events through entity event managers. +- **Listeners and Triggers**: Drive ability activation and other game logic from event hooks. + ### Cue System [Cues](cues.md) bridge gameplay systems with visual and audio feedback: @@ -95,6 +116,8 @@ For more detailed information about specific systems, refer to these documentati - [Attributes](attributes.md): Details on attribute definition, evaluation, and channels. - [Effects](effects/README.md): Creating and applying effects to entities. - [Tags](tags.md): Using the tag system for entity identification. +- [Abilities](abilities.md): Ability granting, activation, instancing, costs, cooldowns, and tag requirements. +- [Events](events.md): Event data, tagging, and trigger hooks. - [Cues](cues.md): Connecting gameplay events to visual and audio feedback. ### Effect Features diff --git a/docs/cues.md b/docs/cues.md index 5ab1e62..f2193c4 100644 --- a/docs/cues.md +++ b/docs/cues.md @@ -307,6 +307,29 @@ cuesManager.UpdateCue(burningTag, targetEntity, updatedParameters); cuesManager.RemoveCue(burningTag, targetEntity, interrupted: false); ``` +## Cues vs Events + +Cues are designed for the presentation layer: visual effects, audio, and player feedback. For gameplay logic that affects simulation state, use the [Events system](events.md) instead. + +- **Cues** handle presentation: particle effects, sounds, UI animations. In a networked context, they can use unreliable replication. +- **Events** handle simulation: damage calculations, ability triggers, state changes. In a networked context, they require reliable replication. + +A common pattern is to raise an Event for gameplay logic, then trigger the corresponding Cue for feedback: + +```csharp +// Event for gameplay (reliable, affects game state) +entity.Events. Raise(new EventData +{ + EventTags = damageEventTag.GetSingleTagContainer(), + Source = attacker, + Target = victim, + EventMagnitude = damage +}); + +// Cue for presentation (can be unreliable) +cuesManager.ExecuteCue(damageCueTag, victim, cueParameters); +``` + ## Best Practices 1. **Separate Concerns**: Keep gameplay logic in effects and executions, and visual feedback in cues. diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 0000000..7fe9671 --- /dev/null +++ b/docs/events.md @@ -0,0 +1,166 @@ +# Events System + +The Events system in Forge provides a flexible event bus for triggering gameplay reactions, driving ability activation, and propagating tagged event data. + +## Core Concepts + +- Events carry tags for filtering `EventTags` plus optional source, target, magnitude, and payload data. +- Handlers subscribe by tag and run in priority order (higher priority first). +- Generic events avoid boxing by using typed payloads. +- Generic raises do **not** forward to non-generic handlers. + +## Event Data + +### EventData + +```csharp +public readonly record struct EventData +{ + public TagContainer EventTags { get; init; } + public IForgeEntity? Source { get; init; } + public IForgeEntity? Target { get; init; } + public float EventMagnitude { get; init; } + public object? Payload { get; init; } +} +``` + +### EventData + +```csharp +public readonly record struct EventData +{ + public TagContainer EventTags { get; init; } + public IForgeEntity? Source { get; init; } + public IForgeEntity? Target { get; init; } + public float EventMagnitude { get; init; } + public TPayload Payload { get; init; } +} +``` + +- **EventTags**: Tag-based filtering key (uses `TagContainer. HasTag`). +- **Source/Target**: Originator and intended recipient of the event. +- **EventMagnitude**: Optional numeric intensity. +- **Payload**: Optional opaque object, or a typed payload for generic events. + +## Event Manager + +`EventManager` manages subscriptions and dispatch. While every `IForgeEntity` has an `EventManager` instance, you can create and manage additional instances for any purpose: global event buses, subsystem-specific channels, or custom scopes. + +```csharp +// Per-entity event manager (built-in) +entity.Events. Raise(eventData); + +// Custom event manager for a subsystem +var combatEvents = new EventManager(); +combatEvents. Subscribe(damageTag, OnDamageDealt); + +// Global event bus +public static class GlobalEvents +{ + public static EventManager Instance { get; } = new EventManager(); +} +``` + +### API Reference + +```csharp +public sealed class EventManager +{ + public void Raise(in EventData data); + public void Raise(in EventData data); + + public EventSubscriptionToken Subscribe(Tag eventTag, Action handler, int priority = 0); + public EventSubscriptionToken Subscribe(Tag eventTag, Action> handler, int priority = 0); + + public bool Unsubscribe(EventSubscriptionToken token); +} +``` + +- Subscriptions are sorted by `priority` (higher first). +- A handler is invoked when `data.EventTags. HasTag(eventTag)` is true. +- Generic subscriptions are stored per `TPayload` type and are only invoked for matching generic raises. + +## Usage Examples + +### Subscribe and Raise (non-generic) + +```csharp +var eventTag = Tag.RequestTag(tagsManager, "events.combat. hit"); +EventSubscriptionToken token = entity.Events.Subscribe(eventTag, data => +{ + // React to combat hit + var source = data.Source; + var target = data.Target; + float magnitude = data.EventMagnitude; +}); + +// Raise the event +entity.Events. Raise(new EventData +{ + EventTags = eventTag.GetSingleTagContainer(), + Source = attacker, + Target = victim, + EventMagnitude = 25f +}); +``` + +### Generic Payload + +```csharp +public record struct HitPayload(int Damage, bool Critical); + +var hitTag = Tag.RequestTag(tagsManager, "events.combat.hit"); + +EventSubscriptionToken token = entity.Events.Subscribe(hitTag, data => +{ + HitPayload payload = data. Payload; + int damage = payload. Damage; + bool crit = payload.Critical; +}); + +entity.Events. Raise(new EventData +{ + EventTags = hitTag.GetSingleTagContainer(), + Source = attacker, + Target = victim, + EventMagnitude = 25f, + Payload = new HitPayload(25, critical: true) +}); +``` + +### Unsubscribe + +```csharp +entity.Events. Unsubscribe(token); +``` + +## Tagging and Filtering + +- Use dedicated event tags (e.g., `events.combat.hit`, `events.status.applied`) registered in `TagsManager`. +- Matching uses hierarchy: `EventTags.HasTag(subscriptionTag)` supports parent/child tag relationships. + +## Integration Notes + +- Abilities can use event tags as triggers (see ability trigger configurations in the Abilities system). +- Event tags can align with cues or effects to coordinate cross-system reactions. +- When an event should trigger visual feedback, raise the event for gameplay logic, then trigger the corresponding cue separately for presentation. + +## Events vs Cues + +Events and [Cues](cues.md) serve distinct purposes: + +- **Events** are part of the core simulation. They drive gameplay logic, trigger abilities, and propagate state changes. In a networked context (planned), events would require reliable replication. +- **Cues** are for the presentation layer. They handle visual effects, audio, and player feedback. In a networked context (planned), cues can use unreliable replication since they don't affect game state. + +Use Events when the outcome affects game state or triggers other gameplay systems. Use Cues when you need to communicate changes to the player through feedback. + +## Best Practices + +1. **Define Tag Conventions**: Use consistent prefixes (e.g., `events.*`) for clarity. +2. **Prefer Typed Payloads**: Use `EventData` to avoid boxing and improve safety. +3. **Use Priorities Sparingly**: Reserve high priorities for critical handlers; keep most at default. +4. **Unsubscribe When Done**: Store tokens and call `Unsubscribe` to avoid stale handlers. +5. **Keep Handlers Lightweight**: Avoid heavy work inside handlers; defer long tasks if needed. +6. **Validate Tags**: Ensure tags are registered in `TagsManager` before use. +7. **Separate Events from Cues**: Use events for gameplay-affecting logic; trigger cues separately for presentation. +8. **Consider Scope**: Use entity-level `EventManager` for entity-specific events; create custom instances for broader or specialized scopes. From 9c3d49fb7d45bce62d2c236e52e1ef63ab103566 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 20 Dec 2025 23:38:41 -0300 Subject: [PATCH 46/87] Refactored grant ability --- Forge/Abilities/Ability.cs | 13 -- Forge/Abilities/EffectGrantSource.cs | 18 ++ Forge/Abilities/IAbilityGrantSource.cs | 23 +++ Forge/Abilities/PermanentGrantSource.cs | 11 ++ Forge/Abilities/TransientGrantSource.cs | 11 ++ Forge/Core/EntityAbilities.cs | 154 +++++++----------- .../Components/GrantAbilityEffectComponent.cs | 38 +++-- 7 files changed, 146 insertions(+), 122 deletions(-) create mode 100644 Forge/Abilities/EffectGrantSource.cs create mode 100644 Forge/Abilities/IAbilityGrantSource.cs create mode 100644 Forge/Abilities/PermanentGrantSource.cs create mode 100644 Forge/Abilities/TransientGrantSource.cs diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 678fbce..6ccd46b 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -41,10 +41,6 @@ private record struct BehaviorBinding(IAbilityBehavior Behavior, AbilityBehavior internal int Level { get; set; } - internal AbilityDeactivationPolicy GrantedAbilityRemovalPolicy { get; } - - internal AbilityDeactivationPolicy GrantedAbilityInhibitionPolicy { get; } - internal IForgeEntity? SourceEntity { get; } internal AbilityHandle Handle { get; } @@ -59,25 +55,16 @@ private record struct BehaviorBinding(IAbilityBehavior Behavior, AbilityBehavior /// The entity that owns this ability. /// The data defining this ability. /// The level of the ability. - /// The policy that determines when this granted ability should be - /// removed. - /// - /// The policy that determines how this ability behaves when it is - /// inhibited. /// The entity that granted us this ability. internal Ability( IForgeEntity owner, AbilityData abilityData, int level, - AbilityDeactivationPolicy grantedAbilityRemovalPolicy = AbilityDeactivationPolicy.CancelImmediately, - AbilityDeactivationPolicy grantedAbilityInhibitionPolicy = AbilityDeactivationPolicy.CancelImmediately, IForgeEntity? sourceEntity = null) { Owner = owner; AbilityData = abilityData; Level = level; - GrantedAbilityRemovalPolicy = grantedAbilityRemovalPolicy; - GrantedAbilityInhibitionPolicy = grantedAbilityInhibitionPolicy; SourceEntity = sourceEntity; IsInhibited = false; diff --git a/Forge/Abilities/EffectGrantSource.cs b/Forge/Abilities/EffectGrantSource.cs new file mode 100644 index 0000000..5b9d789 --- /dev/null +++ b/Forge/Abilities/EffectGrantSource.cs @@ -0,0 +1,18 @@ +// Copyright © Gamesmiths Guild. +using Gamesmiths.Forge.Effects; + +namespace Gamesmiths.Forge.Abilities; + +internal sealed class EffectGrantSource( + ActiveEffectHandle effectHandle, + AbilityDeactivationPolicy removalPolicy, + AbilityDeactivationPolicy inhibitionPolicy) : IAbilityGrantSource +{ + public ActiveEffectHandle EffectHandle { get; init; } = effectHandle; + + public bool IsInhibited => EffectHandle.IsInhibited; + + public AbilityDeactivationPolicy RemovalPolicy { get; init; } = removalPolicy; + + public AbilityDeactivationPolicy InhibitionPolicy { get; init; } = inhibitionPolicy; +} diff --git a/Forge/Abilities/IAbilityGrantSource.cs b/Forge/Abilities/IAbilityGrantSource.cs new file mode 100644 index 0000000..2698b5a --- /dev/null +++ b/Forge/Abilities/IAbilityGrantSource.cs @@ -0,0 +1,23 @@ +// Copyright © Gamesmiths Guild. +namespace Gamesmiths.Forge.Abilities; + +/// +/// Interface for sources that can grant abilities. +/// +internal interface IAbilityGrantSource +{ + /// + /// Gets a value indicating whether the ability grant source is currently inhibited. + /// + bool IsInhibited { get; } + + /// + /// Gets the policy for removing the ability when the grant source is removed. + /// + AbilityDeactivationPolicy RemovalPolicy { get; } + + /// + /// Gets the policy for inhibiting the ability when the grant source is inhibited. + /// + AbilityDeactivationPolicy InhibitionPolicy { get; } +} diff --git a/Forge/Abilities/PermanentGrantSource.cs b/Forge/Abilities/PermanentGrantSource.cs new file mode 100644 index 0000000..c989149 --- /dev/null +++ b/Forge/Abilities/PermanentGrantSource.cs @@ -0,0 +1,11 @@ +// Copyright © Gamesmiths Guild. +namespace Gamesmiths.Forge.Abilities; + +internal sealed class PermanentGrantSource : IAbilityGrantSource +{ + public bool IsInhibited => false; + + public AbilityDeactivationPolicy RemovalPolicy => AbilityDeactivationPolicy.Ignore; + + public AbilityDeactivationPolicy InhibitionPolicy => AbilityDeactivationPolicy.Ignore; +} diff --git a/Forge/Abilities/TransientGrantSource.cs b/Forge/Abilities/TransientGrantSource.cs new file mode 100644 index 0000000..62e8f53 --- /dev/null +++ b/Forge/Abilities/TransientGrantSource.cs @@ -0,0 +1,11 @@ +// Copyright © Gamesmiths Guild. +namespace Gamesmiths.Forge.Abilities; + +internal sealed class TransientGrantSource : IAbilityGrantSource +{ + public bool IsInhibited => false; + + public AbilityDeactivationPolicy RemovalPolicy => AbilityDeactivationPolicy.RemoveOnEnd; + + public AbilityDeactivationPolicy InhibitionPolicy => AbilityDeactivationPolicy.Ignore; +} diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index c07cf02..da659a0 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -2,7 +2,6 @@ using System.Diagnostics.CodeAnalysis; using Gamesmiths.Forge.Abilities; -using Gamesmiths.Forge.Effects; using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Core; @@ -16,9 +15,7 @@ namespace Gamesmiths.Forge.Core; /// The owner of this manager. public class EntityAbilities(IForgeEntity owner) { - private readonly Dictionary?> _grantSources = []; - - private readonly Dictionary?> _inhibitSources = []; + private readonly Dictionary> _grantSources = []; /// /// Event invoked when an ability ends. @@ -140,42 +137,48 @@ public bool TryActivateAbilitiesByTag( /// /// The configuration data of the ability to grant and activate. /// The level at which to grant the ability. - /// The policy for removing the granted ability. - /// The policy for inhibiting the granted ability. /// The policy for overriding the level of an existing granted ability. - /// The handle of the active effect that is the source of this granted - /// ability. /// The source entity of the granted ability, if any. + /// The target entity for the ability activation, if any. /// The result of the ability activation attempt. - /// Returns if the ability was successfully activated; otherwise, - /// . - public bool GrantAbilityAndActivateOnce( + /// The handle of the granted ability. + public AbilityHandle GrantAbilityAndActivateOnce( AbilityData abilityData, int abilityLevel, - AbilityDeactivationPolicy removalPolicy, - AbilityDeactivationPolicy inhibitionPolicy, LevelComparison levelOverridePolicy, - ActiveEffectHandle sourceActiveEffectHandle, IForgeEntity? sourceEntity, + IForgeEntity? targetEntity, out AbilityActivationResult activationResult) { + var grantSource = new TransientGrantSource(); + AbilityHandle abilityHandle = GrantAbility( abilityData, abilityLevel, - removalPolicy, - inhibitionPolicy, levelOverridePolicy, - sourceActiveEffectHandle, + grantSource, sourceEntity); - return abilityHandle.Activate(out activationResult, null); + abilityHandle.Activate(out activationResult, targetEntity); + + RemoveGrantedAbility(abilityHandle, grantSource); + + return abilityHandle; } - internal void GrantAbilityPermanently( + /// + /// Grants an ability permanently. + /// + /// + /// Abilities granted permanently cannot be removed nor inhibited. + /// + /// The configuration data of the ability to grant. + /// The level at which to grant the ability. + /// The policy for overriding the level of an existing granted ability. + /// The source entity of the granted ability, if any. + public void GrantAbilityPermanently( AbilityData abilityData, int abilityLevel, - AbilityDeactivationPolicy removalPolicy, - AbilityDeactivationPolicy inhibitionPolicy, LevelComparison levelOverridePolicy, IForgeEntity? sourceEntity) { @@ -184,8 +187,7 @@ internal void GrantAbilityPermanently( if (existingAbility is not null && existingAbility.SourceEntity == sourceEntity) { - _grantSources[existingAbility] = null; - _inhibitSources.Remove(existingAbility); + _grantSources[existingAbility].Add(new PermanentGrantSource()); // If the ability was fully inhibited, this permanent grant should re-enable it. existingAbility.IsInhibited = false; @@ -203,17 +205,16 @@ internal void GrantAbilityPermanently( return; } - var newAbility = new Ability(Owner, abilityData, abilityLevel, removalPolicy, inhibitionPolicy, sourceEntity); + var newAbility = new Ability(Owner, abilityData, abilityLevel, sourceEntity); GrantedAbilities.Add(newAbility.Handle); + _grantSources[newAbility] = [new PermanentGrantSource()]; } internal AbilityHandle GrantAbility( AbilityData abilityData, int abilityLevel, - AbilityDeactivationPolicy removalPolicy, - AbilityDeactivationPolicy inhibitionPolicy, LevelComparison levelOverridePolicy, - ActiveEffectHandle sourceActiveEffectHandle, + IAbilityGrantSource grantSource, IForgeEntity? sourceEntity) { Ability? existingAbility = @@ -221,26 +222,11 @@ internal AbilityHandle GrantAbility( if (existingAbility is not null && existingAbility.SourceEntity == sourceEntity) { - if (_grantSources.TryGetValue(existingAbility, out List? grantSources) - && grantSources is not null) - { - List? inhibitSources = _inhibitSources[existingAbility]; - - Validation.Assert( - inhibitSources is not null, - "inhibitSources should not be null if grant grantSources are not null."); - - // Ability already granted, just add the new source to the mapping. - grantSources.Add(sourceActiveEffectHandle); - - if (sourceActiveEffectHandle.IsInhibited) - { - inhibitSources.Add(sourceActiveEffectHandle); - } + // Ability already granted, just add the new source to the mapping. + _grantSources[existingAbility].Add(grantSource); - // If the ability was fully inhibited, this new grant may need to re-enable it. - existingAbility.IsInhibited = inhibitSources.Count == grantSources.Count; - } + // If the ability was fully inhibited, this new grant may need to re-enable it. + existingAbility.IsInhibited = CheckIsInhibited(); var shouldOverride = (levelOverridePolicy.HasFlag(LevelComparison.Higher) && abilityLevel > existingAbility.Level) || @@ -255,51 +241,42 @@ internal AbilityHandle GrantAbility( return existingAbility.Handle; } - var newAbility = new Ability(Owner, abilityData, abilityLevel, removalPolicy, inhibitionPolicy, sourceEntity); + var newAbility = new Ability(Owner, abilityData, abilityLevel, sourceEntity); GrantedAbilities.Add(newAbility.Handle); - _grantSources[newAbility] = [sourceActiveEffectHandle]; + _grantSources[newAbility] = [grantSource]; - newAbility.IsInhibited = sourceActiveEffectHandle.IsInhibited; - _inhibitSources[newAbility] = newAbility.IsInhibited ? [sourceActiveEffectHandle] : []; + newAbility.IsInhibited = grantSource.IsInhibited; return newAbility.Handle; } - internal void RemoveGrantedAbility(AbilityHandle abilityHandle, ActiveEffectHandle effectHandle) + internal void RemoveGrantedAbility(AbilityHandle abilityHandle, IAbilityGrantSource grantSource) { - RemoveGrantedAbility(GrantedAbilities.FirstOrDefault(x => x == abilityHandle)?.Ability, effectHandle); + RemoveGrantedAbility(GrantedAbilities.FirstOrDefault(x => x == abilityHandle)?.Ability, grantSource); } - internal void RemoveGrantedAbility(Ability? abilityToRemove, ActiveEffectHandle effectHandle) + internal void RemoveGrantedAbility(Ability? abilityToRemove, IAbilityGrantSource grantSource) { - if (abilityToRemove is null - || abilityToRemove.GrantedAbilityRemovalPolicy == AbilityDeactivationPolicy.Ignore - || !_grantSources.TryGetValue(abilityToRemove, out List? grantSources) - || grantSources is null) + if (abilityToRemove is null || grantSource.RemovalPolicy == AbilityDeactivationPolicy.Ignore) { return; } - List? inhibitSources = _inhibitSources[abilityToRemove]; + List grantSources = _grantSources[abilityToRemove]; - Validation.Assert( - inhibitSources is not null, - "InhibitAbilityBasedOnPolicy inhibitSources should not be null if grant grantSources are not null."); - - grantSources.Remove(effectHandle); - inhibitSources.Remove(effectHandle); + grantSources.Remove(grantSource); if (grantSources.Count > 0) { - if (inhibitSources.Count == grantSources.Count) + if (CheckIsInhibited()) { - InhibitAbilityBasedOnPolicy(abilityToRemove); + InhibitAbilityBasedOnPolicy(abilityToRemove, grantSource.InhibitionPolicy); } return; } - switch (abilityToRemove.GrantedAbilityRemovalPolicy) + switch (grantSource.RemovalPolicy) { case AbilityDeactivationPolicy.Ignore: return; @@ -323,36 +300,19 @@ internal void RemoveGrantedAbility(Ability? abilityToRemove, ActiveEffectHandle } } - internal void InhibitGrantedAbility(AbilityHandle abilityHandle, bool inhibit, ActiveEffectHandle effectHandle) + internal void InhibitGrantedAbility(AbilityHandle abilityHandle, IAbilityGrantSource grantSource) { - InhibitGrantedAbility(GrantedAbilities.FirstOrDefault(x => x == abilityHandle)?.Ability, inhibit, effectHandle); + InhibitGrantedAbility(GrantedAbilities.FirstOrDefault(x => x == abilityHandle)?.Ability, grantSource); } - internal void InhibitGrantedAbility(Ability? abilityToInhibit, bool inhibit, ActiveEffectHandle effectHandle) + internal void InhibitGrantedAbility(Ability? abilityToInhibit, IAbilityGrantSource grantSource) { - if (abilityToInhibit is null - || abilityToInhibit.GrantedAbilityInhibitionPolicy == AbilityDeactivationPolicy.Ignore - || !_inhibitSources.TryGetValue(abilityToInhibit, out List? inhibitSources) - || inhibitSources is null) + if (abilityToInhibit is null || grantSource.InhibitionPolicy == AbilityDeactivationPolicy.Ignore) { return; } - if (inhibit) - { - inhibitSources.Add(effectHandle); - - InhibitAbilityBasedOnPolicy(abilityToInhibit); - } - else - { - inhibitSources.Remove(effectHandle); - - if (_inhibitSources[abilityToInhibit]?.Count < _grantSources[abilityToInhibit]?.Count) - { - abilityToInhibit.IsInhibited = false; - } - } + InhibitAbilityBasedOnPolicy(abilityToInhibit, grantSource.InhibitionPolicy); } internal void NotifyAbilityEnded(AbilityEndedData abilityEndedData) @@ -360,9 +320,9 @@ internal void NotifyAbilityEnded(AbilityEndedData abilityEndedData) OnAbilityEnded?.Invoke(abilityEndedData); } - private void InhibitAbilityBasedOnPolicy(Ability abilityToInhibit) + private void InhibitAbilityBasedOnPolicy(Ability abilityToInhibit, AbilityDeactivationPolicy inhibitionPolicy) { - switch (abilityToInhibit.GrantedAbilityInhibitionPolicy) + switch (inhibitionPolicy) { case AbilityDeactivationPolicy.Ignore: return; @@ -390,7 +350,7 @@ private void RemoveAbility(Ability abilityToRemove) { abilityToRemove.OnAbilityDeactivated -= RemoveAbility; - if (_grantSources.TryGetValue(abilityToRemove, out List? grantSources) + if (_grantSources.TryGetValue(abilityToRemove, out List? grantSources) && grantSources?.Count > 0) { return; @@ -403,10 +363,14 @@ private void RemoveAbility(Ability abilityToRemove) private void InhibitAbility(Ability abilityToInhibit) { abilityToInhibit.OnAbilityDeactivated -= InhibitAbility; + abilityToInhibit.IsInhibited = CheckIsInhibited(); + } - if (_grantSources[abilityToInhibit]?.Count == _inhibitSources[abilityToInhibit]?.Count) + private bool CheckIsInhibited() + { + return _grantSources.Values.All(x => { - abilityToInhibit.IsInhibited = true; - } + return x.TrueForAll(source => source.IsInhibited); + }); } } diff --git a/Forge/Effects/Components/GrantAbilityEffectComponent.cs b/Forge/Effects/Components/GrantAbilityEffectComponent.cs index db8bc67..f67b8d9 100644 --- a/Forge/Effects/Components/GrantAbilityEffectComponent.cs +++ b/Forge/Effects/Components/GrantAbilityEffectComponent.cs @@ -14,9 +14,15 @@ public class GrantAbilityEffectComponent(GrantAbilityConfig[] grantAbilityConfig private readonly GrantAbilityConfig[] _grantAbilityConfigs = grantAbilityConfigs; private readonly AbilityHandle[] _grantedAbilities = new AbilityHandle[grantAbilityConfigs.Length]; + private readonly IAbilityGrantSource[] _grantSources = new IAbilityGrantSource[grantAbilityConfigs.Length]; private bool _isInhibited; + /// + /// Gets a read-only list of the granted abilities. + /// + public IReadOnlyList GrantedAbilities => _grantedAbilities; + /// public void OnEffectExecuted(IForgeEntity target, in EffectEvaluatedData effectEvaluatedData) { @@ -37,7 +43,7 @@ public void OnPostActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluate if (activeEffectEvaluatedData.ActiveEffectHandle.IsInhibited) { _isInhibited = activeEffectEvaluatedData.ActiveEffectHandle.IsInhibited; - InhibitGrantedAbilities(target, _isInhibited, activeEffectEvaluatedData.ActiveEffectHandle); + InhibitGrantedAbilities(target); } } @@ -49,7 +55,7 @@ public void OnActiveEffectUnapplied( { if (removed) { - RemoveGrantedAbilities(target, activeEffectEvaluatedData.ActiveEffectHandle); + RemoveGrantedAbilities(target); } } @@ -59,7 +65,7 @@ public void OnActiveEffectChanged(IForgeEntity target, in ActiveEffectEvaluatedD if (_isInhibited != activeEffectEvaluatedData.ActiveEffectHandle.IsInhibited) { _isInhibited = activeEffectEvaluatedData.ActiveEffectHandle.IsInhibited; - InhibitGrantedAbilities(target, _isInhibited, activeEffectEvaluatedData.ActiveEffectHandle); + InhibitGrantedAbilities(target); } } @@ -72,8 +78,6 @@ private void GrantAbilitiesPermanently(IForgeEntity target, in EffectEvaluatedDa target.Abilities.GrantAbilityPermanently( config.AbilityData, config.ScalableLevel.GetValue(effectEvaluatedData.Level), - config.RemovalPolicy, - config.InhibitionPolicy, config.LevelOverridePolicy, effectEvaluatedData.Effect.Ownership.Source); } @@ -85,30 +89,36 @@ private void GrantAbilities(IForgeEntity target, in ActiveEffectEvaluatedData ac { GrantAbilityConfig config = _grantAbilityConfigs[i]; + var grantSource = new EffectGrantSource( + activeEffectEvaluatedData.ActiveEffectHandle, + config.RemovalPolicy, + config.InhibitionPolicy); + _grantSources[i] = grantSource; + _grantedAbilities[i] = target.Abilities.GrantAbility( config.AbilityData, config.ScalableLevel.GetValue(activeEffectEvaluatedData.EffectEvaluatedData.Level), - config.RemovalPolicy, - config.InhibitionPolicy, config.LevelOverridePolicy, - activeEffectEvaluatedData.ActiveEffectHandle, + grantSource, activeEffectEvaluatedData.EffectEvaluatedData.Effect.Ownership.Source); } } - private void RemoveGrantedAbilities(IForgeEntity target, ActiveEffectHandle activeEffectHandle) + private void RemoveGrantedAbilities(IForgeEntity target) { - foreach (AbilityHandle ability in _grantedAbilities) + for (var i = 0; i < _grantedAbilities.Length; i++) { - target.Abilities.RemoveGrantedAbility(ability, activeEffectHandle); + AbilityHandle ability = _grantedAbilities[i]; + target.Abilities.RemoveGrantedAbility(ability, _grantSources[i]); } } - private void InhibitGrantedAbilities(IForgeEntity target, bool inhibit, ActiveEffectHandle effectHandle) + private void InhibitGrantedAbilities(IForgeEntity target) { - foreach (AbilityHandle ability in _grantedAbilities) + for (var i = 0; i < _grantedAbilities.Length; i++) { - target.Abilities.InhibitGrantedAbility(ability, inhibit, effectHandle); + AbilityHandle ability = _grantedAbilities[i]; + target.Abilities.InhibitGrantedAbility(ability, _grantSources[i]); } } } From 1a7b23b1b8b550adfb0cfdf1b247563668d65683 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 20 Dec 2025 23:51:58 -0300 Subject: [PATCH 47/87] Added GrantAbilityAndActivateOnce tests --- Forge.Tests/Abilities/AbilitiesTests.cs | 30 +++++++++++++++++++ Forge.Tests/Abilities/AbilityBehaviorTests.cs | 26 ++++++++++++++++ Forge/Core/EntityAbilities.cs | 10 +++---- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index 952a8ae..e8bb3f6 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -2259,6 +2259,36 @@ public void OnAbilityEnded_fires_when_ability_instance_is_canceled() capturedData.Value.WasCanceled.Should().BeTrue(); } + [Fact] + [Trait("Grant ability", null)] + public void Ability_is_granted_and_activated_once() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle abilityHandle = entity.Abilities.GrantAbilityAndActivateOnce( + abilityData, + 1, + LevelComparison.None, + out AbilityActivationResult activationResult, + entity, + entity); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle.IsActive.Should().BeTrue(); + + abilityHandle.Cancel(); + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + } + private static AbilityHandle? SetupAbility( TestEntity targetEntity, AbilityData abilityData, diff --git a/Forge.Tests/Abilities/AbilityBehaviorTests.cs b/Forge.Tests/Abilities/AbilityBehaviorTests.cs index de0e9e8..4e074ee 100644 --- a/Forge.Tests/Abilities/AbilityBehaviorTests.cs +++ b/Forge.Tests/Abilities/AbilityBehaviorTests.cs @@ -347,6 +347,32 @@ public void OnAbilityEnded_fires_when_ability_instance_ends() capturedData.Value.WasCanceled.Should().BeFalse(); } + [Fact] + [Trait("Ability Ended Event", null)] + public void Ability_is_granted_and_activated_once() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new TrackingBehavior(); + + AbilityData data = CreateAbilityData("Tracked", behaviorFactory: () => behavior); + entity.Abilities.GrantAbilityAndActivateOnce( + data, + 1, + LevelComparison.None, + out AbilityActivationResult result); + + result.Should().Be(AbilityActivationResult.Success); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + behavior.StartCount.Should().Be(1); + behavior.EndCount.Should().Be(0); + + behavior.End(); + behavior.StartCount.Should().Be(1); + behavior.EndCount.Should().Be(1); + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + } + private static AbilityHandle? Grant( TestEntity target, AbilityData data, diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index da659a0..659d4a6 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -138,17 +138,17 @@ public bool TryActivateAbilitiesByTag( /// The configuration data of the ability to grant and activate. /// The level at which to grant the ability. /// The policy for overriding the level of an existing granted ability. - /// The source entity of the granted ability, if any. - /// The target entity for the ability activation, if any. /// The result of the ability activation attempt. + /// The target entity for the ability activation, if any. + /// The source entity of the granted ability, if any. /// The handle of the granted ability. public AbilityHandle GrantAbilityAndActivateOnce( AbilityData abilityData, int abilityLevel, LevelComparison levelOverridePolicy, - IForgeEntity? sourceEntity, - IForgeEntity? targetEntity, - out AbilityActivationResult activationResult) + out AbilityActivationResult activationResult, + IForgeEntity? targetEntity = null, + IForgeEntity? sourceEntity = null) { var grantSource = new TransientGrantSource(); From 43196536e483c826307e13fb07ed1bca75a878a5 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 21 Dec 2025 00:13:42 -0300 Subject: [PATCH 48/87] More GrantAbilityAndActivateOnce tests --- Forge.Tests/Abilities/AbilitiesTests.cs | 26 +++++++++++++++++++++++++ Forge/Core/EntityAbilities.cs | 2 ++ 2 files changed, 28 insertions(+) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index e8bb3f6..cbfde42 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -2289,6 +2289,32 @@ [new ScalableFloat(3f)], entity.Abilities.GrantedAbilities.Should().BeEmpty(); } + [Fact] + [Trait("Grant ability", null)] + public void Ability_fails_to_be_granted_and_activated_once() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-100)); + + AbilityHandle abilityHandle = entity.Abilities.GrantAbilityAndActivateOnce( + abilityData, + 1, + LevelComparison.None, + out AbilityActivationResult activationResult, + entity, + entity); + + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + activationResult.Should().Be(AbilityActivationResult.FailedInsufficientResources); + abilityHandle.IsActive.Should().BeFalse(); + } + private static AbilityHandle? SetupAbility( TestEntity targetEntity, AbilityData abilityData, diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index 659d4a6..77e00ff 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -294,8 +294,10 @@ internal void RemoveGrantedAbility(Ability? abilityToRemove, IAbilityGrantSource if (abilityToRemove.IsActive) { abilityToRemove.OnAbilityDeactivated += RemoveAbility; + return; } + RemoveAbility(abilityToRemove); return; } } From 334656a656f27482aa4c2d86f19b6fe8ea7174f7 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 21 Dec 2025 10:51:40 -0300 Subject: [PATCH 49/87] Added new tests for Remove and Inhibit policies --- Forge.Tests/Abilities/AbilitiesTests.cs | 165 +++++++++++++++++++++++- 1 file changed, 164 insertions(+), 1 deletion(-) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index cbfde42..a420bb3 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -83,7 +83,7 @@ [new ScalableFloat(3f)], [Fact] [Trait("Remove ability", null)] - public void Ability_is_only_removed_after_being_deactivated() + public void Ability_is_removed_only_after_deactivation_when_granted_with_RemoveOnEnd() { TestEntity entity = new(_tagsManager, _cuesManager); @@ -120,6 +120,101 @@ [new ScalableFloat(3f)], abilityHandle.IsActive.Should().BeFalse(); } + [Fact] + [Trait("Remove ability", null)] + public void Grants_with_different_remove_policies_work_independently() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle, + AbilityDeactivationPolicy.RemoveOnEnd, + AbilityDeactivationPolicy.RemoveOnEnd); + + AbilityHandle? abilityHandle2 = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle2, + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.RemoveOnEnd); + + abilityHandle.Should().NotBeNull(); + abilityHandle2.Should().Be(abilityHandle); + effectHandle.Should().NotBeNull(); + effectHandle2.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle.IsActive.Should().BeTrue(); + + entity.EffectsManager.UnapplyEffect(effectHandle!); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + abilityHandle.IsActive.Should().BeTrue(); + + entity.EffectsManager.UnapplyEffect(effectHandle2!); + + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Remove ability", null)] + public void CancelImmediately_takes_precedence_and_removes_the_ability_immediately() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle, + AbilityDeactivationPolicy.RemoveOnEnd, + AbilityDeactivationPolicy.Ignore); + + AbilityHandle? abilityHandle2 = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle2, + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.Ignore); + + abilityHandle.Should().NotBeNull(); + abilityHandle2.Should().Be(abilityHandle); + effectHandle.Should().NotBeNull(); + effectHandle2.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle.IsActive.Should().BeTrue(); + + entity.EffectsManager.UnapplyEffect(effectHandle!); + entity.EffectsManager.UnapplyEffect(effectHandle2!); + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + + abilityHandle.IsActive.Should().BeFalse(); + } + [Fact] [Trait("Inhibit ability", null)] public void Ability_gets_inhibited_temporarily_while_granting_effect_is_inhibited() @@ -687,6 +782,74 @@ [new ScalableFloat(3f)], abilityHandle!.IsInhibited.Should().BeTrue(); } + [Fact] + [Trait("Inhibit ability", null)] + public void Grants_with_different_inhibit_policies_work_independently() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + grantedAbilityInhibitionPolicy: AbilityDeactivationPolicy.RemoveOnEnd, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags))); + + abilityHandle.Should().NotBeNull(); + + TagContainer? ignoreTags2 = Tag.RequestTag(_tagsManager, "simple.tag").GetSingleTagContainer(); + ignoreTags2.Should().NotBeNull(); + + AbilityHandle? abilityHandle2 = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + grantedAbilityInhibitionPolicy: AbilityDeactivationPolicy.CancelImmediately, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags2))); + + abilityHandle2.Should().NotBeNull(); + abilityHandle.Should().Be(abilityHandle2); + + abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); + activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle.IsActive.Should().BeTrue(); + abilityHandle2!.IsActive.Should().BeTrue(); + + // Inhibit the granting effect with RemoveOnEnd policy. + CreateAndApplyTagEffect(entity, ignoreTags!); + + // With RemoveOnEnd policy, the ability is not inhibited while active. + abilityHandle.IsInhibited.Should().BeFalse(); + abilityHandle.IsActive.Should().BeTrue(); + abilityHandle2!.IsInhibited.Should().BeFalse(); + abilityHandle2.IsActive.Should().BeTrue(); + + // Inhibit the second granting effect with CancelImmediately policy. + CreateAndApplyTagEffect(entity, ignoreTags2!); + + // Now that it's no longer active, it should become inhibited. + abilityHandle.IsActive.Should().BeFalse(); + abilityHandle.IsInhibited.Should().BeTrue(); + abilityHandle2.IsActive.Should().BeFalse(); + abilityHandle2.IsInhibited.Should().BeTrue(); + } + [Fact] [Trait("Grant ability", null)] public void Ability_level_is_set_correctly() From ef30d25e021a0ae48711c5c52ec6031fc119c5ab Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 21 Dec 2025 12:00:52 -0300 Subject: [PATCH 50/87] Update Handles and GrantAbilityAndActivateOnce methods --- Forge.Tests/Abilities/AbilitiesTests.cs | 8 ++++---- Forge/Abilities/Ability.cs | 2 ++ Forge/Abilities/AbilityBehaviorContext.cs | 2 +- Forge/Abilities/AbilityHandle.cs | 5 +++++ Forge/Abilities/AbilityInstance.cs | 3 +++ Forge/Abilities/AbilityInstanceHandle.cs | 18 ++++++++++++++---- Forge/Core/EntityAbilities.cs | 9 +++++---- Forge/Effects/ActiveEffectHandle.cs | 5 +++++ 8 files changed, 39 insertions(+), 13 deletions(-) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index a420bb3..e47c4e2 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -2435,7 +2435,7 @@ [new ScalableFloat(3f)], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); - AbilityHandle abilityHandle = entity.Abilities.GrantAbilityAndActivateOnce( + AbilityHandle? abilityHandle = entity.Abilities.GrantAbilityAndActivateOnce( abilityData, 1, LevelComparison.None, @@ -2446,7 +2446,7 @@ [new ScalableFloat(3f)], entity.Abilities.GrantedAbilities.Should().ContainSingle(); activationResult.Should().Be(AbilityActivationResult.Success); - abilityHandle.IsActive.Should().BeTrue(); + abilityHandle!.IsActive.Should().BeTrue(); abilityHandle.Cancel(); entity.Abilities.GrantedAbilities.Should().BeEmpty(); @@ -2465,7 +2465,7 @@ [new ScalableFloat(3f)], "TestAttributeSet.Attribute90", new ScalableFloat(-100)); - AbilityHandle abilityHandle = entity.Abilities.GrantAbilityAndActivateOnce( + AbilityHandle? abilityHandle = entity.Abilities.GrantAbilityAndActivateOnce( abilityData, 1, LevelComparison.None, @@ -2475,7 +2475,7 @@ [new ScalableFloat(3f)], entity.Abilities.GrantedAbilities.Should().BeEmpty(); activationResult.Should().Be(AbilityActivationResult.FailedInsufficientResources); - abilityHandle.IsActive.Should().BeFalse(); + abilityHandle.Should().BeNull(); } private static AbilityHandle? SetupAbility( diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 6ccd46b..ff5de43 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -238,6 +238,8 @@ internal void OnInstanceEnded(AbilityInstance instance) } } + instance.Handle.Free(); + if (_activeInstances.Count == 0) { OnAbilityDeactivated?.Invoke(this); diff --git a/Forge/Abilities/AbilityBehaviorContext.cs b/Forge/Abilities/AbilityBehaviorContext.cs index 459af70..c8b0318 100644 --- a/Forge/Abilities/AbilityBehaviorContext.cs +++ b/Forge/Abilities/AbilityBehaviorContext.cs @@ -42,7 +42,7 @@ public sealed class AbilityBehaviorContext internal AbilityBehaviorContext(Ability ability, AbilityInstance instance) { AbilityHandle = ability.Handle; - InstanceHandle = new AbilityInstanceHandle(instance); + InstanceHandle = instance.Handle; Owner = ability.Owner; Source = ability.SourceEntity; diff --git a/Forge/Abilities/AbilityHandle.cs b/Forge/Abilities/AbilityHandle.cs index 6bb1ede..fe1e377 100644 --- a/Forge/Abilities/AbilityHandle.cs +++ b/Forge/Abilities/AbilityHandle.cs @@ -20,6 +20,11 @@ public class AbilityHandle /// public bool IsInhibited => Ability?.IsInhibited == true; + /// + /// Gets a value indicating whether the handle is valid. + /// + public bool IsValid => Ability is not null; + /// /// Gets a value indicating the level of the ability associated with this handle. /// diff --git a/Forge/Abilities/AbilityInstance.cs b/Forge/Abilities/AbilityInstance.cs index 9795804..d937c26 100644 --- a/Forge/Abilities/AbilityInstance.cs +++ b/Forge/Abilities/AbilityInstance.cs @@ -17,10 +17,13 @@ internal sealed class AbilityInstance internal IForgeEntity? Target { get; } + internal AbilityInstanceHandle Handle { get; } + internal AbilityInstance(Ability ability, IForgeEntity? target) { _ability = ability; Target = target; + Handle = new AbilityInstanceHandle(this); } internal void Start() diff --git a/Forge/Abilities/AbilityInstanceHandle.cs b/Forge/Abilities/AbilityInstanceHandle.cs index 57715b6..a42e98b 100644 --- a/Forge/Abilities/AbilityInstanceHandle.cs +++ b/Forge/Abilities/AbilityInstanceHandle.cs @@ -10,17 +10,22 @@ namespace Gamesmiths.Forge.Abilities; /// public sealed class AbilityInstanceHandle { - private readonly AbilityInstance _instance; + private AbilityInstance? _instance; /// /// Gets the target entity of this ability instance. /// - public IForgeEntity? Target => _instance.Target; + public IForgeEntity? Target => _instance?.Target; /// /// Gets a value indicating whether this ability instance is currently active. /// - public bool IsActive => _instance.IsActive; + public bool IsActive => _instance?.IsActive ?? false; + + /// + /// Gets a value indicating whether the handle is valid. + /// + public bool IsValid => _instance is not null; internal AbilityInstanceHandle(AbilityInstance instance) { @@ -32,6 +37,11 @@ internal AbilityInstanceHandle(AbilityInstance instance) /// public void End() { - _instance.End(); + _instance?.End(); + } + + internal void Free() + { + _instance = null; } } diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index 77e00ff..55aea9e 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -133,7 +133,7 @@ public bool TryActivateAbilitiesByTag( } /// - /// Grants an ability and activates it once. + /// Grants an ability and activates it once. The ability grant will be removed if activation fails or after it ends. /// /// The configuration data of the ability to grant and activate. /// The level at which to grant the ability. @@ -141,8 +141,9 @@ public bool TryActivateAbilitiesByTag( /// The result of the ability activation attempt. /// The target entity for the ability activation, if any. /// The source entity of the granted ability, if any. - /// The handle of the granted ability. - public AbilityHandle GrantAbilityAndActivateOnce( + /// The handle of the granted and activated ability, or if activation failed. + /// + public AbilityHandle? GrantAbilityAndActivateOnce( AbilityData abilityData, int abilityLevel, LevelComparison levelOverridePolicy, @@ -163,7 +164,7 @@ public AbilityHandle GrantAbilityAndActivateOnce( RemoveGrantedAbility(abilityHandle, grantSource); - return abilityHandle; + return abilityHandle.IsValid ? abilityHandle : null; } /// diff --git a/Forge/Effects/ActiveEffectHandle.cs b/Forge/Effects/ActiveEffectHandle.cs index 08f8025..73647f4 100644 --- a/Forge/Effects/ActiveEffectHandle.cs +++ b/Forge/Effects/ActiveEffectHandle.cs @@ -12,6 +12,11 @@ public class ActiveEffectHandle /// public bool IsInhibited => ActiveEffect?.IsInhibited ?? false; + /// + /// Gets a value indicating whether the handle is valid. + /// + public bool IsValid => ActiveEffect is not null; + internal ActiveEffect? ActiveEffect { get; private set; } internal ActiveEffectHandle(ActiveEffect activeEffect) From cc96b4db6d173863cea2611c5087030e4461dbd2 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 21 Dec 2025 22:00:26 -0300 Subject: [PATCH 51/87] Refactored ActivationResult into FailureFlags --- Forge.Tests/Abilities/AbilitiesTests.cs | 364 ++++++++++-------- Forge.Tests/Abilities/AbilityBehaviorTests.cs | 46 +-- Forge/Abilities/Ability.cs | 44 ++- ...Result.cs => AbilityActivationFailures.cs} | 29 +- Forge/Abilities/AbilityHandle.cs | 16 +- Forge/Core/EntityAbilities.cs | 24 +- 6 files changed, 281 insertions(+), 242 deletions(-) rename Forge/Abilities/{AbilityActivationResult.cs => AbilityActivationFailures.cs} (77%) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index e47c4e2..de725f5 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -43,8 +43,8 @@ [new ScalableFloat(3f)], abilityHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); } @@ -71,14 +71,17 @@ [new ScalableFloat(3f)], effectHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); entity.EffectsManager.UnapplyEffect(effectHandle!); entity.Abilities.GrantedAbilities.Should().BeEmpty(); abilityHandle.IsActive.Should().BeFalse(); + + abilityHandle.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.InvalidHandler); } [Fact] @@ -106,8 +109,8 @@ [new ScalableFloat(3f)], effectHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); entity.EffectsManager.UnapplyEffect(effectHandle!); @@ -155,8 +158,8 @@ [new ScalableFloat(3f)], effectHandle2.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); entity.EffectsManager.UnapplyEffect(effectHandle!); @@ -204,8 +207,8 @@ [new ScalableFloat(3f)], effectHandle2.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); entity.EffectsManager.UnapplyEffect(effectHandle!); @@ -243,16 +246,16 @@ [new ScalableFloat(3f)], abilityHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); ActiveEffectHandle? tagEffectHandle = CreateAndApplyTagEffect(entity, ignoreTags!); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle.Activate(out activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedInhibition); + abilityHandle.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.Inhibited); abilityHandle.IsActive.Should().BeFalse(); abilityHandle.IsInhibited.Should().BeTrue(); @@ -260,8 +263,8 @@ [new ScalableFloat(3f)], entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); abilityHandle.IsInhibited.Should().BeFalse(); } @@ -291,8 +294,8 @@ [new ScalableFloat(3f)], effectHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); entity.EffectsManager.UnapplyEffect(effectHandle!); @@ -599,8 +602,8 @@ [new ScalableFloat(3f)], abilityHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); // Inhibit the first effect. @@ -688,8 +691,8 @@ [new ScalableFloat(3f)], abilityHandle.Should().NotBeNull(); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); // Inhibit the granting effect. @@ -735,8 +738,8 @@ [new ScalableFloat(3f)], abilityHandle.Should().NotBeNull(); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); // Inhibit the granting effect. CreateAndApplyTagEffect(entity, ignoreTags!); @@ -826,8 +829,8 @@ [new ScalableFloat(3f)], abilityHandle2.Should().NotBeNull(); abilityHandle.Should().Be(abilityHandle2); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); abilityHandle2!.IsActive.Should().BeTrue(); @@ -998,8 +1001,8 @@ [new ScalableFloat(3f)], entity1.Abilities.GrantedAbilities.Should().HaveCount(2); // Activate one and ensure the other is not affected - abilityHandle1!.Activate(out AbilityActivationResult activationResult1).Should().BeTrue(); - activationResult1.Should().Be(AbilityActivationResult.Success); + abilityHandle1!.Activate(out AbilityActivationFailures failureFlags1).Should().BeTrue(); + failureFlags1.Should().Be(AbilityActivationFailures.None); abilityHandle1.IsActive.Should().BeTrue(); abilityHandle2!.IsActive.Should().BeFalse(); } @@ -1023,25 +1026,25 @@ [new ScalableFloat(3f)], new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); abilityHandle.CommitCooldown(); abilityHandle.Cancel(); - abilityHandle!.Activate(out activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedCooldown); + abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.Cooldown); entity.EffectsManager.UpdateEffects(2f); - abilityHandle!.Activate(out activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedCooldown); + abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.Cooldown); entity.EffectsManager.UpdateEffects(1f); - abilityHandle!.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); } [Fact] @@ -1063,25 +1066,25 @@ public void Ability_wont_activate_until_last_cooldown_effect_is_removed() new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); abilityHandle.CommitCooldown(); abilityHandle.Cancel(); - abilityHandle!.Activate(out activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedCooldown); + abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.Cooldown); entity.EffectsManager.UpdateEffects(2f); - abilityHandle!.Activate(out activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedCooldown); + abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.Cooldown); entity.EffectsManager.UpdateEffects(1f); - abilityHandle!.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); } [Fact] @@ -1103,8 +1106,8 @@ public void GetCooldownData_and_GetRemainingCooldownTime_return_correct_values() new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); CooldownData[]? cooldownData = abilityHandle.GetCooldownData()!; @@ -1127,8 +1130,8 @@ public void GetCooldownData_and_GetRemainingCooldownTime_return_correct_values() abilityHandle.CommitCooldown(); abilityHandle.Cancel(); - abilityHandle!.Activate(out activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedCooldown); + abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.Cooldown); cooldownData = abilityHandle.GetCooldownData()!; cooldownData.Should().HaveCount(2); @@ -1146,8 +1149,8 @@ public void GetCooldownData_and_GetRemainingCooldownTime_return_correct_values() entity.EffectsManager.UpdateEffects(0.5f); - abilityHandle!.Activate(out activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedCooldown); + abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.Cooldown); cooldownData = abilityHandle.GetCooldownData()!; cooldownData.Should().HaveCount(2); @@ -1195,8 +1198,8 @@ public void GetCooldownData_and_GetRemainingCooldownTime_return_correct_values() abilityHandle.GetRemainingCooldownTime(simpleTag).Should().Be(0f); abilityHandle.GetRemainingCooldownTime(tag).Should().Be(0f); - abilityHandle!.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); } [Theory] @@ -1221,14 +1224,14 @@ [new ScalableFloat(3f)], new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); abilityHandle.CommitCost(); - abilityHandle!.Activate(out activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedInsufficientResources); + abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.InsufficientResources); } [Fact] @@ -1252,8 +1255,8 @@ [new ScalableFloat(3f)], new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedOwnerTagRequirements); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.OwnerTagRequirements); abilityHandle.IsActive.Should().BeFalse(); } @@ -1278,8 +1281,8 @@ [new ScalableFloat(3f)], new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedOwnerTagRequirements); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.OwnerTagRequirements); abilityHandle.IsActive.Should().BeFalse(); } @@ -1306,8 +1309,8 @@ [new ScalableFloat(3f)], out _, sourceEntity: source); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedSourceTagRequirements); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.SourceTagRequirements); abilityHandle.IsActive.Should().BeFalse(); } @@ -1334,8 +1337,8 @@ [new ScalableFloat(3f)], out _, sourceEntity: source); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedSourceTagRequirements); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.SourceTagRequirements); abilityHandle.IsActive.Should().BeFalse(); } @@ -1361,8 +1364,8 @@ [new ScalableFloat(3f)], out _, sourceEntity: null); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedSourceTagRequirements); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.SourceTagRequirements); abilityHandle.IsActive.Should().BeFalse(); } @@ -1388,8 +1391,8 @@ [new ScalableFloat(3f)], out _, sourceEntity: null); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); } @@ -1415,8 +1418,8 @@ [new ScalableFloat(3f)], new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationResult activationResult, target).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedTargetTagRequirements); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags, target).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.TargetTagRequirements); abilityHandle.IsActive.Should().BeFalse(); } @@ -1442,8 +1445,8 @@ [new ScalableFloat(3f)], new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationResult activationResult, target).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedTargetTagRequirements); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags, target).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.TargetTagRequirements); abilityHandle.IsActive.Should().BeFalse(); } @@ -1468,8 +1471,8 @@ [new ScalableFloat(3f)], new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedTargetTagRequirements); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.TargetTagRequirements); abilityHandle.IsActive.Should().BeFalse(); } @@ -1494,8 +1497,8 @@ [new ScalableFloat(3f)], new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); } @@ -1550,22 +1553,22 @@ [new ScalableFloat(3f)], new ScalableInt(1), out _); - blockerAbilityHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + blockerAbilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); blockerAbilityHandle.IsActive.Should().BeTrue(); - unblockedAbilityHandle!.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + unblockedAbilityHandle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); unblockedAbilityHandle.IsActive.Should().BeTrue(); - blockedAbilityHandle!.Activate(out activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedBlockedByTags); + blockedAbilityHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.BlockedByTags); blockedAbilityHandle.IsActive.Should().BeFalse(); blockerAbilityHandle!.Cancel(); - blockedAbilityHandle!.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + blockedAbilityHandle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); blockedAbilityHandle.IsActive.Should().BeTrue(); } @@ -1587,13 +1590,13 @@ [new ScalableFloat(3f)], AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); // No retrigger, single instance. - handle!.Activate(out activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedPersistentInstanceActive); + handle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.PersistentInstanceActive); handle.IsActive.Should().BeTrue(); handle.Cancel(); @@ -1618,13 +1621,13 @@ [new ScalableFloat(3f)], AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); // Retrigger replaces the running instance. - handle!.Activate(out AbilityActivationResult activationResult2).Should().BeTrue(); - activationResult2.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out AbilityActivationFailures failureFlags2).Should().BeTrue(); + failureFlags2.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); // One End should fully deactivate because retrigger replaced the instance instead of stacking. @@ -1649,16 +1652,16 @@ [new ScalableFloat(3f)], AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); - handle!.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); - handle!.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); // Cancel ends all instances. @@ -1683,12 +1686,12 @@ [new ScalableFloat(3f)], AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); - handle!.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); // One Cancel should fully deactivate if multiple instances exist. @@ -1713,16 +1716,16 @@ [new ScalableFloat(3f)], AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); - handle!.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); - handle!.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); handle.Cancel(); @@ -1757,12 +1760,12 @@ [new ScalableFloat(3f)], AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); AbilityHandle? victimHandle = SetupAbility(entity, victim, new ScalableInt(1), out _); - victimHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + victimHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); victimHandle.IsActive.Should().BeTrue(); - cancellerHandle!.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + cancellerHandle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); cancellerHandle.IsActive.Should().BeTrue(); victimHandle.IsActive.Should().BeFalse(); @@ -1795,12 +1798,12 @@ [new ScalableFloat(3f)], AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); AbilityHandle? unrelatedHandle = SetupAbility(entity, unrelated, new ScalableInt(1), out _); - unrelatedHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + unrelatedHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); unrelatedHandle.IsActive.Should().BeTrue(); - cancellerHandle!.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + cancellerHandle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); cancellerHandle.IsActive.Should().BeTrue(); unrelatedHandle.IsActive.Should().BeTrue(); @@ -1834,12 +1837,12 @@ [new ScalableFloat(3f)], AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); AbilityHandle? victimHandle = SetupAbility(entity, victim, new ScalableInt(1), out _); - victimHandle!.Activate(out AbilityActivationResult activationResultA).Should().BeTrue(); - activationResultA.Should().Be(AbilityActivationResult.Success); + victimHandle!.Activate(out AbilityActivationFailures failureFlagsA).Should().BeTrue(); + failureFlagsA.Should().Be(AbilityActivationFailures.None); victimHandle.IsActive.Should().BeTrue(); - cancellerHandle!.Activate(out AbilityActivationResult activationResultB).Should().BeTrue(); - activationResultB.Should().Be(AbilityActivationResult.Success); + cancellerHandle!.Activate(out AbilityActivationFailures failureFlagsB).Should().BeTrue(); + failureFlagsB.Should().Be(AbilityActivationFailures.None); cancellerHandle.IsActive.Should().BeTrue(); victimHandle.IsActive.Should().BeFalse(); @@ -1864,8 +1867,8 @@ [new ScalableFloat(3f)], AbilityHandle? handle = SetupAbility(entity, selfCanceller, new ScalableInt(1), out _); - handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); } @@ -1897,23 +1900,23 @@ [new ScalableFloat(3f)], AbilityHandle? blockerHandle = SetupAbility(entity, blocker, new ScalableInt(1), out _); AbilityHandle? blockedHandle = SetupAbility(entity, blocked, new ScalableInt(1), out _); - blockerHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + blockerHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); blockerHandle.IsActive.Should().BeTrue(); - blockerHandle!.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + blockerHandle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); blockerHandle.IsActive.Should().BeTrue(); // While any blocker instance active, blocked ability cannot activate. - blockedHandle!.Activate(out activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedBlockedByTags); + blockedHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.BlockedByTags); blockedHandle.IsActive.Should().BeFalse(); // End all blocker instances. blockerHandle.Cancel(); - blockedHandle.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + blockedHandle.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); blockedHandle.IsActive.Should().BeTrue(); } @@ -1935,8 +1938,8 @@ [new ScalableFloat(3f)], AbilityHandle? handle = SetupAbility(entity, abilityWithOwned, new ScalableInt(1), out _); - handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); entity.Tags.CombinedTags.HasAll(ownedTags).Should().BeTrue(); handle.IsActive.Should().BeTrue(); @@ -1963,16 +1966,16 @@ [new ScalableFloat(3f)], AbilityHandle? handle = SetupAbility(entity, abilityWithOwned, new ScalableInt(1), out _); - handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); - handle!.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); - handle!.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); entity.Tags.CombinedTags.HasAll(ownedTags).Should().BeTrue(); @@ -2009,21 +2012,21 @@ [new ScalableFloat(3f)], AbilityHandle? needsHandle = SetupAbility(entity, requiresBuff, new ScalableInt(1), out _); // Cannot activate without buff. - needsHandle!.Activate(out AbilityActivationResult activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedOwnerTagRequirements); + needsHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.OwnerTagRequirements); needsHandle.IsActive.Should().BeFalse(); // Gain buff, then can activate. - giverHandle!.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); - needsHandle.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + giverHandle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + needsHandle.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); // Lose buff, then cannot activate again. giverHandle.Cancel(); needsHandle.Cancel(); - needsHandle.Activate(out activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedOwnerTagRequirements); + needsHandle.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.OwnerTagRequirements); } [Fact] @@ -2053,10 +2056,10 @@ [new ScalableFloat(3f)], grantHandle.Should().NotBeNull(); // Activate twice to simulate two instances. - handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); - handle!.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + handle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); // Remove grant; ability should not be removed until all instances end. entity.EffectsManager.UnapplyEffect(grantHandle!); @@ -2086,14 +2089,14 @@ [new ScalableFloat(3f)], AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); handle.Cancel(); handle.IsActive.Should().BeFalse(); // Should be able to activate again, implying the persistent instance was cleared. - handle.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + handle.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); } [Fact] @@ -2127,8 +2130,8 @@ [new ScalableFloat(3f)], victimHandle!.IsActive.Should().BeFalse(); // Activating canceller should not affect inactive victim. - cancellerHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + cancellerHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); victimHandle.IsActive.Should().BeFalse(); entity.Abilities.GrantedAbilities.Should().Contain(victimHandle); } @@ -2164,10 +2167,10 @@ [new ScalableFloat(3f)], AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); AbilityHandle? victimHandle = SetupAbility(entity, victim, new ScalableInt(1), out _); - victimHandle!.Activate(out AbilityActivationResult activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); - cancellerHandle!.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + victimHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + cancellerHandle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); // Victim must be canceled; canceller remains active. victimHandle.IsActive.Should().BeFalse(); @@ -2413,7 +2416,7 @@ public void OnAbilityEnded_fires_when_ability_instance_is_canceled() targetEntity.Abilities.OnAbilityEnded += x => { capturedData = x; }; // Activate the ability - abilityHandle!.Activate(out AbilityActivationResult result).Should().BeTrue(); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); abilityHandle.Cancel(); // Verify event was fired @@ -2439,13 +2442,13 @@ [new ScalableFloat(3f)], abilityData, 1, LevelComparison.None, - out AbilityActivationResult activationResult, + out AbilityActivationFailures failureFlags, entity, entity); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - activationResult.Should().Be(AbilityActivationResult.Success); + failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle!.IsActive.Should().BeTrue(); abilityHandle.Cancel(); @@ -2469,15 +2472,46 @@ [new ScalableFloat(3f)], abilityData, 1, LevelComparison.None, - out AbilityActivationResult activationResult, + out AbilityActivationFailures failureFlags, entity, entity); entity.Abilities.GrantedAbilities.Should().BeEmpty(); - activationResult.Should().Be(AbilityActivationResult.FailedInsufficientResources); + failureFlags.Should().Be(AbilityActivationFailures.InsufficientResources); abilityHandle.Should().BeNull(); } + [Fact] + [Trait("Failure reason", null)] + public void Failure_reason_contains_all_failureFlags_reasons() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-50), + retriggerInstancedAbility: true); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + + abilityHandle.CommitAbility(); + + abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.InsufficientResources | + AbilityActivationFailures.Cooldown); + } + private static AbilityHandle? SetupAbility( TestEntity targetEntity, AbilityData abilityData, diff --git a/Forge.Tests/Abilities/AbilityBehaviorTests.cs b/Forge.Tests/Abilities/AbilityBehaviorTests.cs index 4e074ee..b410d89 100644 --- a/Forge.Tests/Abilities/AbilityBehaviorTests.cs +++ b/Forge.Tests/Abilities/AbilityBehaviorTests.cs @@ -29,8 +29,8 @@ public void Behavior_OnStarted_and_OnEnded_are_invoked_per_instance() AbilityHandle? handle = Grant(entity, data); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationResult result).Should().BeTrue(); - result.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); behavior.StartCount.Should().Be(1); behavior.EndCount.Should().Be(0); @@ -99,20 +99,20 @@ public void Blocked_ability_tags_are_removed_only_after_last_instance_ends() blockerHandle!.Activate(out _).Should().BeTrue(); // While any blocker instance active, blocked ability cannot activate. - blockedHandle!.Activate(out AbilityActivationResult activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedBlockedByTags); + blockedHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.BlockedByTags); blockedHandle.IsActive.Should().BeFalse(); // End one blocker instance; still blocked. behaviors[0].End(); - blockedHandle.Activate(out activationResult).Should().BeFalse(); - activationResult.Should().Be(AbilityActivationResult.FailedBlockedByTags); + blockedHandle.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.BlockedByTags); blockedHandle.IsActive.Should().BeFalse(); // End last blocker instance; now unblocked. behaviors[1].End(); - blockedHandle.Activate(out activationResult).Should().BeTrue(); - activationResult.Should().Be(AbilityActivationResult.Success); + blockedHandle.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); blockedHandle.IsActive.Should().BeTrue(); } @@ -202,8 +202,8 @@ public void Context_provides_expected_values() AbilityHandle? handle = Grant(target, data, sourceEntity: source); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationResult result, target).Should().BeTrue(); - result.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out AbilityActivationFailures failureFlags, target).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); captured.Should().NotBeNull(); captured!.Owner.Should().Be(target); captured.Source.Should().Be(source); @@ -224,8 +224,8 @@ public void Behavior_can_end_instance_during_OnStarted() AbilityHandle? handle = Grant(entity, data); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationResult result).Should().BeTrue(); - result.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeFalse(); } @@ -246,19 +246,19 @@ public void Behavior_commits_cooldown_and_cost_on_start() AbilityHandle? handle = Grant(entity, data); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationResult result).Should().BeTrue(); - result.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); entity.Attributes["TestAttributeSet.Attribute90"].BaseValue.Should().Be(baseBefore - 5); // Attempt re-activate during cooldown should fail. - handle.Activate(out result).Should().BeFalse(); - result.Should().Be(AbilityActivationResult.FailedCooldown); + handle.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.Cooldown); // Advance time until cooldown expires. entity.EffectsManager.UpdateEffects(2f); - handle.Activate(out result).Should().BeTrue(); - result.Should().Be(AbilityActivationResult.Success); + handle.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); } [Fact] @@ -273,7 +273,7 @@ public void Exception_in_OnStarted_cancels_instance_and_does_not_crash() handle.Should().NotBeNull(); // Activation returns success (instance created then canceled). - handle!.Activate(out AbilityActivationResult result).Should().BeTrue(); + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); handle.IsActive.Should().BeFalse(); behavior.StartAttempts.Should().Be(1); } @@ -332,8 +332,8 @@ public void OnAbilityEnded_fires_when_ability_instance_ends() AbilityEndedData? capturedData = null; entity.Abilities.OnAbilityEnded += x => { capturedData = x; }; - handle!.Activate(out AbilityActivationResult result).Should().BeTrue(); - result.Should().Be(AbilityActivationResult.Success); + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); behavior.StartCount.Should().Be(1); behavior.EndCount.Should().Be(0); @@ -359,9 +359,9 @@ public void Ability_is_granted_and_activated_once() data, 1, LevelComparison.None, - out AbilityActivationResult result); + out AbilityActivationFailures failureFlags); - result.Should().Be(AbilityActivationResult.Success); + failureFlags.Should().Be(AbilityActivationFailures.None); entity.Abilities.GrantedAbilities.Should().ContainSingle(); behavior.StartCount.Should().Be(1); diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index ff5de43..9130c98 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -117,9 +117,9 @@ internal Ability( Handle = new AbilityHandle(this); } - internal bool TryActivateAbility(IForgeEntity? abilityTarget, out AbilityActivationResult activationResult) + internal bool TryActivateAbility(IForgeEntity? abilityTarget, out AbilityActivationFailures failureFlags) { - if (CanActivate(abilityTarget, out activationResult)) + if (CanActivate(abilityTarget, out failureFlags)) { Activate(abilityTarget); return true; @@ -247,12 +247,15 @@ internal void OnInstanceEnded(AbilityInstance instance) } } - internal bool CanActivate(IForgeEntity? abilityTarget, out AbilityActivationResult activationResult) + internal bool CanActivate(IForgeEntity? abilityTarget, out AbilityActivationFailures failureFlags) { + var canActivate = true; + failureFlags = AbilityActivationFailures.None; + if (IsInhibited) { - activationResult = AbilityActivationResult.FailedInhibition; - return false; + failureFlags |= AbilityActivationFailures.Inhibited; + canActivate = false; } // Check instance policy for non re-triggerable persistent instance. @@ -260,8 +263,8 @@ internal bool CanActivate(IForgeEntity? abilityTarget, out AbilityActivationResu && !AbilityData.RetriggerInstancedAbility && _persistentInstance?.IsActive == true) { - activationResult = AbilityActivationResult.FailedPersistentInstanceActive; - return false; + failureFlags |= AbilityActivationFailures.PersistentInstanceActive; + canActivate = false; } // Check cooldown. @@ -271,8 +274,8 @@ internal bool CanActivate(IForgeEntity? abilityTarget, out AbilityActivationResu { if (effect?.CachedGrantedTags is not null && Owner.Tags.CombinedTags.HasAny(effect.CachedGrantedTags)) { - activationResult = AbilityActivationResult.FailedCooldown; - return false; + failureFlags |= AbilityActivationFailures.Cooldown; + canActivate = false; } } } @@ -281,8 +284,8 @@ internal bool CanActivate(IForgeEntity? abilityTarget, out AbilityActivationResu if (_costEffect is not null && !Owner.EffectsManager.CanApplyEffect(_costEffect, Level)) { - activationResult = AbilityActivationResult.FailedInsufficientResources; - return false; + failureFlags |= AbilityActivationFailures.InsufficientResources; + canActivate = false; } // Check tags condition. @@ -294,35 +297,34 @@ internal bool CanActivate(IForgeEntity? abilityTarget, out AbilityActivationResu if (FailsRequiredTags(AbilityData.ActivationRequiredTags, ownerTags) || HasBlockedTags(AbilityData.ActivationBlockedTags, ownerTags)) { - activationResult = AbilityActivationResult.FailedOwnerTagRequirements; - return false; + failureFlags |= AbilityActivationFailures.OwnerTagRequirements; + canActivate = false; } // Source tags. if (FailsRequiredTags(AbilityData.SourceRequiredTags, sourceTags) || HasBlockedTags(AbilityData.SourceBlockedTags, sourceTags)) { - activationResult = AbilityActivationResult.FailedSourceTagRequirements; - return false; + failureFlags |= AbilityActivationFailures.SourceTagRequirements; + canActivate = false; } // Target tags. if (FailsRequiredTags(AbilityData.TargetRequiredTags, targetTags) || HasBlockedTags(AbilityData.TargetBlockedTags, targetTags)) { - activationResult = AbilityActivationResult.FailedTargetTagRequirements; - return false; + failureFlags |= AbilityActivationFailures.TargetTagRequirements; + canActivate = false; } // Check ability tags against BlockAbilitiesWithTag if (_abilityTags?.HasAny(Owner.Abilities.BlockedAbilityTags.CombinedTags) == true) { - activationResult = AbilityActivationResult.FailedBlockedByTags; - return false; + failureFlags |= AbilityActivationFailures.BlockedByTags; + canActivate = false; } - activationResult = AbilityActivationResult.Success; - return true; + return canActivate; } internal CooldownData[] GetCooldownData() diff --git a/Forge/Abilities/AbilityActivationResult.cs b/Forge/Abilities/AbilityActivationFailures.cs similarity index 77% rename from Forge/Abilities/AbilityActivationResult.cs rename to Forge/Abilities/AbilityActivationFailures.cs index 16bfb3a..98b7889 100644 --- a/Forge/Abilities/AbilityActivationResult.cs +++ b/Forge/Abilities/AbilityActivationFailures.cs @@ -3,72 +3,73 @@ namespace Gamesmiths.Forge.Abilities; /// -/// Represents the result of an attempt to activate an ability. +/// Flags indicating the result of an ability activation attempt. /// /// /// This enumeration provides detailed outcomes for ability activation attempts, allowing the caller to determine the /// specific reason for success or failure. Use this result to handle activation logic appropriately based on the /// returned value. /// -public enum AbilityActivationResult +[Flags] +public enum AbilityActivationFailures { /// /// Successfully activated the ability. /// - Success = 0, + None = 0, /// /// Failed to activate the ability due to an invalid handler. /// - FailedInvalidHandler = 1, + InvalidHandler = 1 << 0, /// /// Failed to activate the ability because it is currently inhibited. /// - FailedInhibition = 2, + Inhibited = 1 << 1, /// /// Failed to activate the ability because a persistent instance is already active. /// - FailedPersistentInstanceActive = 3, + PersistentInstanceActive = 1 << 2, /// /// Failed to activate the ability because it is on cooldown. /// - FailedCooldown = 4, + Cooldown = 1 << 3, /// /// Failed to activate the ability due to insufficient resources. /// - FailedInsufficientResources = 5, + InsufficientResources = 1 << 4, /// /// Failed to activate the ability due to unmet tag requirements. /// - FailedOwnerTagRequirements = 6, + OwnerTagRequirements = 1 << 5, /// /// Failed to activate the ability due to unmet source tag requirements. /// - FailedSourceTagRequirements = 7, + SourceTagRequirements = 1 << 6, /// /// Failed to activate the ability due to unmet target tag requirements. /// - FailedTargetTagRequirements = 8, + TargetTagRequirements = 1 << 7, /// /// Failed to activate the ability due to being blocked by tags. /// - FailedBlockedByTags = 9, + BlockedByTags = 1 << 8, /// /// Failed to activate the ability because the target tag is not present. /// - FailedTargetTagNotPresent = 10, + TargetTagNotPresent = 1 << 9, /// /// Failed to activate the ability due to invalid tag configuration. /// - FailedInvalidTagConfiguration = 11, + InvalidTagConfiguration = 1 << 11, } diff --git a/Forge/Abilities/AbilityHandle.cs b/Forge/Abilities/AbilityHandle.cs index fe1e377..3e6e169 100644 --- a/Forge/Abilities/AbilityHandle.cs +++ b/Forge/Abilities/AbilityHandle.cs @@ -40,14 +40,14 @@ internal AbilityHandle(Ability ability) /// /// Activates the ability associated with this handle. /// - /// The result of the ability activation attempt. + /// Flags indicating the failure reasons for the ability activation. /// The target entity for the ability activation. /// Return if the ability was successfully activated; /// otherwise, . - public bool Activate(out AbilityActivationResult activationResult, IForgeEntity? target = null) + public bool Activate(out AbilityActivationFailures failureFlags, IForgeEntity? target = null) { - activationResult = AbilityActivationResult.FailedInvalidHandler; - return Ability?.TryActivateAbility(target, out activationResult) ?? false; + failureFlags = AbilityActivationFailures.InvalidHandler; + return Ability?.TryActivateAbility(target, out failureFlags) ?? false; } /// @@ -85,14 +85,14 @@ public void CommitCost() /// /// Checks if the ability can be activated for the given target. /// - /// The result of the ability activation check. + /// Flags indicating the failure reasons for the ability activation. /// Optional target entity for the ability activation check. /// Returns if the ability can be activated; otherwise, . /// - public bool CanActivate(out AbilityActivationResult activationResult, IForgeEntity? abilityTarget = null) + public bool CanActivate(out AbilityActivationFailures failureFlags, IForgeEntity? abilityTarget = null) { - activationResult = AbilityActivationResult.FailedInvalidHandler; - return Ability?.CanActivate(abilityTarget, out activationResult) ?? false; + failureFlags = AbilityActivationFailures.InvalidHandler; + return Ability?.CanActivate(abilityTarget, out failureFlags) ?? false; } /// diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index 55aea9e..7784f3f 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -74,7 +74,6 @@ public void CancelAbilitiesWithTag(TagContainer tagsToCancel) return; } - // Enumerate snapshot to avoid modification during cancel. foreach (AbilityHandle? handle in GrantedAbilities.ToArray()) { Ability? ability = handle?.Ability; @@ -96,26 +95,29 @@ public void CancelAbilitiesWithTag(TagContainer tagsToCancel) /// /// Tags that identify abilities to activate. /// Optional target for the abilities. - /// The result of the ability activation attempt. + /// Flags indicating the failure reasons for the abilities activation. /// Returns if any abilities were activated; otherwise, . /// public bool TryActivateAbilitiesByTag( TagContainer tagsToActivate, IForgeEntity? target, - out AbilityActivationResult activationResult) + out AbilityActivationFailures[] failureFlags) { if (tagsToActivate is null) { - activationResult = AbilityActivationResult.FailedInvalidTagConfiguration; + failureFlags = + [.. Enumerable.Repeat(AbilityActivationFailures.InvalidTagConfiguration, GrantedAbilities.Count)]; return false; } var anyActivated = false; - activationResult = AbilityActivationResult.FailedTargetTagNotPresent; + failureFlags = + [.. Enumerable.Repeat(AbilityActivationFailures.TargetTagNotPresent, GrantedAbilities.Count)]; - // Enumerate snapshot to avoid modification during activation. - foreach (AbilityHandle? handle in GrantedAbilities.ToArray()) + AbilityHandle[] array = [.. GrantedAbilities]; + for (var i = 0; i < array.Length; i++) { + AbilityHandle? handle = array[i]; Ability? ability = handle?.Ability; if (ability is null) { @@ -125,7 +127,7 @@ public bool TryActivateAbilitiesByTag( TagContainer? abilityTags = ability.AbilityData.AbilityTags; if (abilityTags?.HasAny(tagsToActivate) == true) { - anyActivated |= ability.TryActivateAbility(target, out activationResult); + anyActivated |= ability.TryActivateAbility(target, out failureFlags[i]); } } @@ -138,7 +140,7 @@ public bool TryActivateAbilitiesByTag( /// The configuration data of the ability to grant and activate. /// The level at which to grant the ability. /// The policy for overriding the level of an existing granted ability. - /// The result of the ability activation attempt. + /// Flags indicating the failure reasons for the ability activation. /// The target entity for the ability activation, if any. /// The source entity of the granted ability, if any. /// The handle of the granted and activated ability, or if activation failed. @@ -147,7 +149,7 @@ public bool TryActivateAbilitiesByTag( AbilityData abilityData, int abilityLevel, LevelComparison levelOverridePolicy, - out AbilityActivationResult activationResult, + out AbilityActivationFailures failureFlags, IForgeEntity? targetEntity = null, IForgeEntity? sourceEntity = null) { @@ -160,7 +162,7 @@ public bool TryActivateAbilitiesByTag( grantSource, sourceEntity); - abilityHandle.Activate(out activationResult, targetEntity); + abilityHandle.Activate(out failureFlags, targetEntity); RemoveGrantedAbility(abilityHandle, grantSource); From 328ec3d028a5abd0acab72165c92107f513b6a7c Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 21 Dec 2025 23:23:00 -0300 Subject: [PATCH 52/87] Added abilities documentation --- docs/abilities.md | 741 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 741 insertions(+) create mode 100644 docs/abilities.md diff --git a/docs/abilities.md b/docs/abilities.md new file mode 100644 index 0000000..35cd383 --- /dev/null +++ b/docs/abilities.md @@ -0,0 +1,741 @@ +# Abilities System + +The Abilities system in Forge provides a framework for defining, granting, activating, and managing gameplay abilities. Abilities encapsulate discrete actions or powers that entities can perform, with built-in support for cooldowns, costs, tag requirements, instancing policies, and triggered activation. + +## Core Concepts + +- **Granting**: Abilities are granted through [Effects](effects/README.md) or directly via the `EntityAbilities` manager. +- **Identity**: An ability is uniquely identified by the combination of the **Owner**, the **AbilityData**, and the **Source Entity**. +- **Activation**: Each ability has configurable activation requirements, costs, and cooldowns. +- **Instancing**: Policies control how multiple concurrent activations are handled. +- **Triggers**: Activation can be triggered manually, by events, or by tag changes. +- **Behaviors**: Custom logic is implemented through the `IAbilityBehavior` interface. + +## Ability Data + +`AbilityData` defines the configuration for an ability: + +```csharp +var abilityData = new AbilityData( + name: "Fireball", + costEffect: costEffectData, + cooldownEffects: [cooldownEffectData, globalCooldownData], + abilityTags: fireballTags, + instancingPolicy: AbilityInstancingPolicy.PerEntity, + retriggerInstancedAbility: false, + abilityTriggerData: null, + cancelAbilitiesWithTag: null, + blockAbilitiesWithTag: null, + activationOwnedTags: castingTags, + activationRequiredTags: null, + activationBlockedTags: stunnedTags, + sourceRequiredTags: null, + sourceBlockedTags: null, + targetRequiredTags: enemyTags, + targetBlockedTags: immuneTags, + behaviorFactory: () => new FireballBehavior()); +``` + +### Configuration Options + +- **Name**: Identifier for the ability. +- **CostEffect**: An instant effect defining resource costs. +- **CooldownEffects**: Duration effects preventing reactivation. +- **AbilityTags**: Tags identifying this ability for blocking/cancellation. +- **InstancingPolicy**: Controls concurrent activation handling. +- **RetriggerInstancedAbility**: Restarts persistent instances on re-activation. +- **AbilityTriggerData**: Configuration for automatic activation triggers. +- **CancelAbilitiesWithTag**: Cancels matching abilities on activation. +- **BlockAbilitiesWithTag**: Blocks matching abilities while active. +- **ActivationOwnedTags**: Tags applied to owner while active. +- **ActivationRequiredTags**: Owner tags required to activate. +- **ActivationBlockedTags**: Owner tags preventing activation. +- **SourceRequiredTags**: Source tags required to activate. +- **SourceBlockedTags**: Source tags preventing activation. +- **TargetRequiredTags**: Target tags required to activate. +- **TargetBlockedTags**: Target tags preventing activation. +- **BehaviorFactory**: Factory creating the behavior instance. + +## Granting Abilities + +Abilities can be granted to entities in several ways: through effects, permanently, or transiently for one-time activation. + +### Granting Through Effects + +Use `GrantAbilityEffectComponent` to grant abilities that are tied to an effect's lifecycle. The ability's level is determined by the `abilityLevel` ScalableInt evaluated against the **granting effect's level**. + +```csharp +var grantAbilityConfig = new GrantAbilityConfig( + abilityData, + abilityLevel: new ScalableInt(1, curve: myLevelCurve), + grantedAbilityRemovalPolicy: AbilityDeactivationPolicy.CancelImmediately, + grantedAbilityInhibitionPolicy: AbilityDeactivationPolicy.CancelImmediately, + levelOverridePolicy: LevelComparison.Higher); + +var grantEffect = new EffectData( + "Grant Fireball", + new DurationData(DurationType.Infinite), + effectComponents: [new GrantAbilityEffectComponent([grantAbilityConfig])]); + +// If the effect is applied at level 5, the ScalableInt calculates the ability level accordingly +entity.EffectsManager.ApplyEffect(new Effect(grantEffect, ownership, level: 5)); +``` + +Abilities granted by **duration or infinite effects** are generally temporary and tied to the effect's lifecycle, unless configured otherwise (see below). + +### Granting Permanently + +There are three primary ways to grant an ability that persists permanently on an entity: + +1. **GrantAbilityPermanently Method**: + Using `entity.Abilities.GrantAbilityPermanently(...)` creates a grant that cannot be removed or inhibited by any means. Useful for base character skills. + + ```csharp + entity.Abilities.GrantAbilityPermanently( + abilityData: fireballAbility, + abilityLevel: 1, + levelOverridePolicy: LevelComparison.Higher, + sourceEntity: null); + ``` + +2. **Instant Effects**: + Using `GrantAbilityEffectComponent` inside an effect with `DurationType.Instant`. Because the effect applies and immediately expires (leaving no handle behind), the grant becomes permanent and cannot be inhibited. + +3. **Removal Policy Ignore**: + Using a standard Duration/Infinite effect but setting `grantedAbilityRemovalPolicy` to `Ignore`. + * **Note**: Unlike the first two methods, the granting effect *remains* on the entity. + * If you set `grantedAbilityInhibitionPolicy` to something other than `Ignore`, the ability **can still be inhibited** if the effect is inhibited (e.g., via tags), even though the ability won't be removed when the effect ends. + +### Granting and Activating Once + +Use `GrantAbilityAndActivateOnce` to grant an ability temporarily and immediately attempt to activate it: + +```csharp +AbilityHandle? handle = entity.Abilities.GrantAbilityAndActivateOnce( + abilityData: consumableAbility, + abilityLevel: 1, + levelOverridePolicy: LevelComparison.None, + out AbilityActivationFailures failureFlags, + targetEntity: enemy, + sourceEntity: item); + +if (handle is not null) +{ + // Ability activated successfully (failureFlags == AbilityActivationFailures.None) + // The grant will be removed automatically when the ability ends +} +else +{ + // Activation failed, the grant was already removed + // Check failureFlags for the specific reasons (e.g. failureFlags.HasFlag(AbilityActivationFailures.InsufficientResources)) +} +``` + +The ability grant is automatically removed when the ability ends. If activation fails, the grant is removed immediately and the method returns `null`. + +## Grant Sources and Policies + +Each time an ability is granted, a **grant source** is created that tracks how that specific grant should behave. An ability can have multiple grant sources if it's granted multiple times (e.g., by different effects or methods). + +### Deactivation Policies + +`AbilityDeactivationPolicy` controls behavior when a grant source is removed or inhibited: + +- **CancelImmediately**: Cancel all active instances and remove/inhibit immediately. +- **RemoveOnEnd**: Wait for all active instances to end before removing/inhibiting. +- **Ignore**: The grant source ignores removal/inhibition requests entirely. + +### Policy Interactions Between Grant Sources + +When an ability has multiple grant sources, each source has its own policies. The behavior depends on how these policies interact: + +```csharp +// Create two effects that grant the same ability with different policies +var grantConfig1 = new GrantAbilityConfig( + abilityData, + new ScalableInt(1), + removalPolicy: AbilityDeactivationPolicy.RemoveOnEnd, + inhibitionPolicy: AbilityDeactivationPolicy.Ignore); + +var grantConfig2 = new GrantAbilityConfig( + abilityData, + new ScalableInt(1), + removalPolicy: AbilityDeactivationPolicy.CancelImmediately, + inhibitionPolicy: AbilityDeactivationPolicy.Ignore); + +// Assume grantEffect1 and grantEffect2 are created using the configs above... + +// Apply both effects - they grant the same ability +ActiveEffectHandle? effectHandle1 = entity.EffectsManager.ApplyEffect(grantEffect1); +ActiveEffectHandle? effectHandle2 = entity.EffectsManager.ApplyEffect(grantEffect2); + +// Get the ability handle (both grants reference the same ability) +entity.Abilities.TryGetAbility(abilityData, out AbilityHandle? handle); +handle.Activate(out _); + +// Removing effect 1 (RemoveOnEnd): ability stays active, waits for end +entity.EffectsManager.UnapplyEffect(effectHandle1); +// Ability is still active and granted + +// Removing effect 2 (CancelImmediately): cancels immediately and removes +entity.EffectsManager.UnapplyEffect(effectHandle2); +// Ability is now canceled and removed (no more grant sources) +``` + +**Key behaviors:** + +1. **Multiple sources, one removed**: The ability remains granted as long as at least one grant source exists. +2. **CancelImmediately takes precedence**: If any remaining grant source has `CancelImmediately` policy when removed, it will cancel the ability immediately regardless of other sources' policies. +3. **Inhibition is cumulative**: The ability is only inhibited when ALL non-ignored grant sources are inhibited. + +### Multiple Grant Sources + +If an ability is granted by multiple sources, it remains granted until all sources are removed: + +```csharp +// Apply two effects that grant the same ability +ActiveEffectHandle? effectHandle1 = entity.EffectsManager.ApplyEffect(grantEffect1); +ActiveEffectHandle? effectHandle2 = entity.EffectsManager.ApplyEffect(grantEffect2); + +// Only one ability instance exists +entity.Abilities.GrantedAbilities.Count; // 1 + +// Remove first grant - ability still exists +entity.EffectsManager.UnapplyEffect(effectHandle1); +entity.Abilities.GrantedAbilities.Count; // 1 + +// Remove second grant - now the ability is removed +entity.EffectsManager.UnapplyEffect(effectHandle2); +entity.Abilities.GrantedAbilities.Count; // 0 +``` + +### Level Override Policy + +When an ability is granted multiple times, the `LevelOverridePolicy` determines whether the level should be updated: + +```csharp +// First grant at level 2 +var config1 = new GrantAbilityConfig(abilityData, new ScalableInt(2), ...); +entity.EffectsManager.ApplyEffect(grantEffect1); +// handle.Level == 2 + +// Second grant at level 3 with Higher policy: level updates +var config2 = new GrantAbilityConfig( + abilityData, + new ScalableInt(3), + levelOverridePolicy: LevelComparison.Higher, ... ); +entity.EffectsManager.ApplyEffect(grantEffect2); +// handle.Level == 3 + +// Third grant at level 1 with Higher policy: level stays at 3 +var config3 = new GrantAbilityConfig( + abilityData, + new ScalableInt(1), + levelOverridePolicy: LevelComparison.Higher, ...); +entity.EffectsManager.ApplyEffect(grantEffect3); +// handle.Level == 3 +``` + +## Entity Abilities Manager + +`EntityAbilities` is the manager that handles all ability operations for an entity: + +```csharp +// Access through the entity +EntityAbilities abilities = entity.Abilities; + +// Get all granted abilities +HashSet granted = abilities.GrantedAbilities; + +// Get blocked ability tags (used internally for ability blocking) +EntityTags blockedTags = abilities.BlockedAbilityTags; +``` + +### Finding Abilities + +Use `TryGetAbility` to find a granted ability by its data. + +**Note on Identity:** An ability is uniquely identified by its `AbilityData` **and** its `SourceEntity`. You can have the same ability granted multiple times if the sources differ (e.g., one from an Item, one from a Class). + +```csharp +if (entity.Abilities.TryGetAbility(fireballData, out AbilityHandle? handle)) +{ + // Ability is granted, use the handle + handle.Activate(out AbilityActivationFailures failures); +} + +// With a specific source entity +if (entity.Abilities.TryGetAbility(buffData, out AbilityHandle? handle, sourceEntity: caster)) +{ + // Found the ability granted by this specific source +} +``` + +### Activating Abilities by Tag + +Use `TryActivateAbilitiesByTag` to activate all abilities that match specific tags: + +```csharp +var attackTags = new TagContainer(tagsManager, [attackTag]); + +bool anyActivated = entity.Abilities.TryActivateAbilitiesByTag( + attackTags, + target: enemy, + out AbilityActivationFailures[] failures); + +if (anyActivated) +{ + // At least one ability with matching tags was activated +} +``` + +This is useful for input handling where a single button might activate different abilities based on context. + +### Canceling Abilities by Tag + +Use `CancelAbilitiesWithTag` to cancel all active abilities that match specific tags: + +```csharp +var interruptibleTags = new TagContainer(tagsManager, [interruptibleTag]); + +// Cancel all interruptible abilities (e.g., when stunned) +entity.Abilities.CancelAbilitiesWithTag(interruptibleTags); +``` + +### Ability Events + +Subscribe to `OnAbilityEnded` to react when abilities end: + +```csharp +entity.Abilities.OnAbilityEnded += data => +{ + AbilityHandle ability = data.Ability; + bool wasCanceled = data.WasCanceled; + + if (wasCanceled) + { + // Ability was interrupted + ShowInterruptedFeedback(); + } + else + { + // Ability completed normally + ShowCompletedFeedback(); + } +}; +``` + +## Ability Handle + +`AbilityHandle` is the public interface for interacting with a granted ability: + +```csharp +if (entity.Abilities.TryGetAbility(abilityData, out AbilityHandle? handle)) +{ + if (handle.Activate(out AbilityActivationFailures failureFlags)) + { + // Ability activated successfully + } + else + { + // Check specific failure flags + if (failureFlags.HasFlag(AbilityActivationFailures.Cooldown)) + { + // Show cooldown UI + } + + if (failureFlags.HasFlag(AbilityActivationFailures.InsufficientResources)) + { + // Show "not enough mana" message + } + } +} +``` + +### Handle Properties and Methods + +- **IsActive**: Whether any instance of the ability is currently active. +- **IsInhibited**: Whether the ability is inhibited by its granting effect. +- **IsValid**: Whether the handle still references a valid granted ability. +- **Level**: The current level of the ability. +- **Activate(out failureFlags)**: Attempt to activate the ability. Returns true if successful. +- **Activate(out failureFlags, target)**: Attempt to activate with a specific target. +- **Cancel()**: Cancel all active instances. +- **CommitAbility()**: Helper that calls both `CommitCooldown()` and `CommitCost()`. +- **CommitCooldown()**: Apply the cooldown effects. +- **CommitCost()**: Apply the cost effect. +- **GetCooldownData()**: Get information about all cooldowns. +- **GetRemainingCooldownTime(tag)**: Get remaining time for a specific cooldown. +- **GetCostData()**: Get information about all costs. +- **GetCostForAttribute(attribute)**: Get cost for a specific attribute. + +### Activation Failures + +`AbilityActivationFailures` is a **Flags Enum** that indicates all reasons why an activation failed. Unlike a simple result code, this allows the system to report multiple failures simultaneously (e.g., Insufficient Resources AND Cooldown). + +- **None**: Successfully activated. +- **InvalidHandler**: The ability handle is invalid. +- **Inhibited**: Ability is inhibited by its granting effect. +- **PersistentInstanceActive**: A non-retriggerable persistent instance is already active. +- **Cooldown**: Ability is on cooldown. +- **InsufficientResources**: Cannot afford the cost. +- **OwnerTagRequirements**: Owner doesn't meet tag requirements. +- **SourceTagRequirements**: Source doesn't meet tag requirements. +- **TargetTagRequirements**: Target doesn't meet tag requirements. +- **BlockedByTags**: Another active ability is blocking this one. +- **TargetTagNotPresent**: No abilities matched the requested tags (when using `TryActivateAbilitiesByTag`). +- **InvalidTagConfiguration**: Invalid tag configuration provided. + +## Instancing Policies + +`AbilityInstancingPolicy` determines how multiple activations are handled. + +**Note on Identity**: Forge creates one instance of the Ability per entity + source entity. This means if you have a source entity configured (e.g., two different equipped swords granting "Slash"), you will have two distinct abilities that can execute independently with their own levels and cooldowns. + +### PerEntity + +Only one instance can be active at a time per entity (per unique ability identity): + +```csharp +var abilityData = new AbilityData( + "Shield Block", + instancingPolicy: AbilityInstancingPolicy.PerEntity, + retriggerInstancedAbility: false); +``` + +With `retriggerInstancedAbility: false`, attempting to activate while active fails with `AbilityActivationFailures.PersistentInstanceActive`. + +With `retriggerInstancedAbility: true`, the active instance is canceled and a new one starts: + +```csharp +var abilityData = new AbilityData( + "Channeled Beam", + instancingPolicy: AbilityInstancingPolicy.PerEntity, + retriggerInstancedAbility: true); +``` + +### PerExecution + +Multiple instances can be active simultaneously: + +```csharp +var abilityData = new AbilityData( + "Trap", + instancingPolicy: AbilityInstancingPolicy.PerExecution); + +// Each activation creates a new instance +handle.Activate(out _); // Instance 1 +handle.Activate(out _); // Instance 2 +handle.Activate(out _); // Instance 3 + +// Cancel ends all instances +handle.Cancel(); +``` + +## Cooldowns + +Cooldowns prevent ability reactivation for a duration. They are implemented as duration effects that grant tags. + +**Requirements**: +- Cooldown effects **must** have a Duration (not Instant, not Infinite). +- Cooldown effects **must** have a `ModifierTagsEffectComponent`. + +The system receives an array of cooldown effects, allowing you to trigger multiple independent cooldowns at once (e.g., a short "Skill Cooldown" and a longer "Global Cooldown"). + +```csharp +var cooldownEffect = new EffectData( + "Fireball Cooldown", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(5f))), + effectComponents: [new ModifierTagsEffectComponent(cooldownTags)]); + +var abilityData = new AbilityData( + "Fireball", + cooldownEffects: [cooldownEffect]); +``` + +Multiple cooldown effects can be used for abilities with multiple cooldown conditions: + +```csharp +// Ability has both a short cooldown and a charge system +var abilityData = new AbilityData( + "Dash", + cooldownEffects: [dashCooldownEffect, globalCooldownEffect]); +``` + +### Querying Cooldown State + +```csharp +// Get all cooldown information +CooldownData[] cooldowns = handle.GetCooldownData(); +foreach (CooldownData cd in cooldowns) +{ + float remaining = cd.RemainingTime; + float total = cd.TotalTime; + float progress = 1f - (remaining / total); +} + +// Get specific cooldown by tag +float remainingTime = handle.GetRemainingCooldownTime(cooldownTag); +``` + +Cooldowns are checked during activation but only applied when `CommitCooldown()` or `CommitAbility()` is called. + +## Costs + +Costs are instant effects that modify attributes when committed. + +**Requirements**: +- Cost effects **must** be Instant. +- Attribute modifiers must be **negative** to consume resources (e.g., -30 Mana). + +**Validation Logic**: + +Cost modifiers are validated against the attribute's configured min/max bounds: +- If the modifier is **negative** (consumption), it tests against the attribute's **Minimum Value** (e.g., Do I have enough Mana to pay -30 without going below 0?) +- If the modifier is **positive** (restoration), it tests against the attribute's **Maximum Value**. (e.g., Is my Health low enough to receive +50 healing without exceeding Max Health?) + +You can add multiple modifiers to the single `CostEffect`, allowing an ability to consume multiple different attributes (e.g., Mana and Health). + +```csharp +var costEffect = new EffectData( + "Fireball Cost", + new DurationData(DurationType.Instant), + [new Modifier( + "ManaAttributeSet.CurrentMana", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-30f)))]); + +var abilityData = new AbilityData( + "Fireball", + costEffect: costEffect); +``` + +Cost is checked during activation but only applied when `CommitCost()` or `CommitAbility()` is called. + +## Ability Behavior + +`IAbilityBehavior` defines custom logic that runs during an ability's lifecycle. It gives the developer total control, but comes with important responsibilities. + +### Developer Responsibilities + +1. **Ending Instances**: It is up to the developer to call `context.InstanceHandle.End()` when the ability logic is complete. If you fail to do this, the system will consider the ability "Active" indefinitely. + * **Passive Abilities**: For passive abilities, you may intentionally **not** call `End()`. This keeps the ability active (and listening to events/tags) until it is manually canceled or the grant is removed. +2. **Committing**: Resources and Cooldowns are not applied automatically. You must call `context.AbilityHandle.CommitAbility()` (or `CommitCost` / `CommitCooldown` separately). + * `CommitAbility()` calls both `CommitCost()` and `CommitCooldown()`. + * Do **not** call all three; it is redundant. + * Deferring commits allows for mechanics like "free cast if cancelled early." + +```csharp +public class FireballBehavior : IAbilityBehavior +{ + public void OnStarted(AbilityBehaviorContext context) + { + // Called when the ability instance starts + IForgeEntity owner = context.Owner; + IForgeEntity? source = context.Source; + IForgeEntity? target = context.Target; + int level = context.Level; + AbilityHandle abilityHandle = context.AbilityHandle; + AbilityInstanceHandle instanceHandle = context.InstanceHandle; + + // Commit cooldown and cost + // This calls both CommitCooldown() and CommitCost() + abilityHandle.CommitAbility(); + + // Spawn projectile, start animation, etc. + SpawnFireball(owner, target, level); + } + + public void OnEnded(AbilityBehaviorContext context) + { + // Called when the ability instance ends + // Clean up effects, stop animations, etc. + } +} +``` + +### Behavior Context + +`AbilityBehaviorContext` provides access to ability state: + +- **Owner**: The entity that owns this ability. +- **Source**: The entity that granted this ability (may be null). +- **Target**: The target passed during activation (may be null). +- **Level**: The ability's current level. +- **AbilityHandle**: Handle to the ability for committing cost/cooldown. +- **InstanceHandle**: Handle to this specific instance for ending it. + +### Ending Instances + +Behaviors can end their instance at any time: + +```csharp +public class InstantAbilityBehavior : IAbilityBehavior +{ + public void OnStarted(AbilityBehaviorContext context) + { + context.AbilityHandle.CommitAbility(); + + // Do the instant effect + ApplyDamage(context.Target); + + // End immediately + context.InstanceHandle.End(); + } + + public void OnEnded(AbilityBehaviorContext context) + { + // Cleanup if needed + } +} +``` + +### Behavior Factory + +The behavior factory creates a new behavior instance for **each activation**: + +```csharp +// Simple factory +var abilityData = new AbilityData( + "Fireball", + behaviorFactory: () => new FireballBehavior()); + +// Factory with dependencies +var abilityData = new AbilityData( + "Fireball", + behaviorFactory: () => new FireballBehavior(projectilePool, audioManager)); + +// Per-execution instancing creates separate behavior instances +var abilityData = new AbilityData( + "Trap", + instancingPolicy: AbilityInstancingPolicy.PerExecution, + behaviorFactory: () => new TrapBehavior()); // Each trap gets its own behavior +``` + +## Ability Triggers + +Abilities can be automatically activated in response to events or tag changes: + +### Event Trigger + +Activate when a specific event is raised: + +```csharp +var abilityData = new AbilityData( + "Counter Attack", + abilityTriggerData: new AbilityTriggerData( + TriggerTag: Tag.RequestTag(tagsManager, "events.combat.blocked"), + TriggerSource: AbilityTriggerSource.Event)); + +// Later, when the entity blocks an attack: +entity.Events.Raise(new EventData +{ + EventTags = blockedEventTags, + Source = attacker, + Target = entity +}); +// Counter Attack activates automatically +``` + +### Tag Added Trigger + +Activate when a tag is added to the entity: + +```csharp +var abilityData = new AbilityData( + "Rage", + abilityTriggerData: new AbilityTriggerData( + TriggerTag: Tag.RequestTag(tagsManager, "status.enraged"), + TriggerSource: AbilityTriggerSource.TagAdded)); + +// When the entity gains the "status.enraged" tag, Rage activates +``` + +### Tag Present Trigger + +Stay active while a tag is present. This acts as a toggle: + +```csharp +var abilityData = new AbilityData( + "Burning Aura", + abilityTriggerData: new AbilityTriggerData( + TriggerTag: Tag.RequestTag(tagsManager, "status.on_fire"), + TriggerSource: AbilityTriggerSource.TagPresent)); + +// 1. Tag "status.on_fire" added -> Ability Activates +// 2. Tag "status.on_fire" removed -> Ability is Canceled +``` + +## Tag Interactions + +### Blocking and Canceling + +Abilities can block or cancel other abilities based on tags: + +```csharp +// This ability cancels any active ability with "ability.interruptible" tag +var interruptAbility = new AbilityData( + "Interrupt", + cancelAbilitiesWithTag: interruptibleTags); + +// This ability prevents abilities with "ability.movement" from activating +var rootAbility = new AbilityData( + "Root", + blockAbilitiesWithTag: movementTags); +``` + +Blocking tags are tracked per-instance. If multiple instances of a blocking ability are active, the blocked abilities remain blocked until all instances end. + +### Activation Owned Tags + +Tags that are applied to the owner while the ability is active: + +```csharp +var channelAbility = new AbilityData( + "Channel", + activationOwnedTags: channelingTags); + +// While Channel is active, owner has "status.channeling" tag +// Other abilities can check for this tag in requirements +``` + +## Inhibition + +When a granting effect is inhibited (e.g., due to tag requirements), the granted ability becomes inhibited: + +```csharp +// Grant ability with ongoing tag requirements +var grantEffect = new EffectData( + "Grant Fireball", + new DurationData(DurationType.Infinite), + effectComponents: + [ + new GrantAbilityEffectComponent([grantConfig]), + new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements(IgnoreTags: silencedTags)) + ]); + +// When entity gains "status.silenced", the ability becomes inhibited +// Activation fails with AbilityActivationFailures.Inhibited +``` + +With `GrantedAbilityInhibitionPolicy.RemoveOnEnd`, an active ability continues running but becomes inhibited after it ends. + +Abilities granted permanently via `GrantAbilityPermanently` cannot be inhibited. + +## Best Practices + +1. **Separate Data from Behavior**: Define ability configuration in `AbilityData` and implement logic in `IAbilityBehavior`. +2. **Use Appropriate Instancing**: Choose `PerEntity` for abilities that should have one active instance, `PerExecution` for stackable abilities. +3. **Commit Explicitly**: Call `CommitAbility()` (or individual commits) inside your behavior. +4. **End Instances**: Always call `context.InstanceHandle.End()` when logic completes to prevent "stuck" abilities. +5. **Handle Failure Flags**: Use the `AbilityActivationFailures` flags to provide specific feedback to the player (e.g. check for `Cooldown` and `InsufficientResources`). +6. **Clean Up in OnEnded**: Always clean up spawned objects, effects, and state in `OnEnded`. +7. **Use Tag Requirements**: Leverage tag-based requirements for complex activation conditions. +8. **Consider Policy Interactions**: When granting abilities from multiple sources, be aware that `CancelImmediately` policies take precedence. +9. **Query Before Activation**: Use `GetCooldownData()` and `GetCostData()` to show UI state before attempting activation. +10. **Use Permanent Grants for Innate Abilities**: Use `GrantAbilityPermanently` for abilities that should always be available. +11. **Use Tag-Based Activation**: Use `TryActivateAbilitiesByTag` for flexible input handling where multiple abilities share activation contexts. +12. **Check Validation Rules**: Ensure cooldowns have durations/tags and costs are instant. From 2a402f6f4d4b35979fc153f4fb8ee3fc4220c2cf Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 21 Dec 2025 23:37:56 -0300 Subject: [PATCH 53/87] Update components documentation --- docs/abilities.md | 44 +++++++++++++++++------------------ docs/effects/components.md | 47 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 22 deletions(-) diff --git a/docs/abilities.md b/docs/abilities.md index 35cd383..a246afe 100644 --- a/docs/abilities.md +++ b/docs/abilities.md @@ -72,39 +72,38 @@ var grantAbilityConfig = new GrantAbilityConfig( grantedAbilityInhibitionPolicy: AbilityDeactivationPolicy.CancelImmediately, levelOverridePolicy: LevelComparison.Higher); +var grantComponent = new GrantAbilityEffectComponent([grantAbilityConfig]); + var grantEffect = new EffectData( "Grant Fireball", new DurationData(DurationType.Infinite), - effectComponents: [new GrantAbilityEffectComponent([grantAbilityConfig])]); + effectComponents: [grantComponent]); // If the effect is applied at level 5, the ScalableInt calculates the ability level accordingly entity.EffectsManager.ApplyEffect(new Effect(grantEffect, ownership, level: 5)); ``` -Abilities granted by **duration or infinite effects** are generally temporary and tied to the effect's lifecycle, unless configured otherwise (see below). - -### Granting Permanently +**Tip:** By holding a reference to the `GrantAbilityEffectComponent` used in your `EffectData`, you can access the `grantComponent.GrantedAbilities` list. This provides a direct reference to the `AbilityHandle`s created by the effect, which can be more reliable than searching via `TryGetAbility` if you need to manipulate that specific instance immediately. -There are three primary ways to grant an ability that persists permanently on an entity: +Abilities granted by **instant effects** become permanent, while abilities granted by **duration or infinite effects** are temporary and tied to the effect's lifecycle. -1. **GrantAbilityPermanently Method**: - Using `entity.Abilities.GrantAbilityPermanently(...)` creates a grant that cannot be removed or inhibited by any means. Useful for base character skills. +### Granting Permanently - ```csharp - entity.Abilities.GrantAbilityPermanently( - abilityData: fireballAbility, - abilityLevel: 1, - levelOverridePolicy: LevelComparison.Higher, - sourceEntity: null); - ``` +There are three ways to grant an ability that persists permanently: -2. **Instant Effects**: - Using `GrantAbilityEffectComponent` inside an effect with `DurationType.Instant`. Because the effect applies and immediately expires (leaving no handle behind), the grant becomes permanent and cannot be inhibited. +1. **Direct API**: Use `entity.Abilities.GrantAbilityPermanently(...)`. These abilities cannot be removed or inhibited by the effects system. +2. **Instant Effects**: Apply an effect with `DurationType.Instant` that contains a `GrantAbilityEffectComponent`. These behave exactly like manually granted permanent abilities. +3. **Ignore Policy**: Apply a Duration/Infinite effect with a `GrantAbilityEffectComponent` configured with `RemovalPolicy = AbilityDeactivationPolicy.Ignore`. + * Unlike the other two methods, abilities granted this way *can* still be inhibited if the source effect is inhibited (depending on `InhibitionPolicy`). + * They will simply not be removed when the source effect is removed. -3. **Removal Policy Ignore**: - Using a standard Duration/Infinite effect but setting `grantedAbilityRemovalPolicy` to `Ignore`. - * **Note**: Unlike the first two methods, the granting effect *remains* on the entity. - * If you set `grantedAbilityInhibitionPolicy` to something other than `Ignore`, the ability **can still be inhibited** if the effect is inhibited (e.g., via tags), even though the ability won't be removed when the effect ends. +```csharp +entity.Abilities.GrantAbilityPermanently( + abilityData: fireballAbility, + abilityLevel: 1, + levelOverridePolicy: LevelComparison.Higher, + sourceEntity: null); +``` ### Granting and Activating Once @@ -521,12 +520,13 @@ Cost is checked during activation but only applied when `CommitCost()` or `Commi ### Developer Responsibilities 1. **Ending Instances**: It is up to the developer to call `context.InstanceHandle.End()` when the ability logic is complete. If you fail to do this, the system will consider the ability "Active" indefinitely. - * **Passive Abilities**: For passive abilities, you may intentionally **not** call `End()`. This keeps the ability active (and listening to events/tags) until it is manually canceled or the grant is removed. 2. **Committing**: Resources and Cooldowns are not applied automatically. You must call `context.AbilityHandle.CommitAbility()` (or `CommitCost` / `CommitCooldown` separately). * `CommitAbility()` calls both `CommitCost()` and `CommitCooldown()`. * Do **not** call all three; it is redundant. * Deferring commits allows for mechanics like "free cast if cancelled early." +**Note**: It is entirely possible to **not end** an ability. This is useful for passive abilities or toggles that should run continuously until cancelled externally or by tag triggers. + ```csharp public class FireballBehavior : IAbilityBehavior { @@ -698,7 +698,7 @@ var channelAbility = new AbilityData( activationOwnedTags: channelingTags); // While Channel is active, owner has "status.channeling" tag -// Other abilities can check for this tag in requirements +// Other abilities can check for this tag in their requirements ``` ## Inhibition diff --git a/docs/effects/components.md b/docs/effects/components.md index 517b896..81b8b05 100644 --- a/docs/effects/components.md +++ b/docs/effects/components.md @@ -224,6 +224,53 @@ Components can be used to implement complex systems that integrate with your gam Forge includes several built-in components that demonstrate the component system's capabilities and provide ready-to-use functionality. +### GrantAbilityEffectComponent + +Grants one or more abilities to the target entity. This is the primary bridge between the Effects system and the Abilities system. + +```csharp +public class GrantAbilityEffectComponent(GrantAbilityConfig[] grantAbilityConfigs) : IEffectComponent +{ + public IReadOnlyList GrantedAbilities { get; } + // Implementation... +} +``` + +#### Usage Example + +```csharp +var grantConfig = new GrantAbilityConfig( + abilityData: fireballData, + abilityLevel: new ScalableInt(1), // Scales with effect level + removalPolicy: AbilityDeactivationPolicy.CancelImmediately, // Cancels running instances immediately when effect ends + inhibitionPolicy: AbilityDeactivationPolicy.CancelImmediately, // Cancels running instances immediately if effect is inhibited + levelOverridePolicy: LevelComparison.Higher // Update level if higher than existing grant +); + +// Keep a reference to the component if you need to access the granted ability handles later +var grantComponent = new GrantAbilityEffectComponent([grantConfig]); + +var grantEffect = new EffectData( + "Grant Fireball", + new DurationData(DurationType.Infinite), + effectComponents: [grantComponent] +); + +// Apply the effect +entity.EffectsManager.ApplyEffect(new Effect(grantEffect, ownership)); + +// Access the handle directly from the component instance +AbilityHandle fireballHandle = grantComponent.GrantedAbilities[0]; +``` + +Key points: + +- **Direct Handle Access**: You can hold onto the component instance to access `GrantedAbilities`. This provides direct references to the `AbilityHandle`s created by this specific effect application, which is often more reliable than searching via `TryGetAbility`. +- **Lifecycle Management**: Automatically handles granting, removing, and inhibiting abilities based on the effect's lifecycle and the configured policies. +- **Permanent vs. Temporary**: + - If used in an **Instant** effect, the ability is granted permanently. + - If used in a **Duration** effect, the ability exists only while the effect is active (unless removal policy is set to `Ignore`). + ### ChanceToApplyEffectComponent Adds a random chance for effects to be applied, with support for level-based scaling. From 6f2dc8299cb5467ccf596d2d9f9d5eb0c0ae2edf Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 28 Dec 2025 23:31:03 -0300 Subject: [PATCH 54/87] Updated quick-star examples --- Forge.Tests/Samples/ExamplesTestFixture.cs | 6 +- Forge.Tests/Samples/QuickStartTests.cs | 367 +++++++++++++++++++++ Forge/Core/EntityAbilities.cs | 7 +- README.md | 2 +- docs/abilities.md | 2 +- docs/quick-start.md | 328 +++++++++++++++++- 6 files changed, 702 insertions(+), 10 deletions(-) diff --git a/Forge.Tests/Samples/ExamplesTestFixture.cs b/Forge.Tests/Samples/ExamplesTestFixture.cs index 9c72c1c..01731f6 100644 --- a/Forge.Tests/Samples/ExamplesTestFixture.cs +++ b/Forge.Tests/Samples/ExamplesTestFixture.cs @@ -25,8 +25,12 @@ public ExamplesTestFixture() "class.warrior", "status.stunned", "status.burning", + "status.enraged", "status.immune.fire", - "cues.damage.fire" + "cues.damage.fire", + "events.combat.damage", + "events.combat.hit", + "cooldown.fireball" }); CuesManager.RegisterCue( diff --git a/Forge.Tests/Samples/QuickStartTests.cs b/Forge.Tests/Samples/QuickStartTests.cs index e12e50b..77f2fbb 100644 --- a/Forge.Tests/Samples/QuickStartTests.cs +++ b/Forge.Tests/Samples/QuickStartTests.cs @@ -6,6 +6,7 @@ using FluentAssertions; using Gamesmiths; using Gamesmiths.Forge; +using Gamesmiths.Forge.Abilities; using Gamesmiths.Forge.Attributes; using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Cues; @@ -42,6 +43,7 @@ public void Creating_a_basic_entity() var player = new Player(tagsManager, cuesManager); player.Attributes["PlayerAttributeSet.Health"].CurrentValue.Should().Be(100); + player.Attributes["PlayerAttributeSet.Mana"].CurrentValue.Should().Be(100); player.Attributes["PlayerAttributeSet.Strength"].CurrentValue.Should().Be(10); player.Attributes["PlayerAttributeSet.Speed"].CurrentValue.Should().Be(5); } @@ -733,15 +735,346 @@ public void Manually_triggering_a_cue() mockCueHandler.Magnitudes.Should().Contain(25); } + [Fact] + [Trait("QuickStart", null)] + public void Subscribing_and_raising_an_event() + { + // Initialize managers + var tagsManager = _tagsManager; + var cuesManager = _cuesManager; + + var player = new Player(tagsManager, cuesManager); + var damageTag = Tag.RequestTag(tagsManager, "events.combat.damage"); + + float receivedDamage = 0f; + bool eventFired = false; + + player.Events.Subscribe(damageTag, eventData => + { + eventFired = true; + receivedDamage = eventData.EventMagnitude; + }); + + player.Events.Raise(new EventData + { + EventTags = damageTag.GetSingleTagContainer(), + Source = null, + Target = player, + EventMagnitude = 50f + }); + + eventFired.Should().BeTrue(); + receivedDamage.Should().Be(50f); + } + + [Fact] + [Trait("QuickStart", null)] + public void Strongly_typed_events() + { + // Initialize managers + var tagsManager = _tagsManager; + var cuesManager = _cuesManager; + + var player = new Player(tagsManager, cuesManager); + var damageTag = Tag.RequestTag(tagsManager, "events.combat.damage"); + + string logMessage = string.Empty; + int logValue = 0; + + // Subscribe with generic type + player.Events.Subscribe(damageTag, eventData => + { + logMessage = eventData.Payload.Message; + logValue = eventData.Payload.Value; + }); + + // Raise with generic type + player.Events.Raise(new EventData + { + EventTags = damageTag.GetSingleTagContainer(), + Source = null, + Target = player, + Payload = new CombatLogPayload("Critical Hit", 9999) + }); + + logMessage.Should().Be("Critical Hit"); + logValue.Should().Be(9999); + } + + [Fact] + [Trait("QuickStart", null)] + public void Granting_activating_and_removing_an_ability() + { + // Initialize managers + var tagsManager = _tagsManager; + var cuesManager = _cuesManager; + + var player = new Player(tagsManager, cuesManager); + + var fireballCostEffect = new EffectData( + "Fireball Mana Cost", + new DurationData(DurationType.Instant), + new[] { + new Modifier( + "PlayerAttributeSet.Mana", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(-20) // -20 mana cost + ) + ) + }); + + var fireballCooldownEffect = new EffectData( + "Fireball Cooldown", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f))), // 10 seconds cooldown + effectComponents: new[] { + new ModifierTagsEffectComponent( + tagsManager.RequestTagContainer(new[] { "cooldown.fireball" }) + ) + }); + + var fireballData = new AbilityData( + name: "Fireball", + costEffect: fireballCostEffect, + cooldownEffects: [fireballCooldownEffect], + instancingPolicy: AbilityInstancingPolicy.PerEntity, + behaviorFactory: () => new CustomAbilityBehavior("Fireball")); + + var grantConfig = new GrantAbilityConfig + { + AbilityData = fireballData, + ScalableLevel = new ScalableInt(1), + LevelOverridePolicy = LevelComparison.None, + RemovalPolicy = AbilityDeactivationPolicy.CancelImmediately, + InhibitionPolicy = AbilityDeactivationPolicy.CancelImmediately, + }; + + var grantAbilityComponent = new GrantAbilityEffectComponent([grantConfig]); + + var grantFireballEffect = new EffectData( + "Grant Fireball Effect", + new DurationData(DurationType.Infinite), + effectComponents: [grantAbilityComponent] + ); + + var grantEffectHandle = player.EffectsManager.ApplyEffect( + new Effect(grantFireballEffect, new EffectOwnership(player, player))); + + // Retrieve handle directly from component as shown in docs + var fireballAbilityHandle = grantAbilityComponent.GrantedAbilities[0]; + + bool successfulActivation = fireballAbilityHandle.Activate(out AbilityActivationFailures failures); + + successfulActivation.Should().BeTrue(); + failures.Should().Be(AbilityActivationFailures.None); + fireballAbilityHandle.IsActive.Should().BeFalse(); + + player.EffectsManager.UnapplyEffect(grantEffectHandle); + fireballAbilityHandle.IsValid.Should().BeFalse(); + } + + [Fact] + [Trait("QuickStart", null)] + public void Activating_an_ability_with_checks() + { + // Initialize managers + var tagsManager = _tagsManager; + var cuesManager = _cuesManager; + + var player = new Player(tagsManager, cuesManager); + + // Setup ability with cost and cooldown + var fireballCostEffect = new EffectData( + "Fireball Mana Cost", + new DurationData(DurationType.Instant), + new[] { + new Modifier( + "PlayerAttributeSet.Mana", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(-20) + ) + ) + }); + + var fireballCooldownEffect = new EffectData( + "Fireball Cooldown", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f))), + effectComponents: new[] { + new ModifierTagsEffectComponent( + tagsManager.RequestTagContainer(new[] { "cooldown.fireball" }) + ) + }); + + var fireballData = new AbilityData( + name: "Fireball", + costEffect: fireballCostEffect, + cooldownEffects: [fireballCooldownEffect], + instancingPolicy: AbilityInstancingPolicy.PerEntity, + behaviorFactory: () => new CustomAbilityBehavior("Fireball")); + + // Grant permanently + AbilityHandle handle = player.Abilities.GrantAbilityPermanently( + fireballData, + abilityLevel: 1, + levelOverridePolicy: LevelComparison.None, + sourceEntity: player); + + // Check Cooldown + var cooldowns = handle.GetCooldownData(); + cooldowns.Should().NotBeEmpty(); + cooldowns[0].RemainingTime.Should().Be(0); + + // Check Cost + var costs = handle.GetCostData(); + costs.Should().Contain(c => c.Attribute == "PlayerAttributeSet.Mana" && c.Cost == -20); + + // Activate + bool success = handle.Activate(out AbilityActivationFailures failures); + success.Should().BeTrue(); + + // Verify resources consumed and cooldown started + player.Attributes["PlayerAttributeSet.Mana"].CurrentValue.Should().Be(80); + handle.GetCooldownData()[0].RemainingTime.Should().BeGreaterThan(0); + } + + [Fact] + [Trait("QuickStart", null)] + public void Granting_an_ability_and_activating_once() + { + // Initialize managers + var tagsManager = _tagsManager; + var cuesManager = _cuesManager; + + var player = new Player(tagsManager, cuesManager); + + // Simple fireball data + var fireballData = new AbilityData( + name: "Fireball", + instancingPolicy: AbilityInstancingPolicy.PerEntity, + behaviorFactory: () => new CustomAbilityBehavior("Fireball")); + + // Simulate using a scroll + AbilityHandle? handle = player.Abilities.GrantAbilityAndActivateOnce( + abilityData: fireballData, + abilityLevel: 1, + levelOverridePolicy: LevelComparison.None, + out AbilityActivationFailures failureFlags, + targetEntity: player, // Target of the fireball + sourceEntity: player // Source (e.g., the scroll item) + ); + + // Fireball ends instantly so handle is null + handle.Should().BeNull(); + failureFlags.Should().Be(AbilityActivationFailures.None); + } + + [Fact] + [Trait("QuickStart", null)] + public void Triggering_an_ability_through_an_event() + { + // Initialize managers + var tagsManager = _tagsManager; + var cuesManager = _cuesManager; + + var player = new Player(tagsManager, cuesManager); + var hitTag = Tag.RequestTag(tagsManager, "events.combat.hit"); + + var autoShieldData = new AbilityData( + name: "Auto Shield", + // Configure the trigger + abilityTriggerData: new AbilityTriggerData( + TriggerTag: hitTag, + TriggerSource: AbitityTriggerSource.Event + ), + instancingPolicy: AbilityInstancingPolicy.PerEntity, + behaviorFactory: () => new CustomAbilityBehavior("Auto Shield")); + + var handle = player.Abilities.GrantAbilityPermanently(autoShieldData, 1, LevelComparison.None, player); + + handle!.IsActive.Should().BeFalse(); + + player.Events.Raise(new EventData + { + EventTags = hitTag.GetSingleTagContainer(), + }); + + // Ability ends itself instantly so it should be false here + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("QuickStart", null)] + public void Triggering_an_ability_through_tags() + { + // Initialize managers + var tagsManager = _tagsManager; + var cuesManager = _cuesManager; + + var player = new Player(tagsManager, cuesManager); + var rageTag = Tag.RequestTag(tagsManager, "status.enraged"); + + // Ability configuration + var rageAbilityData = new AbilityData( + "Rage Aura", + abilityTriggerData: new AbilityTriggerData( + TriggerTag: rageTag, + TriggerSource: AbitityTriggerSource.TagPresent), + instancingPolicy: AbilityInstancingPolicy.PerEntity, + // Using a persistent behavior to verify active state + behaviorFactory: () => new PersistentAbilityBehavior()); + + // Grant permanently + var handle = player.Abilities.GrantAbilityPermanently(rageAbilityData, 1, LevelComparison.None, player); + + handle.IsActive.Should().BeFalse(); + + // Apply effect that adds the tag + var enrageEffect = new EffectData( + "Enrage", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10f))), + effectComponents: [ + new ModifierTagsEffectComponent(tagsManager.RequestTagContainer(["status.enraged"])) + ]); + + var effectHandle = player.EffectsManager.ApplyEffect( + new Effect(enrageEffect, new EffectOwnership(player, player))); + + // Should activate automatically + handle.IsActive.Should().BeTrue(); + + // Remove effect (removes tag) + player.EffectsManager.UnapplyEffect(effectHandle); + + // Should deactivate automatically + handle.IsActive.Should().BeFalse(); + } + public class PlayerAttributeSet : AttributeSet { public EntityAttribute Health { get; } + public EntityAttribute Mana { get; } public EntityAttribute Strength { get; } public EntityAttribute Speed { get; } public PlayerAttributeSet() { Health = InitializeAttribute(nameof(Health), 100, 0, 150); + Mana = InitializeAttribute(nameof(Mana), 100, 0, 100); Strength = InitializeAttribute(nameof(Strength), 10, 0, 99); Speed = InitializeAttribute(nameof(Speed), 5, 0, 10); } @@ -950,4 +1283,38 @@ public void Reset() Magnitudes.Clear(); } } + + private class CustomAbilityBehavior(string parameter) : IAbilityBehavior + { + public void OnStarted(AbilityBehaviorContext context) + { + context.AbilityHandle.CommitAbility(); + + // Instantiate a projectile here (omitted for brevity) + Console.WriteLine($"{context.Owner} used ability ({parameter}) on target {context.Target}"); + + context.InstanceHandle.End(); + } + + public void OnEnded(AbilityBehaviorContext context) + { + // Cleanup if necessary + } + } + + private class PersistentAbilityBehavior : IAbilityBehavior + { + public void OnStarted(AbilityBehaviorContext context) + { + context.AbilityHandle.CommitAbility(); + // Does NOT call End() to simulate a persistent effect/aura + } + + public void OnEnded(AbilityBehaviorContext context) + { + // Cleanup + } + } + + public record struct CombatLogPayload(string Message, int Value); } diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index 7784f3f..8ce1735 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -179,7 +179,8 @@ public bool TryActivateAbilitiesByTag( /// The level at which to grant the ability. /// The policy for overriding the level of an existing granted ability. /// The source entity of the granted ability, if any. - public void GrantAbilityPermanently( + /// The handle of the granted ability. + public AbilityHandle GrantAbilityPermanently( AbilityData abilityData, int abilityLevel, LevelComparison levelOverridePolicy, @@ -205,12 +206,14 @@ public void GrantAbilityPermanently( existingAbility.Level = abilityLevel; } - return; + return existingAbility.Handle; } var newAbility = new Ability(Owner, abilityData, abilityLevel, sourceEntity); GrantedAbilities.Add(newAbility.Handle); _grantSources[newAbility] = [new PermanentGrantSource()]; + + return newAbility.Handle; } internal AbilityHandle GrantAbility( diff --git a/README.md b/README.md index f757214..99fa0e6 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Install via NuGet, reference the Forge project directly, or download the precomp Install the package via .NET CLI: ```shell -dotnet add package Gamesmiths.Forge --version 0.1.2 +dotnet add package Gamesmiths.Forge --version 0.2.0 ``` Or search for `Gamesmiths.Forge` in the NuGet Package Manager UI in Visual Studio. diff --git a/docs/abilities.md b/docs/abilities.md index a246afe..bb41c4c 100644 --- a/docs/abilities.md +++ b/docs/abilities.md @@ -98,7 +98,7 @@ There are three ways to grant an ability that persists permanently: * They will simply not be removed when the source effect is removed. ```csharp -entity.Abilities.GrantAbilityPermanently( +AbilityHandle handle = entity.Abilities.GrantAbilityPermanently( abilityData: fireballAbility, abilityLevel: 1, levelOverridePolicy: LevelComparison.Higher, diff --git a/docs/quick-start.md b/docs/quick-start.md index f474693..e503987 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -9,7 +9,7 @@ This guide will help you quickly get started with the Forge framework, showing y Install Forge via NuGet (recommended): ```shell -dotnet add package Gamesmiths.Forge --version 0.1.2 +dotnet add package Gamesmiths.Forge --version 0.2.0 ``` For other installation methods, see the [main README](../README.md). @@ -18,7 +18,7 @@ For other installation methods, see the [main README](../README.md). ## Creating a Basic Entity -Let's create a simple player entity with three attributes: health, strength and speed. +Let's create a simple player entity with three attributes: health, mana, strength, and speed. For that we need to first define an `AttributeSet` that will hold those attributes. @@ -27,6 +27,7 @@ For that we need to first define an `AttributeSet` that will hold those attribut public class PlayerAttributeSet : AttributeSet { public EntityAttribute Health { get; } + public EntityAttribute Mana { get; } public EntityAttribute Strength { get; } public EntityAttribute Speed { get; } @@ -34,6 +35,7 @@ public class PlayerAttributeSet : AttributeSet { // Initialize the attributes with the current, min and max values. Health = InitializeAttribute(nameof(Health), 100, 0, 150); + Mana = InitializeAttribute(nameof(Mana), 100, 0, 100); Strength = InitializeAttribute(nameof(Strength), 10, 0, 99); Speed = InitializeAttribute(nameof(Speed), 5, 0, 10); } @@ -75,8 +77,12 @@ var tagsManager = new TagsManager(new string[] "class.warrior", "status.stunned", "status.burning", + "status.enraged", "status.immune.fire", - "cues.damage.fire" + "cues.damage.fire", + "events.combat.damage", + "events.combat.hit", + "cooldown.fireball" }); // Create player instance @@ -84,6 +90,7 @@ var player = new Player(tagsManager, cuesManager); // Access the player's attribute values var health = player.Attributes["PlayerAttributeSet.Health"].CurrentValue; // 100 +var mana = player.Attributes["PlayerAttributeSet.Mana"].CurrentValue; // 100 var strength = player.Attributes["PlayerAttributeSet.Strength"].CurrentValue; // 10 var speed = player.Attributes["PlayerAttributeSet.Speed"].CurrentValue; // 5 ``` @@ -726,7 +733,7 @@ player2.EffectsManager.ApplyEffect(drainEffect); --- -### Cue Examples +## Cue Examples Cues bridge gameplay mechanics with visual and audio feedback. Here's how to define, trigger, and implement them. @@ -862,6 +869,315 @@ public class FireDamageCueHandler : ICueHandler --- +## Events + +The Events system allows entities to communicate and trigger reactions through tagged events. + +### Subscribing and Raising an Event + +```csharp +// Subscribe to the damage event +var damageTag = Tag.RequestTag(tagsManager, "events.combat.damage"); +player.Events.Subscribe(damageTag, eventData => +{ + Console.WriteLine($"Player took {eventData.EventMagnitude} damage!"); +}); + +// Raise the event +player.Events.Raise(new EventData +{ + EventTags = damageTag.GetSingleTagContainer(), + Source = null, + Target = player, + EventMagnitude = 50f +}); +``` + +You can also instantiate your own `EventManager` and use it in any part of your code, providing a way to handle global or system-specific events independently of entities. + +### Strongly Typed Events + +You can optimize events to avoid boxing by using generic `EventData`. + +```csharp +// Define a strongly typed payload +public record struct CombatLogPayload(string Message, int Value); + +var damageTag = Tag.RequestTag(tagsManager, "events.combat.damage"); + +// Subscribe using the specific payload type +player.Events.Subscribe(damageTag, eventData => +{ + Console.WriteLine($"[Combat Log] {eventData.Payload.Message}: {eventData.Payload.Value}"); +}); + +// Raise the event with the typed payload +player.Events.Raise(new EventData +{ + EventTags = damageTag.GetSingleTagContainer(), + Source = null, + Target = player, + Payload = new CombatLogPayload("Critical Hit", 9999) +}); +``` + +--- + +## Abilities + +Abilities are discrete actions that can have costs, cooldowns, and custom behaviors. They can be triggered manually, by events, or in reaction to tag application. + +### Defining an Ability + +When defining an ability, you typically configure effects for costs and cooldowns, implement a behavior, and then tie it all together in the `AbilityData`. + +```csharp +// Define cost: 20 Mana +var fireballCostEffect = new EffectData( + "Fireball Mana Cost", + new DurationData(DurationType.Instant), + new[] { + new Modifier( + "PlayerAttributeSet.Mana", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(-20) + ) + ) + }); + +// Define cooldown: 10 seconds +var fireballCooldownEffect = new EffectData( + "Fireball Cooldown", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f))), + effectComponents: new[] { + new ModifierTagsEffectComponent( + tagsManager.RequestTagContainer(new[] { "cooldown.fireball" }) + ) + }); + +// Define behavior +public class FireballBehavior : IAbilityBehavior +{ + public void OnStarted(AbilityBehaviorContext context) + { + // Apply costs and cooldowns + context.AbilityHandle.CommitAbility(); + Console.WriteLine("Fireball cast!"); + context.InstanceHandle.End(); + } + + public void OnEnded(AbilityBehaviorContext context) + { + // Do any necessary cleanups + } +} + +// Define Ability Data +var fireballData = new AbilityData( + name: "Fireball", + costEffect: fireballCostEffect, + cooldownEffects: [fireballCooldownEffect], + instancingPolicy: AbilityInstancingPolicy.PerEntity, + behaviorFactory: () => new FireballBehavior()); +``` + +--- + +### Granting and Removing an Ability + +Abilities can be granted through effects and will be tied to the effect's lifetime. If the effect has a duration, the ability will be granted only while the effect is active. + +```csharp +// Grant an ability via a GrantAbilityEffectComponent +var grantConfig = new GrantAbilityConfig +{ + AbilityData = fireballData, + ScalableLevel = new ScalableInt(1), + LevelOverridePolicy = LevelComparison.None, + RemovalPolicy = AbilityDeactivationPolicy.CancelImmediately, + InhibitionPolicy = AbilityDeactivationPolicy.CancelImmediately, +}; + +var grantAbilityComponent = new GrantAbilityEffectComponent([grantConfig]); + +// Wrap the component in an infinite effect +var grantFireballEffect = new EffectData( + "Grant Fireball Effect", + new DurationData(DurationType.Infinite), + effectComponents: [grantAbilityComponent] +); + +// Apply the effect to grant the ability (e.g., when Wand of Fireball is equipped) +var grantEffectHandle = player.EffectsManager.ApplyEffect( + new Effect(grantFireballEffect, new EffectOwnership(player, player))); + +// You can access the granted ability handle directly from the component +// This list contains handles for all abilities granted by this specific effect component instance +AbilityHandle grantedHandle = grantAbilityComponent.GrantedAbilities[0]; + +// The ability is now granted. To remove it, simply unapply the effect. (e.g., when the wand is unequipped) +player.EffectsManager.UnapplyEffect(grantEffectHandle); +``` + +--- + +### Activating an Ability + +Abilities can be activated directly through their handle. You can also use the handle to find out what's the required cost and cooldown of the ability, useful for updating the UI. The activation returns flags indicating failure reasons, which is useful for player feedback. + +```csharp +// Retrieve the handle from the granted ability component or via TryGetAbility +if (player.Abilities.TryGetAbility(fireballData, out AbilityHandle? handle)) +{ + // Check cooldown state before activation (useful for UI) + var cooldowns = handle.GetCooldownData(); + foreach (var cd in cooldowns) + { + Console.WriteLine($"Cooldown remaining: {cd.RemainingTime}"); + } + + // Check cost state before activation (useful for UI) + var costs = handle.GetCostData(); + foreach (var cost in costs) + { + // Assuming you want to check Mana costs + if (cost.AttributeName == "PlayerAttributeSet.Mana") + { + Console.WriteLine($"Mana Cost: {cost.Value}"); + } + } + + // Try to activate + if (handle.Activate(out AbilityActivationFailures failures)) + { + Console.WriteLine("Activation successful"); + } + else + { + Console.WriteLine($"Activation failed: {failures}"); + + if (failures.HasFlag(AbilityActivationFailures.InsufficientResources)) + { + Console.WriteLine("Not enough mana!"); + } + } +} +``` + +--- + +### Granting an Ability Permanently + +One other way to grant an ability permanently is directly through the entity's `EntityAbilities` component. + +```csharp +// Grant permanently +AbilityHandle handle = player.Abilities.GrantAbilityPermanently( + fireballData, + abilityLevel: 1, + levelOverridePolicy: LevelComparison.None, + sourceEntity: player); +``` + +--- + +### Granting an Ability and Activating Once + +In some cases you just want a quick way to activate an ability on a target without creating a persistent effect or granting it permanently. + +The example below shows the use of a "Scroll of Fireball" that grants the fireball ability transiently, attempts to activate it immediately, and then removes the grant once the ability concludes or fails. + +```csharp +AbilityHandle? handle = player.Abilities.GrantAbilityAndActivateOnce( + abilityData: fireballData, + abilityLevel: 1, + levelOverridePolicy: LevelComparison.None, + out AbilityActivationFailures failureFlags, + targetEntity: enemy, // The target of the fireball + sourceEntity: scrollItem // The source (e.g., the scroll item) +); + +if (handle is not null) +{ + Console.WriteLine("Scroll used successfully! Fireball cast."); +} +else +{ + Console.WriteLine($"Failed to use scroll: {failureFlags}"); +} +``` + +--- + +### Triggering an Ability via Events + +You can configure abilities to trigger automatically when specific events occur. + +```csharp +var hitTag = Tag.RequestTag(tagsManager, "events.combat.hit"); + +var autoShieldData = new AbilityData( + name: "Auto Shield", + // Configure the trigger + abilityTriggerData: new AbilityTriggerData( + TriggerTag: hitTag, + TriggerSource: AbilityTriggerSource.Event + ), + instancingPolicy: AbilityInstancingPolicy.PerEntity, + behaviorFactory: () => new ShieldBehavior()); // Assumes ShieldBehavior exists + +// Grant the ability +player.Abilities.GrantAbilityPermanently(autoShieldData, 1, LevelComparison.None, player); + +// Raising the event will automatically trigger the ability +player.Events.Raise(new EventData +{ + EventTags = hitTag.GetSingleTagContainer(), + Target = player +}); +``` + +--- + +### Triggering an Ability through Tags + +In this example, a granted ability (like a passive aura) is activated automatically while the character has a specific tag (e.g., "status.enraged"). + +```csharp +// Define an ability that triggers when the "status.enraged" tag is present +var rageAbilityData = new AbilityData( + "Rage Aura", + abilityTriggerData: new AbilityTriggerData( + TriggerTag: Tag.RequestTag(tagsManager, "status.enraged"), + TriggerSource: AbilityTriggerSource.TagPresent), + instancingPolicy: AbilityInstancingPolicy.PerEntity, + behaviorFactory: () => new RageBehavior()); + +// Grant the ability permanently so it monitors tags +player.Abilities.GrantAbilityPermanently(rageAbilityData, 1, LevelComparison.None, player); + +// Apply an effect that grants the "status.enraged" tag +// The Rage Aura ability will automatically activate when this tag is added +var enrageEffect = new EffectData( + "Enrage", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10f))), + effectComponents: [ + new ModifierTagsEffectComponent(tagsManager.RequestTagContainer(["status.enraged"])) + ]); + +player.EffectsManager.ApplyEffect(new Effect(enrageEffect, new EffectOwnership(player, player))); +``` + ## Next Steps Now that you've seen the basics of Forge, you can: @@ -873,6 +1189,8 @@ Now that you've seen the basics of Forge, you can: 5. Use [Periodic Effects](effects/periodic.md) for recurring gameplay mechanics. 6. Extend effects with [Components](effects/components.md) for custom behaviors. 7. Integrate [Cues](cues.md) for visual and audio feedback. -8. For catching configuration errors during development, see [Validation and Debugging](README.md#validation-and-debugging). +8. Orchestrate gameplay reactions with [Events](events.md). +9. Define discrete actions and skills using [Abilities](abilities.md). +10. For catching configuration errors during development, see [Validation and Debugging](README.md#validation-and-debugging). For more detailed documentation, refer to the [Forge Documentation Index](README.md). From 356b24467bb5b5edbc43d50e6e27ac6f194c6587 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 1 Jan 2026 23:50:08 -0300 Subject: [PATCH 55/87] Added payload for ability activation --- Forge.Tests/Abilities/AbilitiesTests.cs | 18 +- Forge.Tests/Abilities/AbilityBehaviorTests.cs | 326 +++++++++++++++++- Forge.Tests/Samples/QuickStartTests.cs | 9 +- Forge/Abilities/Ability.cs | 115 +++++- .../AbilityBehaviorContext.Payload.cs | 25 ++ Forge/Abilities/AbilityBehaviorContext.cs | 5 +- Forge/Abilities/AbilityHandle.cs | 25 +- Forge/Abilities/AbilityInstance.cs | 36 +- Forge/Abilities/AbilityInstanceHandle.cs | 16 +- Forge/Abilities/AbilityTriggerData.cs | 77 ++++- Forge/Abilities/IAbilityBehavior.cs | 21 ++ 11 files changed, 609 insertions(+), 64 deletions(-) create mode 100644 Forge/Abilities/AbilityBehaviorContext.Payload.cs diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index de725f5..a32d6b1 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -2278,11 +2278,7 @@ [new ScalableFloat(3f)], ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1), - abilityTriggerData: new() - { - TriggerTag = triggerTag, - TriggerSource = AbitityTriggerSource.Event, - }); + abilityTriggerData: AbilityTriggerData.ForEvent(triggerTag)); AbilityHandle? abilityHandle = SetupAbility( entity, @@ -2332,11 +2328,7 @@ [new ScalableFloat(3f)], ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1), - abilityTriggerData: new() - { - TriggerTag = triggerTag, - TriggerSource = AbitityTriggerSource.TagAdded, - }); + abilityTriggerData: AbilityTriggerData.ForTagAdded(triggerTag)); AbilityHandle? abilityHandle = SetupAbility( entity, @@ -2366,11 +2358,7 @@ [new ScalableFloat(3f)], ["simple.tag"], "TestAttributeSet.Attribute90", new ScalableFloat(-1), - abilityTriggerData: new() - { - TriggerTag = triggerTag, - TriggerSource = AbitityTriggerSource.TagPresent, - }); + abilityTriggerData: AbilityTriggerData.ForTagPresent(triggerTag)); AbilityHandle? abilityHandle = SetupAbility( entity, diff --git a/Forge.Tests/Abilities/AbilityBehaviorTests.cs b/Forge.Tests/Abilities/AbilityBehaviorTests.cs index b410d89..7c8bf5e 100644 --- a/Forge.Tests/Abilities/AbilityBehaviorTests.cs +++ b/Forge.Tests/Abilities/AbilityBehaviorTests.cs @@ -9,6 +9,7 @@ using Gamesmiths.Forge.Effects.Duration; using Gamesmiths.Forge.Effects.Magnitudes; using Gamesmiths.Forge.Effects.Modifiers; +using Gamesmiths.Forge.Events; using Gamesmiths.Forge.Tags; using Gamesmiths.Forge.Tests.Helpers; @@ -373,6 +374,306 @@ public void Ability_is_granted_and_activated_once() entity.Abilities.GrantedAbilities.Should().BeEmpty(); } + [Fact] + [Trait("CustomContext", null)] + public void Generic_activate_creates_typed_context_with_payload() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + AbilityBehaviorContext? capturedContext = null; + + AbilityData data = CreateAbilityData( + "TypedContextAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedContext = ctx)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + var activationData = new TestActivationData("TestValue", 42); + handle!.Activate(activationData, out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + capturedContext.Should().NotBeNull(); + capturedContext.Should().BeOfType>(); + + var typedContext = (AbilityBehaviorContext)capturedContext!; + typedContext.Payload.StringValue.Should().Be("TestValue"); + typedContext.Payload.IntValue.Should().Be(42); + } + + [Fact] + [Trait("CustomContext", null)] + public void Non_generic_activate_creates_base_context() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + AbilityBehaviorContext? capturedContext = null; + + AbilityData data = CreateAbilityData( + "NoContextFactoryAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedContext = ctx)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + capturedContext.Should().NotBeNull(); + capturedContext.Should().BeOfType(); + } + + [Fact] + [Trait("CustomContext", null)] + public void Value_type_payload_is_preserved_in_context() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + AbilityBehaviorContext? capturedContext = null; + + AbilityData data = CreateAbilityData( + "ValueTypeContextAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedContext = ctx)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + var activationData = new ValueTypeActivationData(1.5f, 2.5f, 3.5f); + handle!.Activate(activationData, out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + capturedContext.Should().NotBeNull(); + capturedContext.Should().BeOfType>(); + + var typedContext = (AbilityBehaviorContext)capturedContext!; + typedContext.Payload.X.Should().Be(1.5f); + typedContext.Payload.Y.Should().Be(2.5f); + typedContext.Payload.Z.Should().Be(3.5f); + } + + [Fact] + [Trait("CustomContext", null)] + public void Context_data_is_passed_through_for_each_instance_in_PerExecution() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var capturedContexts = new List(); + + AbilityData data = CreateAbilityData( + "PerExecutionContextAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedContexts.Add(ctx)), + instancingPolicy: AbilityInstancingPolicy.PerExecution); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + var activationData1 = new TestActivationData("First", 1); + var activationData2 = new TestActivationData("Second", 2); + + handle!.Activate(activationData1, out _).Should().BeTrue(); + handle.Activate(activationData2, out _).Should().BeTrue(); + + capturedContexts.Should().HaveCount(2); + + capturedContexts[0].Should().BeOfType>(); + capturedContexts[1].Should().BeOfType>(); + + var context1 = (AbilityBehaviorContext)capturedContexts[0]; + var context2 = (AbilityBehaviorContext)capturedContexts[1]; + + context1.Payload.StringValue.Should().Be("First"); + context1.Payload.IntValue.Should().Be(1); + context2.Payload.StringValue.Should().Be("Second"); + context2.Payload.IntValue.Should().Be(2); + } + + [Fact] + [Trait("CustomContext", null)] + public void PerEntity_retrigger_passes_new_context_data() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var capturedContexts = new List(); + + AbilityData data = CreateAbilityData( + "RetriggerContextAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedContexts.Add(ctx)), + retriggerInstancedAbility: true); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + var activationData1 = new TestActivationData("First", 1); + var activationData2 = new TestActivationData("Second", 2); + + handle!.Activate(activationData1, out _).Should().BeTrue(); + handle.Activate(activationData2, out _).Should().BeTrue(); + + // Both activations should have succeeded with their own context data + capturedContexts.Should().HaveCount(2); + + var context1 = (AbilityBehaviorContext)capturedContexts[0]; + var context2 = (AbilityBehaviorContext)capturedContexts[1]; + + context1.Payload.StringValue.Should().Be("First"); + context2.Payload.StringValue.Should().Be("Second"); + } + + [Fact] + [Trait("EventTrigger", null)] + public void Event_triggered_ability_activates_on_event() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new TrackingBehavior(); + var eventTag = Tag.RequestTag(_tagsManager, "color.red"); + + AbilityData data = CreateAbilityData( + "EventTriggered", + behaviorFactory: () => behavior, + abilityTriggerData: AbilityTriggerData.ForEvent(eventTag)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + behavior.StartCount.Should().Be(0); + + // Raise the event + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + }); + + behavior.StartCount.Should().Be(1); + handle!.IsActive.Should().BeTrue(); + + handle.Cancel(); + behavior.EndCount.Should().Be(1); + } + + [Fact] + [Trait("EventTrigger", null)] + public void Event_triggered_ability_with_typed_payload_receives_payload() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + TestEventPayload? capturedPayload = null; + AbilityBehaviorContext? capturedContext = null; + + var eventTag = Tag.RequestTag(_tagsManager, "color.green"); + + AbilityData data = CreateAbilityData( + "TypedEventTriggered", + behaviorFactory: () => new TypedPayloadBehavior((ctx, payload) => + { + capturedContext = ctx; + capturedPayload = payload; + }), + abilityTriggerData: AbilityTriggerData.ForEvent(eventTag)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + var expectedPayload = new TestEventPayload("Damage received", 42, true); + + // Raise the typed event + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Payload = expectedPayload, + }); + + capturedContext.Should().NotBeNull(); + capturedPayload.Should().NotBeNull(); + capturedPayload!.Message.Should().Be("Damage received"); + capturedPayload.Value.Should().Be(42); + capturedPayload.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("EventTrigger", null)] + public void Event_triggered_ability_with_value_type_payload_receives_payload() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + ValueTypeActivationData capturedPayload = default; + + var eventTag = Tag.RequestTag(_tagsManager, "color.blue"); + + AbilityData data = CreateAbilityData( + "ValueTypeEventTriggered", + behaviorFactory: () => new TypedPayloadBehavior((_, payload) => + { + capturedPayload = payload; + }), + abilityTriggerData: AbilityTriggerData.ForEvent(eventTag)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + var expectedPayload = new ValueTypeActivationData(1.5f, 2.5f, 3.5f); + + // Raise the typed event + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Payload = expectedPayload, + }); + + capturedPayload.X.Should().Be(1.5f); + capturedPayload.Y.Should().Be(2.5f); + capturedPayload.Z.Should().Be(3.5f); + } + + [Fact] + [Trait("EventTrigger", null)] + public void Event_triggered_ability_respects_cooldown() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new CallbackBehavior(x => x.AbilityHandle.CommitAbility()); + var eventTag = Tag.RequestTag(_tagsManager, "color.dark.red"); + + AbilityData data = CreateAbilityData( + "EventWithCooldown", + behaviorFactory: () => behavior, + cooldownSeconds: 5f, + abilityTriggerData: AbilityTriggerData.ForEvent(eventTag)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + // First event should activate + entity.Events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + handle!.IsActive.Should().BeTrue(); + handle.Cancel(); + + // Second event should fail due to cooldown + entity.Events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + handle.IsActive.Should().BeFalse(); + + // After cooldown expires, should work again + entity.EffectsManager.UpdateEffects(5f); + entity.Events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + handle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("EventTrigger", null)] + public void Event_triggered_ability_multiple_events_with_PerExecution() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var activationCount = 0; + var eventTag = Tag.RequestTag(_tagsManager, "color.dark.green"); + + AbilityData data = CreateAbilityData( + "MultiEventPerExecution", + behaviorFactory: () => new CallbackBehavior(_ => activationCount++), + instancingPolicy: AbilityInstancingPolicy.PerExecution, + abilityTriggerData: AbilityTriggerData.ForEvent(eventTag)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + // Raise multiple events + entity.Events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + entity.Events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + entity.Events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + + activationCount.Should().Be(3); + } + private static AbilityHandle? Grant( TestEntity target, AbilityData data, @@ -414,7 +715,8 @@ private AbilityData CreateAbilityData( float costMagnitude = -1f, TagContainer? abilityTags = null, TagContainer? blockAbilitiesWithTag = null, - TagContainer? activationOwnedTags = null) + TagContainer? activationOwnedTags = null, + AbilityTriggerData? abilityTriggerData = null) { EffectData[] cooldownEffectData = [new EffectData( $"{name} Cooldown", @@ -448,11 +750,18 @@ private AbilityData CreateAbilityData( abilityTags: abilityTags, instancingPolicy: instancingPolicy, retriggerInstancedAbility: retriggerInstancedAbility, + abilityTriggerData: abilityTriggerData, blockAbilitiesWithTag: blockAbilitiesWithTag, activationOwnedTags: activationOwnedTags, behaviorFactory: behaviorFactory ?? (() => behavior!)); } + private sealed record TestActivationData(string StringValue, int IntValue); + + private readonly record struct ValueTypeActivationData(float X, float Y, float Z); + + private sealed record TestEventPayload(string Message, int Value, bool IsActive); + private sealed class TrackingBehavior(Action? onStartExtra = null) : IAbilityBehavior { private readonly Action? _onStartExtra = onStartExtra; @@ -493,6 +802,21 @@ public void OnEnded(AbilityBehaviorContext context) } } + private sealed class TypedPayloadBehavior( + Action callback) : IAbilityBehavior + { + public void OnStarted(AbilityBehaviorContext context, TPayload payload) + { + callback(context, payload); + context.InstanceHandle.End(); + } + + public void OnEnded(AbilityBehaviorContext context) + { + // No-op + } + } + private sealed class ExceptionBehaviorOnStart : IAbilityBehavior { public int StartAttempts { get; private set; } diff --git a/Forge.Tests/Samples/QuickStartTests.cs b/Forge.Tests/Samples/QuickStartTests.cs index 77f2fbb..aee64a9 100644 --- a/Forge.Tests/Samples/QuickStartTests.cs +++ b/Forge.Tests/Samples/QuickStartTests.cs @@ -993,10 +993,7 @@ public void Triggering_an_ability_through_an_event() var autoShieldData = new AbilityData( name: "Auto Shield", // Configure the trigger - abilityTriggerData: new AbilityTriggerData( - TriggerTag: hitTag, - TriggerSource: AbitityTriggerSource.Event - ), + abilityTriggerData: AbilityTriggerData.ForEvent(hitTag), instancingPolicy: AbilityInstancingPolicy.PerEntity, behaviorFactory: () => new CustomAbilityBehavior("Auto Shield")); @@ -1027,9 +1024,7 @@ public void Triggering_an_ability_through_tags() // Ability configuration var rageAbilityData = new AbilityData( "Rage Aura", - abilityTriggerData: new AbilityTriggerData( - TriggerTag: rageTag, - TriggerSource: AbitityTriggerSource.TagPresent), + abilityTriggerData: AbilityTriggerData.ForTagPresent(rageTag), instancingPolicy: AbilityInstancingPolicy.PerEntity, // Using a persistent behavior to verify active state behaviorFactory: () => new PersistentAbilityBehavior()); diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 9130c98..964b9d4 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -1,5 +1,6 @@ // Copyright © Gamesmiths Guild. +using System.Reflection; using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Effects; using Gamesmiths.Forge.Effects.Calculator; @@ -97,7 +98,9 @@ internal Ability( if (abilityData.AbilityTriggerData is not null) { - switch (abilityData.AbilityTriggerData.Value.TriggerSource) + AbilityTriggerData triggerData = abilityData.AbilityTriggerData.Value; + + switch (triggerData.TriggerSource) { case AbitityTriggerSource.TagAdded: owner.Tags.OnTagsChanged += TagAdded_OnTagChanged; @@ -106,10 +109,18 @@ internal Ability( owner.Tags.OnTagsChanged += TagPresent_OnTagChanged; break; case AbitityTriggerSource.Event: - owner.Events.Subscribe( - abilityData.AbilityTriggerData.Value.TriggerTag, - x => TryActivateAbility(x.Target, out _), - priority: 0); + if (triggerData.PayloadType is not null) + { + SubscribeTypedEvent(triggerData); + } + else + { + owner.Events.Subscribe( + triggerData.TriggerTag, + x => TryActivateAbility(x.Target, out _), + triggerData.Priority); + } + break; } } @@ -117,7 +128,9 @@ internal Ability( Handle = new AbilityHandle(this); } - internal bool TryActivateAbility(IForgeEntity? abilityTarget, out AbilityActivationFailures failureFlags) + internal bool TryActivateAbility( + IForgeEntity? abilityTarget, + out AbilityActivationFailures failureFlags) { if (CanActivate(abilityTarget, out failureFlags)) { @@ -128,6 +141,20 @@ internal bool TryActivateAbility(IForgeEntity? abilityTarget, out AbilityActivat return false; } + internal bool TryActivateAbility( + IForgeEntity? abilityTarget, + out AbilityActivationFailures failureFlags, + TPayload payload) + { + if (CanActivate(abilityTarget, out failureFlags)) + { + Activate(abilityTarget, payload); + return true; + } + + return false; + } + internal void CommitAbility() { CommitCooldown(); @@ -217,6 +244,40 @@ internal void OnInstanceStarted(AbilityInstance instance) } } + internal void OnInstanceStarted(AbilityInstance instance, TPayload payload) + { + if (AbilityData.BehaviorFactory is null) + { + return; + } + + IAbilityBehavior? behavior = AbilityData.BehaviorFactory.Invoke(); + if (behavior is null) + { + return; + } + + var context = new AbilityBehaviorContext(this, instance, payload); + _behaviors[instance] = new BehaviorBinding(behavior, context); + + try + { + if (behavior is IAbilityBehavior typedBehavior) + { + typedBehavior.OnStarted(context, payload); + } + else + { + behavior.OnStarted(context); + } + } + catch (Exception ex) + { + Console.WriteLine($"Ability behavior threw on start: {ex}"); + instance.Cancel(); + } + } + internal void OnInstanceEnded(AbilityInstance instance) { _activeInstances.Remove(instance); @@ -460,7 +521,41 @@ private static bool HasBlockedTags(TagContainer? blocked, TagContainer? present) return blocked is not null && (present?.HasAny(blocked) == true); } + private void SubscribeTypedEvent(AbilityTriggerData triggerData) + { +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + // Do not attempt this in production environments without adult supervision. + MethodInfo method = typeof(Ability) + .GetMethod(nameof(SubscribeTypedEventCore), BindingFlags.NonPublic | BindingFlags.Instance)! + .MakeGenericMethod(triggerData.PayloadType!); +#pragma warning restore S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + + method.Invoke(this, [triggerData.TriggerTag, triggerData.Priority]); + } + + private void SubscribeTypedEventCore(Tag tag, int priority) + { + Owner.Events.Subscribe( + tag, + x => TryActivateAbility(x.Target, out _, x.Payload), + priority: priority); + } + private void Activate(IForgeEntity? abilityTarget) + { + AbilityInstance instance = CreateInstance(abilityTarget); + _activeInstances.Add(instance); + instance.Start(); + } + + private void Activate(IForgeEntity? abilityTarget, TPayload payload) + { + AbilityInstance instance = CreateInstance(abilityTarget); + _activeInstances.Add(instance); + instance.Start(payload); + } + + private AbilityInstance CreateInstance(IForgeEntity? abilityTarget) { // Cancel conflicting abilities before we start this one. if (AbilityData.CancelAbilitiesWithTag is not null) @@ -480,14 +575,10 @@ private void Activate(IForgeEntity? abilityTarget) } _persistentInstance ??= new AbilityInstance(this, abilityTarget); - _activeInstances.Add(_persistentInstance); - _persistentInstance.Start(); - return; + return _persistentInstance; } - var instance = new AbilityInstance(this, abilityTarget); - _activeInstances.Add(instance); - instance.Start(); + return new AbilityInstance(this, abilityTarget); } private ModifierEvaluatedData[] EvaluateInstantModifiers(Effect effect, StringKey? specificAttribute = null) diff --git a/Forge/Abilities/AbilityBehaviorContext.Payload.cs b/Forge/Abilities/AbilityBehaviorContext.Payload.cs new file mode 100644 index 0000000..42bb6ce --- /dev/null +++ b/Forge/Abilities/AbilityBehaviorContext.Payload.cs @@ -0,0 +1,25 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Context that carries strongly-typed activation data. +/// Created automatically when using . +/// +/// The activation data type. +public sealed class AbilityBehaviorContext : AbilityBehaviorContext +{ + /// + /// Gets the activation data passed during ability activation. + /// + public TPayload Payload { get; } + + internal AbilityBehaviorContext( + Ability ability, + AbilityInstance instance, + TPayload payload) + : base(ability, instance) + { + Payload = payload; + } +} diff --git a/Forge/Abilities/AbilityBehaviorContext.cs b/Forge/Abilities/AbilityBehaviorContext.cs index c8b0318..3d8f0be 100644 --- a/Forge/Abilities/AbilityBehaviorContext.cs +++ b/Forge/Abilities/AbilityBehaviorContext.cs @@ -5,9 +5,9 @@ namespace Gamesmiths.Forge.Abilities; /// -/// Runtime context for a single ability activation. Provides data and helpers for user behaviors. +/// Runtime context for a single ability activation. /// -public sealed class AbilityBehaviorContext +public class AbilityBehaviorContext { /// /// Gets the owner of this ability. @@ -43,7 +43,6 @@ internal AbilityBehaviorContext(Ability ability, AbilityInstance instance) { AbilityHandle = ability.Handle; InstanceHandle = instance.Handle; - Owner = ability.Owner; Source = ability.SourceEntity; } diff --git a/Forge/Abilities/AbilityHandle.cs b/Forge/Abilities/AbilityHandle.cs index 3e6e169..632c00c 100644 --- a/Forge/Abilities/AbilityHandle.cs +++ b/Forge/Abilities/AbilityHandle.cs @@ -1,4 +1,4 @@ -// Copyright © Gamesmiths Guild. +// Copyr ight © Gamesmiths Guild. using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Tags; @@ -50,6 +50,29 @@ public bool Activate(out AbilityActivationFailures failureFlags, IForgeEntity? t return Ability?.TryActivateAbility(target, out failureFlags) ?? false; } + /// + /// Activates the ability associated with this handle with strongly-typed payload data. + /// + /// The type of payload data to pass to the ability behavior. + /// The payload data to pass to the behavior. + /// Flags indicating the failure reasons for the ability activation. + /// The target entity for the ability activation. + /// Return if the ability was successfully activated; otherwise, + /// . + public bool Activate( + TPayload payload, + out AbilityActivationFailures failureFlags, + IForgeEntity? target = null) + { + if (Ability is null) + { + failureFlags = AbilityActivationFailures.InvalidHandler; + return false; + } + + return Ability.TryActivateAbility(target, out failureFlags, payload); + } + /// /// Cancels all instances of the ability associated with this handle. /// diff --git a/Forge/Abilities/AbilityInstance.cs b/Forge/Abilities/AbilityInstance.cs index d937c26..ae05c21 100644 --- a/Forge/Abilities/AbilityInstance.cs +++ b/Forge/Abilities/AbilityInstance.cs @@ -33,21 +33,21 @@ internal void Start() return; } - // Apply activation-owned tags. - if (_ability.AbilityData.ActivationOwnedTags is not null) - { - _ability.Owner.Tags.AddModifierTags(_ability.AbilityData.ActivationOwnedTags); - } + ApplyActivationState(); + IsActive = true; + _ability.OnInstanceStarted(this); + } - // Block abilities with tags while this instance is active. - TagContainer? blockTags = _ability.AbilityData.BlockAbilitiesWithTag; - if (blockTags is not null) + internal void Start(TPayload payload) + { + if (IsActive) { - _ability.Owner.Abilities.BlockedAbilityTags.AddModifierTags(blockTags); + return; } + ApplyActivationState(); IsActive = true; - _ability.OnInstanceStarted(this); + _ability.OnInstanceStarted(this, payload); } internal void End() @@ -78,4 +78,20 @@ internal void Cancel() { End(); } + + private void ApplyActivationState() + { + // Apply activation-owned tags. + if (_ability.AbilityData.ActivationOwnedTags is not null) + { + _ability.Owner.Tags.AddModifierTags(_ability.AbilityData.ActivationOwnedTags); + } + + // Block abilities with tags while this instance is active. + TagContainer? blockTags = _ability.AbilityData.BlockAbilitiesWithTag; + if (blockTags is not null) + { + _ability.Owner.Abilities.BlockedAbilityTags.AddModifierTags(blockTags); + } + } } diff --git a/Forge/Abilities/AbilityInstanceHandle.cs b/Forge/Abilities/AbilityInstanceHandle.cs index a42e98b..3c2d7f6 100644 --- a/Forge/Abilities/AbilityInstanceHandle.cs +++ b/Forge/Abilities/AbilityInstanceHandle.cs @@ -10,26 +10,26 @@ namespace Gamesmiths.Forge.Abilities; /// public sealed class AbilityInstanceHandle { - private AbilityInstance? _instance; - /// /// Gets the target entity of this ability instance. /// - public IForgeEntity? Target => _instance?.Target; + public IForgeEntity? Target => AbilityInstance?.Target; /// /// Gets a value indicating whether this ability instance is currently active. /// - public bool IsActive => _instance?.IsActive ?? false; + public bool IsActive => AbilityInstance?.IsActive ?? false; /// /// Gets a value indicating whether the handle is valid. /// - public bool IsValid => _instance is not null; + public bool IsValid => AbilityInstance is not null; + + internal AbilityInstance? AbilityInstance { get; private set; } internal AbilityInstanceHandle(AbilityInstance instance) { - _instance = instance; + AbilityInstance = instance; } /// @@ -37,11 +37,11 @@ internal AbilityInstanceHandle(AbilityInstance instance) /// public void End() { - _instance?.End(); + AbilityInstance?.End(); } internal void Free() { - _instance = null; + AbilityInstance = null; } } diff --git a/Forge/Abilities/AbilityTriggerData.cs b/Forge/Abilities/AbilityTriggerData.cs index 71612e9..d17f5df 100644 --- a/Forge/Abilities/AbilityTriggerData.cs +++ b/Forge/Abilities/AbilityTriggerData.cs @@ -5,11 +5,74 @@ namespace Gamesmiths.Forge.Abilities; /// -/// Represents data associated with an ability trigger, including the trigger's tag and source. +/// Data for triggering abilities based on tags or events. Provides factory methods to create trigger configurations. /// -/// The tag identifying the specific trigger. This value is used to categorize or distinguish -/// the trigger. -/// The source of the trigger, indicating where or how the trigger originated. -public readonly record struct AbilityTriggerData( - Tag TriggerTag, - AbitityTriggerSource TriggerSource); +public readonly record struct AbilityTriggerData +{ + internal AbitityTriggerSource TriggerSource { get; } + + internal Tag TriggerTag { get; } + + internal int Priority { get; } + + internal Type? PayloadType { get; } + + private AbilityTriggerData( + AbitityTriggerSource triggerSource, + Tag triggerTag, + int priority, + Type? payloadType = null) + { + TriggerSource = triggerSource; + TriggerTag = triggerTag; + Priority = priority; + PayloadType = payloadType; + } + + /// + /// Creates trigger data for event-based activation with strongly-typed payloads. + /// + /// + /// Use this when your ability behavior implements + /// to receive the event payload directly in . + /// + /// The payload type from the triggering event. + /// The tag that triggers the ability. + /// The priority of the event subscription. + /// Configured trigger data for typed event handling. + public static AbilityTriggerData ForEvent(Tag triggerTag, int priority = 0) + { + return new AbilityTriggerData(AbitityTriggerSource.Event, triggerTag, priority, typeof(TPayload)); + } + + /// + /// Creates trigger data for event-based activation without typed payloads. + /// + /// The tag that triggers the ability. + /// The priority of the event subscription. + /// Configured trigger data for non-typed event handling. + public static AbilityTriggerData ForEvent(Tag triggerTag, int priority = 0) + { + return new AbilityTriggerData(AbitityTriggerSource.Event, triggerTag, priority); + } + + /// + /// Creates trigger data that activates when the specified tag is added to the entity. + /// + /// The tag that triggers the ability when added. + /// Configured trigger data for tag-added triggers. + public static AbilityTriggerData ForTagAdded(Tag triggerTag) + { + return new AbilityTriggerData(AbitityTriggerSource.TagAdded, triggerTag, 0); + } + + /// + /// Creates trigger data that activates while the specified tag is present on the entity. + /// + /// The tag that triggers the ability while present. + /// Configured trigger data for tag-present triggers. + public static AbilityTriggerData ForTagPresent(Tag triggerTag) + { + return new AbilityTriggerData(AbitityTriggerSource.TagPresent, triggerTag, 0); + } +} diff --git a/Forge/Abilities/IAbilityBehavior.cs b/Forge/Abilities/IAbilityBehavior.cs index 01fcdb1..b1cbe93 100644 --- a/Forge/Abilities/IAbilityBehavior.cs +++ b/Forge/Abilities/IAbilityBehavior.cs @@ -19,3 +19,24 @@ public interface IAbilityBehavior /// The context for the ended ability instance. void OnEnded(AbilityBehaviorContext context); } + +/// +/// Interface for defining custom behavior with a strongly-typed payload. +/// +/// The type of payload expected from the triggering event. +public interface IAbilityBehavior : IAbilityBehavior +{ + /// + /// Called when an ability instance has started with a typed payload. + /// + /// The context for the started ability instance. + /// The strongly-typed payload from the triggering event. + void OnStarted(AbilityBehaviorContext context, TPayload payload); + + /// + void IAbilityBehavior.OnStarted(AbilityBehaviorContext context) + { + // Default implementation for non-payload activations + OnStarted(context, default!); + } +} From 741298f7646016cbfc00d5e628a8eb6024824cad Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 2 Jan 2026 08:28:32 -0300 Subject: [PATCH 56/87] Added EventTests --- Forge.Tests/Events/EventTests.cs | 591 +++++++++++++++++++++++++ Forge.Tests/Samples/QuickStartTests.cs | 14 +- Forge/Events/EventManager.cs | 5 +- 3 files changed, 602 insertions(+), 8 deletions(-) create mode 100644 Forge.Tests/Events/EventTests.cs diff --git a/Forge.Tests/Events/EventTests.cs b/Forge.Tests/Events/EventTests.cs new file mode 100644 index 0000000..769a080 --- /dev/null +++ b/Forge.Tests/Events/EventTests.cs @@ -0,0 +1,591 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Cues; +using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Tags; +using Gamesmiths.Forge.Tests.Helpers; +using static Gamesmiths.Forge.Tests.Samples.QuickStartTests; + +namespace Gamesmiths.Forge.Tests.Events; + +public class EventTests(TagsAndCuesFixture tagsAndCueFixture) : IClassFixture +{ + private readonly TagsManager _tagsManager = tagsAndCueFixture.TagsManager; + private readonly CuesManager _cuesManager = tagsAndCueFixture.CuesManager; + + [Fact] + [Trait("Simple", null)] + public void Subscribe_and_raise_triggers_handler_with_correct_magnitude() + { + // Initialize managers + TagsManager tagsManager = _tagsManager; + CuesManager cuesManager = _cuesManager; + + var entity = new TestEntity(tagsManager, cuesManager); + var damageTag = Tag.RequestTag(tagsManager, "simple.tag"); + + var receivedDamage = 0f; + var eventFired = false; + + entity.Events.Subscribe(damageTag, x => + { + eventFired = true; + receivedDamage = x.EventMagnitude; + }); + + entity.Events.Raise(new EventData + { + EventTags = damageTag.GetSingleTagContainer()!, + Source = null, + Target = entity, + EventMagnitude = 50f, + }); + + eventFired.Should().BeTrue(); + receivedDamage.Should().Be(50f); + } + + [Fact] + [Trait("Typed", null)] + public void Typed_event_subscription_receives_correct_payload() + { + // Initialize managers + TagsManager tagsManager = _tagsManager; + CuesManager cuesManager = _cuesManager; + + var entity = new TestEntity(tagsManager, cuesManager); + var damageTag = Tag.RequestTag(tagsManager, "simple.tag"); + + var logMessage = string.Empty; + var logValue = 0; + + // Subscribe with generic type + entity.Events.Subscribe(damageTag, x => + { + logMessage = x.Payload.Message; + logValue = x.Payload.Value; + }); + + // Raise with generic type + entity.Events.Raise(new EventData + { + EventTags = damageTag.GetSingleTagContainer()!, + Source = null, + Target = entity, + Payload = new CombatLogPayload("Critical Hit", 9999), + }); + + logMessage.Should().Be("Critical Hit"); + logValue.Should().Be(9999); + } + + [Fact] + [Trait("Standalone", null)] + public void EventManager_can_be_created_without_entity() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var eventFired = false; + + events.Subscribe(eventTag, _ => eventFired = true); + + events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + }); + + eventFired.Should().BeTrue(); + } + + [Fact] + [Trait("Standalone", null)] + public void Standalone_EventManager_supports_typed_events() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var capturedPayload = string.Empty; + + events.Subscribe(eventTag, x => capturedPayload = x.Payload); + + events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Payload = "Hello from standalone", + }); + + capturedPayload.Should().Be("Hello from standalone"); + } + + [Fact] + [Trait("Priority", null)] + public void Higher_priority_handlers_are_invoked_first() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var invocationOrder = new List(); + + events.Subscribe(eventTag, _ => invocationOrder.Add("low"), priority: 0); + events.Subscribe(eventTag, _ => invocationOrder.Add("high"), priority: 100); + events.Subscribe(eventTag, _ => invocationOrder.Add("medium"), priority: 50); + + events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + }); + + invocationOrder.Should().ContainInOrder("high", "medium", "low"); + } + + [Fact] + [Trait("Priority", null)] + public void Typed_events_respect_priority_ordering() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var invocationOrder = new List(); + + events.Subscribe(eventTag, _ => invocationOrder.Add(1), priority: 10); + events.Subscribe(eventTag, _ => invocationOrder.Add(2), priority: 50); + events.Subscribe(eventTag, _ => invocationOrder.Add(3), priority: 30); + + events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Payload = 42, + }); + + invocationOrder.Should().ContainInOrder(2, 3, 1); + } + + [Fact] + [Trait("Priority", null)] + public void Negative_priority_handlers_are_invoked_last() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var invocationOrder = new List(); + + events.Subscribe(eventTag, _ => invocationOrder.Add("negative"), priority: -100); + events.Subscribe(eventTag, _ => invocationOrder.Add("zero"), priority: 0); + events.Subscribe(eventTag, _ => invocationOrder.Add("positive"), priority: 100); + + events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + }); + + invocationOrder.Should().ContainInOrder("positive", "zero", "negative"); + } + + [Fact] + [Trait("Unsubscribe", null)] + public void Unsubscribe_prevents_handler_from_being_called() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var callCount = 0; + + EventSubscriptionToken token = events.Subscribe(eventTag, _ => callCount++); + + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + callCount.Should().Be(1); + + var unsubscribed = events.Unsubscribe(token); + unsubscribed.Should().BeTrue(); + + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + callCount.Should().Be(1, "handler should not be called after un-subscription"); + } + + [Fact] + [Trait("Unsubscribe", null)] + public void Unsubscribe_typed_event_prevents_handler_from_being_called() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var callCount = 0; + + EventSubscriptionToken token = events.Subscribe(eventTag, _ => callCount++); + + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()!, Payload = 1 }); + callCount.Should().Be(1); + + var unsubscribed = events.Unsubscribe(token); + unsubscribed.Should().BeTrue(); + + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()!, Payload = 2 }); + callCount.Should().Be(1, "handler should not be called after un-subscription"); + } + + [Fact] + [Trait("Unsubscribe", null)] + public void Unsubscribe_with_invalid_token_returns_false() + { + var events = new EventManager(); + var invalidToken = new EventSubscriptionToken(Guid.NewGuid()); + + var result = events.Unsubscribe(invalidToken); + + result.Should().BeFalse(); + } + + [Fact] + [Trait("Unsubscribe", null)] + public void Double_unsubscribe_returns_false_on_second_call() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + EventSubscriptionToken token = events.Subscribe(eventTag, _ => { }); + + events.Unsubscribe(token).Should().BeTrue(); + events.Unsubscribe(token).Should().BeFalse(); + } + + [Fact] + [Trait("Multiple", null)] + public void Multiple_handlers_on_same_tag_all_receive_event() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var handler1Called = false; + var handler2Called = false; + var handler3Called = false; + + events.Subscribe(eventTag, _ => handler1Called = true); + events.Subscribe(eventTag, _ => handler2Called = true); + events.Subscribe(eventTag, _ => handler3Called = true); + + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + + handler1Called.Should().BeTrue(); + handler2Called.Should().BeTrue(); + handler3Called.Should().BeTrue(); + } + + [Fact] + [Trait("Multiple", null)] + public void Handlers_on_different_tags_only_receive_matching_events() + { + var events = new EventManager(); + var redTag = Tag.RequestTag(_tagsManager, "color.red"); + var blueTag = Tag.RequestTag(_tagsManager, "color.blue"); + var redCalled = false; + var blueCalled = false; + + events.Subscribe(redTag, _ => redCalled = true); + events.Subscribe(blueTag, _ => blueCalled = true); + + events.Raise(new EventData { EventTags = redTag.GetSingleTagContainer()! }); + + redCalled.Should().BeTrue(); + blueCalled.Should().BeFalse(); + } + + [Fact] + [Trait("Multiple", null)] + public void Unsubscribing_one_handler_does_not_affect_others() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var handler1Count = 0; + var handler2Count = 0; + + EventSubscriptionToken token1 = events.Subscribe(eventTag, _ => handler1Count++); + events.Subscribe(eventTag, _ => handler2Count++); + + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + handler1Count.Should().Be(1); + handler2Count.Should().Be(1); + + events.Unsubscribe(token1); + + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + handler1Count.Should().Be(1, "unsubscribed handler should not be called"); + handler2Count.Should().Be(2, "remaining handler should still be called"); + } + + [Fact] + [Trait("TagHierarchy", null)] + public void Parent_tag_subscription_receives_child_tag_events() + { + var events = new EventManager(); + var colorTag = Tag.RequestTag(_tagsManager, "color"); + var redTag = Tag.RequestTag(_tagsManager, "color.red"); + var colorEventReceived = false; + + events.Subscribe(colorTag, _ => colorEventReceived = true); + + // Raise event with child tag + events.Raise(new EventData { EventTags = redTag.GetSingleTagContainer()! }); + + colorEventReceived.Should().BeTrue("parent tag subscription should receive child tag events"); + } + + [Fact] + [Trait("TagHierarchy", null)] + public void Child_tag_subscription_does_not_receive_parent_tag_events() + { + var events = new EventManager(); + var colorTag = Tag.RequestTag(_tagsManager, "color"); + var redTag = Tag.RequestTag(_tagsManager, "color.red"); + var redEventReceived = false; + + events.Subscribe(redTag, _ => redEventReceived = true); + + // Raise event with parent tag + events.Raise(new EventData { EventTags = colorTag.GetSingleTagContainer()! }); + + redEventReceived.Should().BeFalse("child tag subscription should not receive parent tag events"); + } + + [Fact] + [Trait("TagHierarchy", null)] + public void Parent_tag_receives_events_from_multiple_child_tags() + { + var events = new EventManager(); + var colorTag = Tag.RequestTag(_tagsManager, "color"); + var redTag = Tag.RequestTag(_tagsManager, "color.red"); + var blueTag = Tag.RequestTag(_tagsManager, "color.blue"); + var greenTag = Tag.RequestTag(_tagsManager, "color.green"); + var eventsReceived = new List(); + + events.Subscribe(colorTag, x => + { + if (x.EventTags.HasTagExact(redTag)) + { + eventsReceived.Add("red"); + } + + if (x.EventTags.HasTagExact(blueTag)) + { + eventsReceived.Add("blue"); + } + + if (x.EventTags.HasTagExact(greenTag)) + { + eventsReceived.Add("green"); + } + }); + + events.Raise(new EventData { EventTags = redTag.GetSingleTagContainer()! }); + events.Raise(new EventData { EventTags = blueTag.GetSingleTagContainer()! }); + events.Raise(new EventData { EventTags = greenTag.GetSingleTagContainer()! }); + + eventsReceived.Should().ContainInOrder("red", "blue", "green"); + } + + [Fact] + [Trait("TagHierarchy", null)] + public void Deep_hierarchy_parent_receives_deeply_nested_child_events() + { + var events = new EventManager(); + var itemTag = Tag.RequestTag(_tagsManager, "item"); + var swordTag = Tag.RequestTag(_tagsManager, "item.equipment.weapon.sword"); + var itemEventReceived = false; + + events.Subscribe(itemTag, _ => itemEventReceived = true); + + events.Raise(new EventData { EventTags = swordTag.GetSingleTagContainer()! }); + + itemEventReceived.Should().BeTrue("deeply nested child should trigger parent subscription"); + } + + [Fact] + [Trait("MultipleTags", null)] + public void Event_with_multiple_tags_triggers_all_matching_subscriptions() + { + var events = new EventManager(); + var redTag = Tag.RequestTag(_tagsManager, "color.red"); + var enemyTag = Tag.RequestTag(_tagsManager, "enemy.undead.zombie"); + var redCalled = false; + var enemyCalled = false; + + events.Subscribe(redTag, _ => redCalled = true); + events.Subscribe(enemyTag, _ => enemyCalled = true); + + // Create event with multiple tags + var multiTagContainer = new TagContainer(_tagsManager, [redTag, enemyTag]); + events.Raise(new EventData { EventTags = multiTagContainer }); + + redCalled.Should().BeTrue(); + enemyCalled.Should().BeTrue(); + } + + [Fact] + [Trait("MultipleTags", null)] + public void Single_handler_called_once_even_with_multiple_matching_tags() + { + var events = new EventManager(); + var colorTag = Tag.RequestTag(_tagsManager, "color"); + var redTag = Tag.RequestTag(_tagsManager, "color.red"); + var blueTag = Tag.RequestTag(_tagsManager, "color.blue"); + var callCount = 0; + + // Subscribe to parent tag + events.Subscribe(colorTag, _ => callCount++); + + // Raise event with multiple child tags + var multiTagContainer = new TagContainer(_tagsManager, [redTag, blueTag]); + events.Raise(new EventData { EventTags = multiTagContainer }); + + // Handler should only be called once per Raise, not per matching tag + callCount.Should().Be(1); + } + + [Fact] + [Trait("Isolation", null)] + public void Generic_raise_does_not_trigger_non_generic_handlers() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var nonGenericCalled = false; + var genericCalled = false; + + events.Subscribe(eventTag, _ => nonGenericCalled = true); + events.Subscribe(eventTag, _ => genericCalled = true); + + // Raise generic event + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()!, Payload = 42 }); + + nonGenericCalled.Should().BeFalse("non-generic handler should not be called by generic raise"); + genericCalled.Should().BeTrue(); + } + + [Fact] + [Trait("Isolation", null)] + public void Non_generic_raise_does_not_trigger_generic_handlers() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var nonGenericCalled = false; + var genericCalled = false; + + events.Subscribe(eventTag, _ => nonGenericCalled = true); + events.Subscribe(eventTag, _ => genericCalled = true); + + // Raise non-generic event + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + + nonGenericCalled.Should().BeTrue(); + genericCalled.Should().BeFalse("generic handler should not be called by non-generic raise"); + } + + [Fact] + [Trait("Isolation", null)] + public void Different_generic_types_are_isolated() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var intCalled = false; + var stringCalled = false; + + events.Subscribe(eventTag, _ => intCalled = true); + events.Subscribe(eventTag, _ => stringCalled = true); + + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()!, Payload = 42 }); + + intCalled.Should().BeTrue(); + stringCalled.Should().BeFalse("string handler should not receive int events"); + } + + [Fact] + [Trait("EdgeCase", null)] + public void Raising_event_with_no_subscribers_does_not_throw() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + Action act = () => events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + + act.Should().NotThrow(); + } + + [Fact] + [Trait("EdgeCase", null)] + public void Raising_generic_event_with_no_subscribers_does_not_throw() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + Action act = () => events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Payload = 42, + }); + + act.Should().NotThrow(); + } + + [Fact] + [Trait("EdgeCase", null)] + public void Event_data_contains_source_and_target_information() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var source = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + EventData? capturedData = null; + + events.Subscribe(eventTag, x => capturedData = x); + + events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Source = source, + Target = target, + EventMagnitude = 123.5f, + }); + + capturedData.Should().NotBeNull(); + capturedData!.Value.Source.Should().Be(source); + capturedData.Value.Target.Should().Be(target); + capturedData.Value.EventMagnitude.Should().Be(123.5f); + } + + [Fact] + [Trait("EdgeCase", null)] + public void Value_type_payload_is_not_boxed() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var capturedPayload = default(TestValuePayload); + + events.Subscribe(eventTag, x => capturedPayload = x.Payload); + + var payload = new TestValuePayload(1.5f, 2.5f, 3.5f); + events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Payload = payload, + }); + + capturedPayload.X.Should().Be(1.5f); + capturedPayload.Y.Should().Be(2.5f); + capturedPayload.Z.Should().Be(3.5f); + } + + [Fact] + [Trait("EdgeCase", null)] + public void Subscription_order_preserved_for_same_priority() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var invocationOrder = new List(); + + // All same priority, should be invoked in subscription order + events.Subscribe(eventTag, _ => invocationOrder.Add(1), priority: 0); + events.Subscribe(eventTag, _ => invocationOrder.Add(2), priority: 0); + events.Subscribe(eventTag, _ => invocationOrder.Add(3), priority: 0); + + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + + // Note: The actual order depends on the Sort stability + // If unstable, order might not be preserved + invocationOrder.Should().HaveCount(3); + invocationOrder.Should().Contain([1, 2, 3]); + } + + private readonly record struct TestValuePayload(float X, float Y, float Z); +} diff --git a/Forge.Tests/Samples/QuickStartTests.cs b/Forge.Tests/Samples/QuickStartTests.cs index aee64a9..699c9ab 100644 --- a/Forge.Tests/Samples/QuickStartTests.cs +++ b/Forge.Tests/Samples/QuickStartTests.cs @@ -736,7 +736,7 @@ public void Manually_triggering_a_cue() } [Fact] - [Trait("QuickStart", null)] + [Trait("Quick Start", null)] public void Subscribing_and_raising_an_event() { // Initialize managers @@ -768,7 +768,7 @@ public void Subscribing_and_raising_an_event() } [Fact] - [Trait("QuickStart", null)] + [Trait("Quick Start", null)] public void Strongly_typed_events() { // Initialize managers @@ -802,7 +802,7 @@ public void Strongly_typed_events() } [Fact] - [Trait("QuickStart", null)] + [Trait("Quick Start", null)] public void Granting_activating_and_removing_an_ability() { // Initialize managers @@ -879,7 +879,7 @@ public void Granting_activating_and_removing_an_ability() } [Fact] - [Trait("QuickStart", null)] + [Trait("Quick Start", null)] public void Activating_an_ability_with_checks() { // Initialize managers @@ -949,7 +949,7 @@ public void Activating_an_ability_with_checks() } [Fact] - [Trait("QuickStart", null)] + [Trait("Quick Start", null)] public void Granting_an_ability_and_activating_once() { // Initialize managers @@ -980,7 +980,7 @@ public void Granting_an_ability_and_activating_once() } [Fact] - [Trait("QuickStart", null)] + [Trait("Quick Start", null)] public void Triggering_an_ability_through_an_event() { // Initialize managers @@ -1011,7 +1011,7 @@ public void Triggering_an_ability_through_an_event() } [Fact] - [Trait("QuickStart", null)] + [Trait("Quick Start", null)] public void Triggering_an_ability_through_tags() { // Initialize managers diff --git a/Forge/Events/EventManager.cs b/Forge/Events/EventManager.cs index 55af14f..82e928c 100644 --- a/Forge/Events/EventManager.cs +++ b/Forge/Events/EventManager.cs @@ -79,7 +79,10 @@ public EventSubscriptionToken Subscribe(Tag eventTag, Action handler, /// The handler to invoke when the event is raised. /// The priority of the subscription; higher values indicate higher priority. /// The subscription token for later un-subscription. - public EventSubscriptionToken Subscribe(Tag eventTag, Action> handler, int priority = 0) + public EventSubscriptionToken Subscribe( + Tag eventTag, + Action> handler, + int priority = 0) { var token = new EventSubscriptionToken(Guid.NewGuid()); Type key = typeof(TPayload); From 04ce7f9cb666bca61d251a262f368d910b518f78 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 2 Jan 2026 23:47:39 -0300 Subject: [PATCH 57/87] Added magnitude parameter --- Forge.Tests/Abilities/AbilityBehaviorTests.cs | 22 ++++++------ Forge/Abilities/Ability.cs | 36 ++++++++++--------- .../AbilityBehaviorContext.Payload.cs | 19 +++++----- Forge/Abilities/AbilityBehaviorContext.cs | 8 ++++- Forge/Abilities/AbilityHandle.cs | 30 +++++++++------- Forge/Abilities/AbilityInstance.cs | 8 ++--- Forge/Abilities/IAbilityBehavior.cs | 12 +++---- 7 files changed, 75 insertions(+), 60 deletions(-) diff --git a/Forge.Tests/Abilities/AbilityBehaviorTests.cs b/Forge.Tests/Abilities/AbilityBehaviorTests.cs index 7c8bf5e..648c02c 100644 --- a/Forge.Tests/Abilities/AbilityBehaviorTests.cs +++ b/Forge.Tests/Abilities/AbilityBehaviorTests.cs @@ -396,8 +396,8 @@ public void Generic_activate_creates_typed_context_with_payload() capturedContext.Should().BeOfType>(); var typedContext = (AbilityBehaviorContext)capturedContext!; - typedContext.Payload.StringValue.Should().Be("TestValue"); - typedContext.Payload.IntValue.Should().Be(42); + typedContext.Data.StringValue.Should().Be("TestValue"); + typedContext.Data.IntValue.Should().Be(42); } [Fact] @@ -443,9 +443,9 @@ public void Value_type_payload_is_preserved_in_context() capturedContext.Should().BeOfType>(); var typedContext = (AbilityBehaviorContext)capturedContext!; - typedContext.Payload.X.Should().Be(1.5f); - typedContext.Payload.Y.Should().Be(2.5f); - typedContext.Payload.Z.Should().Be(3.5f); + typedContext.Data.X.Should().Be(1.5f); + typedContext.Data.Y.Should().Be(2.5f); + typedContext.Data.Z.Should().Be(3.5f); } [Fact] @@ -477,10 +477,10 @@ public void Context_data_is_passed_through_for_each_instance_in_PerExecution() var context1 = (AbilityBehaviorContext)capturedContexts[0]; var context2 = (AbilityBehaviorContext)capturedContexts[1]; - context1.Payload.StringValue.Should().Be("First"); - context1.Payload.IntValue.Should().Be(1); - context2.Payload.StringValue.Should().Be("Second"); - context2.Payload.IntValue.Should().Be(2); + context1.Data.StringValue.Should().Be("First"); + context1.Data.IntValue.Should().Be(1); + context2.Data.StringValue.Should().Be("Second"); + context2.Data.IntValue.Should().Be(2); } [Fact] @@ -510,8 +510,8 @@ public void PerEntity_retrigger_passes_new_context_data() var context1 = (AbilityBehaviorContext)capturedContexts[0]; var context2 = (AbilityBehaviorContext)capturedContexts[1]; - context1.Payload.StringValue.Should().Be("First"); - context2.Payload.StringValue.Should().Be("Second"); + context1.Data.StringValue.Should().Be("First"); + context2.Data.StringValue.Should().Be("Second"); } [Fact] diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 964b9d4..f925101 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -117,7 +117,7 @@ internal Ability( { owner.Events.Subscribe( triggerData.TriggerTag, - x => TryActivateAbility(x.Target, out _), + x => TryActivateAbility(x.Target, out _, x.EventMagnitude), triggerData.Priority); } @@ -130,25 +130,27 @@ internal Ability( internal bool TryActivateAbility( IForgeEntity? abilityTarget, - out AbilityActivationFailures failureFlags) + out AbilityActivationFailures failureFlags, + float magnitude) { if (CanActivate(abilityTarget, out failureFlags)) { - Activate(abilityTarget); + Activate(abilityTarget, magnitude); return true; } return false; } - internal bool TryActivateAbility( + internal bool TryActivateAbility( IForgeEntity? abilityTarget, out AbilityActivationFailures failureFlags, - TPayload payload) + TData data, + float magnitude) { if (CanActivate(abilityTarget, out failureFlags)) { - Activate(abilityTarget, payload); + Activate(abilityTarget, data, magnitude); return true; } @@ -217,7 +219,7 @@ internal void CancelAllInstances() Owner.Abilities.NotifyAbilityEnded(new AbilityEndedData(Handle, true)); } - internal void OnInstanceStarted(AbilityInstance instance) + internal void OnInstanceStarted(AbilityInstance instance, float magnitude) { if (AbilityData.BehaviorFactory is null) { @@ -230,7 +232,7 @@ internal void OnInstanceStarted(AbilityInstance instance) return; } - var context = new AbilityBehaviorContext(this, instance); + var context = new AbilityBehaviorContext(this, instance, magnitude); _behaviors[instance] = new BehaviorBinding(behavior, context); try @@ -244,7 +246,7 @@ internal void OnInstanceStarted(AbilityInstance instance) } } - internal void OnInstanceStarted(AbilityInstance instance, TPayload payload) + internal void OnInstanceStarted(AbilityInstance instance, TPayload payload, float magnitude) { if (AbilityData.BehaviorFactory is null) { @@ -257,7 +259,7 @@ internal void OnInstanceStarted(AbilityInstance instance, TPayload pay return; } - var context = new AbilityBehaviorContext(this, instance, payload); + var context = new AbilityBehaviorContext(this, instance, payload, magnitude); _behaviors[instance] = new BehaviorBinding(behavior, context); try @@ -537,22 +539,22 @@ private void SubscribeTypedEventCore(Tag tag, int priority) { Owner.Events.Subscribe( tag, - x => TryActivateAbility(x.Target, out _, x.Payload), + x => TryActivateAbility(x.Target, out _, x.Payload, x.EventMagnitude), priority: priority); } - private void Activate(IForgeEntity? abilityTarget) + private void Activate(IForgeEntity? abilityTarget, float magnitude) { AbilityInstance instance = CreateInstance(abilityTarget); _activeInstances.Add(instance); - instance.Start(); + instance.Start(magnitude); } - private void Activate(IForgeEntity? abilityTarget, TPayload payload) + private void Activate(IForgeEntity? abilityTarget, TData data, float magnitude) { AbilityInstance instance = CreateInstance(abilityTarget); _activeInstances.Add(instance); - instance.Start(payload); + instance.Start(data, magnitude); } private AbilityInstance CreateInstance(IForgeEntity? abilityTarget) @@ -619,7 +621,7 @@ private void TagPresent_OnTagChanged(TagContainer container) { if (container.HasTag(AbilityData.AbilityTriggerData!.Value.TriggerTag)) { - TryActivateAbility(null, out _); + TryActivateAbility(null, out _, 0f); } else { @@ -631,7 +633,7 @@ private void TagAdded_OnTagChanged(TagContainer container) { if (container.HasTag(AbilityData.AbilityTriggerData!.Value.TriggerTag)) { - TryActivateAbility(null, out _); + TryActivateAbility(null, out _, 0f); } } } diff --git a/Forge/Abilities/AbilityBehaviorContext.Payload.cs b/Forge/Abilities/AbilityBehaviorContext.Payload.cs index 42bb6ce..7dc3110 100644 --- a/Forge/Abilities/AbilityBehaviorContext.Payload.cs +++ b/Forge/Abilities/AbilityBehaviorContext.Payload.cs @@ -3,23 +3,24 @@ namespace Gamesmiths.Forge.Abilities; /// -/// Context that carries strongly-typed activation data. -/// Created automatically when using . +/// Context that carries strongly-typed additional data. +/// Created automatically when using . /// -/// The activation data type. -public sealed class AbilityBehaviorContext : AbilityBehaviorContext +/// The activation data type. +public sealed class AbilityBehaviorContext : AbilityBehaviorContext { /// - /// Gets the activation data passed during ability activation. + /// Gets the additional data passed during ability activation. /// - public TPayload Payload { get; } + public TData Data { get; } internal AbilityBehaviorContext( Ability ability, AbilityInstance instance, - TPayload payload) - : base(ability, instance) + TData data, + float magnitude) + : base(ability, instance, magnitude) { - Payload = payload; + Data = data; } } diff --git a/Forge/Abilities/AbilityBehaviorContext.cs b/Forge/Abilities/AbilityBehaviorContext.cs index 3d8f0be..eb5ec62 100644 --- a/Forge/Abilities/AbilityBehaviorContext.cs +++ b/Forge/Abilities/AbilityBehaviorContext.cs @@ -39,11 +39,17 @@ public class AbilityBehaviorContext /// public AbilityInstanceHandle InstanceHandle { get; } - internal AbilityBehaviorContext(Ability ability, AbilityInstance instance) + /// + /// Gets the magnitude associated with the activation of this ability, if applicable. + /// + public float Magnitude { get; } + + internal AbilityBehaviorContext(Ability ability, AbilityInstance instance, float magnitude) { AbilityHandle = ability.Handle; InstanceHandle = instance.Handle; Owner = ability.Owner; Source = ability.SourceEntity; + Magnitude = magnitude; } } diff --git a/Forge/Abilities/AbilityHandle.cs b/Forge/Abilities/AbilityHandle.cs index 632c00c..23093af 100644 --- a/Forge/Abilities/AbilityHandle.cs +++ b/Forge/Abilities/AbilityHandle.cs @@ -1,4 +1,4 @@ -// Copyr ight © Gamesmiths Guild. +// Copyright © Gamesmiths Guild. using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Tags; @@ -41,28 +41,34 @@ internal AbilityHandle(Ability ability) /// Activates the ability associated with this handle. /// /// Flags indicating the failure reasons for the ability activation. - /// The target entity for the ability activation. + /// Optional target entity for the ability activation. + /// Optional magnitude value for the ability activation. /// Return if the ability was successfully activated; /// otherwise, . - public bool Activate(out AbilityActivationFailures failureFlags, IForgeEntity? target = null) + public bool Activate( + out AbilityActivationFailures failureFlags, + IForgeEntity? target = null, + float magnitude = 0) { failureFlags = AbilityActivationFailures.InvalidHandler; - return Ability?.TryActivateAbility(target, out failureFlags) ?? false; + return Ability?.TryActivateAbility(target, out failureFlags, magnitude) ?? false; } /// - /// Activates the ability associated with this handle with strongly-typed payload data. + /// Activates the ability associated with this handle with strongly-typed additional data. /// - /// The type of payload data to pass to the ability behavior. - /// The payload data to pass to the behavior. + /// The type of the data to pass to the ability behavior. + /// Additional data to pass to the behavior. /// Flags indicating the failure reasons for the ability activation. - /// The target entity for the ability activation. + /// Optional target entity for the ability activation. + /// Optional magnitude value for the ability activation. /// Return if the ability was successfully activated; otherwise, /// . - public bool Activate( - TPayload payload, + public bool Activate( + TData data, out AbilityActivationFailures failureFlags, - IForgeEntity? target = null) + IForgeEntity? target = null, + float magnitude = 0f) { if (Ability is null) { @@ -70,7 +76,7 @@ public bool Activate( return false; } - return Ability.TryActivateAbility(target, out failureFlags, payload); + return Ability.TryActivateAbility(target, out failureFlags, data, magnitude); } /// diff --git a/Forge/Abilities/AbilityInstance.cs b/Forge/Abilities/AbilityInstance.cs index ae05c21..05aa6e2 100644 --- a/Forge/Abilities/AbilityInstance.cs +++ b/Forge/Abilities/AbilityInstance.cs @@ -26,7 +26,7 @@ internal AbilityInstance(Ability ability, IForgeEntity? target) Handle = new AbilityInstanceHandle(this); } - internal void Start() + internal void Start(float magnitude = 0f) { if (IsActive) { @@ -35,10 +35,10 @@ internal void Start() ApplyActivationState(); IsActive = true; - _ability.OnInstanceStarted(this); + _ability.OnInstanceStarted(this, magnitude); } - internal void Start(TPayload payload) + internal void Start(TData data, float magnitude = 0f) { if (IsActive) { @@ -47,7 +47,7 @@ internal void Start(TPayload payload) ApplyActivationState(); IsActive = true; - _ability.OnInstanceStarted(this, payload); + _ability.OnInstanceStarted(this, data, magnitude); } internal void End() diff --git a/Forge/Abilities/IAbilityBehavior.cs b/Forge/Abilities/IAbilityBehavior.cs index b1cbe93..2be6c01 100644 --- a/Forge/Abilities/IAbilityBehavior.cs +++ b/Forge/Abilities/IAbilityBehavior.cs @@ -21,17 +21,17 @@ public interface IAbilityBehavior } /// -/// Interface for defining custom behavior with a strongly-typed payload. +/// Interface for defining custom behavior with strongly-typed additional data. /// -/// The type of payload expected from the triggering event. -public interface IAbilityBehavior : IAbilityBehavior +/// The type of the additional data expected. +public interface IAbilityBehavior : IAbilityBehavior { /// - /// Called when an ability instance has started with a typed payload. + /// Called when an ability instance has started with a typed data. /// /// The context for the started ability instance. - /// The strongly-typed payload from the triggering event. - void OnStarted(AbilityBehaviorContext context, TPayload payload); + /// The strongly-typed additional data from the triggering event. + void OnStarted(AbilityBehaviorContext context, TData data); /// void IAbilityBehavior.OnStarted(AbilityBehaviorContext context) From 1351f89c435ceb8c9a9bd80e6ff6c57b1b5f2a42 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 3 Jan 2026 00:07:18 -0300 Subject: [PATCH 58/87] Added activation magnitude tests --- Forge.Tests/Abilities/AbilityBehaviorTests.cs | 184 ++++++++++++++++++ Forge/Core/EntityAbilities.cs | 2 +- 2 files changed, 185 insertions(+), 1 deletion(-) diff --git a/Forge.Tests/Abilities/AbilityBehaviorTests.cs b/Forge.Tests/Abilities/AbilityBehaviorTests.cs index 648c02c..340d947 100644 --- a/Forge.Tests/Abilities/AbilityBehaviorTests.cs +++ b/Forge.Tests/Abilities/AbilityBehaviorTests.cs @@ -674,6 +674,190 @@ public void Event_triggered_ability_multiple_events_with_PerExecution() activationCount.Should().Be(3); } + #region Magnitude Tests + + [Fact] + [Trait("Magnitude", null)] + public void Context_contains_magnitude_when_activated_with_magnitude() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + AbilityBehaviorContext? capturedContext = null; + + AbilityData data = CreateAbilityData( + "MagnitudeAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedContext = ctx)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationFailures failureFlags, magnitude: 75.5f).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + capturedContext.Should().NotBeNull(); + capturedContext!.Magnitude.Should().Be(75.5f); + } + + [Fact] + [Trait("Magnitude", null)] + public void Context_magnitude_defaults_to_zero_when_not_specified() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + AbilityBehaviorContext? capturedContext = null; + + AbilityData data = CreateAbilityData( + "NoMagnitudeAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedContext = ctx)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + capturedContext.Should().NotBeNull(); + capturedContext!.Magnitude.Should().Be(0f); + } + + [Fact] + [Trait("Magnitude", null)] + public void Event_triggered_ability_receives_magnitude_from_event() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + float capturedMagnitude = 0f; + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + AbilityData data = CreateAbilityData( + "EventMagnitudeAbility", + behaviorFactory: () => new CallbackBehavior(ctx => + { + capturedMagnitude = ctx.Magnitude; + }), + abilityTriggerData: AbilityTriggerData.ForEvent(eventTag)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + EventMagnitude = 123.5f, + }); + + capturedMagnitude.Should().Be(123.5f); + handle!.Cancel(); + } + + [Fact] + [Trait("Magnitude", null)] + public void Typed_event_triggered_ability_receives_both_magnitude_and_data() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + float capturedMagnitude = 0f; + TestEventPayload? capturedData = null; + var eventTag = Tag.RequestTag(_tagsManager, "tag"); + + AbilityData data = CreateAbilityData( + "TypedEventMagnitudeAbility", + behaviorFactory: () => new TypedPayloadBehavior((ctx, payload) => + { + capturedMagnitude = ctx.Magnitude; + capturedData = payload; + }), + abilityTriggerData: AbilityTriggerData.ForEvent(eventTag)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + EventMagnitude = 50f, + Payload = new TestEventPayload("Test", 42, true), + }); + + capturedMagnitude.Should().Be(50f); + capturedData.Should().NotBeNull(); + capturedData!.Message.Should().Be("Test"); + capturedData.Value.Should().Be(42); + } + + [Fact] + [Trait("Magnitude", null)] + public void Generic_activate_passes_both_data_and_magnitude() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + AbilityBehaviorContext? capturedContext = null; + + AbilityData data = CreateAbilityData( + "DataAndMagnitudeAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedContext = ctx)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + var activationData = new TestActivationData("TestValue", 42); + handle!.Activate(activationData, out AbilityActivationFailures failureFlags, magnitude: 100f).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + capturedContext.Should().NotBeNull(); + capturedContext.Should().BeOfType>(); + + var typedContext = (AbilityBehaviorContext)capturedContext!; + typedContext.Magnitude.Should().Be(100f); + typedContext.Data.StringValue.Should().Be("TestValue"); + typedContext.Data.IntValue.Should().Be(42); + } + + [Fact] + [Trait("Magnitude", null)] + public void Magnitude_is_preserved_across_instances_in_PerExecution() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var capturedMagnitudes = new List(); + + AbilityData data = CreateAbilityData( + "MultiMagnitudeAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedMagnitudes.Add(ctx.Magnitude)), + instancingPolicy: AbilityInstancingPolicy.PerExecution); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out _, magnitude: 10f).Should().BeTrue(); + handle.Activate(out _, magnitude: 20f).Should().BeTrue(); + handle.Activate(out _, magnitude: 30f).Should().BeTrue(); + + capturedMagnitudes.Should().HaveCount(3); + capturedMagnitudes[0].Should().Be(10f); + capturedMagnitudes[1].Should().Be(20f); + capturedMagnitudes[2].Should().Be(30f); + } + + [Fact] + [Trait("Magnitude", null)] + public void PerEntity_retrigger_uses_new_magnitude() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var capturedMagnitudes = new List(); + + AbilityData data = CreateAbilityData( + "RetriggerMagnitudeAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedMagnitudes.Add(ctx.Magnitude)), + retriggerInstancedAbility: true); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out _, magnitude: 50f).Should().BeTrue(); + handle.Activate(out _, magnitude: 75f).Should().BeTrue(); + + capturedMagnitudes.Should().HaveCount(2); + capturedMagnitudes[0].Should().Be(50f); + capturedMagnitudes[1].Should().Be(75f); + } + + #endregion + private static AbilityHandle? Grant( TestEntity target, AbilityData data, diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index 8ce1735..b9fd53f 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -127,7 +127,7 @@ public bool TryActivateAbilitiesByTag( TagContainer? abilityTags = ability.AbilityData.AbilityTags; if (abilityTags?.HasAny(tagsToActivate) == true) { - anyActivated |= ability.TryActivateAbility(target, out failureFlags[i]); + anyActivated |= ability.TryActivateAbility(target, out failureFlags[i], 0f); } } From b7c7910e37b6b26bf682c95aa46bd55f76e2a7e0 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 4 Jan 2026 21:47:23 -0300 Subject: [PATCH 59/87] Fixed custom execution evaluation order --- Forge.Tests/Cues/CueTests.cs | 22 ++++-- .../Effects/CustomCalculatorsEffectsTests.cs | 73 +++++++++++++++---- .../Helpers/CustomTestExecutionClass.cs | 13 +++- Forge/Effects/Calculator/CustomCalculator.cs | 50 ++++++++++++- Forge/Effects/Calculator/CustomExecution.cs | 5 +- Forge/Effects/EffectEvaluatedData.cs | 8 ++ 6 files changed, 146 insertions(+), 25 deletions(-) diff --git a/Forge.Tests/Cues/CueTests.cs b/Forge.Tests/Cues/CueTests.cs index ce5a67e..415ccc3 100644 --- a/Forge.Tests/Cues/CueTests.cs +++ b/Forge.Tests/Cues/CueTests.cs @@ -2320,7 +2320,11 @@ public void Custom_executions_sets_custom_cues_parameters_correctly() "Test Effect", new DurationData(DurationType.Instant), customExecutions: [customCalculatorClass], - cues: [new CueData(Tag.RequestTag(_tagsManager, "Test.Cue1").GetSingleTagContainer(), 0, 10, CueMagnitudeType.EffectLevel)]); + cues: [new CueData( + Tag.RequestTag(_tagsManager, "Test.Cue1").GetSingleTagContainer(), + 0, + 10, + CueMagnitudeType.EffectLevel)]); var effect = new Effect(effectData, new EffectOwnership(owner, owner)); @@ -2329,7 +2333,7 @@ public void Custom_executions_sets_custom_cues_parameters_correctly() target.EffectsManager.ApplyEffect(effect); TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [89, 89, 0, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 16, 0, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 10, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 11, 0, 0]); _testCues[0].ExecuteData.Count.Should().Be(1); _testCues[0].ExecuteData.CustomParameters.Should().NotBeNull(); @@ -2338,7 +2342,7 @@ public void Custom_executions_sets_custom_cues_parameters_correctly() target.EffectsManager.ApplyEffect(effect); TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [88, 88, 0, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [31, 31, 0, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [18, 18, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [35, 35, 0, 0]); _testCues[0].ExecuteData.Count.Should().Be(2); _testCues[0].ExecuteData.CustomParameters.Should().NotBeNull(); @@ -2347,7 +2351,7 @@ public void Custom_executions_sets_custom_cues_parameters_correctly() target.EffectsManager.ApplyEffect(effect); TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [87, 87, 0, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [46, 46, 0, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [26, 26, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [74, 74, 0, 0]); _testCues[0].ExecuteData.Count.Should().Be(3); _testCues[0].ExecuteData.CustomParameters.Should().NotBeNull(); @@ -2454,7 +2458,11 @@ public void Custom_executions_sets_custom_update_cues_parameters_correctly() new DurationData(DurationType.Infinite), snapshotLevel: false, customExecutions: [customCalculatorClass], - cues: [new CueData(Tag.RequestTag(_tagsManager, "Test.Cue1").GetSingleTagContainer(), 0, 10, CueMagnitudeType.EffectLevel)]); + cues: [new CueData( + Tag.RequestTag(_tagsManager, "Test.Cue1").GetSingleTagContainer(), + 0, + 10, + CueMagnitudeType.EffectLevel)]); var effect = new Effect(effectData, new EffectOwnership(owner, owner)); @@ -2463,7 +2471,7 @@ public void Custom_executions_sets_custom_update_cues_parameters_correctly() target.EffectsManager.ApplyEffect(effect); TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [89, 90, -1, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 2, 8, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); _testCues[0].ApplyData.Count.Should().Be(1); _testCues[0].ApplyData.CustomParameters.Should().NotBeNull(); @@ -2478,7 +2486,7 @@ public void Custom_executions_sets_custom_update_cues_parameters_correctly() effect.LevelUp(); TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [88, 90, -2, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [31, 1, 30, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [18, 2, 16, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [35, 2, 33, 0]); _testCues[0].UpdateData.Count.Should().Be(2); _testCues[0].UpdateData.CustomParameters.Should().NotBeNull(); diff --git a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs index 052d296..9091c78 100644 --- a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs +++ b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs @@ -350,17 +350,17 @@ public void Custom_executions_modifies_attribute_accordingly() target.EffectsManager.ApplyEffect(effect); TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [89, 89, 0, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 16, 0, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 10, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 11, 0, 0]); target.EffectsManager.ApplyEffect(effect); TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [88, 88, 0, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [31, 31, 0, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [18, 18, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [35, 35, 0, 0]); target.EffectsManager.ApplyEffect(effect); TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [87, 87, 0, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [46, 46, 0, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [26, 26, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [74, 74, 0, 0]); } [Fact] @@ -426,27 +426,27 @@ public void Custom_executions_modifies_update_with_non_snapshot_attributes() TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [89, 90, -1, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 2, 8, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); ActiveEffectHandle? effectHandler1 = owner.EffectsManager.ApplyEffect(effect2); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [21, 1, 20, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [12, 2, 10, 0]); ActiveEffectHandle? effectHandler2 = owner.EffectsManager.ApplyEffect(effect3); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [29, 1, 28, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [13, 2, 11, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [14, 2, 12, 0]); owner.EffectsManager.UnapplyEffect(effectHandler2!); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [21, 1, 20, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [12, 2, 10, 0]); owner.EffectsManager.UnapplyEffect(effectHandler1!); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 2, 8, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); } [Fact] @@ -548,6 +548,53 @@ public void Custom_execution_without_valid_target_attributes_applies_with_no_att TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [90, 90, 0, 0]); } + [Fact] + [Trait("Execution", null)] + public void Custom_execution_captures_pending_modifiers_from_same_effect() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customCalculatorClass = new CustomTestExecutionClass(false); + + var effectData = new EffectData( + "Test Effect", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(5))), + ], + customExecutions: + [ + customCalculatorClass + ]); + + var effect = new Effect( + effectData, + new EffectOwnership( + owner, + owner)); + + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [89, 89, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [21, 21, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [16, 16, 0, 0]); + + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [88, 88, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [41, 41, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [50, 50, 0, 0]); + + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [87, 87, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [61, 61, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [99, 99, 0, 0]); + } + [Fact] [Trait("Instant", null)] public void Custom_calculator_class_with_invalid_ownership_applies_with_no_attribute_changes() @@ -835,31 +882,31 @@ public void Custom_executions_does_not_update_with_snapshot_attributes_when_effe TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [89, 90, -1, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 2, 8, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); ActiveEffectHandle? effectHandler1 = owner.EffectsManager.ApplyEffect(effect2); effect.LevelUp(); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 2, 8, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); ActiveEffectHandle? effectHandler2 = owner.EffectsManager.ApplyEffect(effect3); effect.LevelUp(); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 2, 8, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); owner.EffectsManager.UnapplyEffect(effectHandler2!); effect.LevelUp(); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 2, 8, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); owner.EffectsManager.UnapplyEffect(effectHandler1!); effect.LevelUp(); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 2, 8, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); } private sealed class CustomMagnitudeCalculator : CustomModifierMagnitudeCalculator diff --git a/Forge.Tests/Helpers/CustomTestExecutionClass.cs b/Forge.Tests/Helpers/CustomTestExecutionClass.cs index afed1d8..2bdd6f5 100644 --- a/Forge.Tests/Helpers/CustomTestExecutionClass.cs +++ b/Forge.Tests/Helpers/CustomTestExecutionClass.cs @@ -65,12 +65,19 @@ public override ModifierEvaluatedData[] EvaluateExecution( var sourceAttribute1value = CaptureAttributeMagnitude( SourceAttribute1, effect, - effect.Ownership.Source, + target, effectEvaluatedData); + var sourceAttribute2value = CaptureAttributeMagnitude( SourceAttribute2, effect, - effect.Ownership.Source, + target, + effectEvaluatedData); + + var targetAttribute1value = CaptureAttributeMagnitude( + TargetAttribute1, + effect, + target, effectEvaluatedData); if (TargetAttribute1.TryGetAttribute(target, out EntityAttribute? targetAttribute1)) @@ -86,7 +93,7 @@ public override ModifierEvaluatedData[] EvaluateExecution( result.Add(new ModifierEvaluatedData( targetAttribute2, ModifierOperation.FlatBonus, - sourceAttribute1value + sourceAttribute2value)); + sourceAttribute1value + sourceAttribute2value + targetAttribute1value)); } if (SourceAttribute3.TryGetAttribute(effect.Ownership.Source, out EntityAttribute? sourceAttribute3)) diff --git a/Forge/Effects/Calculator/CustomCalculator.cs b/Forge/Effects/Calculator/CustomCalculator.cs index 1ed719f..b298e9f 100644 --- a/Forge/Effects/Calculator/CustomCalculator.cs +++ b/Forge/Effects/Calculator/CustomCalculator.cs @@ -3,6 +3,7 @@ using Gamesmiths.Forge.Attributes; using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Effects.Magnitudes; +using Gamesmiths.Forge.Effects.Modifiers; namespace Gamesmiths.Forge.Effects.Calculator; @@ -53,12 +54,59 @@ protected static int CaptureAttributeMagnitude( return 0; } - return (int)CaptureAttributeSnapshotAware( + var capturedValue = (int)CaptureAttributeSnapshotAware( capturedAttribute, calculationType, finalChannel, captureTarget, effectEvaluatedData); + + capturedValue += GetPendingModifierContribution( + capturedAttribute.Attribute, + capturedValue, + effectEvaluatedData); + + return capturedValue; + } + + private static int GetPendingModifierContribution( + StringKey attribute, + int currentValue, + EffectEvaluatedData? effectEvaluatedData) + { + var flatBonus = 0f; + var percentBonus = 0f; + + if (effectEvaluatedData is null || effectEvaluatedData.ModifiersEvaluatedData is null) + { + return 0; + } + + foreach (ModifierEvaluatedData modifier in effectEvaluatedData.ModifiersEvaluatedData) + { + if (modifier.Attribute.Key != attribute) + { + continue; + } + + switch (modifier.ModifierOperation) + { + case ModifierOperation.FlatBonus: + flatBonus += modifier.Magnitude; + break; + case ModifierOperation.PercentBonus: + percentBonus += modifier.Magnitude; + break; + case ModifierOperation.Override: + return (int)modifier.Magnitude - currentValue; + } + } + + // Apply flat first, then percent (matching the attribute calculation order) + var withFlat = currentValue + flatBonus; + var withPercent = withFlat * (1 + percentBonus); + + return (int)(withPercent - currentValue); } private static float CaptureAttributeSnapshotAware( diff --git a/Forge/Effects/Calculator/CustomExecution.cs b/Forge/Effects/Calculator/CustomExecution.cs index 9c50fb9..f77d42c 100644 --- a/Forge/Effects/Calculator/CustomExecution.cs +++ b/Forge/Effects/Calculator/CustomExecution.cs @@ -20,7 +20,10 @@ public abstract class CustomExecution : CustomCalculator public abstract ModifierEvaluatedData[] EvaluateExecution( Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData); - internal static bool ExecutionHasInvalidAttributeCaptures(CustomExecution execution, Effect effect, IForgeEntity target) + internal static bool ExecutionHasInvalidAttributeCaptures( + CustomExecution execution, + Effect effect, + IForgeEntity target) { foreach (AttributeCaptureDefinition capturedAttribute in execution.AttributesToCapture) { diff --git a/Forge/Effects/EffectEvaluatedData.cs b/Forge/Effects/EffectEvaluatedData.cs index cd0e066..e0a1c56 100644 --- a/Forge/Effects/EffectEvaluatedData.cs +++ b/Forge/Effects/EffectEvaluatedData.cs @@ -118,6 +118,7 @@ internal void ReEvaluate(Effect effect, int stack = 1, int? level = null) Effect = effect; Stack = stack; Level = level ?? effect.Level; + ModifiersEvaluatedData = []; if (level is null && effect.EffectData.SnapshotLevel) { @@ -181,6 +182,13 @@ private ModifierEvaluatedData[] EvaluateModifiers() modifier.Channel)); } + if (Effect.EffectData.CustomExecutions.Length == 0) + { + return [.. modifiersEvaluatedData]; + } + + ModifiersEvaluatedData = [.. modifiersEvaluatedData]; + foreach (CustomExecution execution in Effect.EffectData.CustomExecutions) { if (CustomExecution.ExecutionHasInvalidAttributeCaptures(execution, Effect, Target)) From 04173a9a059a5ba5a81c7314df7a5107853216ab Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 5 Jan 2026 22:18:30 -0300 Subject: [PATCH 60/87] Fixed filename --- ...yBehaviorContext.Payload.cs => AbilityBehaviorContext.Data.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Forge/Abilities/{AbilityBehaviorContext.Payload.cs => AbilityBehaviorContext.Data.cs} (100%) diff --git a/Forge/Abilities/AbilityBehaviorContext.Payload.cs b/Forge/Abilities/AbilityBehaviorContext.Data.cs similarity index 100% rename from Forge/Abilities/AbilityBehaviorContext.Payload.cs rename to Forge/Abilities/AbilityBehaviorContext.Data.cs From 55f64ef0ca7d373deacfa15ec6302d52390dbf79 Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 5 Jan 2026 23:24:37 -0300 Subject: [PATCH 61/87] Added custom data support for effects --- .../Effects/EffectApplicationContextTests.cs | 421 ++++++++++++++++++ Forge/Effects/ActiveEffect.cs | 4 +- .../Effects/EffectApplicationContext.Data.cs | 24 + Forge/Effects/EffectApplicationContext.cs | 39 ++ Forge/Effects/EffectEvaluatedData.cs | 35 +- Forge/Effects/EffectsManager.cs | 108 +++-- 6 files changed, 586 insertions(+), 45 deletions(-) create mode 100644 Forge.Tests/Effects/EffectApplicationContextTests.cs create mode 100644 Forge/Effects/EffectApplicationContext.Data.cs create mode 100644 Forge/Effects/EffectApplicationContext.cs diff --git a/Forge.Tests/Effects/EffectApplicationContextTests.cs b/Forge.Tests/Effects/EffectApplicationContextTests.cs new file mode 100644 index 0000000..8c81155 --- /dev/null +++ b/Forge.Tests/Effects/EffectApplicationContextTests.cs @@ -0,0 +1,421 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Attributes; +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Cues; +using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Effects.Calculator; +using Gamesmiths.Forge.Effects.Duration; +using Gamesmiths.Forge.Effects.Magnitudes; +using Gamesmiths.Forge.Effects.Modifiers; +using Gamesmiths.Forge.Tags; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Effects; + +public class EffectApplicationContextTests(TagsAndCuesFixture tagsAndCuesFixture) : IClassFixture +{ + private enum HealType + { + Instant = 0, + OverTime = 1, + } + + private readonly TagsManager _tagsManager = tagsAndCuesFixture.TagsManager; + private readonly CuesManager _cuesManager = tagsAndCuesFixture.CuesManager; + + [Fact] + [Trait("Context", "Instant")] + public void Custom_execution_can_access_context_data_from_instant_effect() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customExecution = new ContextAwareExecution(); + + var effectData = new EffectData( + "Context Effect", + new DurationData(DurationType.Instant), + customExecutions: [customExecution]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + var contextData = new DamageContext(25.5f, true, ["Head", "Torso"]); + + target.EffectsManager.ApplyEffect(effect, contextData); + + customExecution.ReceivedContext.Should().NotBeNull(); + customExecution.ReceivedDamage.Should().Be(25.5f); + customExecution.ReceivedIsCritical.Should().BeTrue(); + customExecution.ReceivedHitLocations.Should().BeEquivalentTo("Head", "Torso"); + } + + [Fact] + [Trait("Context", "Instant")] + public void Custom_execution_receives_null_context_when_applied_without_context() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customExecution = new ContextAwareExecution(); + + var effectData = new EffectData( + "Context Effect", + new DurationData(DurationType.Instant), + customExecutions: [customExecution]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + target.EffectsManager.ApplyEffect(effect); + + customExecution.ReceivedContext.Should().BeNull(); + customExecution.WasExecuted.Should().BeTrue(); + } + + [Fact] + [Trait("Context", "Infinite")] + public void Custom_execution_can_access_context_data_from_infinite_effect() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customExecution = new ContextAwareExecution(); + + var effectData = new EffectData( + "Context Effect", + new DurationData(DurationType.Infinite), + customExecutions: [customExecution]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + var contextData = new DamageContext(100f, false, ["Arm"]); + + target.EffectsManager.ApplyEffect(effect, contextData); + + customExecution.ReceivedContext.Should().NotBeNull(); + customExecution.ReceivedDamage.Should().Be(100f); + customExecution.ReceivedIsCritical.Should().BeFalse(); + customExecution.ReceivedHitLocations.Should().BeEquivalentTo("Arm"); + } + + [Fact] + [Trait("Context", "Calculator")] + public void Custom_magnitude_calculator_can_access_context_data() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customCalculator = new ContextAwareMagnitudeCalculator(); + + var effectData = new EffectData( + "Context Effect", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.CustomCalculatorClass, + customCalculationBasedFloat: new CustomCalculationBasedFloat( + customCalculator, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))) + ]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + var contextData = new DamageContext(10f, true, []); + + target.EffectsManager.ApplyEffect(effect, contextData); + + // Base magnitude is contextData.Damage * 2 (if critical) = 10 * 2 = 20 + // Attribute1 base is 1, so result should be 1 + 20 = 21 + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [21, 21, 0, 0]); + } + + [Fact] + [Trait("Context", "Calculator")] + public void Custom_magnitude_calculator_uses_base_damage_when_not_critical() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customCalculator = new ContextAwareMagnitudeCalculator(); + + var effectData = new EffectData( + "Context Effect", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.CustomCalculatorClass, + customCalculationBasedFloat: new CustomCalculationBasedFloat( + customCalculator, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))) + ]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + var contextData = new DamageContext(15f, false, []); + + target.EffectsManager.ApplyEffect(effect, contextData); + + // Base magnitude is contextData.Damage = 15 (not critical, no multiplier) + // Attribute1 base is 1, so result should be 1 + 15 = 16 + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 16, 0, 0]); + } + + [Fact] + [Trait("Context", "Calculator")] + public void Custom_magnitude_calculator_returns_zero_when_no_context() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customCalculator = new ContextAwareMagnitudeCalculator(); + + var effectData = new EffectData( + "Context Effect", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.CustomCalculatorClass, + customCalculationBasedFloat: new CustomCalculationBasedFloat( + customCalculator, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))) + ]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + target.EffectsManager.ApplyEffect(effect); + + // No context, so magnitude should be 0 + // Attribute1 base is 1, so result should be 1 + 0 = 1 + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); + } + + [Fact] + [Trait("Context", "TryGetData")] + public void EffectApplicationContext_TryGetData_returns_true_for_matching_type() + { + var context = new EffectApplicationContext(new DamageContext(50f, true, ["Leg"])); + + var result = context.TryGetData(out DamageContext? data); + + result.Should().BeTrue(); + data.Should().NotBeNull(); + data!.Damage.Should().Be(50f); + data.IsCritical.Should().BeTrue(); + data.HitLocations.Should().BeEquivalentTo("Leg"); + } + + [Fact] + [Trait("Context", "TryGetData")] + public void EffectApplicationContext_TryGetData_returns_false_for_mismatched_type() + { + var context = new EffectApplicationContext(new DamageContext(50f, true, ["Leg"])); + + var result = context.TryGetData(out HealingContext? data); + + result.Should().BeFalse(); + data.Should().BeNull(); + } + + [Fact] + [Trait("Context", "MultipleHits")] + public void Context_data_can_carry_multiple_hit_locations_for_shotgun_style_effects() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customExecution = new MultiHitExecution(); + + var effectData = new EffectData( + "Shotgun Blast", + new DurationData(DurationType.Instant), + customExecutions: [customExecution]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + // Simulate a shotgun hitting 5 different locations + var contextData = new DamageContext( + Damage: 10f, + IsCritical: false, + HitLocations: ["Head", "Torso", "LeftArm", "RightArm", "Leg"]); + + target.EffectsManager.ApplyEffect(effect, contextData); + + // Each hit location adds 10 damage = 5 * 10 = 50 total + // Attribute1 base is 1, so result should be 1 + 50 = 51 + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [51, 51, 0, 0]); + } + + [Fact] + [Trait("Context", "DifferentDataTypes")] + public void Different_context_types_can_be_used_for_different_effects() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var damageExecution = new ContextAwareExecution(); + var healingExecution = new HealingExecution(); + + var damageEffectData = new EffectData( + "Damage Effect", + new DurationData(DurationType.Instant), + customExecutions: [damageExecution]); + + var healingEffectData = new EffectData( + "Healing Effect", + new DurationData(DurationType.Instant), + customExecutions: [healingExecution]); + + var damageEffect = new Effect(damageEffectData, new EffectOwnership(owner, owner)); + var healingEffect = new Effect(healingEffectData, new EffectOwnership(owner, owner)); + + target.EffectsManager.ApplyEffect(damageEffect, new DamageContext(25f, true, ["Head"])); + target.EffectsManager.ApplyEffect(healingEffect, new HealingContext(50f, HealType.OverTime)); + + damageExecution.ReceivedDamage.Should().Be(25f); + healingExecution.ReceivedHealAmount.Should().Be(50f); + healingExecution.ReceivedHealType.Should().Be(HealType.OverTime); + } + + private sealed record DamageContext(float Damage, bool IsCritical, string[] HitLocations); + + private sealed record HealingContext(float HealAmount, HealType Type); + + /// + /// Test custom execution that accesses context data. + /// + private sealed class ContextAwareExecution : CustomExecution + { + public EffectApplicationContext? ReceivedContext { get; private set; } + + public float ReceivedDamage { get; private set; } + + public bool ReceivedIsCritical { get; private set; } + + public string[] ReceivedHitLocations { get; private set; } = []; + + public bool WasExecuted { get; private set; } + + public override ModifierEvaluatedData[] EvaluateExecution( + Effect effect, + IForgeEntity target, + EffectEvaluatedData? effectEvaluatedData) + { + WasExecuted = true; + ReceivedContext = effectEvaluatedData?.ApplicationContext; + + if (effectEvaluatedData?.TryGetContextData(out DamageContext? damageContext) == true) + { + ReceivedDamage = damageContext.Damage; + ReceivedIsCritical = damageContext.IsCritical; + ReceivedHitLocations = damageContext.HitLocations; + } + + return []; + } + } + + /// + /// Test custom magnitude calculator that uses context data. + /// + private sealed class ContextAwareMagnitudeCalculator : CustomModifierMagnitudeCalculator + { + public override float CalculateBaseMagnitude( + Effect effect, + IForgeEntity target, + EffectEvaluatedData? effectEvaluatedData) + { + if (effectEvaluatedData?.TryGetContextData(out DamageContext? damageContext) != true) + { + return 0f; + } + + // Double damage if critical + return damageContext!.IsCritical ? damageContext.Damage * 2 : damageContext.Damage; + } + } + + /// + /// Test execution that applies damage per hit location. + /// + private sealed class MultiHitExecution : CustomExecution + { + private readonly AttributeCaptureDefinition _targetAttribute; + + public MultiHitExecution() + { + _targetAttribute = new AttributeCaptureDefinition( + "TestAttributeSet.Attribute1", + AttributeCaptureSource.Target, + true); + + AttributesToCapture.Add(_targetAttribute); + } + + public override ModifierEvaluatedData[] EvaluateExecution( + Effect effect, + IForgeEntity target, + EffectEvaluatedData? effectEvaluatedData) + { + if (effectEvaluatedData?.TryGetContextData(out DamageContext? damageContext) != true) + { + return []; + } + + if (!_targetAttribute.TryGetAttribute(target, out EntityAttribute? attribute)) + { + return []; + } + + // Apply damage for each hit location + var totalDamage = damageContext!.Damage * damageContext.HitLocations.Length; + + return + [ + new ModifierEvaluatedData( + attribute, + ModifierOperation.FlatBonus, + totalDamage) + ]; + } + } + + /// + /// Test execution for healing context. + /// + private sealed class HealingExecution : CustomExecution + { + public float ReceivedHealAmount { get; private set; } + + public HealType ReceivedHealType { get; private set; } + + public override ModifierEvaluatedData[] EvaluateExecution( + Effect effect, + IForgeEntity target, + EffectEvaluatedData? effectEvaluatedData) + { + if (effectEvaluatedData?.TryGetContextData(out HealingContext? healingContext) == true) + { + ReceivedHealAmount = healingContext.HealAmount; + ReceivedHealType = healingContext.Type; + } + + return []; + } + } +} diff --git a/Forge/Effects/ActiveEffect.cs b/Forge/Effects/ActiveEffect.cs index c6d886b..66a9acf 100644 --- a/Forge/Effects/ActiveEffect.cs +++ b/Forge/Effects/ActiveEffect.cs @@ -44,7 +44,7 @@ internal sealed class ActiveEffect internal Effect Effect => EffectEvaluatedData.Effect; - internal ActiveEffect(Effect effect, IForgeEntity target) + internal ActiveEffect(Effect effect, IForgeEntity target, EffectApplicationContext? applicationContext = null) { Handle = new ActiveEffectHandle(this); @@ -57,7 +57,7 @@ internal ActiveEffect(Effect effect, IForgeEntity target) StackCount = 1; } - EffectEvaluatedData = new EffectEvaluatedData(effect, target, StackCount); + EffectEvaluatedData = new EffectEvaluatedData(effect, target, StackCount, applicationContext: applicationContext); _nonSnapshotSetByCallerTags = []; diff --git a/Forge/Effects/EffectApplicationContext.Data.cs b/Forge/Effects/EffectApplicationContext.Data.cs new file mode 100644 index 0000000..06da308 --- /dev/null +++ b/Forge/Effects/EffectApplicationContext.Data.cs @@ -0,0 +1,24 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Effects; + +/// +/// Strongly-typed effect application context for passing custom data through the effect pipeline. +/// +/// The type of the context data. +/// +/// Created automatically when using . +/// Access the data in CustomExecution or CustomModifierMagnitudeCalculator via +/// . +/// +/// +/// Initializes a new instance of the class. +/// +/// The custom data for this effect application. +public sealed class EffectApplicationContext(TData data) : EffectApplicationContext +{ + /// + /// Gets the custom context data. + /// + public TData Data { get; } = data; +} diff --git a/Forge/Effects/EffectApplicationContext.cs b/Forge/Effects/EffectApplicationContext.cs new file mode 100644 index 0000000..ddc0205 --- /dev/null +++ b/Forge/Effects/EffectApplicationContext.cs @@ -0,0 +1,39 @@ +// Copyright © Gamesmiths Guild. + +using System.Diagnostics.CodeAnalysis; +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Effects; + +/// +/// Base class for custom effect application context data. +/// Subclass this to pass arbitrary data through the effect pipeline to CustomCalculators and CustomExecutions. +/// +/// +/// This allows custom data to flow through the effect system during application and execution. +/// +public abstract class EffectApplicationContext +{ + /// + /// Attempts to get the context data as a specific type. + /// + /// The expected data type. + /// The extracted data, if successful. + /// if the context contains data of the expected type. + public bool TryGetData([NotNullWhen(true)] out TData? data) + { +#pragma warning disable S3060 // "is" should not be used with "this" + if (this is EffectApplicationContext typedContext) + { + data = typedContext.Data; + Validation.Assert( + data is not null, + "EffectApplicationContext data should never be null for a successfully typed context."); + return true; + } +#pragma warning restore S3060 // "is" should not be used with "this" + + data = default; + return false; + } +} diff --git a/Forge/Effects/EffectEvaluatedData.cs b/Forge/Effects/EffectEvaluatedData.cs index e0a1c56..50af15a 100644 --- a/Forge/Effects/EffectEvaluatedData.cs +++ b/Forge/Effects/EffectEvaluatedData.cs @@ -71,6 +71,15 @@ public sealed class EffectEvaluatedData /// public Dictionary? CustomCueParameters { get; private set; } + /// + /// Gets the optional application context for this effect evaluation. + /// + /// + /// Contains custom data passed during effect application via + /// . + /// + public EffectApplicationContext? ApplicationContext { get; } + internal Dictionary SnapshotAttributes { get; } = []; internal Dictionary SnapshotSetByCallers { get; } = []; @@ -82,16 +91,19 @@ public sealed class EffectEvaluatedData /// The target of this evaluated data. /// The stack for this evaluated data. /// The level for this evaluated data. + /// Optional custom context data for this effect application. public EffectEvaluatedData( Effect effect, IForgeEntity target, int stack = 1, - int? level = null) + int? level = null, + EffectApplicationContext? applicationContext = null) { Effect = effect; Target = target; Stack = stack; Level = level ?? effect.Level; + ApplicationContext = applicationContext; if (effect.EffectData.SnapshotLevel) { @@ -113,6 +125,27 @@ public EffectEvaluatedData( AttributesToCapture = EvaluateAttributesToCapture(); } + /// + /// Gets the application context data as a specific type, if available. + /// + /// The expected data type. + /// The extracted data, if successful. + /// if the context contains data of the expected type. + public bool TryGetContextData([NotNullWhen(true)] out TData? data) + { + if (ApplicationContext is EffectApplicationContext typedContext) + { + data = typedContext.Data; + Validation.Assert( + data is not null, + "EffectApplicationContext data should never be null for a successfully typed context."); + return true; + } + + data = default; + return false; + } + internal void ReEvaluate(Effect effect, int stack = 1, int? level = null) { Effect = effect; diff --git a/Forge/Effects/EffectsManager.cs b/Forge/Effects/EffectsManager.cs index c48e12b..6a92c7a 100644 --- a/Forge/Effects/EffectsManager.cs +++ b/Forge/Effects/EffectsManager.cs @@ -33,47 +33,26 @@ public class EffectsManager(IForgeEntity owner, CuesManager cuesManager) /// public ActiveEffectHandle? ApplyEffect(Effect effect) { - if (!effect.CanApply(Owner)) - { - return null; - } - - if (effect.EffectData.DurationData.DurationType == DurationType.Instant) - { - var evaluatedData = new EffectEvaluatedData(effect, Owner); - - foreach (IEffectComponent component in effect.EffectData.EffectComponents) - { - component.OnEffectApplied(Owner, in evaluatedData); - } - - Effect.Execute(in evaluatedData); - return null; - } - - if (!effect.EffectData.StackingData.HasValue) - { - return ApplyNewEffect(effect).Handle; - } - - ActiveEffect? stackableEffect = FindStackableEffect(effect); - - if (stackableEffect is not null) - { - var successfulApplication = stackableEffect.AddStack(effect); - - if (successfulApplication) - { - foreach (IEffectComponent component in stackableEffect.EffectData.EffectComponents) - { - component.OnEffectApplied(Owner, stackableEffect.EffectEvaluatedData); - } - } - - return stackableEffect.Handle; - } + return ApplyEffectInternal(effect, applicationContext: null); + } - return ApplyNewEffect(effect).Handle; + /// + /// Applies an effect to the owner of this manager with custom context data. + /// + /// The type of custom data to pass through the effect pipeline. + /// The instance of the effect to be applied. + /// Custom data to pass through the effect pipeline to CustomCalculators and + /// CustomExecutions. + /// A handle to the applied effect if it was successfully applied as an . + /// + /// + /// The context data can be accessed in or + /// via + /// . + /// + public ActiveEffectHandle? ApplyEffect(Effect effect, TData contextData) + { + return ApplyEffectInternal(effect, new EffectApplicationContext(contextData)); } /// @@ -264,9 +243,54 @@ private IEnumerable FilterEffectsByEffect(Effect effect) MatchesStackLevelPolicy(x, effect)); } - private ActiveEffect ApplyNewEffect(Effect effect) + private ActiveEffectHandle? ApplyEffectInternal(Effect effect, EffectApplicationContext? applicationContext) + { + if (!effect.CanApply(Owner)) + { + return null; + } + + if (effect.EffectData.DurationData.DurationType == DurationType.Instant) + { + var evaluatedData = new EffectEvaluatedData(effect, Owner, applicationContext: applicationContext); + + foreach (IEffectComponent component in effect.EffectData.EffectComponents) + { + component.OnEffectApplied(Owner, in evaluatedData); + } + + Effect.Execute(in evaluatedData); + return null; + } + + if (!effect.EffectData.StackingData.HasValue) + { + return ApplyNewEffect(effect, applicationContext).Handle; + } + + ActiveEffect? stackableEffect = FindStackableEffect(effect); + + if (stackableEffect is not null) + { + var successfulApplication = stackableEffect.AddStack(effect); + + if (successfulApplication) + { + foreach (IEffectComponent component in stackableEffect.EffectData.EffectComponents) + { + component.OnEffectApplied(Owner, stackableEffect.EffectEvaluatedData); + } + } + + return stackableEffect.Handle; + } + + return ApplyNewEffect(effect, applicationContext).Handle; + } + + private ActiveEffect ApplyNewEffect(Effect effect, EffectApplicationContext? applicationContext = null) { - var activeEffect = new ActiveEffect(effect, Owner); + var activeEffect = new ActiveEffect(effect, Owner, applicationContext); _activeEffects.Add(activeEffect); var remainActive = true; From d91e8ffd149ef3b30cc93062ec16c29dce54fa20 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 6 Jan 2026 21:24:44 -0300 Subject: [PATCH 62/87] Fixed possible exception on effect update --- Forge/Effects/EffectsManager.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Forge/Effects/EffectsManager.cs b/Forge/Effects/EffectsManager.cs index 6a92c7a..7c4fe18 100644 --- a/Forge/Effects/EffectsManager.cs +++ b/Forge/Effects/EffectsManager.cs @@ -48,7 +48,7 @@ public class EffectsManager(IForgeEntity owner, CuesManager cuesManager) /// /// The context data can be accessed in or /// via - /// . + /// . /// public ActiveEffectHandle? ApplyEffect(Effect effect, TData contextData) { @@ -103,12 +103,13 @@ public void UnapplyEffectData(EffectData effectData, bool forceUnapply = false) /// Time passed since the last update call. public void UpdateEffects(double deltaTime) { - foreach (ActiveEffect effect in _activeEffects) + ActiveEffect[] effectsToUpdate = [.. _activeEffects]; + foreach (ActiveEffect effect in effectsToUpdate) { effect.Update(deltaTime); } - foreach (ActiveEffect expiredEffect in _activeEffects.Where(x => x.IsExpired).ToArray()) + foreach (ActiveEffect expiredEffect in effectsToUpdate.Where(x => x.IsExpired).ToArray()) { RemoveActiveEffect(expiredEffect, false); } From cba3273373e8be2bfa03cb357104a9c0e89e0b6d Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 10 Jan 2026 01:25:44 -0300 Subject: [PATCH 63/87] Fixed channels for CustomCalculators --- .../Effects/CustomCalculatorsEffectsTests.cs | 57 ++++++++++++++++++ Forge/Attributes/EntityAttribute.cs | 43 +++++++++++++ Forge/Effects/Calculator/CustomCalculator.cs | 60 ++++++++++++++----- 3 files changed, 144 insertions(+), 16 deletions(-) diff --git a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs index 9091c78..569edff 100644 --- a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs +++ b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs @@ -595,6 +595,63 @@ public void Custom_execution_captures_pending_modifiers_from_same_effect() TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [99, 99, 0, 0]); } + [Fact] + [Trait("Execution", null)] + public void Custom_execution_considers_previously_applied_modifiers_on_different_channels() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var buffEffectData = new EffectData( + "Test Buff Effect", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.PercentBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(0.5f)), + 1), + ]); + + var buffEffect = new Effect( + buffEffectData, + new EffectOwnership( + owner, + owner)); + + var customCalculatorClass = new CustomTestExecutionClass(false); + + var effectData = new EffectData( + "Test Effect", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.Override, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(20))), + ], + customExecutions: + [ + customCalculatorClass + ]); + + var effect = new Effect( + effectData, + new EffectOwnership( + owner, + owner)); + + target.EffectsManager.ApplyEffect(buffEffect); + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [89, 89, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [52, 35, 17, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [40, 40, 0, 0]); + } + [Fact] [Trait("Instant", null)] public void Custom_calculator_class_with_invalid_ownership_applies_with_no_attribute_changes() diff --git a/Forge/Attributes/EntityAttribute.cs b/Forge/Attributes/EntityAttribute.cs index d411c67..a337697 100644 --- a/Forge/Attributes/EntityAttribute.cs +++ b/Forge/Attributes/EntityAttribute.cs @@ -279,6 +279,49 @@ internal float CalculateMagnitudeUpToChannel(int finalChanel) return Math.Clamp((int)evaluatedValue, Min, Max); } + internal float CalculateValueWithPendingModifiers( + Dictionary? pendingFlatBonusByChannel, + Dictionary? pendingPercentBonusByChannel, + Dictionary? pendingOverrideByChannel) + { + var evaluatedValue = (float)BaseValue; + + for (var i = 0; i < _channels.Length; i++) + { + if (pendingOverrideByChannel is not null && + pendingOverrideByChannel.TryGetValue(i, out var pendingOverride)) + { + evaluatedValue = pendingOverride; + continue; + } + + var channelOverride = _channels[i].Override; + if (channelOverride.HasValue) + { + evaluatedValue = channelOverride.Value; + continue; + } + + var flatBonus = _channels[i].FlatModifier; + if (pendingFlatBonusByChannel is not null && + pendingFlatBonusByChannel.TryGetValue(i, out var pendingFlat)) + { + flatBonus += (int)pendingFlat; + } + + var percentMultiplier = _channels[i].PercentModifier; + if (pendingPercentBonusByChannel is not null && + pendingPercentBonusByChannel.TryGetValue(i, out var pendingPercent)) + { + percentMultiplier += pendingPercent; + } + + evaluatedValue = (evaluatedValue + flatBonus) * percentMultiplier; + } + + return Math.Clamp((int)evaluatedValue, Min, Max); + } + internal void ApplyPendingValueChanges() { if (PendingValueChange != 0) diff --git a/Forge/Effects/Calculator/CustomCalculator.cs b/Forge/Effects/Calculator/CustomCalculator.cs index b298e9f..e93c97c 100644 --- a/Forge/Effects/Calculator/CustomCalculator.cs +++ b/Forge/Effects/Calculator/CustomCalculator.cs @@ -55,15 +55,16 @@ protected static int CaptureAttributeMagnitude( } var capturedValue = (int)CaptureAttributeSnapshotAware( - capturedAttribute, - calculationType, - finalChannel, - captureTarget, - effectEvaluatedData); + capturedAttribute, + calculationType, + finalChannel, + captureTarget, + effectEvaluatedData); capturedValue += GetPendingModifierContribution( capturedAttribute.Attribute, capturedValue, + captureTarget, effectEvaluatedData); return capturedValue; @@ -72,16 +73,18 @@ protected static int CaptureAttributeMagnitude( private static int GetPendingModifierContribution( StringKey attribute, int currentValue, + IForgeEntity captureTarget, EffectEvaluatedData? effectEvaluatedData) { - var flatBonus = 0f; - var percentBonus = 0f; - - if (effectEvaluatedData is null || effectEvaluatedData.ModifiersEvaluatedData is null) + if (!captureTarget.Attributes.ContainsAttribute(attribute) || effectEvaluatedData?.ModifiersEvaluatedData is null) { return 0; } + Dictionary? pendingFlatBonusByChannel = null; + Dictionary? pendingPercentBonusByChannel = null; + Dictionary? pendingOverrideByChannel = null; + foreach (ModifierEvaluatedData modifier in effectEvaluatedData.ModifiersEvaluatedData) { if (modifier.Attribute.Key != attribute) @@ -92,21 +95,46 @@ private static int GetPendingModifierContribution( switch (modifier.ModifierOperation) { case ModifierOperation.FlatBonus: - flatBonus += modifier.Magnitude; + pendingFlatBonusByChannel ??= []; + if (!pendingFlatBonusByChannel.TryGetValue(modifier.Channel, out var flatValue)) + { + flatValue = 0f; + } + + pendingFlatBonusByChannel[modifier.Channel] = flatValue + modifier.Magnitude; break; + case ModifierOperation.PercentBonus: - percentBonus += modifier.Magnitude; + pendingPercentBonusByChannel ??= []; + if (!pendingPercentBonusByChannel.TryGetValue(modifier.Channel, out var percentValue)) + { + percentValue = 0f; + } + + pendingPercentBonusByChannel[modifier.Channel] = percentValue + modifier.Magnitude; break; + case ModifierOperation.Override: - return (int)modifier.Magnitude - currentValue; + pendingOverrideByChannel ??= []; + pendingOverrideByChannel[modifier.Channel] = modifier.Magnitude; + break; } } - // Apply flat first, then percent (matching the attribute calculation order) - var withFlat = currentValue + flatBonus; - var withPercent = withFlat * (1 + percentBonus); + if (pendingFlatBonusByChannel is null && + pendingPercentBonusByChannel is null && + pendingOverrideByChannel is null) + { + return 0; + } + + EntityAttribute entityAttribute = captureTarget.Attributes[attribute]; + var newValue = (int)entityAttribute.CalculateValueWithPendingModifiers( + pendingFlatBonusByChannel, + pendingPercentBonusByChannel, + pendingOverrideByChannel); - return (int)(withPercent - currentValue); + return newValue - currentValue; } private static float CaptureAttributeSnapshotAware( From 3cf05e93bda0e501c35ff9c6945add05fc314f8b Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 10 Jan 2026 18:38:47 -0300 Subject: [PATCH 64/87] Fixed floating point issues with percent multipliers --- Forge.Tests/Effects/EffectsTests.cs | 51 +++++++++++++++++++++++++ Forge.Tests/Helpers/TestAttributeSet.cs | 3 ++ Forge/Attributes/ChannelData.cs | 4 +- Forge/Attributes/EntityAttribute.cs | 10 ++--- Forge/Effects/ActiveEffect.cs | 14 +++---- 5 files changed, 68 insertions(+), 14 deletions(-) diff --git a/Forge.Tests/Effects/EffectsTests.cs b/Forge.Tests/Effects/EffectsTests.cs index 1c4e496..1df2879 100644 --- a/Forge.Tests/Effects/EffectsTests.cs +++ b/Forge.Tests/Effects/EffectsTests.cs @@ -156,12 +156,63 @@ public void Attribute_based_effect_with_curve_modifies_values_based_on_curve_loo TestUtils.TestAttribute(target, targetAttribute, [expectedResult, expectedResult, 0, 0]); } + [Fact] + [Trait("Duration", null)] + public void Multiple_instant_effects_of_different_operations_modify_base_value_accordingly_2() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var effectData = new EffectData( + "Level Up", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute1000", + ModifierOperation.PercentBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(-0.8f)), + 1) + ]); + + var effect = new Effect( + effectData, + new EffectOwnership(owner, new TestEntity(_tagsManager, _cuesManager))); + + target.EffectsManager.ApplyEffect(effect); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1000", [0, 0, 0, 0]); + + var effectData2 = new EffectData( + "Rank Up", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute1000", + ModifierOperation.Override, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(100))) + ]); + + var effect2 = new Effect( + effectData2, + new EffectOwnership(owner, new TestEntity(_tagsManager, _cuesManager))); + + target.EffectsManager.ApplyEffect(effect2); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1000", [20, 0, 20, 0]); + } + + [Theory] [Trait("Instant", null)] [InlineData("TestAttributeSet.Attribute1", 4, 5, 4, 25, -0.66f, 8, 42, 42)] [InlineData("TestAttributeSet.Attribute2", 8, 10, 2, 30, -0.66f, 10, 99, 99)] [InlineData("TestAttributeSet.Attribute3", 20, 23, 0.5f, 34, 1, 68, -10, 0)] [InlineData("TestAttributeSet.Attribute90", 90, 99, 0.3f, 99, 0f, 99, 100, 99)] + [InlineData("TestAttributeSet.Attribute1000", 100, 100, -0.8f, 20, 0.2f, 24, 200, 200)] [InlineData("Invalid.Attribute", 4, 0, 4, 0, -0.66f, 0, 42, 0)] public void Multiple_instant_effects_of_different_operations_modify_base_value_accordingly( string targetAttribute, diff --git a/Forge.Tests/Helpers/TestAttributeSet.cs b/Forge.Tests/Helpers/TestAttributeSet.cs index 76b3c7b..cc34025 100644 --- a/Forge.Tests/Helpers/TestAttributeSet.cs +++ b/Forge.Tests/Helpers/TestAttributeSet.cs @@ -16,6 +16,8 @@ public class TestAttributeSet : AttributeSet public EntityAttribute Attribute90 { get; } + public EntityAttribute Attribute1000 { get; } + public TestAttributeSet() { Attribute1 = InitializeAttribute(nameof(Attribute1), 1, 0, 99, 2); @@ -23,5 +25,6 @@ public TestAttributeSet() Attribute3 = InitializeAttribute(nameof(Attribute3), 3, 0, 99, 2); Attribute5 = InitializeAttribute(nameof(Attribute5), 5, 0, 99, 2); Attribute90 = InitializeAttribute(nameof(Attribute90), 90, 0, 99, 2); + Attribute1000 = InitializeAttribute(nameof(Attribute1000), 0, 0, 1000, 2); } } diff --git a/Forge/Attributes/ChannelData.cs b/Forge/Attributes/ChannelData.cs index 4bcb514..51b49f4 100644 --- a/Forge/Attributes/ChannelData.cs +++ b/Forge/Attributes/ChannelData.cs @@ -9,7 +9,7 @@ namespace Gamesmiths.Forge.Attributes; /// Attributes have channels for calculating its modifiers. Multiple channels can be used to calculate in sequence, /// granting it's possible to have various kinds of formulas combining flat and percentage modifiers. /// -public struct ChannelData +internal struct ChannelData { /// /// Gets or sets an override value at this channel. @@ -27,5 +27,5 @@ public struct ChannelData /// /// Gets or sets a percent modifier for this channel. /// - public float PercentModifier { get; set; } + public double PercentModifier { get; set; } } diff --git a/Forge/Attributes/EntityAttribute.cs b/Forge/Attributes/EntityAttribute.cs index a337697..d18366c 100644 --- a/Forge/Attributes/EntityAttribute.cs +++ b/Forge/Attributes/EntityAttribute.cs @@ -177,7 +177,7 @@ internal void ExecutePercentModifier(float value) { var oldValue = CurrentValue; - BaseValue = Math.Clamp((int)(BaseValue * (1 + value)), Min, Max); + BaseValue = Math.Clamp((int)(BaseValue * Math.Round(1 + value, 6)), Min, Max); UpdateCachedValues(); @@ -249,7 +249,7 @@ internal void AddPercentModifier(float value, int channel) var oldValue = CurrentValue; ref ChannelData channelData = ref _channels[channel]; - channelData.PercentModifier += value; + channelData.PercentModifier += Math.Round(value, 6); UpdateCachedValues(); @@ -273,7 +273,7 @@ internal float CalculateMagnitudeUpToChannel(int finalChanel) continue; } - evaluatedValue = (evaluatedValue + _channels[i].FlatModifier) * _channels[i].PercentModifier; + evaluatedValue = (float)((evaluatedValue + _channels[i].FlatModifier) * _channels[i].PercentModifier); } return Math.Clamp((int)evaluatedValue, Min, Max); @@ -316,7 +316,7 @@ internal float CalculateValueWithPendingModifiers( percentMultiplier += pendingPercent; } - evaluatedValue = (evaluatedValue + flatBonus) * percentMultiplier; + evaluatedValue = (float)((evaluatedValue + flatBonus) * percentMultiplier); } return Math.Clamp((int)evaluatedValue, Min, Max); @@ -344,7 +344,7 @@ private void UpdateCachedValues() continue; } - evaluatedValue = (evaluatedValue + channel.FlatModifier) * channel.PercentModifier; + evaluatedValue = (float)((evaluatedValue + channel.FlatModifier) * channel.PercentModifier); } CurrentValue = Math.Clamp((int)evaluatedValue, Min, Max); diff --git a/Forge/Effects/ActiveEffect.cs b/Forge/Effects/ActiveEffect.cs index 66a9acf..2bdac4b 100644 --- a/Forge/Effects/ActiveEffect.cs +++ b/Forge/Effects/ActiveEffect.cs @@ -430,18 +430,18 @@ private void ReapplyEffect(Effect effect, int? level = null, bool isStackingCall private void ApplyModifiers(bool unapply = false) { - var multiplier = unapply ? -1 : 1; - foreach (ModifierEvaluatedData modifier in EffectEvaluatedData.ModifiersEvaluatedData) { switch (modifier.ModifierOperation) { case ModifierOperation.FlatBonus: - modifier.Attribute.AddFlatModifier(multiplier * (int)modifier.Magnitude, modifier.Channel); + var flatMagnitude = unapply ? -(int)modifier.Magnitude : (int)modifier.Magnitude; + modifier.Attribute.AddFlatModifier(flatMagnitude, modifier.Channel); break; case ModifierOperation.PercentBonus: - modifier.Attribute.AddPercentModifier(multiplier * modifier.Magnitude, modifier.Channel); + var percentMagnitude = unapply ? -modifier.Magnitude : modifier.Magnitude; + modifier.Attribute.AddPercentModifier(percentMagnitude, modifier.Channel); break; case ModifierOperation.Override: @@ -449,13 +449,13 @@ private void ApplyModifiers(bool unapply = false) modifier.AttributeOverride is not null, "AttributeOverrideData should never be null at this point."); - if (multiplier == 1) + if (unapply) { - modifier.Attribute.AddOverride(modifier.AttributeOverride.Value); + modifier.Attribute.ClearOverride(modifier.AttributeOverride.Value); break; } - modifier.Attribute.ClearOverride(modifier.AttributeOverride.Value); + modifier.Attribute.AddOverride(modifier.AttributeOverride.Value); break; } } From 862368f2a2b7ca65a14f53fe6c9ed946a62a5daa Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 10 Jan 2026 22:15:42 -0300 Subject: [PATCH 65/87] Fixed RetriggerInstancedAbility assert --- Forge/Abilities/AbilityData.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Forge/Abilities/AbilityData.cs b/Forge/Abilities/AbilityData.cs index 4070bc4..ae4ded7 100644 --- a/Forge/Abilities/AbilityData.cs +++ b/Forge/Abilities/AbilityData.cs @@ -205,8 +205,11 @@ private void ValidateData() "Cost effects should be instant."); } - Validation.Assert( - RetriggerInstancedAbility && InstancingPolicy == AbilityInstancingPolicy.PerEntity, - "RetriggerInstancedAbility is only used when InstancingPolicy is PerEntity."); + if (RetriggerInstancedAbility) + { + Validation.Assert( + InstancingPolicy == AbilityInstancingPolicy.PerEntity, + "RetriggerInstancedAbility can only be true when InstancingPolicy is PerEntity."); + } } } From b1af9e7df8cc9eeeb4a874faccf39454e97c2da6 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 11 Jan 2026 09:40:13 -0300 Subject: [PATCH 66/87] Remove unnecessary comment and log --- Forge/Abilities/Ability.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index f925101..c08da30 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -190,10 +190,8 @@ internal void CommitCost() internal void End() { - // End the most recent active instance, if any. if (_activeInstances.Count == 0) { - Console.WriteLine($"Ability {AbilityData.Name} is not active."); return; } From edbfc9b6299c32678aaf123c9d9d4a23d8ebc2ad Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 11 Jan 2026 09:40:24 -0300 Subject: [PATCH 67/87] Fixed inverted assert --- Forge/Abilities/Ability.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index c08da30..69217ff 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -568,7 +568,7 @@ private AbilityInstance CreateInstance(IForgeEntity? abilityTarget) if (_persistentInstance?.IsActive == true) { Validation.Assert( - !AbilityData.RetriggerInstancedAbility, "Should not reach here due to CanActivate check."); + AbilityData.RetriggerInstancedAbility, "Should not reach here due to CanActivate check."); _persistentInstance.Cancel(); _persistentInstance = null; From a3158c70c284a59e2c54691ffa8882a56eb938f5 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 11 Jan 2026 15:21:36 -0300 Subject: [PATCH 68/87] Fixed periodic effects granting abilities permanently --- Forge.Tests/Abilities/AbilitiesTests.cs | 44 +++++++++++++++++++ .../Components/GrantAbilityEffectComponent.cs | 8 ++++ 2 files changed, 52 insertions(+) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index a32d6b1..d2a86b7 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -9,6 +9,7 @@ using Gamesmiths.Forge.Effects.Duration; using Gamesmiths.Forge.Effects.Magnitudes; using Gamesmiths.Forge.Effects.Modifiers; +using Gamesmiths.Forge.Effects.Periodic; using Gamesmiths.Forge.Events; using Gamesmiths.Forge.Tags; using Gamesmiths.Forge.Tests.Core; @@ -2500,6 +2501,45 @@ [new ScalableFloat(3f)], AbilityActivationFailures.Cooldown); } + [Fact] + [Trait("Grant ability", null)] + public void Periodic_effects_grants_ability_while_active() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle, + durationData: new DurationData(DurationType.Infinite), + periodicData: new PeriodicData(new ScalableFloat(1f), true, PeriodInhibitionRemovedPolicy.ResetPeriod)); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + + entity.EffectsManager.UpdateEffects(10f); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + entity.EffectsManager.UnapplyEffect(effectHandle!); + + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + abilityHandle.IsActive.Should().BeFalse(); + } + private static AbilityHandle? SetupAbility( TestEntity targetEntity, AbilityData abilityData, @@ -2509,6 +2549,7 @@ [new ScalableFloat(3f)], AbilityDeactivationPolicy grantedAbilityInhibitionPolicy = AbilityDeactivationPolicy.CancelImmediately, IForgeEntity? sourceEntity = null, DurationData? durationData = null, + PeriodicData? periodicData = null, IEffectComponent? extraComponent = null, int effectLevel = 1, LevelComparison levelOverridePolicy = LevelComparison.Higher) @@ -2525,6 +2566,7 @@ [new ScalableFloat(3f)], grantAbilityConfig, sourceEntity, durationData, + periodicData, extraComponent, effectLevel); @@ -2539,6 +2581,7 @@ private static Effect CreateAbilityApplierEffect( GrantAbilityConfig grantAbilityConfig, IForgeEntity? sourceEntity, DurationData? durationData, + PeriodicData? periodicData, IEffectComponent? extraComponent, int effectLevel) { @@ -2554,6 +2597,7 @@ private static Effect CreateAbilityApplierEffect( var grantAbilityEffectData = new EffectData( effectName, durationData.Value, + periodicData: periodicData, effectComponents: [.. effectComponents]); return new Effect( diff --git a/Forge/Effects/Components/GrantAbilityEffectComponent.cs b/Forge/Effects/Components/GrantAbilityEffectComponent.cs index f67b8d9..5dcd20c 100644 --- a/Forge/Effects/Components/GrantAbilityEffectComponent.cs +++ b/Forge/Effects/Components/GrantAbilityEffectComponent.cs @@ -16,6 +16,7 @@ public class GrantAbilityEffectComponent(GrantAbilityConfig[] grantAbilityConfig private readonly AbilityHandle[] _grantedAbilities = new AbilityHandle[grantAbilityConfigs.Length]; private readonly IAbilityGrantSource[] _grantSources = new IAbilityGrantSource[grantAbilityConfigs.Length]; + private bool _hasGrantedAbilities; private bool _isInhibited; /// @@ -26,6 +27,11 @@ public class GrantAbilityEffectComponent(GrantAbilityConfig[] grantAbilityConfig /// public void OnEffectExecuted(IForgeEntity target, in EffectEvaluatedData effectEvaluatedData) { + if (_hasGrantedAbilities) + { + return; + } + GrantAbilitiesPermanently(target, effectEvaluatedData); } @@ -102,6 +108,8 @@ private void GrantAbilities(IForgeEntity target, in ActiveEffectEvaluatedData ac grantSource, activeEffectEvaluatedData.EffectEvaluatedData.Effect.Ownership.Source); } + + _hasGrantedAbilities = true; } private void RemoveGrantedAbilities(IForgeEntity target) From 11d4b8c55f0c73579779401f4940c659ba6826fe Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 11 Jan 2026 16:49:53 -0300 Subject: [PATCH 69/87] Added component config for activate on grant --- Forge.Tests/Abilities/AbilitiesTests.cs | 286 ++++++++++++++++++ Forge.Tests/Abilities/AbilityBehaviorTests.cs | 2 + .../Effects/Components/GrantAbilityConfig.cs | 7 +- .../Components/GrantAbilityEffectComponent.cs | 33 +- 4 files changed, 326 insertions(+), 2 deletions(-) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index d2a86b7..c925e6f 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -2540,6 +2540,288 @@ [new ScalableFloat(3f)], abilityHandle.IsActive.Should().BeFalse(); } + [Fact] + [Trait("TryActivateOnGrant", null)] + public void Duration_effect_with_TryActivateOnGrant_activates_ability_immediately() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + tryActivateOnGrant: true); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + abilityHandle!.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("TryActivateOnGrant", null)] + public void Instant_effect_with_TryActivateOnGrant_activates_ability_immediately() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + durationData: new DurationData(DurationType.Instant), + tryActivateOnGrant: true); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + abilityHandle!.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("TryActivateOnGrant", null)] + public void Effect_inhibited_at_grant_does_not_try_to_activate_ability() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + // Apply the inhibiting tag before granting the ability + CreateAndApplyTagEffect(entity, ignoreTags!); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements(IgnoreTags: ignoreTags)), + tryActivateOnGrant: true); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + abilityHandle!.IsInhibited.Should().BeTrue(); + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("TryActivateOnEnable", null)] + public void Effect_enabled_from_inhibition_with_TryActivateOnEnable_activates_ability() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + // Apply the inhibiting tag before granting the ability + ActiveEffectHandle? tagEffectHandle = CreateAndApplyTagEffect(entity, ignoreTags!); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements(IgnoreTags: ignoreTags)), + tryActivateOnEnable: true); + + abilityHandle.Should().NotBeNull(); + abilityHandle!.IsInhibited.Should().BeTrue(); + abilityHandle.IsActive.Should().BeFalse(); + + // Remove the inhibiting tag to enable the effect + entity.EffectsManager.UnapplyEffect(tagEffectHandle!); + + abilityHandle.IsInhibited.Should().BeFalse(); + abilityHandle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("TryActivateOnEnable", null)] + public void Effect_enabled_from_inhibition_without_TryActivateOnEnable_does_not_activate_ability() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + // Apply the inhibiting tag before granting the ability + ActiveEffectHandle? tagEffectHandle = CreateAndApplyTagEffect(entity, ignoreTags!); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements(IgnoreTags: ignoreTags)), + tryActivateOnEnable: false); + + abilityHandle.Should().NotBeNull(); + abilityHandle!.IsInhibited.Should().BeTrue(); + abilityHandle.IsActive.Should().BeFalse(); + + // Remove the inhibiting tag to enable the effect + entity.EffectsManager.UnapplyEffect(tagEffectHandle!); + + abilityHandle.IsInhibited.Should().BeFalse(); + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("TryActivateOnGrant", null)] + public void TryActivateOnGrant_does_not_activate_if_CanActivate_fails() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + // Create ability that requires a tag that the entity doesn't have + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + activationRequiredTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + tryActivateOnGrant: true); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + // Ability should be granted but not active because CanActivate fails (required tag) + abilityHandle!.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("TryActivateOnGrant", null)] + public void Both_TryActivateOnGrant_and_TryActivateOnEnable_work_together() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + // Grant ability without inhibition should activate on grant + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements(IgnoreTags: ignoreTags)), + tryActivateOnGrant: true, + tryActivateOnEnable: true); + + abilityHandle.Should().NotBeNull(); + abilityHandle!.IsActive.Should().BeTrue(); + + // Cancel the ability + abilityHandle.Cancel(); + abilityHandle.IsActive.Should().BeFalse(); + + // Inhibit the effect + ActiveEffectHandle? tagEffectHandle = CreateAndApplyTagEffect(entity, ignoreTags!); + abilityHandle.IsInhibited.Should().BeTrue(); + + // Remove inhibition - should activate on enable + entity.EffectsManager.UnapplyEffect(tagEffectHandle!); + abilityHandle.IsInhibited.Should().BeFalse(); + abilityHandle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("TryActivateOnEnable", null)] + public void TryActivateOnEnable_does_not_activate_if_CanActivate_fails() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + TagContainer? requiredTag = Tag.RequestTag(_tagsManager, "other.tag").GetSingleTagContainer(); + requiredTag.Should().NotBeNull(); + + // Create ability that requires a tag that the entity doesn't have + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + activationRequiredTags: requiredTag); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + // Apply the inhibiting tag before granting the ability + ActiveEffectHandle? tagEffectHandle = CreateAndApplyTagEffect(entity, ignoreTags!); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements(IgnoreTags: ignoreTags)), + tryActivateOnEnable: true); + + abilityHandle.Should().NotBeNull(); + abilityHandle!.IsInhibited.Should().BeTrue(); + abilityHandle.IsActive.Should().BeFalse(); + + // Remove the inhibiting tag to enable the effect + entity.EffectsManager.UnapplyEffect(tagEffectHandle!); + + // Ability should not activate because CanActivate fails (missing required tag) + abilityHandle.IsInhibited.Should().BeFalse(); + abilityHandle.IsActive.Should().BeFalse(); + } + private static AbilityHandle? SetupAbility( TestEntity targetEntity, AbilityData abilityData, @@ -2552,6 +2834,8 @@ [new ScalableFloat(3f)], PeriodicData? periodicData = null, IEffectComponent? extraComponent = null, int effectLevel = 1, + bool tryActivateOnGrant = false, + bool tryActivateOnEnable = false, LevelComparison levelOverridePolicy = LevelComparison.Higher) { GrantAbilityConfig grantAbilityConfig = new( @@ -2559,6 +2843,8 @@ [new ScalableFloat(3f)], abilityLevelScaling, grantedAbilityRemovalPolicy, grantedAbilityInhibitionPolicy, + tryActivateOnGrant, + tryActivateOnEnable, levelOverridePolicy); Effect grantAbilityEffect = CreateAbilityApplierEffect( diff --git a/Forge.Tests/Abilities/AbilityBehaviorTests.cs b/Forge.Tests/Abilities/AbilityBehaviorTests.cs index 340d947..b863910 100644 --- a/Forge.Tests/Abilities/AbilityBehaviorTests.cs +++ b/Forge.Tests/Abilities/AbilityBehaviorTests.cs @@ -868,6 +868,8 @@ public void PerEntity_retrigger_uses_new_magnitude() new ScalableInt(1), AbilityDeactivationPolicy.CancelImmediately, AbilityDeactivationPolicy.CancelImmediately, + false, + false, LevelComparison.Higher); Effect grantEffect = CreateGrantEffect("Grant", grantConfig, sourceEntity); diff --git a/Forge/Effects/Components/GrantAbilityConfig.cs b/Forge/Effects/Components/GrantAbilityConfig.cs index 779254c..724b9cd 100644 --- a/Forge/Effects/Components/GrantAbilityConfig.cs +++ b/Forge/Effects/Components/GrantAbilityConfig.cs @@ -12,8 +12,11 @@ namespace Gamesmiths.Forge.Effects.Components; /// The data defining the ability to be granted. /// The level of the granted ability, which can scale based on the effect level. /// Which policy to use when determining when to remove the granted ability. -/// Which policy to use when determining how the granted ability behaves when it is +/// Which policy to use when determining how the granted ability behaves when it becomes /// inhibited. +/// Whether to attempt to activate the ability immediately upon granting it. +/// Whether to attempt to activate the ability when the effect is enabled back from +/// inhibition. /// How to override the level of the granted ability if it already exists on the /// target. public readonly record struct GrantAbilityConfig( @@ -21,4 +24,6 @@ public readonly record struct GrantAbilityConfig( ScalableInt ScalableLevel, AbilityDeactivationPolicy RemovalPolicy, AbilityDeactivationPolicy InhibitionPolicy, + bool TryActivateOnGrant = false, + bool TryActivateOnEnable = false, LevelComparison LevelOverridePolicy = LevelComparison.None); diff --git a/Forge/Effects/Components/GrantAbilityEffectComponent.cs b/Forge/Effects/Components/GrantAbilityEffectComponent.cs index 5dcd20c..57d540b 100644 --- a/Forge/Effects/Components/GrantAbilityEffectComponent.cs +++ b/Forge/Effects/Components/GrantAbilityEffectComponent.cs @@ -50,6 +50,15 @@ public void OnPostActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluate { _isInhibited = activeEffectEvaluatedData.ActiveEffectHandle.IsInhibited; InhibitGrantedAbilities(target); + return; + } + + for (var i = 0; i < _grantAbilityConfigs.Length; i++) + { + if (_grantAbilityConfigs[i].TryActivateOnGrant) + { + _grantedAbilities[i].Activate(out _); + } } } @@ -72,6 +81,17 @@ public void OnActiveEffectChanged(IForgeEntity target, in ActiveEffectEvaluatedD { _isInhibited = activeEffectEvaluatedData.ActiveEffectHandle.IsInhibited; InhibitGrantedAbilities(target); + + if (!_isInhibited) + { + for (var i = 0; i < _grantAbilityConfigs.Length; i++) + { + if (_grantAbilityConfigs[i].TryActivateOnEnable) + { + _grantedAbilities[i].Activate(out _); + } + } + } } } @@ -81,12 +101,23 @@ private void GrantAbilitiesPermanently(IForgeEntity target, in EffectEvaluatedDa { GrantAbilityConfig config = _grantAbilityConfigs[i]; - target.Abilities.GrantAbilityPermanently( + _grantedAbilities[i] = target.Abilities.GrantAbilityPermanently( config.AbilityData, config.ScalableLevel.GetValue(effectEvaluatedData.Level), config.LevelOverridePolicy, effectEvaluatedData.Effect.Ownership.Source); } + + _hasGrantedAbilities = true; + + // Try to activate on grant for permanent abilities + for (var i = 0; i < _grantAbilityConfigs.Length; i++) + { + if (_grantAbilityConfigs[i].TryActivateOnGrant) + { + _grantedAbilities[i].Activate(out _); + } + } } private void GrantAbilities(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) From eabfb22d8cdba4c60b865d8f7d9c1fe5d01c87ff Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 11 Jan 2026 17:02:52 -0300 Subject: [PATCH 70/87] Renamed UnapplyEffect to RemoveEffect --- Forge.Tests/Abilities/AbilitiesTests.cs | 38 +++++++++---------- Forge.Tests/Cues/CueTests.cs | 6 +-- .../Effects/CustomCalculatorsEffectsTests.cs | 10 ++--- Forge.Tests/Effects/EffectsTests.cs | 26 ++++++------- .../Effects/ModifierTagsComponentTests.cs | 8 ++-- .../TargetTagRequirementsComponentTests.cs | 18 ++++----- Forge.Tests/Samples/QuickStartTests.cs | 6 +-- .../TargetTagRequirementsEffectComponent.cs | 2 +- Forge/Effects/EffectsManager.cs | 28 +++++++------- docs/abilities.md | 8 ++-- docs/effects/README.md | 8 ++-- docs/effects/duration.md | 6 +-- docs/quick-start.md | 6 +-- 13 files changed, 85 insertions(+), 85 deletions(-) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index c925e6f..b82bf29 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -76,7 +76,7 @@ [new ScalableFloat(3f)], failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); - entity.EffectsManager.UnapplyEffect(effectHandle!); + entity.EffectsManager.RemoveEffect(effectHandle!); entity.Abilities.GrantedAbilities.Should().BeEmpty(); abilityHandle.IsActive.Should().BeFalse(); @@ -114,7 +114,7 @@ [new ScalableFloat(3f)], failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); - entity.EffectsManager.UnapplyEffect(effectHandle!); + entity.EffectsManager.RemoveEffect(effectHandle!); entity.Abilities.GrantedAbilities.Should().ContainSingle(); @@ -163,11 +163,11 @@ [new ScalableFloat(3f)], failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); - entity.EffectsManager.UnapplyEffect(effectHandle!); + entity.EffectsManager.RemoveEffect(effectHandle!); entity.Abilities.GrantedAbilities.Should().ContainSingle(); abilityHandle.IsActive.Should().BeTrue(); - entity.EffectsManager.UnapplyEffect(effectHandle2!); + entity.EffectsManager.RemoveEffect(effectHandle2!); entity.Abilities.GrantedAbilities.Should().BeEmpty(); abilityHandle.IsActive.Should().BeFalse(); @@ -212,8 +212,8 @@ [new ScalableFloat(3f)], failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); - entity.EffectsManager.UnapplyEffect(effectHandle!); - entity.EffectsManager.UnapplyEffect(effectHandle2!); + entity.EffectsManager.RemoveEffect(effectHandle!); + entity.EffectsManager.RemoveEffect(effectHandle2!); entity.Abilities.GrantedAbilities.Should().BeEmpty(); abilityHandle.IsActive.Should().BeFalse(); @@ -260,7 +260,7 @@ [new ScalableFloat(3f)], abilityHandle.IsActive.Should().BeFalse(); abilityHandle.IsInhibited.Should().BeTrue(); - entity.EffectsManager.UnapplyEffect(tagEffectHandle!); + entity.EffectsManager.RemoveEffect(tagEffectHandle!); entity.Abilities.GrantedAbilities.Should().ContainSingle(); @@ -299,7 +299,7 @@ [new ScalableFloat(3f)], failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); - entity.EffectsManager.UnapplyEffect(effectHandle!); + entity.EffectsManager.RemoveEffect(effectHandle!); entity.Abilities.GrantedAbilities.Should().ContainSingle(); @@ -341,11 +341,11 @@ [new ScalableFloat(3f)], abilityHandle1.Should().Be(abilityHandle2); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - entity.EffectsManager.UnapplyEffect(effectHandle1!); + entity.EffectsManager.RemoveEffect(effectHandle1!); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - entity.EffectsManager.UnapplyEffect(effectHandle2!); + entity.EffectsManager.RemoveEffect(effectHandle2!); entity.Abilities.GrantedAbilities.Should().BeEmpty(); } @@ -427,7 +427,7 @@ [new ScalableFloat(3f)], entity.Abilities.GrantedAbilities.Should().ContainSingle(); // Remove the temporary effect. - entity.EffectsManager.UnapplyEffect(temporaryEffectHandle!); + entity.EffectsManager.RemoveEffect(temporaryEffectHandle!); // The ability should still be granted because of the initial permanent grant. entity.Abilities.GrantedAbilities.Should().ContainSingle(); @@ -468,7 +468,7 @@ [new ScalableFloat(3f)], permanentAbilityHandle.Should().Be(temporaryAbilityHandle); // Remove the temporary effect. - entity.EffectsManager.UnapplyEffect(temporaryEffectHandle!); + entity.EffectsManager.RemoveEffect(temporaryEffectHandle!); // The ability should still be granted because of the initial permanent grant. entity.Abilities.GrantedAbilities.Should().ContainSingle(); @@ -2063,7 +2063,7 @@ [new ScalableFloat(3f)], failureFlags.Should().Be(AbilityActivationFailures.None); // Remove grant; ability should not be removed until all instances end. - entity.EffectsManager.UnapplyEffect(grantHandle!); + entity.EffectsManager.RemoveEffect(grantHandle!); // Still present because policy is RemoveOnEnd and still active. entity.Abilities.GrantedAbilities.Should().Contain(handle); @@ -2373,7 +2373,7 @@ [new ScalableFloat(3f)], abilityHandle!.IsActive.Should().BeTrue(); - entity.EffectsManager.UnapplyEffect(effectHandle!); + entity.EffectsManager.RemoveEffect(effectHandle!); abilityHandle!.IsActive.Should().BeFalse(); } @@ -2534,7 +2534,7 @@ [new ScalableFloat(3f)], abilityHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - entity.EffectsManager.UnapplyEffect(effectHandle!); + entity.EffectsManager.RemoveEffect(effectHandle!); entity.Abilities.GrantedAbilities.Should().BeEmpty(); abilityHandle.IsActive.Should().BeFalse(); @@ -2658,7 +2658,7 @@ [new ScalableFloat(3f)], abilityHandle.IsActive.Should().BeFalse(); // Remove the inhibiting tag to enable the effect - entity.EffectsManager.UnapplyEffect(tagEffectHandle!); + entity.EffectsManager.RemoveEffect(tagEffectHandle!); abilityHandle.IsInhibited.Should().BeFalse(); abilityHandle.IsActive.Should().BeTrue(); @@ -2697,7 +2697,7 @@ [new ScalableFloat(3f)], abilityHandle.IsActive.Should().BeFalse(); // Remove the inhibiting tag to enable the effect - entity.EffectsManager.UnapplyEffect(tagEffectHandle!); + entity.EffectsManager.RemoveEffect(tagEffectHandle!); abilityHandle.IsInhibited.Should().BeFalse(); abilityHandle.IsActive.Should().BeFalse(); @@ -2772,7 +2772,7 @@ [new ScalableFloat(3f)], abilityHandle.IsInhibited.Should().BeTrue(); // Remove inhibition - should activate on enable - entity.EffectsManager.UnapplyEffect(tagEffectHandle!); + entity.EffectsManager.RemoveEffect(tagEffectHandle!); abilityHandle.IsInhibited.Should().BeFalse(); abilityHandle.IsActive.Should().BeTrue(); } @@ -2815,7 +2815,7 @@ [new ScalableFloat(3f)], abilityHandle.IsActive.Should().BeFalse(); // Remove the inhibiting tag to enable the effect - entity.EffectsManager.UnapplyEffect(tagEffectHandle!); + entity.EffectsManager.RemoveEffect(tagEffectHandle!); // Ability should not activate because CanActivate fails (missing required tag) abilityHandle.IsInhibited.Should().BeFalse(); diff --git a/Forge.Tests/Cues/CueTests.cs b/Forge.Tests/Cues/CueTests.cs index 415ccc3..1771624 100644 --- a/Forge.Tests/Cues/CueTests.cs +++ b/Forge.Tests/Cues/CueTests.cs @@ -605,7 +605,7 @@ public void Infinite_effect_triggers_apply_and_remove_cues_with_expected_results ActiveEffectHandle? activeEffectHandler = entity.EffectsManager.ApplyEffect(effect); TestCueExecutionData(TestCueExecutionType.Application, cueTestData1); - entity.EffectsManager.UnapplyEffect(activeEffectHandler!); + entity.EffectsManager.RemoveEffect(activeEffectHandler!); TestCueExecutionData(TestCueExecutionType.Application, cueTestData2); } @@ -1205,7 +1205,7 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData2); TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData2); - entity.EffectsManager.UnapplyEffect(activeEffectHandler!); + entity.EffectsManager.RemoveEffect(activeEffectHandler!); TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData3); TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData3); } @@ -1565,7 +1565,7 @@ public void Attribute_based_modifiers_triggers_update_cues_when_attribute_change TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData2); TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData2); - entity.EffectsManager.UnapplyEffect(activeEffectHandler!); + entity.EffectsManager.RemoveEffect(activeEffectHandler!); TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData3); TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData3); } diff --git a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs index 569edff..12bbd23 100644 --- a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs +++ b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs @@ -319,7 +319,7 @@ public void Custom_calculator_class_non_snapshot_modifies_attribute_accordingly( TestUtils.TestAttribute(target, targetAttribute, expectedResults2); - effect2Target.EffectsManager.UnapplyEffect(effectHandler!); + effect2Target.EffectsManager.RemoveEffect(effectHandler!); TestUtils.TestAttribute(target, targetAttribute, expectedResults1); } @@ -438,12 +438,12 @@ public void Custom_executions_modifies_update_with_non_snapshot_attributes() TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [29, 1, 28, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [14, 2, 12, 0]); - owner.EffectsManager.UnapplyEffect(effectHandler2!); + owner.EffectsManager.RemoveEffect(effectHandler2!); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [21, 1, 20, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [12, 2, 10, 0]); - owner.EffectsManager.UnapplyEffect(effectHandler1!); + owner.EffectsManager.RemoveEffect(effectHandler1!); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); @@ -953,13 +953,13 @@ public void Custom_executions_does_not_update_with_snapshot_attributes_when_effe TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); - owner.EffectsManager.UnapplyEffect(effectHandler2!); + owner.EffectsManager.RemoveEffect(effectHandler2!); effect.LevelUp(); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); - owner.EffectsManager.UnapplyEffect(effectHandler1!); + owner.EffectsManager.RemoveEffect(effectHandler1!); effect.LevelUp(); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); diff --git a/Forge.Tests/Effects/EffectsTests.cs b/Forge.Tests/Effects/EffectsTests.cs index 1df2879..b6756d0 100644 --- a/Forge.Tests/Effects/EffectsTests.cs +++ b/Forge.Tests/Effects/EffectsTests.cs @@ -418,7 +418,7 @@ public void Override_values_are_applied_temporarily( TestUtils.TestAttribute(target, targetAttribute, secondExpectedResult); - target.EffectsManager.UnapplyEffect(activeEffect2handle!); + target.EffectsManager.RemoveEffect(activeEffect2handle!); TestUtils.TestAttribute(target, targetAttribute, firstExpectedResults); } @@ -529,22 +529,22 @@ public void Multiple_override_values_are_applied_and_removed_correctly( TestUtils.TestAttribute(target, targetAttribute, expectedResults1); // 1,2,3,4,1 - target.EffectsManager.UnapplyEffect(activeEffect1Handle!); + target.EffectsManager.RemoveEffect(activeEffect1Handle!); TestUtils.TestAttribute(target, targetAttribute, expectedResults4); // 1,2,3,4 - target.EffectsManager.UnapplyEffect(activeEffect2Handle1!); + target.EffectsManager.RemoveEffect(activeEffect2Handle1!); TestUtils.TestAttribute(target, targetAttribute, expectedResults4); // 1,3,4 - target.EffectsManager.UnapplyEffect(activeEffect4Handle!); + target.EffectsManager.RemoveEffect(activeEffect4Handle!); TestUtils.TestAttribute(target, targetAttribute, expectedResults3); // 1,3 ActiveEffectHandle? activeEffect2Handle2 = target.EffectsManager.ApplyEffect(effect2); TestUtils.TestAttribute(target, targetAttribute, expectedResults2); // 1,3,2 - target.EffectsManager.UnapplyEffect(activeEffect3Handle!); + target.EffectsManager.RemoveEffect(activeEffect3Handle!); TestUtils.TestAttribute(target, targetAttribute, expectedResults2); // 1,2 - target.EffectsManager.UnapplyEffect(activeEffect2Handle2!); + target.EffectsManager.RemoveEffect(activeEffect2Handle2!); TestUtils.TestAttribute(target, targetAttribute, expectedResults1); // 1 static EffectData CreateOverrideEffect( @@ -3029,7 +3029,7 @@ public void Infinite_stackable_effect_unnaplies_correctly( int modifierMagnitude, int stackLimit, int initialStack, - bool forceUnapply, + bool forceRemoval, StackPolicy stackPolicy, StackLevelPolicy stackLevelPolicy, StackMagnitudePolicy magnitudePolicy, @@ -3092,7 +3092,7 @@ public void Infinite_stackable_effect_unnaplies_correctly( owner, target); - target.EffectsManager.UnapplyEffect(effectHandle!, forceUnapply); + target.EffectsManager.RemoveEffect(effectHandle!, forceRemoval); TestUtils.TestAttribute(target, targetAttribute, secondExpectedResults); @@ -3103,7 +3103,7 @@ public void Infinite_stackable_effect_unnaplies_correctly( owner, target); - target.EffectsManager.UnapplyEffect(effectHandle!, forceUnapply); + target.EffectsManager.RemoveEffect(effectHandle!, forceRemoval); TestUtils.TestAttribute(target, targetAttribute, thirdExpectedResults); @@ -3114,7 +3114,7 @@ public void Infinite_stackable_effect_unnaplies_correctly( owner, target); - target.EffectsManager.UnapplyEffect(effectHandle!, forceUnapply); + target.EffectsManager.RemoveEffect(effectHandle!, forceRemoval); TestUtils.TestAttribute(target, targetAttribute, fourthExpectedResults); @@ -3158,7 +3158,7 @@ public void Infinite_stackable_effect_unnaplies_correctly( 80f, new int[] { }, new int[] { })] - public void Unapply_duration_effect_restores_original_attribute_values( + public void Remove_duration_effect_restores_original_attribute_values( string targetAttribute, float effectMagnitude, int[] firstExpectedResults, @@ -3205,11 +3205,11 @@ public void Unapply_duration_effect_restores_original_attribute_values( TestUtils.TestAttribute(target, targetAttribute, secondExpectedResults); - target.EffectsManager.UnapplyEffect(activeEffectHandle1!); + target.EffectsManager.RemoveEffect(activeEffectHandle1!); TestUtils.TestAttribute(target, targetAttribute, firstExpectedResults); - target.EffectsManager.UnapplyEffect(activeEffectHandle2!); + target.EffectsManager.RemoveEffect(activeEffectHandle2!); TestUtils.TestAttribute( target, diff --git a/Forge.Tests/Effects/ModifierTagsComponentTests.cs b/Forge.Tests/Effects/ModifierTagsComponentTests.cs index 25cb669..2800dbe 100644 --- a/Forge.Tests/Effects/ModifierTagsComponentTests.cs +++ b/Forge.Tests/Effects/ModifierTagsComponentTests.cs @@ -125,7 +125,7 @@ public void Manual_removal_removes_tags_instantly(string[] tagKeys) entity.Tags.CombinedTags.Equals(validationContainer).Should().BeTrue(); entity.Tags.ModifierTags.Equals(modifierTagsContainer).Should().BeTrue(); - entity.EffectsManager.UnapplyEffect(activeEffectHandle!); + entity.EffectsManager.RemoveEffect(activeEffectHandle!); entity.Tags.CombinedTags.Equals(baseTagsContainer).Should().BeTrue(); entity.Tags.ModifierTags.IsEmpty.Should().BeTrue(); } @@ -250,12 +250,12 @@ public void Stackable_effects_keep_tags_until_completely_removed(string[] tagKey for (var i = 0; i < stacks - 1; i++) { - entity.EffectsManager.UnapplyEffect(activeEffectHandle!); + entity.EffectsManager.RemoveEffect(activeEffectHandle!); entity.Tags.CombinedTags.Equals(validationContainer).Should().BeTrue(); entity.Tags.ModifierTags.Equals(modifierTagsContainer).Should().BeTrue(); } - entity.EffectsManager.UnapplyEffect(activeEffectHandle!); + entity.EffectsManager.RemoveEffect(activeEffectHandle!); entity.Tags.CombinedTags.Equals(baseTagsContainer).Should().BeTrue(); entity.Tags.ModifierTags.IsEmpty.Should().BeTrue(); } @@ -285,7 +285,7 @@ public void Stackable_effects_removes_tags_when_forcibly_removed(string[] tagKey entity.Tags.CombinedTags.Equals(validationContainer).Should().BeTrue(); entity.Tags.ModifierTags.Equals(modifierTagsContainer).Should().BeTrue(); - entity.EffectsManager.UnapplyEffect(activeEffectHandle!, true); + entity.EffectsManager.RemoveEffect(activeEffectHandle!, true); entity.Tags.CombinedTags.Equals(baseTagsContainer).Should().BeTrue(); entity.Tags.ModifierTags.IsEmpty.Should().BeTrue(); } diff --git a/Forge.Tests/Effects/TargetTagRequirementsComponentTests.cs b/Forge.Tests/Effects/TargetTagRequirementsComponentTests.cs index 6cce44b..535a788 100644 --- a/Forge.Tests/Effects/TargetTagRequirementsComponentTests.cs +++ b/Forge.Tests/Effects/TargetTagRequirementsComponentTests.cs @@ -308,7 +308,7 @@ public void Effect_gets_removed_after_modifier_tag_is_removed( entity, entity); - entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle!); + entity.EffectsManager.RemoveEffect(activeModifierEffectHandle!); TestUtils.TestStackData( entity.EffectsManager.GetEffectInfo(effectData), @@ -437,7 +437,7 @@ public void Effect_with_ongoing_requirement_initializes_normally( entity, entity); - entity.EffectsManager.UnapplyEffect(activeEffectHandle!); + entity.EffectsManager.RemoveEffect(activeEffectHandle!); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); TestUtils.TestStackData( @@ -481,7 +481,7 @@ public void Effect_without_ongoing_requirement_initializes_inhibited( entity, entity); - entity.EffectsManager.UnapplyEffect(activeEffectHandle!); + entity.EffectsManager.RemoveEffect(activeEffectHandle!); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); TestUtils.TestStackData( @@ -530,7 +530,7 @@ public void Effect_with_ongoing_requirement_gets_inhibited_after_modifier_tag_ap TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); - entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle!); + entity.EffectsManager.RemoveEffect(activeModifierEffectHandle!); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); } @@ -570,7 +570,7 @@ public void Effect_with_ongoing_requirement_gets_inhibited_after_modifier_tag_re entity.EffectsManager.ApplyEffect(effect); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); - entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle!); + entity.EffectsManager.RemoveEffect(activeModifierEffectHandle!); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); } @@ -612,7 +612,7 @@ public void Periodic_effect_with_ongoing_requirement_executes_normally( TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [41, 41, 0, 0]); - entity.EffectsManager.UnapplyEffect(activeEffectHandle!); + entity.EffectsManager.RemoveEffect(activeEffectHandle!); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [41, 41, 0, 0]); TestUtils.TestStackData( @@ -659,7 +659,7 @@ public void Periodic_effect_without_ongoing_requirement_does_not_execute( entity, entity); - entity.EffectsManager.UnapplyEffect(activeEffectHandle!); + entity.EffectsManager.RemoveEffect(activeEffectHandle!); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); TestUtils.TestStackData( @@ -777,7 +777,7 @@ public void Periodic_effect_with_ongoing_requirement_gets_inhibited_after_modifi entity.EffectsManager.UpdateEffects(secondUpdatePeriod); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", thirdExpectedResults); - entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle!); + entity.EffectsManager.RemoveEffect(activeModifierEffectHandle!); entity.EffectsManager.UpdateEffects(thirdUpdatePeriod); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", fourthExpectedResults); } @@ -886,7 +886,7 @@ public void Periodic_effect_with_ongoing_requirement_gets_inhibited_after_modifi entity.EffectsManager.UpdateEffects(firstUpdatePeriod); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", secondExpectedResults); - entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle!); + entity.EffectsManager.RemoveEffect(activeModifierEffectHandle!); entity.EffectsManager.UpdateEffects(secondUpdatePeriod); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", thirdExpectedResults); diff --git a/Forge.Tests/Samples/QuickStartTests.cs b/Forge.Tests/Samples/QuickStartTests.cs index 699c9ab..9d590ec 100644 --- a/Forge.Tests/Samples/QuickStartTests.cs +++ b/Forge.Tests/Samples/QuickStartTests.cs @@ -211,7 +211,7 @@ public void Infinite_effect_example_equipment_buff() // Remove the effect manually (e.g., when the item is unequipped) if (activeEffectHandle is not null) { - player.EffectsManager.UnapplyEffect(activeEffectHandle); + player.EffectsManager.RemoveEffect(activeEffectHandle); } // Assuming base strength was 10 @@ -874,7 +874,7 @@ public void Granting_activating_and_removing_an_ability() failures.Should().Be(AbilityActivationFailures.None); fireballAbilityHandle.IsActive.Should().BeFalse(); - player.EffectsManager.UnapplyEffect(grantEffectHandle); + player.EffectsManager.RemoveEffect(grantEffectHandle); fireballAbilityHandle.IsValid.Should().BeFalse(); } @@ -1053,7 +1053,7 @@ public void Triggering_an_ability_through_tags() handle.IsActive.Should().BeTrue(); // Remove effect (removes tag) - player.EffectsManager.UnapplyEffect(effectHandle); + player.EffectsManager.RemoveEffect(effectHandle); // Should deactivate automatically handle.IsActive.Should().BeFalse(); diff --git a/Forge/Effects/Components/TargetTagRequirementsEffectComponent.cs b/Forge/Effects/Components/TargetTagRequirementsEffectComponent.cs index 103df88..1852498 100644 --- a/Forge/Effects/Components/TargetTagRequirementsEffectComponent.cs +++ b/Forge/Effects/Components/TargetTagRequirementsEffectComponent.cs @@ -63,7 +63,7 @@ void Handler(TagContainer tags) && !RemovalTagRequirements.Value.IsEmpty && RemovalTagRequirements.Value.RequirementsMet(tags)) { - target.EffectsManager.UnapplyEffect(handle, true); + target.EffectsManager.RemoveEffect(handle, true); return; } diff --git a/Forge/Effects/EffectsManager.cs b/Forge/Effects/EffectsManager.cs index 7c4fe18..9abdab6 100644 --- a/Forge/Effects/EffectsManager.cs +++ b/Forge/Effects/EffectsManager.cs @@ -60,11 +60,11 @@ public class EffectsManager(IForgeEntity owner, CuesManager cuesManager) /// . /// /// The instance of the active effect to be removed. - /// Forces unapplication even if is set to + /// Forces removal even if is set to /// . - public void UnapplyEffect(ActiveEffectHandle activeEffect, bool forceUnapply = false) + public void RemoveEffect(ActiveEffectHandle activeEffect, bool forceRemoval = false) { - RemoveStackOrUnapply(activeEffect.ActiveEffect, forceUnapply); + RemoveStackOrUnapply(activeEffect.ActiveEffect, forceRemoval); } /// @@ -72,26 +72,26 @@ public void UnapplyEffect(ActiveEffectHandle activeEffect, bool forceUnapply = f /// . /// /// The instance of the effect to be removed. - /// Forces unapplication even if is set to + /// Forces removal even if is set to /// . - public void UnapplyEffect(Effect effect, bool forceUnapply = false) + public void RemoveEffect(Effect effect, bool forceRemoval = false) { - RemoveStackOrUnapply(FilterEffectsByEffect(effect).FirstOrDefault(), forceUnapply); + RemoveStackOrUnapply(FilterEffectsByEffect(effect).FirstOrDefault(), forceRemoval); } /// - /// Unapply an effect based on an or an stack if it's a stackable effect with + /// Removes an effect based on an or an stack if it's a stackable effect with /// . /// /// /// This method searches for the first instance of the given effect data it can find and removes it. /// /// Which effect data to look for to removal. - /// /// Forces unapplication even if is set to + /// /// Forces removal even if is set to /// . - public void UnapplyEffectData(EffectData effectData, bool forceUnapply = false) + public void RemoveEffectData(EffectData effectData, bool forceRemoval = false) { - RemoveStackOrUnapply(FilterEffectsByData(effectData).FirstOrDefault(), forceUnapply); + RemoveStackOrUnapply(FilterEffectsByData(effectData).FirstOrDefault(), forceRemoval); } /// @@ -344,14 +344,14 @@ private ActiveEffect ApplyNewEffect(Effect effect, EffectApplicationContext? app return activeEffect; } - private void RemoveStackOrUnapply(ActiveEffect? effectToRemove, bool forceUnapply) + private void RemoveStackOrUnapply(ActiveEffect? effectToRemove, bool forceRemoval) { if (effectToRemove is null) { return; } - if (!forceUnapply + if (!forceRemoval && effectToRemove.EffectData.StackingData.HasValue && effectToRemove.EffectData.StackingData.Value.ExpirationPolicy == StackExpirationPolicy.RemoveSingleStackAndRefreshDuration) @@ -369,11 +369,11 @@ private void RemoveStackOrUnapply(ActiveEffect? effectToRemove, bool forceUnappl if (effectToRemove.EffectData.DurationData.DurationType == DurationType.HasDuration) { - forceUnapply = true; + forceRemoval = true; } effectToRemove.Unapply(); - RemoveActiveEffect(effectToRemove, forceUnapply); + RemoveActiveEffect(effectToRemove, forceRemoval); } private void RemoveActiveEffect(ActiveEffect effectToRemove, bool interrupted) diff --git a/docs/abilities.md b/docs/abilities.md index bb41c4c..cbadca2 100644 --- a/docs/abilities.md +++ b/docs/abilities.md @@ -173,11 +173,11 @@ entity.Abilities.TryGetAbility(abilityData, out AbilityHandle? handle); handle.Activate(out _); // Removing effect 1 (RemoveOnEnd): ability stays active, waits for end -entity.EffectsManager.UnapplyEffect(effectHandle1); +entity.EffectsManager.RemoveEffect(effectHandle1); // Ability is still active and granted // Removing effect 2 (CancelImmediately): cancels immediately and removes -entity.EffectsManager.UnapplyEffect(effectHandle2); +entity.EffectsManager.RemoveEffect(effectHandle2); // Ability is now canceled and removed (no more grant sources) ``` @@ -200,11 +200,11 @@ ActiveEffectHandle? effectHandle2 = entity.EffectsManager.ApplyEffect(grantEffec entity.Abilities.GrantedAbilities.Count; // 1 // Remove first grant - ability still exists -entity.EffectsManager.UnapplyEffect(effectHandle1); +entity.EffectsManager.RemoveEffect(effectHandle1); entity.Abilities.GrantedAbilities.Count; // 1 // Remove second grant - now the ability is removed -entity.EffectsManager.UnapplyEffect(effectHandle2); +entity.EffectsManager.RemoveEffect(effectHandle2); entity.Abilities.GrantedAbilities.Count; // 0 ``` diff --git a/docs/effects/README.md b/docs/effects/README.md index 6f27dc5..e64ce09 100644 --- a/docs/effects/README.md +++ b/docs/effects/README.md @@ -54,7 +54,7 @@ ActiveEffectHandle? handle = entity.EffectsManager.ApplyEffect(effect); // Remove an effect by its handle if (handle is not null) { - bool removed = entity.EffectsManager.UnapplyEffect(handle); + bool removed = entity.EffectsManager.RemoveEffect(handle); } // Update all active effects on the entity @@ -97,17 +97,17 @@ if (buffHandle != null) _activeBuffs.Add(buffHandle); // Remove the effect using the handle - target.EffectsManager.UnapplyEffect(buffHandle); + target.EffectsManager.RemoveEffect(buffHandle); } ``` Other removal methods exist but are less precise: ```csharp // Removes first effect instance matching the Effect -entity.EffectsManager.UnapplyEffect(effect); +entity.EffectsManager.RemoveEffect(effect); // Removes first effect instance matching the EffectData -entity.EffectsManager.UnapplyEffectData(effectData); +entity.EffectsManager.RemoveEffectData(effectData); ``` ## Effect Lifecycle diff --git a/docs/effects/duration.md b/docs/effects/duration.md index c2e856c..637c6b1 100644 --- a/docs/effects/duration.md +++ b/docs/effects/duration.md @@ -62,7 +62,7 @@ Infinite effects have no built-in expiration time and remain active until manual - Apply their modifiers continuously. - Remain active indefinitely. -- Must be explicitly removed using `EffectsManager.UnapplyEffect`. +- Must be explicitly removed using `EffectsManager.RemoveEffect`. - Are useful for permanent buffs, persistent status effects, and equipment bonuses. Equipment-based buffs are a perfect use case for Infinite effects: @@ -85,7 +85,7 @@ ActiveEffectHandle? equipmentBuffHandle = character.EffectsManager.ApplyEffect(s // When unequipping the item if (equipmentBuffHandle is not null) { - character.EffectsManager.UnapplyEffect(equipmentBuffHandle); + character.EffectsManager.RemoveEffect(equipmentBuffHandle); } ``` @@ -260,7 +260,7 @@ When working with durations, several constraints apply to ensure effects behave 4. **Handle Effect Removal**: - Always store `ActiveEffectHandle` for `Infinite` effects. - Consider early removal conditions for `HasDuration` effects. - - Use `EffectsManager.UnapplyEffect` appropriately. + - Use `EffectsManager.RemoveEffect` appropriately. 5. **Consider Performance**: - Minimize the number of long-duration effects active simultaneously. diff --git a/docs/quick-start.md b/docs/quick-start.md index e503987..7fb8907 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -251,7 +251,7 @@ var activeEffectHandle = player.EffectsManager.ApplyEffect(equipmentBuffEffect); // Remove the effect manually (e.g., when the item is unequipped) if (activeEffectHandle != null) { - player.EffectsManager.UnapplyEffect(activeEffectHandle); + player.EffectsManager.RemoveEffect(activeEffectHandle); } ``` @@ -1021,8 +1021,8 @@ var grantEffectHandle = player.EffectsManager.ApplyEffect( // This list contains handles for all abilities granted by this specific effect component instance AbilityHandle grantedHandle = grantAbilityComponent.GrantedAbilities[0]; -// The ability is now granted. To remove it, simply unapply the effect. (e.g., when the wand is unequipped) -player.EffectsManager.UnapplyEffect(grantEffectHandle); +// The ability is now granted. To remove it, simply remove the effect. (e.g., when the wand is unequipped) +player.EffectsManager.RemoveEffect(grantEffectHandle); ``` --- From 5cad5534f64acd09c37ecbae60a30fa534fcc18e Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 11 Jan 2026 17:04:38 -0300 Subject: [PATCH 71/87] Removed "uninhibited" terminology --- docs/effects/periodic.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/effects/periodic.md b/docs/effects/periodic.md index 9ece065..e90a367 100644 --- a/docs/effects/periodic.md +++ b/docs/effects/periodic.md @@ -124,21 +124,21 @@ var neverResetPolicy = new PeriodicData( period: new ScalableFloat(5.0f), executeOnApplication: false, periodInhibitionRemovedPolicy: PeriodInhibitionRemovedPolicy.NeverReset - // When uninhibited, continues with original timing - might execute immediately if period elapsed + // When re-enabled, continues with original timing - might execute immediately if period elapsed ); var resetPolicy = new PeriodicData( period: new ScalableFloat(5.0f), executeOnApplication: false, periodInhibitionRemovedPolicy: PeriodInhibitionRemovedPolicy.ResetPeriod - // When uninhibited, restarts the period counter + // When re-enabled, restarts the period counter ); var executeAndResetPolicy = new PeriodicData( period: new ScalableFloat(5.0f), executeOnApplication: false, periodInhibitionRemovedPolicy: PeriodInhibitionRemovedPolicy.ExecuteAndResetPeriod - // When uninhibited, executes immediately and restarts the period counter + // When re-enabled, executes immediately and restarts the period counter ); ``` From 2ec3fa6193c06daddf4446430e84453c9de69374 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 11 Jan 2026 18:54:17 -0300 Subject: [PATCH 72/87] Fixed ability subscription leak --- Forge.Tests/Abilities/AbilitiesTests.cs | 255 ++++++++++++++++++++++++ Forge/Abilities/Ability.cs | 36 +++- Forge/Core/EntityAbilities.cs | 23 ++- 3 files changed, 302 insertions(+), 12 deletions(-) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index b82bf29..e637c4f 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -2822,6 +2822,246 @@ [new ScalableFloat(3f)], abilityHandle.IsActive.Should().BeFalse(); } + [Fact] + [Trait("Cleanup", null)] + public void Removed_ability_with_TagAdded_trigger_does_not_activate_after_removal() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var triggerTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + var activationCount = 0; + + AbilityData abilityData = new( + "Triggered Ability", + instancingPolicy: AbilityInstancingPolicy.PerEntity, + abilityTriggerData: AbilityTriggerData.ForTagAdded(triggerTag), + behaviorFactory: () => new CountingAbilityBehavior(() => activationCount++)); + + // Grant the ability via effect + var grantConfig = new GrantAbilityConfig( + abilityData, + new ScalableInt(1), + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.CancelImmediately); + + var grantComponent = new GrantAbilityEffectComponent([grantConfig]); + var grantEffectData = new EffectData( + "Grant Ability", + new DurationData(DurationType.Infinite), + effectComponents: [grantComponent]); + + var effect = new Effect(grantEffectData, new EffectOwnership(entity, entity)); + ActiveEffectHandle? effectHandle = entity.EffectsManager.ApplyEffect(effect); + + AbilityHandle abilityHandle = grantComponent.GrantedAbilities[0]; + abilityHandle.IsValid.Should().BeTrue(); + + // Create an effect that adds the trigger tag + TagContainer? triggerTagContainer = triggerTag.GetSingleTagContainer(); + var tagEffectData = new EffectData( + "Add Trigger Tag", + new DurationData(DurationType.Infinite), + effectComponents: [new ModifierTagsEffectComponent(triggerTagContainer!)]); + + var tagEffect = new Effect(tagEffectData, new EffectOwnership(entity, entity)); + + // Add tag to trigger the ability + ActiveEffectHandle? tagEffectHandle = entity.EffectsManager.ApplyEffect(tagEffect); + activationCount.Should().Be(1); + + // Remove the tag + entity.EffectsManager.RemoveEffect(tagEffectHandle!); + + // Remove the granting effect (removes the ability) + entity.EffectsManager.RemoveEffect(effectHandle!); + abilityHandle.IsValid.Should().BeFalse(); + + // Add tag again, this should NOT trigger the ability since it's been removed + entity.EffectsManager.ApplyEffect(tagEffect); + activationCount.Should().Be(1); // Should still be 1, not 2 + } + + [Fact] + [Trait("Cleanup", null)] + public void Removed_ability_with_TagPresent_trigger_does_not_activate_after_removal() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var triggerTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + var activationCount = 0; + + AbilityData abilityData = new( + "Triggered Ability", + instancingPolicy: AbilityInstancingPolicy.PerEntity, + abilityTriggerData: AbilityTriggerData.ForTagPresent(triggerTag), + behaviorFactory: () => new CountingAbilityBehavior(() => activationCount++)); + + // Grant the ability via effect + var grantConfig = new GrantAbilityConfig( + abilityData, + new ScalableInt(1), + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.CancelImmediately); + + var grantComponent = new GrantAbilityEffectComponent([grantConfig]); + var grantEffectData = new EffectData( + "Grant Ability", + new DurationData(DurationType.Infinite), + effectComponents: [grantComponent]); + + var effect = new Effect(grantEffectData, new EffectOwnership(entity, entity)); + ActiveEffectHandle? effectHandle = entity.EffectsManager.ApplyEffect(effect); + + AbilityHandle abilityHandle = grantComponent.GrantedAbilities[0]; + abilityHandle.IsValid.Should().BeTrue(); + + // Create an effect that adds the trigger tag + TagContainer? triggerTagContainer = triggerTag.GetSingleTagContainer(); + var tagEffectData = new EffectData( + "Add Trigger Tag", + new DurationData(DurationType.Infinite), + effectComponents: [new ModifierTagsEffectComponent(triggerTagContainer!)]); + + var tagEffect = new Effect(tagEffectData, new EffectOwnership(entity, entity)); + + // Add tag to trigger the ability + ActiveEffectHandle? tagEffectHandle = entity.EffectsManager.ApplyEffect(tagEffect); + activationCount.Should().Be(1); + + // Remove the tag (this will cancel the ability for TagPresent) + entity.EffectsManager.RemoveEffect(tagEffectHandle!); + + // Remove the granting effect (removes the ability) + entity.EffectsManager.RemoveEffect(effectHandle!); + abilityHandle.IsValid.Should().BeFalse(); + + // Add tag again, this should NOT trigger the ability since it's been removed + entity.EffectsManager.ApplyEffect(tagEffect); + activationCount.Should().Be(1); // Should still be 1, not 2 + } + + [Fact] + [Trait("Cleanup", null)] + public void Removed_ability_with_Event_trigger_does_not_activate_after_removal() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + var activationCount = 0; + + AbilityData abilityData = new( + "Triggered Ability", + instancingPolicy: AbilityInstancingPolicy.PerEntity, + abilityTriggerData: AbilityTriggerData.ForEvent(eventTag), + behaviorFactory: () => new CountingAbilityBehavior(() => activationCount++)); + + // Grant the ability via effect + var grantConfig = new GrantAbilityConfig( + abilityData, + new ScalableInt(1), + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.CancelImmediately); + + var grantComponent = new GrantAbilityEffectComponent([grantConfig]); + var grantEffectData = new EffectData( + "Grant Ability", + new DurationData(DurationType.Infinite), + effectComponents: [grantComponent]); + + var effect = new Effect(grantEffectData, new EffectOwnership(entity, entity)); + ActiveEffectHandle? effectHandle = entity.EffectsManager.ApplyEffect(effect); + + AbilityHandle abilityHandle = grantComponent.GrantedAbilities[0]; + abilityHandle.IsValid.Should().BeTrue(); + + // Raise event to trigger the ability + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Source = entity, + Target = entity, + }); + activationCount.Should().Be(1); + + // Remove the granting effect (removes the ability) + entity.EffectsManager.RemoveEffect(effectHandle!); + abilityHandle.IsValid.Should().BeFalse(); + + // Raise event again, this should NOT trigger the ability since it's been removed + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Source = entity, + Target = entity, + }); + + activationCount.Should().Be(1); // Should still be 1, not 2 + } + + [Fact] + [Trait("Cleanup", null)] + public void Removed_ability_with_typed_Event_trigger_does_not_activate_after_removal() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + var activationCount = 0; + + AbilityData abilityData = new( + "Triggered Ability", + instancingPolicy: AbilityInstancingPolicy.PerEntity, + abilityTriggerData: AbilityTriggerData.ForEvent(eventTag), + behaviorFactory: () => new CountingAbilityBehavior(() => activationCount++)); + + // Grant the ability via effect + var grantConfig = new GrantAbilityConfig( + abilityData, + new ScalableInt(1), + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.CancelImmediately); + + var grantComponent = new GrantAbilityEffectComponent([grantConfig]); + var grantEffectData = new EffectData( + "Grant Ability", + new DurationData(DurationType.Infinite), + effectComponents: [grantComponent]); + + var effect = new Effect(grantEffectData, new EffectOwnership(entity, entity)); + ActiveEffectHandle? effectHandle = entity.EffectsManager.ApplyEffect(effect); + + AbilityHandle abilityHandle = grantComponent.GrantedAbilities[0]; + abilityHandle.IsValid.Should().BeTrue(); + + // Raise typed event to trigger the ability + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Source = entity, + Target = entity, + Payload = 42, + }); + activationCount.Should().Be(1); + + // Remove the granting effect (removes the ability) + entity.EffectsManager.RemoveEffect(effectHandle!); + abilityHandle.IsValid.Should().BeFalse(); + + // Raise typed event again, this should NOT trigger the ability since it's been removed + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Source = entity, + Target = entity, + Payload = 100, + }); + + activationCount.Should().Be(1); // Should still be 1, not 2 + } + private static AbilityHandle? SetupAbility( TestEntity targetEntity, AbilityData abilityData, @@ -2970,4 +3210,19 @@ private AbilityData CreateAbilityData( targetRequiredTags: targetRequiredTags, targetBlockedTags: targetBlockedTags); } + + private sealed class CountingAbilityBehavior(Action onStarted) : IAbilityBehavior + { + private readonly Action _onStarted = onStarted; + + public void OnStarted(AbilityBehaviorContext context) + { + _onStarted(); + context.InstanceHandle.End(); + } + + public void OnEnded(AbilityBehaviorContext context) + { + } + } } diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 69217ff..4ac0e12 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -6,6 +6,7 @@ using Gamesmiths.Forge.Effects.Calculator; using Gamesmiths.Forge.Effects.Components; using Gamesmiths.Forge.Effects.Modifiers; +using Gamesmiths.Forge.Events; using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Abilities; @@ -29,6 +30,10 @@ private record struct BehaviorBinding(IAbilityBehavior Behavior, AbilityBehavior private readonly Dictionary _behaviors = []; + private readonly Action? _tagChangedHandler; + + private readonly EventSubscriptionToken? _eventSubscriptionToken; + private AbilityInstance? _persistentInstance; internal event Action? OnAbilityDeactivated; @@ -103,19 +108,21 @@ internal Ability( switch (triggerData.TriggerSource) { case AbitityTriggerSource.TagAdded: - owner.Tags.OnTagsChanged += TagAdded_OnTagChanged; + _tagChangedHandler = TagAdded_OnTagChanged; + owner.Tags.OnTagsChanged += _tagChangedHandler; break; case AbitityTriggerSource.TagPresent: - owner.Tags.OnTagsChanged += TagPresent_OnTagChanged; + _tagChangedHandler = TagPresent_OnTagChanged; + owner.Tags.OnTagsChanged += _tagChangedHandler; break; case AbitityTriggerSource.Event: if (triggerData.PayloadType is not null) { - SubscribeTypedEvent(triggerData); + _eventSubscriptionToken = SubscribeTypedEvent(triggerData); } else { - owner.Events.Subscribe( + _eventSubscriptionToken = owner.Events.Subscribe( triggerData.TriggerTag, x => TryActivateAbility(x.Target, out _, x.EventMagnitude), triggerData.Priority); @@ -217,6 +224,19 @@ internal void CancelAllInstances() Owner.Abilities.NotifyAbilityEnded(new AbilityEndedData(Handle, true)); } + internal void Cleanup() + { + if (_tagChangedHandler is not null) + { + Owner.Tags.OnTagsChanged -= _tagChangedHandler; + } + + if (_eventSubscriptionToken is not null) + { + Owner.Events.Unsubscribe(_eventSubscriptionToken.Value); + } + } + internal void OnInstanceStarted(AbilityInstance instance, float magnitude) { if (AbilityData.BehaviorFactory is null) @@ -521,7 +541,7 @@ private static bool HasBlockedTags(TagContainer? blocked, TagContainer? present) return blocked is not null && (present?.HasAny(blocked) == true); } - private void SubscribeTypedEvent(AbilityTriggerData triggerData) + private EventSubscriptionToken SubscribeTypedEvent(AbilityTriggerData triggerData) { #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields // Do not attempt this in production environments without adult supervision. @@ -530,12 +550,12 @@ private void SubscribeTypedEvent(AbilityTriggerData triggerData) .MakeGenericMethod(triggerData.PayloadType!); #pragma warning restore S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields - method.Invoke(this, [triggerData.TriggerTag, triggerData.Priority]); + return (EventSubscriptionToken)method.Invoke(this, [triggerData.TriggerTag, triggerData.Priority])!; } - private void SubscribeTypedEventCore(Tag tag, int priority) + private EventSubscriptionToken SubscribeTypedEventCore(Tag tag, int priority) { - Owner.Events.Subscribe( + return Owner.Events.Subscribe( tag, x => TryActivateAbility(x.Target, out _, x.Payload, x.EventMagnitude), priority: priority); diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index b9fd53f..47d7100 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -16,6 +16,8 @@ namespace Gamesmiths.Forge.Core; public class EntityAbilities(IForgeEntity owner) { private readonly Dictionary> _grantSources = []; + private Action? _removeAbility; + private Action? _inhibitAbility; /// /// Event invoked when an ability ends. @@ -299,7 +301,8 @@ internal void RemoveGrantedAbility(Ability? abilityToRemove, IAbilityGrantSource case AbilityDeactivationPolicy.RemoveOnEnd: if (abilityToRemove.IsActive) { - abilityToRemove.OnAbilityDeactivated += RemoveAbility; + _removeAbility = RemoveAbility; + abilityToRemove.OnAbilityDeactivated += _removeAbility; return; } @@ -347,16 +350,22 @@ private void InhibitAbilityBasedOnPolicy(Ability abilityToInhibit, AbilityDeacti case AbilityDeactivationPolicy.RemoveOnEnd: if (abilityToInhibit.IsActive) { - abilityToInhibit.OnAbilityDeactivated += InhibitAbility; + _inhibitAbility = InhibitAbility; + abilityToInhibit.OnAbilityDeactivated += _inhibitAbility; } return; } } + private void RemoveAbility(Ability abilityToRemove) { - abilityToRemove.OnAbilityDeactivated -= RemoveAbility; + if (_removeAbility is not null) + { + abilityToRemove.OnAbilityDeactivated -= _removeAbility; + _removeAbility = null; + } if (_grantSources.TryGetValue(abilityToRemove, out List? grantSources) && grantSources?.Count > 0) @@ -364,13 +373,19 @@ private void RemoveAbility(Ability abilityToRemove) return; } + abilityToRemove.Cleanup(); abilityToRemove.Handle.Free(); GrantedAbilities.Remove(abilityToRemove.Handle); } private void InhibitAbility(Ability abilityToInhibit) { - abilityToInhibit.OnAbilityDeactivated -= InhibitAbility; + if (_inhibitAbility is not null) + { + abilityToInhibit.OnAbilityDeactivated -= _inhibitAbility; + _inhibitAbility = null; + } + abilityToInhibit.IsInhibited = CheckIsInhibited(); } From ebb76c59fabca0b18365349685f2c9fed955e3a5 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 11 Jan 2026 21:58:07 -0300 Subject: [PATCH 73/87] Fixed per effect component instancing --- Forge.Tests/Abilities/AbilitiesTests.cs | 20 +++++------ Forge.Tests/Samples/QuickStartTests.cs | 6 ++-- Forge/Effects/ActiveEffect.cs | 20 ++++++++++- Forge/Effects/ActiveEffectHandle.cs | 35 +++++++++++++++++++ .../Components/GrantAbilityEffectComponent.cs | 12 +++++++ Forge/Effects/Components/IEffectComponent.cs | 31 ++++++++++++++++ .../TargetTagRequirementsEffectComponent.cs | 31 +++++++++++----- Forge/Effects/Effect.cs | 8 +++-- Forge/Effects/EffectsManager.cs | 21 +++++------ 9 files changed, 146 insertions(+), 38 deletions(-) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index e637c4f..919971b 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -2845,16 +2845,15 @@ public void Removed_ability_with_TagAdded_trigger_does_not_activate_after_remova AbilityDeactivationPolicy.CancelImmediately, AbilityDeactivationPolicy.CancelImmediately); - var grantComponent = new GrantAbilityEffectComponent([grantConfig]); var grantEffectData = new EffectData( "Grant Ability", new DurationData(DurationType.Infinite), - effectComponents: [grantComponent]); + effectComponents: [new GrantAbilityEffectComponent([grantConfig])]); var effect = new Effect(grantEffectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? effectHandle = entity.EffectsManager.ApplyEffect(effect); - AbilityHandle abilityHandle = grantComponent.GrantedAbilities[0]; + AbilityHandle abilityHandle = effectHandle!.GetComponent()!.GrantedAbilities[0]; abilityHandle.IsValid.Should().BeTrue(); // Create an effect that adds the trigger tag @@ -2905,16 +2904,15 @@ public void Removed_ability_with_TagPresent_trigger_does_not_activate_after_remo AbilityDeactivationPolicy.CancelImmediately, AbilityDeactivationPolicy.CancelImmediately); - var grantComponent = new GrantAbilityEffectComponent([grantConfig]); var grantEffectData = new EffectData( "Grant Ability", new DurationData(DurationType.Infinite), - effectComponents: [grantComponent]); + effectComponents: [new GrantAbilityEffectComponent([grantConfig])]); var effect = new Effect(grantEffectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? effectHandle = entity.EffectsManager.ApplyEffect(effect); - AbilityHandle abilityHandle = grantComponent.GrantedAbilities[0]; + AbilityHandle abilityHandle = effectHandle!.GetComponent()!.GrantedAbilities[0]; abilityHandle.IsValid.Should().BeTrue(); // Create an effect that adds the trigger tag @@ -2965,16 +2963,15 @@ public void Removed_ability_with_Event_trigger_does_not_activate_after_removal() AbilityDeactivationPolicy.CancelImmediately, AbilityDeactivationPolicy.CancelImmediately); - var grantComponent = new GrantAbilityEffectComponent([grantConfig]); var grantEffectData = new EffectData( "Grant Ability", new DurationData(DurationType.Infinite), - effectComponents: [grantComponent]); + effectComponents: [new GrantAbilityEffectComponent([grantConfig])]); var effect = new Effect(grantEffectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? effectHandle = entity.EffectsManager.ApplyEffect(effect); - AbilityHandle abilityHandle = grantComponent.GrantedAbilities[0]; + AbilityHandle abilityHandle = effectHandle!.GetComponent()!.GrantedAbilities[0]; abilityHandle.IsValid.Should().BeTrue(); // Raise event to trigger the ability @@ -3024,16 +3021,15 @@ public void Removed_ability_with_typed_Event_trigger_does_not_activate_after_rem AbilityDeactivationPolicy.CancelImmediately, AbilityDeactivationPolicy.CancelImmediately); - var grantComponent = new GrantAbilityEffectComponent([grantConfig]); var grantEffectData = new EffectData( "Grant Ability", new DurationData(DurationType.Infinite), - effectComponents: [grantComponent]); + effectComponents: [new GrantAbilityEffectComponent([grantConfig])]); var effect = new Effect(grantEffectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? effectHandle = entity.EffectsManager.ApplyEffect(effect); - AbilityHandle abilityHandle = grantComponent.GrantedAbilities[0]; + AbilityHandle abilityHandle = effectHandle!.GetComponent()!.GrantedAbilities[0]; abilityHandle.IsValid.Should().BeTrue(); // Raise typed event to trigger the ability diff --git a/Forge.Tests/Samples/QuickStartTests.cs b/Forge.Tests/Samples/QuickStartTests.cs index 9d590ec..c90bbce 100644 --- a/Forge.Tests/Samples/QuickStartTests.cs +++ b/Forge.Tests/Samples/QuickStartTests.cs @@ -854,19 +854,17 @@ public void Granting_activating_and_removing_an_ability() InhibitionPolicy = AbilityDeactivationPolicy.CancelImmediately, }; - var grantAbilityComponent = new GrantAbilityEffectComponent([grantConfig]); - var grantFireballEffect = new EffectData( "Grant Fireball Effect", new DurationData(DurationType.Infinite), - effectComponents: [grantAbilityComponent] + effectComponents: [new GrantAbilityEffectComponent([grantConfig])] ); var grantEffectHandle = player.EffectsManager.ApplyEffect( new Effect(grantFireballEffect, new EffectOwnership(player, player))); // Retrieve handle directly from component as shown in docs - var fireballAbilityHandle = grantAbilityComponent.GrantedAbilities[0]; + var fireballAbilityHandle = grantEffectHandle.GetComponent().GrantedAbilities[0]; bool successfulActivation = fireballAbilityHandle.Activate(out AbilityActivationFailures failures); diff --git a/Forge/Effects/ActiveEffect.cs b/Forge/Effects/ActiveEffect.cs index 2bdac4b..bb45767 100644 --- a/Forge/Effects/ActiveEffect.cs +++ b/Forge/Effects/ActiveEffect.cs @@ -2,6 +2,7 @@ using Gamesmiths.Forge.Attributes; using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Effects.Components; using Gamesmiths.Forge.Effects.Duration; using Gamesmiths.Forge.Effects.Magnitudes; using Gamesmiths.Forge.Effects.Modifiers; @@ -44,10 +45,27 @@ internal sealed class ActiveEffect internal Effect Effect => EffectEvaluatedData.Effect; + /// + /// Gets the component instances for this active effect. + /// + /// + /// These are created via when the effect is applied, + /// allowing stateful components to maintain per-effect-instance state. + /// + internal IEffectComponent[] ComponentInstances { get; } + internal ActiveEffect(Effect effect, IForgeEntity target, EffectApplicationContext? applicationContext = null) { Handle = new ActiveEffectHandle(this); + // Create component instances for this specific effect application + IEffectComponent[] definitions = effect.EffectData.EffectComponents; + ComponentInstances = new IEffectComponent[definitions.Length]; + for (var i = 0; i < definitions.Length; i++) + { + ComponentInstances[i] = definitions[i].CreateInstance(); + } + if (effect.EffectData.StackingData.HasValue) { StackCount = effect.EffectData.StackingData.Value.InitialStack.GetValue(effect.Level); @@ -464,7 +482,7 @@ private void ApplyModifiers(bool unapply = false) private void Execute() { EffectEvaluatedData effectEvaluatedData = EffectEvaluatedData; - Effect.Execute(in effectEvaluatedData); + Effect.Execute(in effectEvaluatedData, ComponentInstances); ExecutionCount++; } diff --git a/Forge/Effects/ActiveEffectHandle.cs b/Forge/Effects/ActiveEffectHandle.cs index 73647f4..fd26aa6 100644 --- a/Forge/Effects/ActiveEffectHandle.cs +++ b/Forge/Effects/ActiveEffectHandle.cs @@ -1,5 +1,7 @@ // Copyright © Gamesmiths Guild. +using Gamesmiths.Forge.Effects.Components; + namespace Gamesmiths.Forge.Effects; /// @@ -17,6 +19,15 @@ public class ActiveEffectHandle /// public bool IsValid => ActiveEffect is not null; + /// + /// Gets the component instances for this active effect. + /// + /// + /// These are the actual component instances used for this specific effect application, + /// which may hold per-instance state (such as granted abilities for ). + /// + public IReadOnlyList ComponentInstances => ActiveEffect?.ComponentInstances ?? []; + internal ActiveEffect? ActiveEffect { get; private set; } internal ActiveEffectHandle(ActiveEffect activeEffect) @@ -33,6 +44,30 @@ public void SetInhibit(bool value) ActiveEffect?.SetInhibit(value); } + /// + /// Gets the first component instance of the specified type. + /// + /// The type of component to find. + /// The component instance, or null if not found. + public T? GetComponent() + where T : class, IEffectComponent + { + if (ActiveEffect is null) + { + return null; + } + + foreach (IEffectComponent component in ActiveEffect.ComponentInstances) + { + if (component is T typed) + { + return typed; + } + } + + return null; + } + internal void Free() { ActiveEffect = null; diff --git a/Forge/Effects/Components/GrantAbilityEffectComponent.cs b/Forge/Effects/Components/GrantAbilityEffectComponent.cs index 57d540b..b2b7b8b 100644 --- a/Forge/Effects/Components/GrantAbilityEffectComponent.cs +++ b/Forge/Effects/Components/GrantAbilityEffectComponent.cs @@ -8,6 +8,11 @@ namespace Gamesmiths.Forge.Effects.Components; /// /// Grant an ability to the target when the effect is applied. /// +/// +/// This component maintains per-effect-instance state (granted abilities, inhibition state). +/// When used in , each effect application will create its own instance +/// via to isolate state between different effect applications. +/// /// Configurations for the abilities to be granted. public class GrantAbilityEffectComponent(GrantAbilityConfig[] grantAbilityConfigs) : IEffectComponent { @@ -24,6 +29,13 @@ public class GrantAbilityEffectComponent(GrantAbilityConfig[] grantAbilityConfig /// public IReadOnlyList GrantedAbilities => _grantedAbilities; + /// + public IEffectComponent CreateInstance() + { + // Create a new instance for each effect application to isolate state + return new GrantAbilityEffectComponent(_grantAbilityConfigs); + } + /// public void OnEffectExecuted(IForgeEntity target, in EffectEvaluatedData effectEvaluatedData) { diff --git a/Forge/Effects/Components/IEffectComponent.cs b/Forge/Effects/Components/IEffectComponent.cs index 3beba1a..9894ec4 100644 --- a/Forge/Effects/Components/IEffectComponent.cs +++ b/Forge/Effects/Components/IEffectComponent.cs @@ -8,8 +8,39 @@ namespace Gamesmiths.Forge.Effects.Components; /// Interface for implementing custom effect components. Components can be used to extend effects /// functionality and implement custom conditions for application. /// +/// +/// +/// Components are stored in and should be designed as stateless definitions. +/// If a component needs to maintain per-effect-instance state (such as tracking granted abilities, +/// event subscriptions, or other runtime data), it should return a new instance from +/// that implements the stateful behavior. +/// +/// +/// Stateless components can use the default implementation which returns +/// this, allowing the same component instance to be shared across all effect applications. +/// +/// public interface IEffectComponent { + /// + /// Creates an instance of this component for a specific effect application. + /// + /// + /// + /// Override this method to return a new instance when your component needs to maintain + /// per-effect-instance state. The returned instance will be used for all lifecycle callbacks + /// for that specific effect application. + /// + /// + /// Stateless components can use the default implementation which returns this. + /// + /// + /// An instance to use for this effect application. + IEffectComponent CreateInstance() + { + return this; + } + /// /// A custom validation method for validating whether a effect can be applied or not. /// diff --git a/Forge/Effects/Components/TargetTagRequirementsEffectComponent.cs b/Forge/Effects/Components/TargetTagRequirementsEffectComponent.cs index 1852498..f681dad 100644 --- a/Forge/Effects/Components/TargetTagRequirementsEffectComponent.cs +++ b/Forge/Effects/Components/TargetTagRequirementsEffectComponent.cs @@ -11,6 +11,11 @@ namespace Gamesmiths.Forge.Effects.Components; /// define the conditions under which the effect should be removed, while the /// specify conditions for toggling effect inhibition. /// +/// +/// This component maintains per-effect-instance state (event subscriptions). +/// When used in , each effect application will create its own instance +/// via to isolate state between different effect applications. +/// /// Tags required for the effect to be applied. /// Tags that, if met, trigger effect removal. /// Tags that, if met, toggle the inhibition state of the effect. @@ -19,7 +24,7 @@ public class TargetTagRequirementsEffectComponent( TagRequirements? removalTagRequirements = null, TagRequirements? ongoingTagRequirements = null) : IEffectComponent { - private readonly Dictionary> _subscriptionMap = []; + private Action? _handler; private TagRequirements? ApplicationTagRequirements { get; } = applicationTagRequirements; @@ -27,6 +32,16 @@ public class TargetTagRequirementsEffectComponent( private TagRequirements? OngoingTagRequirements { get; } = ongoingTagRequirements; + /// + public IEffectComponent CreateInstance() + { + // Create a new instance for each effect application to isolate event subscription state + return new TargetTagRequirementsEffectComponent( + ApplicationTagRequirements, + RemovalTagRequirements, + OngoingTagRequirements); + } + /// public bool CanApplyEffect(in IForgeEntity target, in Effect effect) { @@ -56,7 +71,7 @@ public bool OnActiveEffectAdded( { ActiveEffectHandle handle = activeEffectEvaluatedData.ActiveEffectHandle; - // Create a distinct handler that captures this 'target' and 'handle' + // Create a handler that captures this 'target' and 'handle' void Handler(TagContainer tags) { if (RemovalTagRequirements.HasValue @@ -74,8 +89,8 @@ void Handler(TagContainer tags) } // Store it so we can unsubscribe later - _subscriptionMap[handle] = Handler; - target.Tags.OnTagsChanged += Handler; + _handler = Handler; + target.Tags.OnTagsChanged += _handler; return OngoingTagRequirements?.IsEmpty != false || OngoingTagRequirements.Value.RequirementsMet(target.Tags.CombinedTags); @@ -87,12 +102,10 @@ public void OnActiveEffectUnapplied( in ActiveEffectEvaluatedData activeEffectEvaluatedData, bool removed) { - ActiveEffectHandle handle = activeEffectEvaluatedData.ActiveEffectHandle; - - if (removed && _subscriptionMap.TryGetValue(handle, out Action? handler)) + if (removed && _handler is not null) { - target.Tags.OnTagsChanged -= handler; - _subscriptionMap.Remove(handle); + target.Tags.OnTagsChanged -= _handler; + _handler = null; } } } diff --git a/Forge/Effects/Effect.cs b/Forge/Effects/Effect.cs index 6affe24..c988217 100644 --- a/Forge/Effects/Effect.cs +++ b/Forge/Effects/Effect.cs @@ -113,7 +113,9 @@ public void SetSetByCallerMagnitude(Tag identifierTag, float magnitude) OnSetByCallerFloatChanged?.Invoke(identifierTag, magnitude); } - internal static void Execute(in EffectEvaluatedData effectEvaluatedData) + internal static void Execute( + in EffectEvaluatedData effectEvaluatedData, + IEffectComponent[]? componentInstances = null) { foreach (ModifierEvaluatedData modifier in effectEvaluatedData.ModifiersEvaluatedData) { @@ -133,7 +135,9 @@ internal static void Execute(in EffectEvaluatedData effectEvaluatedData) } } - effectEvaluatedData.Target.EffectsManager.OnEffectExecuted_InternalCall(effectEvaluatedData); + effectEvaluatedData.Target.EffectsManager.OnEffectExecuted_InternalCall( + effectEvaluatedData, + componentInstances); effectEvaluatedData.Target.Attributes.ApplyPendingValueChanges(); } diff --git a/Forge/Effects/EffectsManager.cs b/Forge/Effects/EffectsManager.cs index 9abdab6..3e44bf2 100644 --- a/Forge/Effects/EffectsManager.cs +++ b/Forge/Effects/EffectsManager.cs @@ -126,11 +126,12 @@ public IEnumerable GetEffectInfo(EffectData effectData) return ConvertToStackInstanceData(filteredEffects); } - internal void OnEffectExecuted_InternalCall(EffectEvaluatedData executedEffectEvaluatedData) + internal void OnEffectExecuted_InternalCall( + EffectEvaluatedData executedEffectEvaluatedData, + IEffectComponent[]? componentInstances = null) { - EffectData effectData = executedEffectEvaluatedData.Effect.EffectData; - - foreach (IEffectComponent component in effectData.EffectComponents) + foreach (IEffectComponent component in componentInstances + ?? executedEffectEvaluatedData.Effect.EffectData.EffectComponents) { component.OnEffectExecuted(Owner, in executedEffectEvaluatedData); } @@ -140,7 +141,7 @@ internal void OnEffectExecuted_InternalCall(EffectEvaluatedData executedEffectEv internal void OnActiveEffectUnapplied_InternalCall(ActiveEffect removedEffect) { - foreach (IEffectComponent component in removedEffect.Effect.EffectData.EffectComponents) + foreach (IEffectComponent component in removedEffect.ComponentInstances) { component.OnActiveEffectUnapplied( Owner, @@ -156,7 +157,7 @@ internal void OnActiveEffectUnapplied_InternalCall(ActiveEffect removedEffect) internal void OnActiveEffectChanged_InternalCall(ActiveEffect removedEffect) { - foreach (IEffectComponent component in removedEffect.EffectData.EffectComponents) + foreach (IEffectComponent component in removedEffect.ComponentInstances) { component.OnActiveEffectChanged( Owner, @@ -277,7 +278,7 @@ private IEnumerable FilterEffectsByEffect(Effect effect) if (successfulApplication) { - foreach (IEffectComponent component in stackableEffect.EffectData.EffectComponents) + foreach (IEffectComponent component in stackableEffect.ComponentInstances) { component.OnEffectApplied(Owner, stackableEffect.EffectEvaluatedData); } @@ -296,7 +297,7 @@ private ActiveEffect ApplyNewEffect(Effect effect, EffectApplicationContext? app var remainActive = true; - foreach (IEffectComponent component in effect.EffectData.EffectComponents) + foreach (IEffectComponent component in activeEffect.ComponentInstances) { remainActive &= component.OnActiveEffectAdded( Owner, @@ -329,7 +330,7 @@ private ActiveEffect ApplyNewEffect(Effect effect, EffectApplicationContext? app effectEvaluatedData.Target.Attributes.ApplyPendingValueChanges(); - foreach (IEffectComponent component in effect.EffectData.EffectComponents) + foreach (IEffectComponent component in activeEffect.ComponentInstances) { component.OnPostActiveEffectAdded( Owner, @@ -387,7 +388,7 @@ private void RemoveActiveEffect(ActiveEffect effectToRemove, bool interrupted) EffectEvaluatedData effectEvaluatedData = effectToRemove.EffectEvaluatedData; - foreach (IEffectComponent component in effectToRemove.EffectData.EffectComponents) + foreach (IEffectComponent component in effectToRemove.ComponentInstances) { component.OnActiveEffectUnapplied( Owner, From ac986175c4935ea884dd14764262804854ae7132 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 11 Jan 2026 23:18:33 -0300 Subject: [PATCH 74/87] Fixed more component instancing issues --- Forge.Tests/Abilities/AbilitiesTests.cs | 49 +++++++++++++++++++++++++ Forge/Effects/EffectsManager.cs | 10 ++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index 919971b..f1142eb 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -433,6 +433,55 @@ [new ScalableFloat(3f)], entity.Abilities.GrantedAbilities.Should().ContainSingle(); } + [Fact] + [Trait("Grant ability", null)] + public void Ability_granted_by_same_EffectData_to_two_entities_are_different_instances() + { + TestEntity entity1 = new(_tagsManager, _cuesManager); + TestEntity entity2 = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + GrantAbilityConfig grantAbilityConfig = new( + abilityData, + new ScalableInt(1), + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.CancelImmediately, + false, + false, + LevelComparison.None); + + var grantAbilityEffectData = new EffectData( + "GrantFireball", + new DurationData(DurationType.Instant), + effectComponents: [new GrantAbilityEffectComponent([grantAbilityConfig])]); + + var effect1 = new Effect( + grantAbilityEffectData, + new EffectOwnership(null, entity1)); + + var effect2 = new Effect( + grantAbilityEffectData, + new EffectOwnership(null, entity2)); + + entity1.EffectsManager.ApplyEffect(effect1); + entity2.EffectsManager.ApplyEffect(effect2); + + entity1.Abilities.TryGetAbility(abilityData, out AbilityHandle? abilityHandle1, entity1); + entity2.Abilities.TryGetAbility(abilityData, out AbilityHandle? abilityHandle2, entity2); + + abilityHandle1.Should().NotBeNull(); + abilityHandle2.Should().NotBeNull(); + abilityHandle1.Should().NotBe(abilityHandle2); + entity1.Abilities.GrantedAbilities.Should().ContainSingle(); + entity2.Abilities.GrantedAbilities.Should().ContainSingle(); + } + [Fact] [Trait("Grant ability", null)] public void Ability_granted_by_late_instant_effect_is_permanent() diff --git a/Forge/Effects/EffectsManager.cs b/Forge/Effects/EffectsManager.cs index 3e44bf2..2022f36 100644 --- a/Forge/Effects/EffectsManager.cs +++ b/Forge/Effects/EffectsManager.cs @@ -256,12 +256,20 @@ private IEnumerable FilterEffectsByEffect(Effect effect) { var evaluatedData = new EffectEvaluatedData(effect, Owner, applicationContext: applicationContext); + // Create component instances for instant effects to ensure stateful components + IEffectComponent[] definitions = effect.EffectData.EffectComponents; + var componentInstances = new IEffectComponent[definitions.Length]; + for (var i = 0; i < definitions.Length; i++) + { + componentInstances[i] = definitions[i].CreateInstance(); + } + foreach (IEffectComponent component in effect.EffectData.EffectComponents) { component.OnEffectApplied(Owner, in evaluatedData); } - Effect.Execute(in evaluatedData); + Effect.Execute(in evaluatedData, componentInstances); return null; } From 0e9e17f32828d4e4446868e9b2773a70e7f91db6 Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 12 Jan 2026 11:51:07 -0300 Subject: [PATCH 75/87] Remove unecessary optional parameters --- Forge/Effects/ActiveEffect.cs | 2 +- Forge/Effects/Effect.cs | 2 +- Forge/Effects/EffectsManager.cs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Forge/Effects/ActiveEffect.cs b/Forge/Effects/ActiveEffect.cs index bb45767..4c86571 100644 --- a/Forge/Effects/ActiveEffect.cs +++ b/Forge/Effects/ActiveEffect.cs @@ -54,7 +54,7 @@ internal sealed class ActiveEffect /// internal IEffectComponent[] ComponentInstances { get; } - internal ActiveEffect(Effect effect, IForgeEntity target, EffectApplicationContext? applicationContext = null) + internal ActiveEffect(Effect effect, IForgeEntity target, EffectApplicationContext? applicationContext) { Handle = new ActiveEffectHandle(this); diff --git a/Forge/Effects/Effect.cs b/Forge/Effects/Effect.cs index c988217..978fe1c 100644 --- a/Forge/Effects/Effect.cs +++ b/Forge/Effects/Effect.cs @@ -115,7 +115,7 @@ public void SetSetByCallerMagnitude(Tag identifierTag, float magnitude) internal static void Execute( in EffectEvaluatedData effectEvaluatedData, - IEffectComponent[]? componentInstances = null) + IEffectComponent[]? componentInstances) { foreach (ModifierEvaluatedData modifier in effectEvaluatedData.ModifiersEvaluatedData) { diff --git a/Forge/Effects/EffectsManager.cs b/Forge/Effects/EffectsManager.cs index 2022f36..2330fe8 100644 --- a/Forge/Effects/EffectsManager.cs +++ b/Forge/Effects/EffectsManager.cs @@ -128,7 +128,7 @@ public IEnumerable GetEffectInfo(EffectData effectData) internal void OnEffectExecuted_InternalCall( EffectEvaluatedData executedEffectEvaluatedData, - IEffectComponent[]? componentInstances = null) + IEffectComponent[]? componentInstances) { foreach (IEffectComponent component in componentInstances ?? executedEffectEvaluatedData.Effect.EffectData.EffectComponents) @@ -298,7 +298,7 @@ private IEnumerable FilterEffectsByEffect(Effect effect) return ApplyNewEffect(effect, applicationContext).Handle; } - private ActiveEffect ApplyNewEffect(Effect effect, EffectApplicationContext? applicationContext = null) + private ActiveEffect ApplyNewEffect(Effect effect, EffectApplicationContext? applicationContext) { var activeEffect = new ActiveEffect(effect, Owner, applicationContext); _activeEffects.Add(activeEffect); From f0a83049d73e87667f35d3c9cf6fafacc8409b3a Mon Sep 17 00:00:00 2001 From: Lex Date: Wed, 14 Jan 2026 08:42:48 -0300 Subject: [PATCH 76/87] Reset hasGrantedAbility for safety --- Forge/Effects/Components/GrantAbilityEffectComponent.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Forge/Effects/Components/GrantAbilityEffectComponent.cs b/Forge/Effects/Components/GrantAbilityEffectComponent.cs index b2b7b8b..6175cc6 100644 --- a/Forge/Effects/Components/GrantAbilityEffectComponent.cs +++ b/Forge/Effects/Components/GrantAbilityEffectComponent.cs @@ -162,6 +162,8 @@ private void RemoveGrantedAbilities(IForgeEntity target) AbilityHandle ability = _grantedAbilities[i]; target.Abilities.RemoveGrantedAbility(ability, _grantSources[i]); } + + _hasGrantedAbilities = false; } private void InhibitGrantedAbilities(IForgeEntity target) From 31fe769cb6c58c96642b055097125eaafa2ce313 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 16 Jan 2026 22:02:52 -0300 Subject: [PATCH 77/87] More options for trigger requirement --- Forge.Tests/Cues/CueTests.cs | 204 +++++++++++++++++-------- Forge/Cues/CuesManager.cs | 16 +- Forge/Effects/CueTriggerRequirement.cs | 30 ++++ Forge/Effects/EffectData.cs | 11 +- 4 files changed, 188 insertions(+), 73 deletions(-) create mode 100644 Forge/Effects/CueTriggerRequirement.cs diff --git a/Forge.Tests/Cues/CueTests.cs b/Forge.Tests/Cues/CueTests.cs index 1771624..a89ea19 100644 --- a/Forge.Tests/Cues/CueTests.cs +++ b/Forge.Tests/Cues/CueTests.cs @@ -40,7 +40,7 @@ private enum TestCueExecutionType { new object[] { "TestAttributeSet.Attribute1", 3f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -61,7 +61,7 @@ private enum TestCueExecutionType { new object[] { "TestAttributeSet.Attribute1", 3f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -86,7 +86,7 @@ private enum TestCueExecutionType new object[] { "TestAttributeSet.Attribute1", 3f }, new object[] { "TestAttributeSet.Attribute1", 2f }, }, - true, + CueTriggerRequirement.OnExecute, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -110,7 +110,7 @@ private enum TestCueExecutionType { new object[] { "TestAttributeSet.Attribute1", 99f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -134,7 +134,7 @@ private enum TestCueExecutionType { new object[] { "TestAttributeSet.Attribute1", 99f }, }, - true, + CueTriggerRequirement.OnExecute, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -159,7 +159,7 @@ private enum TestCueExecutionType new object[] { "TestAttributeSet.Attribute1", 1f }, new object[] { "TestAttributeSet.Attribute2", 2f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -178,7 +178,7 @@ private enum TestCueExecutionType })] [InlineData( new object[] { }, - true, + CueTriggerRequirement.OnExecute, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -202,7 +202,7 @@ private enum TestCueExecutionType { new object[] { "TestAttributeSet.Attribute1", 3f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "Invalid.Attribute" }, @@ -224,7 +224,7 @@ private enum TestCueExecutionType new object[] { "TestAttributeSet.Attribute1", 3f }, new object[] { "TestAttributeSet.Attribute1", 2f }, }, - true, + CueTriggerRequirement.OnExecute, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "Invalid.Attribute1" }, @@ -245,7 +245,7 @@ private enum TestCueExecutionType })] [InlineData( new object[] { }, - true, + CueTriggerRequirement.OnExecute, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "Invalid.Attribute1" }, @@ -270,7 +270,7 @@ private enum TestCueExecutionType new object[] { "TestAttributeSet.Attribute1", 1f }, new object[] { "Invalid.Attribute", 2f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -292,7 +292,7 @@ private enum TestCueExecutionType { new object[] { "Invalid.Attribute", 3f }, }, - true, + CueTriggerRequirement.OnExecute, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -313,7 +313,7 @@ private enum TestCueExecutionType { new object[] { "TestAttributeSet.Attribute1", 3f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeBaseValue, "TestAttributeSet.Attribute1" }, @@ -328,7 +328,7 @@ private enum TestCueExecutionType })] public void Instant_effect_triggers_execute_cues_with_expected_results( object[] modifiersData, - bool requireModifierSuccessToTriggerCue, + CueTriggerRequirement requireModifierSuccessToTriggerCue, object[] cueData, object[] cueTestData1, object[] cueTestData2) @@ -356,7 +356,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "TestAttributeSet.Attribute1", 5f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -377,7 +377,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "TestAttributeSet.Attribute1", 5f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute2" }, @@ -395,7 +395,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( })] [InlineData( new object[] { }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -413,7 +413,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( })] [InlineData( new object[] { }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -434,7 +434,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "TestAttributeSet.Attribute1", 5f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "Invalid.Attribute" }, @@ -455,7 +455,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "Invalid.Attribute", 5f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -476,7 +476,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "TestAttributeSet.Attribute90", 20f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 100, CueMagnitudeType.AttributeModifier, "TestAttributeSet.Attribute90" }, @@ -494,7 +494,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "TestAttributeSet.Attribute90", 20f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 100, CueMagnitudeType.AttributeOverflow, "TestAttributeSet.Attribute90" }, @@ -512,7 +512,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "TestAttributeSet.Attribute90", 20f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 100, CueMagnitudeType.AttributeValidModifier, "TestAttributeSet.Attribute90" }, @@ -530,7 +530,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "TestAttributeSet.Attribute5", 20f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 100, CueMagnitudeType.AttributeMin, "TestAttributeSet.Attribute90" }, @@ -548,7 +548,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "TestAttributeSet.Attribute5", 20f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 100, CueMagnitudeType.AttributeMax, "TestAttributeSet.Attribute90" }, @@ -566,7 +566,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "TestAttributeSet.Attribute5", 20f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] @@ -588,7 +588,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( })] public void Infinite_effect_triggers_apply_and_remove_cues_with_expected_results( object[] modifiersData, - bool requireModifierSuccessToTriggerCue, + CueTriggerRequirement requireModifierSuccessToTriggerCue, object[] cueData, object[] cueTestData1, object[] cueTestData2) @@ -619,7 +619,7 @@ public void Infinite_effect_triggers_apply_and_remove_cues_with_expected_results { new object[] { "TestAttributeSet.Attribute1", 1f }, }, - false, + CueTriggerRequirement.None, 5f, 5f, new object[] @@ -669,7 +669,7 @@ public void Infinite_effect_triggers_apply_and_remove_cues_with_expected_results new object[] { "TestAttributeSet.Attribute1", 1f }, new object[] { "TestAttributeSet.Attribute2", 2f }, }, - false, + CueTriggerRequirement.None, 5f, 6f, new object[] @@ -719,7 +719,7 @@ public void Infinite_effect_triggers_apply_and_remove_cues_with_expected_results new object[] { "TestAttributeSet.Attribute1", 1f }, new object[] { "TestAttributeSet.Attribute2", 2f }, }, - true, + CueTriggerRequirement.OnApply | CueTriggerRequirement.OnExecute, 5f, 6f, new object[] @@ -769,7 +769,7 @@ public void Infinite_effect_triggers_apply_and_remove_cues_with_expected_results new object[] { "TestAttributeSet.Attribute1", 1f }, new object[] { "TestAttributeSet.Attribute2", 2f }, }, - true, + CueTriggerRequirement.OnApply | CueTriggerRequirement.OnExecute, 5f, 6f, new object[] @@ -818,7 +818,7 @@ public void Infinite_effect_triggers_apply_and_remove_cues_with_expected_results { new object[] { "TestAttributeSet.Attribute1", 1f }, }, - false, + CueTriggerRequirement.None, 5f, 5f, new object[] @@ -859,12 +859,90 @@ public void Infinite_effect_triggers_apply_and_remove_cues_with_expected_results true, true, true)] + [InlineData( + 10f, + 1f, + true, + new object[] { }, + CueTriggerRequirement.OnExecute, + 5f, + 5f, + new object[] + { + new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, + }, + new object[] + { + new object[] { 0, 1, 0, 0f, true }, + }, + new object[] + { + new object[] { 0, 0, 0, 0f, true }, + }, + new object[] + { + new object[] { 0, 1, 0, 0f, true }, + }, + new object[] + { + new object[] { 0, 0, 0, 0f, true }, + }, + new object[] + { + new object[] { 0, 1, 0, 0f, false }, + }, + new object[] + { + new object[] { 0, 0, 0, 0f, false }, + }, + true, + false, + true)] + [InlineData( + 10f, + 1f, + true, + new object[] { }, + CueTriggerRequirement.OnApply, + 5f, + 5f, + new object[] + { + new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, + }, + new object[] + { + new object[] { 0, 0, 0, 0f, false }, + }, + new object[] + { + new object[] { 0, 1, 0, 0f, false }, + }, + new object[] + { + new object[] { 0, 0, 0, 0f, false }, + }, + new object[] + { + new object[] { 0, 6, 0, 0f, false }, + }, + new object[] + { + new object[] { 0, 0, 0, 0f, false }, + }, + new object[] + { + new object[] { 0, 11, 0, 0f, false }, + }, + false, + true, + false)] public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected_results( float duration, float period, bool executeOnApplication, object[] modifiersData, - bool requireModifierSuccessToTriggerCue, + CueTriggerRequirement requireModifierSuccessToTriggerCue, float firstDeltaUpdate, float secondDeltaUpdate, object[] cueData, @@ -930,7 +1008,7 @@ public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected new object[] { "TestAttributeSet.Attribute1", 1f }, }, false, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -972,7 +1050,7 @@ public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected new object[] { "TestAttributeSet.Attribute1", 1f }, }, false, - true, + CueTriggerRequirement.OnApply | CueTriggerRequirement.OnUpdate, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1014,7 +1092,7 @@ public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected new object[] { "TestAttributeSet.Attribute1", 1f }, }, true, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1056,7 +1134,7 @@ public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected new object[] { "TestAttributeSet.Attribute1", 99f }, }, false, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1098,7 +1176,7 @@ public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected new object[] { "TestAttributeSet.Attribute1", 99f }, }, false, - true, + CueTriggerRequirement.OnApply | CueTriggerRequirement.OnUpdate, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1140,7 +1218,7 @@ public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected new object[] { "TestAttributeSet.Attribute1", 1f }, }, false, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "Invalid.Attribute" }, @@ -1179,7 +1257,7 @@ public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_results( object[] modifiersData, bool snapshotLevel, - bool requireModifierSuccessToTriggerCue, + CueTriggerRequirement requireModifierSuccessToTriggerCue, object[] cueData, object[] applicationCueTestData1, object[] updateCueTestData1, @@ -1222,7 +1300,7 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res new object[] { "TestAttributeSet.Attribute2", 1f }, new object[] { "TestAttributeSet.Attribute2", 1f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1267,7 +1345,7 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res { new object[] { "TestAttributeSet.Attribute2", 1f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1312,7 +1390,7 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res { new object[] { "TestAttributeSet.Attribute2", 1f }, }, - true, + CueTriggerRequirement.OnApply | CueTriggerRequirement.OnUpdate, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1357,7 +1435,7 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res { new object[] { "TestAttributeSet.Attribute90", 2f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1402,7 +1480,7 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res { new object[] { "TestAttributeSet.Attribute90", 2f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1447,7 +1525,7 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res { new object[] { "TestAttributeSet.Attribute90", 2f }, }, - true, + CueTriggerRequirement.OnApply | CueTriggerRequirement.OnUpdate, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1492,7 +1570,7 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res { new object[] { "TestAttributeSet.Attribute90", 2f }, }, - true, + CueTriggerRequirement.OnApply | CueTriggerRequirement.OnUpdate, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1531,7 +1609,7 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res public void Attribute_based_modifiers_triggers_update_cues_when_attribute_changes_with_expected_results( object[] attributeBasedModifiersData, object[] modifiersData, - bool requireModifierSuccessToTriggerCue, + CueTriggerRequirement requireModifierSuccessToTriggerCue, object[] cueData, object[] applicationCueTestData1, object[] updateCueTestData1, @@ -1580,7 +1658,7 @@ public void Attribute_based_modifiers_triggers_update_cues_when_attribute_change 10f, 1, 3, - false, + CueTriggerRequirement.None, false, new object[] { @@ -1658,7 +1736,7 @@ public void Attribute_based_modifiers_triggers_update_cues_when_attribute_change 10f, 2, 3, - false, + CueTriggerRequirement.None, false, new object[] { @@ -1736,7 +1814,7 @@ public void Attribute_based_modifiers_triggers_update_cues_when_attribute_change 10f, 2, 3, - false, + CueTriggerRequirement.None, false, new object[] { @@ -1814,7 +1892,7 @@ public void Attribute_based_modifiers_triggers_update_cues_when_attribute_change 10f, 1, 3, - false, + CueTriggerRequirement.None, false, new object[] { @@ -1892,7 +1970,7 @@ public void Attribute_based_modifiers_triggers_update_cues_when_attribute_change 10f, 3, 3, - false, + CueTriggerRequirement.None, false, new object[] { @@ -1970,7 +2048,7 @@ public void Attribute_based_modifiers_triggers_update_cues_when_attribute_change 10f, 3, 3, - true, + CueTriggerRequirement.OnApply | CueTriggerRequirement.OnUpdate, false, new object[] { @@ -2048,7 +2126,7 @@ public void Attribute_based_modifiers_triggers_update_cues_when_attribute_change 10f, 3, 3, - false, + CueTriggerRequirement.None, true, new object[] { @@ -2115,7 +2193,7 @@ public void Attribute_based_modifiers_triggers_update_cues_when_attribute_change 10f, 3, 3, - false, + CueTriggerRequirement.None, true, new object[] { @@ -2179,7 +2257,7 @@ public void Stackable_effect_triggers_update_cues_when_attribute_changes_with_ex float duration, int initialStack, int stackLimit, - bool requireModifierSuccessToTriggerCue, + CueTriggerRequirement requireModifierSuccessToTriggerCue, bool suppressStackingCues, object[] cueData, float deltaUpdate1, @@ -2237,7 +2315,7 @@ public void Invalid_cue_fails_gracefully() var entity = new TestEntity(_tagsManager, _cuesManager); EffectData effectData = CreateInstantEffectData( CreateModifiers([new object[] { "TestAttributeSet.Attribute1", 3f }]), - false, + CueTriggerRequirement.OnExecute, [new CueData( Tag.RequestTag(_tagsManager, "invalid.tag", false).GetSingleTagContainer(), 0, @@ -2373,7 +2451,7 @@ public void Effect_triggers_multiple_cues_with_expected_results() var entity = new TestEntity(_tagsManager, _cuesManager); EffectData effectData = CreateInstantEffectData( CreateModifiers([new object[] { "TestAttributeSet.Attribute1", 3f }]), - true, + CueTriggerRequirement.OnExecute, [new CueData(tagContainer, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1")]); var effect = new Effect(effectData, new EffectOwnership(entity, entity)); @@ -2497,7 +2575,7 @@ public void Custom_executions_sets_custom_update_cues_parameters_correctly() private static EffectData CreateInstantEffectData( Modifier[] modifiers, - bool requireModifierSuccessToTriggerCue, + CueTriggerRequirement requireModifierSuccessToTriggerCue, CueData[] cues) { return new EffectData( @@ -2511,7 +2589,7 @@ private static EffectData CreateInstantEffectData( private static EffectData CreateInfiniteEffectData( Modifier[] modifiers, bool snapshotLevel, - bool requireModifierSuccessToTriggerCue, + CueTriggerRequirement requireModifierSuccessToTriggerCue, CueData[] cues) { return new EffectData( @@ -2528,7 +2606,7 @@ private static EffectData CreateDurationPeriodicEffectData( float period, bool executeOnApplication, Modifier[] modifiers, - bool requireModifierSuccessToTriggerCue, + CueTriggerRequirement requireModifierSuccessToTriggerCue, CueData[] cues) { return new EffectData( @@ -2550,7 +2628,7 @@ private static EffectData CreateDurationStackableEffectData( int initialStack, int stackLimit, Modifier[] modifiers, - bool requireModifierSuccessToTriggerCue, + CueTriggerRequirement requireModifierSuccessToTriggerCue, bool suppressStackingCues, CueData[] cues) { diff --git a/Forge/Cues/CuesManager.cs b/Forge/Cues/CuesManager.cs index 6f3661c..21c5674 100644 --- a/Forge/Cues/CuesManager.cs +++ b/Forge/Cues/CuesManager.cs @@ -130,7 +130,7 @@ internal void ApplyCues(in EffectEvaluatedData effectEvaluatedData) EffectData effectData = effectEvaluatedData.Effect.EffectData; EntityAttributes targetAttributes = effectEvaluatedData.Target.Attributes; - if (!ShouldTriggerCue(in effectData, in targetAttributes)) + if (!ShouldTriggerCue(in effectData, in targetAttributes, CueTriggerRequirement.OnApply)) { return; } @@ -181,7 +181,7 @@ internal void ExecuteCues(in EffectEvaluatedData effectEvaluatedData) EffectData effectData = effectEvaluatedData.Effect.EffectData; EntityAttributes targetAttributes = effectEvaluatedData.Target.Attributes; - if (!ShouldTriggerCue(in effectData, in targetAttributes)) + if (!ShouldTriggerCue(in effectData, in targetAttributes, CueTriggerRequirement.OnExecute)) { return; } @@ -214,7 +214,7 @@ internal void UpdateCues(in EffectEvaluatedData effectEvaluatedData) EffectData effectData = effectEvaluatedData.Effect.EffectData; EntityAttributes targetAttributes = effectEvaluatedData.Target.Attributes; - if (!ShouldTriggerCue(in effectData, in targetAttributes)) + if (!ShouldTriggerCue(in effectData, in targetAttributes, CueTriggerRequirement.OnUpdate)) { return; } @@ -244,9 +244,15 @@ internal void UpdateCues(in EffectEvaluatedData effectEvaluatedData) private static bool ShouldTriggerCue( in EffectData effectData, - in EntityAttributes attributes) + in EntityAttributes attributes, + CueTriggerRequirement triggerRequirements) { - return !effectData.RequireModifierSuccessToTriggerCue || attributes.Any(x => x.PendingValueChange != 0); + if (!effectData.RequireModifierSuccessToTriggerCue.HasFlag(triggerRequirements)) + { + return true; + } + + return attributes.Any(x => x.PendingValueChange != 0); } private static int CalculateMagnitude( diff --git a/Forge/Effects/CueTriggerRequirement.cs b/Forge/Effects/CueTriggerRequirement.cs new file mode 100644 index 0000000..d4c9200 --- /dev/null +++ b/Forge/Effects/CueTriggerRequirement.cs @@ -0,0 +1,30 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Effects; + +/// +/// Defines the requirements for triggering different types of cues. +/// +[Flags] +public enum CueTriggerRequirement +{ + /// + /// No specific trigger requirements. + /// + None = 0, + + /// + /// Requirement for triggering cues on a persistent effect application. + /// + OnApply = 1 << 0, + + /// + /// Requirement for triggering cues update when effect updates. + /// + OnUpdate = 1 << 1, + + /// + /// Requirement for triggering cues on effect execution. + /// + OnExecute = 1 << 2, +} diff --git a/Forge/Effects/EffectData.cs b/Forge/Effects/EffectData.cs index 3d59106..5540984 100644 --- a/Forge/Effects/EffectData.cs +++ b/Forge/Effects/EffectData.cs @@ -64,9 +64,10 @@ public readonly record struct EffectData public IEffectComponent[] EffectComponents { get; } /// - /// Gets a value indicating whether this effect requires the modifier to be successful to trigger cues. + /// Gets a value indicating whether this effect requires the modifier to be successful to trigger cues for each + /// types of cues. /// - public bool RequireModifierSuccessToTriggerCue { get; } + public CueTriggerRequirement RequireModifierSuccessToTriggerCue { get; } /// /// Gets a value indicating whether this effect suppresses stacking cues. @@ -89,8 +90,8 @@ public readonly record struct EffectData /// Whether or not this effect snapshots the level at the moment of creation. /// /// The list of effects components for this effect. - /// Whether or not trigger cues only when modifiers are successfully - /// applied. + /// Flags indicating whether or not, and which types of cues are + /// are triggered when modifiers are successfully applied. /// Whether or not to trigger cues when applying stacks. /// The list of custom executions for this effect. /// The cues associated with this effect. @@ -102,7 +103,7 @@ public EffectData( PeriodicData? periodicData = null, bool snapshotLevel = true, IEffectComponent[]? effectComponents = null, - bool requireModifierSuccessToTriggerCue = false, + CueTriggerRequirement requireModifierSuccessToTriggerCue = CueTriggerRequirement.None, bool suppressStackingCues = false, CustomExecution[]? customExecutions = null, CueData[]? cues = null) From 93b31bafa5871da1d9e9613bec45acc4a33c6329 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 20 Jan 2026 22:18:59 -0300 Subject: [PATCH 78/87] Lint files --- Forge.Tests/Abilities/AbilityBehaviorTests.cs | 22 ++++++++----------- Forge.Tests/Effects/EffectsTests.cs | 1 - Forge/Abilities/Ability.cs | 2 +- Forge/Core/EntityAbilities.cs | 3 +-- 4 files changed, 11 insertions(+), 17 deletions(-) diff --git a/Forge.Tests/Abilities/AbilityBehaviorTests.cs b/Forge.Tests/Abilities/AbilityBehaviorTests.cs index b863910..e040ef2 100644 --- a/Forge.Tests/Abilities/AbilityBehaviorTests.cs +++ b/Forge.Tests/Abilities/AbilityBehaviorTests.cs @@ -674,8 +674,6 @@ public void Event_triggered_ability_multiple_events_with_PerExecution() activationCount.Should().Be(3); } - #region Magnitude Tests - [Fact] [Trait("Magnitude", null)] public void Context_contains_magnitude_when_activated_with_magnitude() @@ -723,7 +721,7 @@ public void Context_magnitude_defaults_to_zero_when_not_specified() public void Event_triggered_ability_receives_magnitude_from_event() { var entity = new TestEntity(_tagsManager, _cuesManager); - float capturedMagnitude = 0f; + var capturedMagnitude = 0f; var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); AbilityData data = CreateAbilityData( @@ -752,7 +750,7 @@ public void Event_triggered_ability_receives_magnitude_from_event() public void Typed_event_triggered_ability_receives_both_magnitude_and_data() { var entity = new TestEntity(_tagsManager, _cuesManager); - float capturedMagnitude = 0f; + var capturedMagnitude = 0f; TestEventPayload? capturedData = null; var eventTag = Tag.RequestTag(_tagsManager, "tag"); @@ -828,9 +826,9 @@ public void Magnitude_is_preserved_across_instances_in_PerExecution() handle.Activate(out _, magnitude: 30f).Should().BeTrue(); capturedMagnitudes.Should().HaveCount(3); - capturedMagnitudes[0].Should().Be(10f); - capturedMagnitudes[1].Should().Be(20f); - capturedMagnitudes[2].Should().Be(30f); + capturedMagnitudes.Should().HaveElementAt(0, 10f); + capturedMagnitudes.Should().HaveElementAt(1, 20f); + capturedMagnitudes.Should().HaveElementAt(2, 30f); } [Fact] @@ -852,12 +850,10 @@ public void PerEntity_retrigger_uses_new_magnitude() handle.Activate(out _, magnitude: 75f).Should().BeTrue(); capturedMagnitudes.Should().HaveCount(2); - capturedMagnitudes[0].Should().Be(50f); - capturedMagnitudes[1].Should().Be(75f); + capturedMagnitudes.Should().HaveElementAt(0, 50f); + capturedMagnitudes.Should().HaveElementAt(1, 75f); } - #endregion - private static AbilityHandle? Grant( TestEntity target, AbilityData data, @@ -991,9 +987,9 @@ public void OnEnded(AbilityBehaviorContext context) private sealed class TypedPayloadBehavior( Action callback) : IAbilityBehavior { - public void OnStarted(AbilityBehaviorContext context, TPayload payload) + public void OnStarted(AbilityBehaviorContext context, TPayload data) { - callback(context, payload); + callback(context, data); context.InstanceHandle.End(); } diff --git a/Forge.Tests/Effects/EffectsTests.cs b/Forge.Tests/Effects/EffectsTests.cs index b6756d0..feeeae8 100644 --- a/Forge.Tests/Effects/EffectsTests.cs +++ b/Forge.Tests/Effects/EffectsTests.cs @@ -205,7 +205,6 @@ public void Multiple_instant_effects_of_different_operations_modify_base_value_a TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1000", [20, 0, 20, 0]); } - [Theory] [Trait("Instant", null)] [InlineData("TestAttributeSet.Attribute1", 4, 5, 4, 25, -0.66f, 8, 42, 42)] diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 4ac0e12..7235ca9 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -14,7 +14,7 @@ namespace Gamesmiths.Forge.Abilities; /// /// Instance of an ability that has been granted to an entity. /// -internal class Ability +internal sealed class Ability { private record struct BehaviorBinding(IAbilityBehavior Behavior, AbilityBehaviorContext Context); diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index 47d7100..c9727da 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -358,7 +358,6 @@ private void InhibitAbilityBasedOnPolicy(Ability abilityToInhibit, AbilityDeacti } } - private void RemoveAbility(Ability abilityToRemove) { if (_removeAbility is not null) @@ -385,7 +384,7 @@ private void InhibitAbility(Ability abilityToInhibit) abilityToInhibit.OnAbilityDeactivated -= _inhibitAbility; _inhibitAbility = null; } - + abilityToInhibit.IsInhibited = CheckIsInhibited(); } From 4c75484cf57521f48a8da0f2099e0d82ac31380f Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 24 Jan 2026 14:44:48 -0300 Subject: [PATCH 79/87] Fix overridden typo --- Forge/Effects/Stacking/StackingData.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Forge/Effects/Stacking/StackingData.cs b/Forge/Effects/Stacking/StackingData.cs index 62c6dbf..ada2c99 100644 --- a/Forge/Effects/Stacking/StackingData.cs +++ b/Forge/Effects/Stacking/StackingData.cs @@ -18,12 +18,12 @@ namespace Gamesmiths.Forge.Effects.Stacking; /// How to handle applications from different owner. /// How to handle the effect's instance owner when accepting application /// from different owner. -/// How to handle the stack count when the owner is overriden. +/// How to handle the stack count when the owner is overridden. /// /// How to handle stack applications of different levels. /// How to handle the effect's instance level when accepting applications of different /// levels. -/// How to handle the stack count when the level is overriden. +/// How to handle the stack count when the level is overridden. /// What happens with the stack duration when a new stack is applied. /// What happens with periodic durations when a new stack is applied. /// Whether the effect executes when a new stack is applied. From 5aa9ec8bff2edb2c66f2c83eb6f4dc8e1a577c2a Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 24 Jan 2026 16:31:21 -0300 Subject: [PATCH 80/87] Changed methods to internal --- Forge/Effects/Magnitudes/AttributeBasedFloat.cs | 11 +---------- .../Effects/Magnitudes/CustomCalculationBasedFloat.cs | 10 +--------- Forge/Effects/Magnitudes/ModifierMagnitude.cs | 11 +---------- 3 files changed, 3 insertions(+), 29 deletions(-) diff --git a/Forge/Effects/Magnitudes/AttributeBasedFloat.cs b/Forge/Effects/Magnitudes/AttributeBasedFloat.cs index d175ea8..c273a10 100644 --- a/Forge/Effects/Magnitudes/AttributeBasedFloat.cs +++ b/Forge/Effects/Magnitudes/AttributeBasedFloat.cs @@ -36,16 +36,7 @@ public readonly record struct AttributeBasedFloat( int FinalChannel = 0, ICurve? LookupCurve = null) { - /// - /// Calculates the final magnitude based on the AttributeBasedFloat configurations. - /// - /// The source effect that will be used to capture source attributes from. - /// The target entity that will be used to capture source attributes from. - /// Level to use in the magnitude calculation. - /// The dictionary containing already captured snapshot attributes for this effect. - /// - /// The calculated magnitude for this . - public readonly float CalculateMagnitude( + internal readonly float CalculateMagnitude( Effect effect, IForgeEntity target, int level, diff --git a/Forge/Effects/Magnitudes/CustomCalculationBasedFloat.cs b/Forge/Effects/Magnitudes/CustomCalculationBasedFloat.cs index be59d4e..0e7a38e 100644 --- a/Forge/Effects/Magnitudes/CustomCalculationBasedFloat.cs +++ b/Forge/Effects/Magnitudes/CustomCalculationBasedFloat.cs @@ -28,15 +28,7 @@ public readonly record struct CustomCalculationBasedFloat( ScalableFloat PostMultiplyAdditiveValue, ICurve? LookupCurve = null) { - /// - /// Calculates the final magnitude based on the CustomCalculationBasedFloat configurations. - /// - /// The source effect that will be used for calculating this magnitude. - /// The target of the effect to be used for calculating this magnitude. - /// Level to use in the final magnitude calculation. - /// The evaluated data for the effect. - /// The calculated magnitude for this . - public float CalculateMagnitude( + internal float CalculateMagnitude( in Effect effect, IForgeEntity target, int level, diff --git a/Forge/Effects/Magnitudes/ModifierMagnitude.cs b/Forge/Effects/Magnitudes/ModifierMagnitude.cs index 855e421..b6ed7bf 100644 --- a/Forge/Effects/Magnitudes/ModifierMagnitude.cs +++ b/Forge/Effects/Magnitudes/ModifierMagnitude.cs @@ -90,16 +90,7 @@ public ModifierMagnitude( SetByCallerFloat = setByCallerFloat; } - /// - /// Gets the calculated magnitude for a given and this - /// configurations. - /// - /// The effect to calculate the magnitude for. - /// The target which might be used for the magnitude calculation. - /// The level to use in the magnitude calculation. - /// The evaluated data for the effect. - /// The evaluated magnitude. - public readonly float GetMagnitude( + internal readonly float GetMagnitude( Effect effect, IForgeEntity target, int level, From 8c4fe2cae1ce2d9d44dec874e480319a557edc14 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 24 Jan 2026 18:03:19 -0300 Subject: [PATCH 81/87] Change CanApply method to internal --- Forge/Effects/Modifiers/Modifier.cs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/Forge/Effects/Modifiers/Modifier.cs b/Forge/Effects/Modifiers/Modifier.cs index 67f5456..3bc41b3 100644 --- a/Forge/Effects/Modifiers/Modifier.cs +++ b/Forge/Effects/Modifiers/Modifier.cs @@ -19,14 +19,7 @@ public readonly record struct Modifier( ModifierMagnitude Magnitude, int Channel = 0) { - /// - /// Checks whether this modifier can be applied to the given target entity at the specified level. - /// - /// The source effect of this modifier. - /// The target entity to check against. - /// The level to be used for magnitude calculation. - /// if the modifier can be applied; otherwise, . - public bool CanApply(Effect effect, IForgeEntity target, int level) + internal bool CanApply(Effect effect, IForgeEntity target, int level) { if (!target.Attributes.ContainsAttribute(Attribute)) { From 8fc607e857e2b6cf48af91182059c63f746332a8 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 24 Jan 2026 22:59:56 -0300 Subject: [PATCH 82/87] Updated more accessibility access modifiers --- Forge/Effects/AttributeSnapshotKey.cs | 9 +-------- Forge/Effects/Effect.cs | 5 +---- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/Forge/Effects/AttributeSnapshotKey.cs b/Forge/Effects/AttributeSnapshotKey.cs index 53601dd..9fb8f96 100644 --- a/Forge/Effects/AttributeSnapshotKey.cs +++ b/Forge/Effects/AttributeSnapshotKey.cs @@ -5,14 +5,7 @@ namespace Gamesmiths.Forge.Effects; -/// -/// Key used for identifying captured attribute snapshots. -/// -/// The attribute being captured. -/// The source from which the attribute is being captured. -/// The type of calculation used for capturing the attribute. -/// The final channel to which the attribute is being captured. -public readonly record struct AttributeSnapshotKey( +internal readonly record struct AttributeSnapshotKey( StringKey Attribute, AttributeCaptureSource Source, AttributeCalculationType CalculationType, diff --git a/Forge/Effects/Effect.cs b/Forge/Effects/Effect.cs index 978fe1c..d7fa3d4 100644 --- a/Forge/Effects/Effect.cs +++ b/Forge/Effects/Effect.cs @@ -44,10 +44,7 @@ public class Effect /// public Dictionary DataTag { get; } = []; - /// - /// Gets the cached granted tags from this effect, if any. - /// - public TagContainer? CachedGrantedTags { get; } + internal TagContainer? CachedGrantedTags { get; } /// /// Initializes a new instance of the class. From e2428cec504742c49a72fc94621e94f81ba41a3d Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 24 Jan 2026 23:35:26 -0300 Subject: [PATCH 83/87] Access to ApplicationContext through method only --- Forge.Tests/Effects/EffectApplicationContextTests.cs | 4 ++-- Forge/Effects/EffectEvaluatedData.cs | 9 +-------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/Forge.Tests/Effects/EffectApplicationContextTests.cs b/Forge.Tests/Effects/EffectApplicationContextTests.cs index 8c81155..19497e4 100644 --- a/Forge.Tests/Effects/EffectApplicationContextTests.cs +++ b/Forge.Tests/Effects/EffectApplicationContextTests.cs @@ -301,7 +301,7 @@ private sealed record HealingContext(float HealAmount, HealType Type); /// private sealed class ContextAwareExecution : CustomExecution { - public EffectApplicationContext? ReceivedContext { get; private set; } + public DamageContext? ReceivedContext { get; private set; } public float ReceivedDamage { get; private set; } @@ -317,10 +317,10 @@ public override ModifierEvaluatedData[] EvaluateExecution( EffectEvaluatedData? effectEvaluatedData) { WasExecuted = true; - ReceivedContext = effectEvaluatedData?.ApplicationContext; if (effectEvaluatedData?.TryGetContextData(out DamageContext? damageContext) == true) { + ReceivedContext = damageContext; ReceivedDamage = damageContext.Damage; ReceivedIsCritical = damageContext.IsCritical; ReceivedHitLocations = damageContext.HitLocations; diff --git a/Forge/Effects/EffectEvaluatedData.cs b/Forge/Effects/EffectEvaluatedData.cs index 50af15a..f04ea28 100644 --- a/Forge/Effects/EffectEvaluatedData.cs +++ b/Forge/Effects/EffectEvaluatedData.cs @@ -71,14 +71,7 @@ public sealed class EffectEvaluatedData /// public Dictionary? CustomCueParameters { get; private set; } - /// - /// Gets the optional application context for this effect evaluation. - /// - /// - /// Contains custom data passed during effect application via - /// . - /// - public EffectApplicationContext? ApplicationContext { get; } + internal EffectApplicationContext? ApplicationContext { get; } internal Dictionary SnapshotAttributes { get; } = []; From 7279fac834c2f923b392a880db9a760c8d289509 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 25 Jan 2026 12:52:23 -0300 Subject: [PATCH 84/87] Updated documentation --- README.md | 7 +- docs/abilities.md | 282 +++++++++++++++++-------- docs/attributes.md | 1 + docs/effects/README.md | 68 +++++- docs/effects/calculators.md | 128 +++++++++-- docs/effects/components.md | 148 ++++++++++--- docs/effects/duration.md | 2 +- docs/effects/modifiers.md | 58 ++--- docs/effects/periodic.md | 408 ------------------------------------ docs/effects/stacking.md | 42 +++- docs/events.md | 57 ++--- docs/quick-start.md | 4 +- 12 files changed, 606 insertions(+), 599 deletions(-) diff --git a/README.md b/README.md index 99fa0e6..a66b34d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Forge Gameplay System +[![CI](https://github.com/gamesmiths-guild/forge/actions/workflows/validate-project.yml/badge.svg)](https://github.com/gamesmiths-guild/forge/actions/workflows/validate-project.yml) [![NuGet](https://img.shields.io/nuget/v/Gamesmiths.Forge.svg)](https://www.nuget.org/packages/Gamesmiths.Forge) +[![License](https://img.shields.io/github/license/gamesmiths-guild/forge)](LICENSE) A gameplay framework for developing games using C#. @@ -8,7 +10,7 @@ Forge is an engine-agnostic gameplay framework designed for building robust game The framework eliminates the need to rebuild status systems for every game project by offering a flexible, data-driven architecture that works seamlessly with Unity, Godot, and other C#-compatible engines. With Forge, all attribute changes are handled through effects, ensuring organized and maintainable code even in complex gameplay scenarios. -**Keywords:** gameplay system, C# game development, Unity, Godot, attribute system, status effects, gameplay abilities, data-driven +**Keywords:** gameplay framework, C#, engine-agnostic, data-driven, attributes, gameplay effects, abilities, gameplay tags ## Quick Start @@ -65,6 +67,7 @@ Forge supports a variety of gameplay mechanics through specialized subsystems: ### Planned Features 🚧 - **Multiplayer Support**: Network replication for all systems. +- **Statescript**: Backend support for state-based scripting of Ability behaviors. ## Installation @@ -82,7 +85,7 @@ Install via NuGet, reference the Forge project directly, or download the precomp Install the package via .NET CLI: ```shell -dotnet add package Gamesmiths.Forge --version 0.2.0 +dotnet add package Gamesmiths.Forge ``` Or search for `Gamesmiths.Forge` in the NuGet Package Manager UI in Visual Studio. diff --git a/docs/abilities.md b/docs/abilities.md index cbadca2..f96de17 100644 --- a/docs/abilities.md +++ b/docs/abilities.md @@ -9,6 +9,7 @@ The Abilities system in Forge provides a framework for defining, granting, activ - **Activation**: Each ability has configurable activation requirements, costs, and cooldowns. - **Instancing**: Policies control how multiple concurrent activations are handled. - **Triggers**: Activation can be triggered manually, by events, or by tag changes. +- **Interruption**: Abilities can be canceled or interrupted, with configurable behavior. - **Behaviors**: Custom logic is implemented through the `IAbilityBehavior` interface. ## Ability Data @@ -40,7 +41,7 @@ var abilityData = new AbilityData( - **Name**: Identifier for the ability. - **CostEffect**: An instant effect defining resource costs. -- **CooldownEffects**: Duration effects preventing reactivation. +- **CooldownEffects**: Duration effects with tags preventing reactivation. - **AbilityTags**: Tags identifying this ability for blocking/cancellation. - **InstancingPolicy**: Controls concurrent activation handling. - **RetriggerInstancedAbility**: Restarts persistent instances on re-activation. @@ -67,10 +68,12 @@ Use `GrantAbilityEffectComponent` to grant abilities that are tied to an effect' ```csharp var grantAbilityConfig = new GrantAbilityConfig( abilityData, - abilityLevel: new ScalableInt(1, curve: myLevelCurve), - grantedAbilityRemovalPolicy: AbilityDeactivationPolicy.CancelImmediately, - grantedAbilityInhibitionPolicy: AbilityDeactivationPolicy.CancelImmediately, - levelOverridePolicy: LevelComparison.Higher); + ScalableLevel: new ScalableInt(1, ScalingCurve: myLevelCurve), + RemovalPolicy: AbilityDeactivationPolicy.CancelImmediately, + InhibitionPolicy: AbilityDeactivationPolicy.CancelImmediately, + TryActivateOnGrant = false, + TryActivateOnEnable = false, + LevelOverridePolicy: LevelComparison.Higher); var grantComponent = new GrantAbilityEffectComponent([grantAbilityConfig]); @@ -87,6 +90,8 @@ entity.EffectsManager.ApplyEffect(new Effect(grantEffect, ownership, level: 5)); Abilities granted by **instant effects** become permanent, while abilities granted by **duration or infinite effects** are temporary and tied to the effect's lifecycle. +`TryActivateOnGrant` attempts to activate the ability immediately when it is granted, while `TryActivateOnEnable` attempts activation when the granting effect is re-enabled after inhibition. + ### Granting Permanently There are three ways to grant an ability that persists permanently: @@ -136,6 +141,54 @@ The ability grant is automatically removed when the ability ends. If activation Each time an ability is granted, a **grant source** is created that tracks how that specific grant should behave. An ability can have multiple grant sources if it's granted multiple times (e.g., by different effects or methods). +### Multiple Grant Sources + +If an ability is granted by multiple sources, it remains granted until all sources are removed: + +```csharp +// Apply two effects that grant the same ability +ActiveEffectHandle? effectHandle0 = entity.EffectsManager.ApplyEffect(grantEffect1); +ActiveEffectHandle? effectHandle1 = entity.EffectsManager.ApplyEffect(grantEffect2); + +// Only one ability instance exists +entity.Abilities.GrantedAbilities.Count; // 0 + +// Remove first grant - ability still exists +entity.EffectsManager.RemoveEffect(effectHandle0); +entity.Abilities.GrantedAbilities.Count; // 0 + +// Remove second grant - now the ability is removed +entity.EffectsManager.RemoveEffect(effectHandle1); +entity.Abilities.GrantedAbilities.Count; // -1 +``` + +### Level Override Policy + +When an ability is granted multiple times, the `LevelOverridePolicy` determines whether the level should be updated: + +```csharp +// First grant at level 2 +var config1 = new GrantAbilityConfig(abilityData, new ScalableInt(2), ...); +entity.EffectsManager.ApplyEffect(grantEffect1); +// handle.Level == 2 + +// Second grant at level 3 with Higher policy: level updates +var config2 = new GrantAbilityConfig( + abilityData, + new ScalableInt(3), + levelOverridePolicy: LevelComparison.Higher, ...); +entity.EffectsManager.ApplyEffect(grantEffect2); +// handle.Level == 3 + +// Third grant at level 1 with Higher policy: level stays at 3 +var config3 = new GrantAbilityConfig( + abilityData, + new ScalableInt(1), + levelOverridePolicy: LevelComparison.Higher, ...); +entity.EffectsManager.ApplyEffect(grantEffect3); +// handle.Level == 3 +``` + ### Deactivation Policies `AbilityDeactivationPolicy` controls behavior when a grant source is removed or inhibited: @@ -185,55 +238,7 @@ entity.EffectsManager.RemoveEffect(effectHandle2); 1. **Multiple sources, one removed**: The ability remains granted as long as at least one grant source exists. 2. **CancelImmediately takes precedence**: If any remaining grant source has `CancelImmediately` policy when removed, it will cancel the ability immediately regardless of other sources' policies. -3. **Inhibition is cumulative**: The ability is only inhibited when ALL non-ignored grant sources are inhibited. - -### Multiple Grant Sources - -If an ability is granted by multiple sources, it remains granted until all sources are removed: - -```csharp -// Apply two effects that grant the same ability -ActiveEffectHandle? effectHandle1 = entity.EffectsManager.ApplyEffect(grantEffect1); -ActiveEffectHandle? effectHandle2 = entity.EffectsManager.ApplyEffect(grantEffect2); - -// Only one ability instance exists -entity.Abilities.GrantedAbilities.Count; // 1 - -// Remove first grant - ability still exists -entity.EffectsManager.RemoveEffect(effectHandle1); -entity.Abilities.GrantedAbilities.Count; // 1 - -// Remove second grant - now the ability is removed -entity.EffectsManager.RemoveEffect(effectHandle2); -entity.Abilities.GrantedAbilities.Count; // 0 -``` - -### Level Override Policy - -When an ability is granted multiple times, the `LevelOverridePolicy` determines whether the level should be updated: - -```csharp -// First grant at level 2 -var config1 = new GrantAbilityConfig(abilityData, new ScalableInt(2), ...); -entity.EffectsManager.ApplyEffect(grantEffect1); -// handle.Level == 2 - -// Second grant at level 3 with Higher policy: level updates -var config2 = new GrantAbilityConfig( - abilityData, - new ScalableInt(3), - levelOverridePolicy: LevelComparison.Higher, ... ); -entity.EffectsManager.ApplyEffect(grantEffect2); -// handle.Level == 3 - -// Third grant at level 1 with Higher policy: level stays at 3 -var config3 = new GrantAbilityConfig( - abilityData, - new ScalableInt(1), - levelOverridePolicy: LevelComparison.Higher, ...); -entity.EffectsManager.ApplyEffect(grantEffect3); -// handle.Level == 3 -``` +3. **Inhibition is cumulative**: The ability is only inhibited when ALL non-ignored grant sources are inhibited. ## Entity Abilities Manager @@ -252,7 +257,7 @@ EntityTags blockedTags = abilities.BlockedAbilityTags; ### Finding Abilities -Use `TryGetAbility` to find a granted ability by its data. +Use `TryGetAbility` to find a granted ability by its data. **Note on Identity:** An ability is uniquely identified by its `AbilityData` **and** its `SourceEntity`. You can have the same ability granted multiple times if the sources differ (e.g., one from an Item, one from a Class). @@ -353,20 +358,20 @@ if (entity.Abilities.TryGetAbility(abilityData, out AbilityHandle? handle)) ### Handle Properties and Methods -- **IsActive**: Whether any instance of the ability is currently active. +- **IsActive**: Whether any instance of the ability is currently active. - **IsInhibited**: Whether the ability is inhibited by its granting effect. - **IsValid**: Whether the handle still references a valid granted ability. -- **Level**: The current level of the ability. -- **Activate(out failureFlags)**: Attempt to activate the ability. Returns true if successful. -- **Activate(out failureFlags, target)**: Attempt to activate with a specific target. -- **Cancel()**: Cancel all active instances. +- **Level**: The current level of the ability. +- **Activate(out failureFlags, target?, magnitude?)**: Attempt to activate the ability with optional target and magnitude. +- **Activate(data, out failureFlags, target?, magnitude?)**: Attempt to activate the ability passing additional typed activation data. +- **Cancel()**: Cancel all active instances. - **CommitAbility()**: Helper that calls both `CommitCooldown()` and `CommitCost()`. - **CommitCooldown()**: Apply the cooldown effects. -- **CommitCost()**: Apply the cost effect. +- **CommitCost()**: Apply the cost effect. - **GetCooldownData()**: Get information about all cooldowns. - **GetRemainingCooldownTime(tag)**: Get remaining time for a specific cooldown. - **GetCostData()**: Get information about all costs. -- **GetCostForAttribute(attribute)**: Get cost for a specific attribute. +- **GetCostForAttribute(attribute)**: Get cost for a specific attribute. ### Activation Failures @@ -374,16 +379,16 @@ if (entity.Abilities.TryGetAbility(abilityData, out AbilityHandle? handle)) - **None**: Successfully activated. - **InvalidHandler**: The ability handle is invalid. -- **Inhibited**: Ability is inhibited by its granting effect. -- **PersistentInstanceActive**: A non-retriggerable persistent instance is already active. +- **Inhibited**: Ability is inhibited by its granting effect. +- **PersistentInstanceActive**: A non-retriggerable persistent instance is already active. - **Cooldown**: Ability is on cooldown. - **InsufficientResources**: Cannot afford the cost. - **OwnerTagRequirements**: Owner doesn't meet tag requirements. - **SourceTagRequirements**: Source doesn't meet tag requirements. - **TargetTagRequirements**: Target doesn't meet tag requirements. -- **BlockedByTags**: Another active ability is blocking this one. +- **BlockedByTags**: Another active ability is blocking this one. - **TargetTagNotPresent**: No abilities matched the requested tags (when using `TryActivateAbilitiesByTag`). -- **InvalidTagConfiguration**: Invalid tag configuration provided. +- **InvalidTagConfiguration**: Invalid tag configuration provided. ## Instancing Policies @@ -404,8 +409,6 @@ var abilityData = new AbilityData( With `retriggerInstancedAbility: false`, attempting to activate while active fails with `AbilityActivationFailures.PersistentInstanceActive`. -With `retriggerInstancedAbility: true`, the active instance is canceled and a new one starts: - ```csharp var abilityData = new AbilityData( "Channeled Beam", @@ -413,6 +416,8 @@ var abilityData = new AbilityData( retriggerInstancedAbility: true); ``` +With `retriggerInstancedAbility: true`, the active instance is canceled and a new one starts: + ### PerExecution Multiple instances can be active simultaneously: @@ -439,7 +444,7 @@ Cooldowns prevent ability reactivation for a duration. They are implemented as d - Cooldown effects **must** have a Duration (not Instant, not Infinite). - Cooldown effects **must** have a `ModifierTagsEffectComponent`. -The system receives an array of cooldown effects, allowing you to trigger multiple independent cooldowns at once (e.g., a short "Skill Cooldown" and a longer "Global Cooldown"). +The system receives an array of cooldown effects, allowing you to trigger multiple independent cooldowns at once (e.g., a long "Skill Cooldown" and a shorter "Global Cooldown"). ```csharp var cooldownEffect = new EffectData( @@ -457,7 +462,7 @@ var abilityData = new AbilityData( Multiple cooldown effects can be used for abilities with multiple cooldown conditions: ```csharp -// Ability has both a short cooldown and a charge system +// Ability has both a long cooldown and a global cooldown var abilityData = new AbilityData( "Dash", cooldownEffects: [dashCooldownEffect, globalCooldownEffect]); @@ -492,7 +497,7 @@ Costs are instant effects that modify attributes when committed. **Validation Logic**: Cost modifiers are validated against the attribute's configured min/max bounds: -- If the modifier is **negative** (consumption), it tests against the attribute's **Minimum Value** (e.g., Do I have enough Mana to pay -30 without going below 0?) +- If the modifier is **negative** (consumption), it tests against the attribute's **Minimum Value**. (e.g., Do I have enough Mana to pay -30 without going below 0?) - If the modifier is **positive** (restoration), it tests against the attribute's **Maximum Value**. (e.g., Is my Health low enough to receive +50 healing without exceeding Max Health?) You can add multiple modifiers to the single `CostEffect`, allowing an ability to consume multiple different attributes (e.g., Mana and Health). @@ -519,11 +524,11 @@ Cost is checked during activation but only applied when `CommitCost()` or `Commi ### Developer Responsibilities -1. **Ending Instances**: It is up to the developer to call `context.InstanceHandle.End()` when the ability logic is complete. If you fail to do this, the system will consider the ability "Active" indefinitely. -2. **Committing**: Resources and Cooldowns are not applied automatically. You must call `context.AbilityHandle.CommitAbility()` (or `CommitCost` / `CommitCooldown` separately). - * `CommitAbility()` calls both `CommitCost()` and `CommitCooldown()`. - * Do **not** call all three; it is redundant. - * Deferring commits allows for mechanics like "free cast if cancelled early." +1. **Ending Instances**: It is up to the developer to call `context.InstanceHandle.End()` when the ability logic is complete. If you fail to do this, the system will consider the ability "Active" indefinitely. +2. **Committing**: Resources and Cooldowns are not applied automatically. You must call `context.AbilityHandle.CommitAbility()` (or `CommitCost` / `CommitCooldown` separately). + - `CommitAbility()` calls both `CommitCost()` and `CommitCooldown()`. + - Do **not** call all three; it is redundant. + - Deferring commits allows for mechanics like "free cast if cancelled early." **Note**: It is entirely possible to **not end** an ability. This is useful for passive abilities or toggles that should run continuously until cancelled externally or by tag triggers. @@ -544,14 +549,14 @@ public class FireballBehavior : IAbilityBehavior // This calls both CommitCooldown() and CommitCost() abilityHandle.CommitAbility(); - // Spawn projectile, start animation, etc. + // Spawn projectile, start animation, etc. SpawnFireball(owner, target, level); } public void OnEnded(AbilityBehaviorContext context) { // Called when the ability instance ends - // Clean up effects, stop animations, etc. + // Clean up effects, stop animations, etc. } } ``` @@ -564,8 +569,17 @@ public class FireballBehavior : IAbilityBehavior - **Source**: The entity that granted this ability (may be null). - **Target**: The target passed during activation (may be null). - **Level**: The ability's current level. -- **AbilityHandle**: Handle to the ability for committing cost/cooldown. +- **AbilityHandle**: Handle to the ability for committing cost/cooldown. - **InstanceHandle**: Handle to this specific instance for ending it. +- **Magnitude**: A numeric value associated with the activation attempt. + +### Behavior Context `` + +In addition to the core fields, the generic behavior context also carries: + +- **Data**: Optional strongly-typed activation data when using generic activation or event triggers. + +This context is primarily consumed by behaviors implementing `IAbilityBehavior`, allowing abilities to react to activation-specific data. ### Ending Instances @@ -721,9 +735,110 @@ var grantEffect = new EffectData( // Activation fails with AbilityActivationFailures.Inhibited ``` -With `GrantedAbilityInhibitionPolicy.RemoveOnEnd`, an active ability continues running but becomes inhibited after it ends. +With `GrantedAbilityInhibitionPolicy.RemoveOnEnd`, an active ability continues running but becomes inhibited after it ends. + +Abilities granted permanently via `GrantAbilityPermanently` cannot be inhibited. + +## Ability Activation Context + +Ability activation supports passing additional contextual information at runtime. This context represents **dynamic execution data**, not static ability configuration. + +Forge exposes this data through the ability behavior context during activation. + +### Magnitude + +`Magnitude` is a numeric value associated with an activation attempt. + +- It can be passed explicitly when calling `AbilityHandle.Activate(...)`. +- It is automatically populated when abilities are triggered by **Event Triggers**. +- It is accessible via `context.Magnitude` inside the behavior. + +Typical use cases include damage scaling, impulse strength, or contextual intensity values. + +### Strongly-Typed Activation Data + +For cases where a numeric magnitude is not sufficient, abilities can receive strongly-typed activation data. + +This is done using the generic activation method: + +```csharp +handle.Activate( + new HitLocationData(HitZone.Head), + out AbilityActivationFailures failures, + target: enemy); +``` + +When using this overload, Forge automatically creates an `AbilityBehaviorContext` instance. + +### AbilityBehaviorContext + +When activated with typed data, the behavior receives an `AbilityBehaviorContext`, which provides: + +- All standard ability context fields. +- Strongly-typed activation data via `context.Data`. + +```csharp +public sealed class HitReactionBehavior : IAbilityBehavior +{ + public void OnStarted(AbilityBehaviorContext context, HitLocationData data) + { + context.AbilityHandle.CommitAbility(); + + switch (data.Zone) + { + case HitZone.Head: + ApplyCriticalDamage(context.Target); + break; + + case HitZone.Arm: + ApplyDisarm(context.Target); + break; + + case HitZone.Leg: + ApplySlow(context.Target); + break; + + default: + ApplyBaseDamage(context.Target); + break; + } + + context.InstanceHandle.End(); + } + + public void OnEnded(AbilityBehaviorContext context) + { + // Cleanup if needed + } +} + +``` + +### Event Triggers and Context Propagation + +Abilities triggered by Event Triggers are the only automatic source of activation context. + +- `EventMagnitude` is mapped to `context.Magnitude`. +- `EventData.Payload` is mapped to `context.Data`. + +This allows external systems to inject runtime context into abilities without direct activation calls. + +```csharp +entity.Events.Raise(new EventData +{ + EventTags = hitEventTags, + Target = enemy, + EventMagnitude = 1.0f, + Payload = new HitLocationData(HitZone.Arm) +}); +``` + +### Context Design Guidelines -Abilities granted permanently via `GrantAbilityPermanently` cannot be inhibited. +- Context data should represent execution-specific state. +- Do not use activation data for static ability configuration. +- Prefer typed data over loosely structured objects. +- Event Triggers are ideal for world-driven context injection. ## Best Practices @@ -734,8 +849,9 @@ Abilities granted permanently via `GrantAbilityPermanently` cannot be inhibited. 5. **Handle Failure Flags**: Use the `AbilityActivationFailures` flags to provide specific feedback to the player (e.g. check for `Cooldown` and `InsufficientResources`). 6. **Clean Up in OnEnded**: Always clean up spawned objects, effects, and state in `OnEnded`. 7. **Use Tag Requirements**: Leverage tag-based requirements for complex activation conditions. -8. **Consider Policy Interactions**: When granting abilities from multiple sources, be aware that `CancelImmediately` policies take precedence. -9. **Query Before Activation**: Use `GetCooldownData()` and `GetCostData()` to show UI state before attempting activation. +8. **Consider Policy Interactions**: When granting abilities from multiple sources, be aware that `CancelImmediately` policies take precedence. +9. **Query Before Activation**: Use `GetCooldownData()` and `GetCostData()` to show UI state before attempting activation. 10. **Use Permanent Grants for Innate Abilities**: Use `GrantAbilityPermanently` for abilities that should always be available. 11. **Use Tag-Based Activation**: Use `TryActivateAbilitiesByTag` for flexible input handling where multiple abilities share activation contexts. 12. **Check Validation Rules**: Ensure cooldowns have durations/tags and costs are instant. +13. **Use Activation Context for Runtime Data**: Pass external execution data via activation context, preferring typed data. diff --git a/docs/attributes.md b/docs/attributes.md index 81651db..c1b7759 100644 --- a/docs/attributes.md +++ b/docs/attributes.md @@ -10,6 +10,7 @@ For a practical guide on using attributes, see the [Quick Start Guide](quick-sta An `EntityAttribute` represents a single numeric property with constraints and modification tracking: +- **Key**: Identifier in the format `.` (e.g. `CombatAttributeSet.MaxHealth`). - **BaseValue**: The fundamental value before modifications. - **CurrentValue**: The actual value after all modifications, constrained by Min/Max. - **Min/Max**: The lower and upper bounds for the attribute. diff --git a/docs/effects/README.md b/docs/effects/README.md index e64ce09..3dd10be 100644 --- a/docs/effects/README.md +++ b/docs/effects/README.md @@ -84,14 +84,14 @@ The `UpdateEffects` method must be called regularly to process all active effect ### ActiveEffectHandle -When a non-instant effect is applied, the `EffectsManager` returns an `ActiveEffectHandle` that provides control over the effect's lifecycle. +When a non-instant effect is applied, the `EffectsManager` returns an `ActiveEffectHandle` that provides control and runtime access to the applied effect instance. ```csharp // Apply a buff and get its handle ActiveEffectHandle? buffHandle = target.EffectsManager.ApplyEffect(buffEffect); // Check if application was successful -if (buffHandle != null) +if (buffHandle is not null) { // Store the handle for later control _activeBuffs.Add(buffHandle); @@ -101,7 +101,31 @@ if (buffHandle != null) } ``` +#### Public Properties + +- **IsInhibited**: Indicates whether the effect is currently inhibited (e.g. due to tag changes or other logic). +- **IsValid**: Indicates whether the handle is valid (the effect is still active). +- **ComponentInstances**: The actual per-application component instances for this effect. These may hold state unique to this particular effect instance (such as granted abilities or subscriptions). + +#### Public Methods + +- **SetInhibit(bool value)**: Sets the inhibition status of the effect (e.g., to temporarily pause its action without removing it). +- **GetComponent()**: Returns the first component instance of type `T` attached to this effect, or `null` if not found. + + Useful for retrieving a specific effect component's runtime state. + + ```csharp + // Retrieve a component instance by type + var grantComponent = handle.GetComponent(); + if (grantComponent is not null) + { + var abilities = grantComponent.GrantedAbilities; + // Use abilities granted by this effect instance + } + ``` + Other removal methods exist but are less precise: + ```csharp // Removes first effect instance matching the Effect entity.EffectsManager.RemoveEffect(effect); @@ -260,12 +284,12 @@ var effectData = new EffectData( name: "Fireball", // Human-readable name for debugging and UI modifiers: [/*...*/], durationData: new DurationData(DurationType.Instant), - snapshopLevel: true // Whether to lock the effect's level when applied + snapshotLevel: true // Whether to lock the effect's level when applied ); ``` - **Name**: Identifies the effect for debugging, UI display, and designer reference. -- **SnapshopLevel**: Controls how the effect responds to level changes after application. +- **SnapshotLevel**: Controls how the effect responds to level changes after application. - `true`: Effect uses the level it had when initially applied (default). - `false`: Effect dynamically updates when the effect's level changes—all modifiers, durations, periods, and other scalable values will automatically adjust if they use curves. @@ -523,7 +547,7 @@ var cueEnabledEffectData = new EffectData( "Fire Strike", durationData: new DurationData(DurationType.Instant), modifiers: [/*...*/], - requireModifierSuccessToTriggerCue: true, // Only trigger if damage was dealt + requireModifierSuccessToTriggerCue: CueTriggerRequirement.OnExecute, // Only trigger if damage was dealt suppressStackingCues: false, // Always trigger cues on stack changes cues: [ new CueData( @@ -538,9 +562,15 @@ var cueEnabledEffectData = new EffectData( **Cue-related properties:** -- **RequireModifierSuccessToTriggerCue**: When true, cues only trigger if at least one attribute was successfully modified. +- **RequireModifierSuccessToTriggerCue**: Specifies for which effect lifecycle events a cue should only trigger if at least one attribute was successfully modified. + - `None`: No modifier success required; cues may trigger regardless of success. + - `OnApply`: Only trigger cues on application if at least one attribute is modified. + - `OnUpdate`: Only trigger cues on update if at least one attribute is modified. + - `OnExecute`: Only trigger cues on execution if at least one attribute is modified. - **SuppressStackingCues**: When true, cues don't trigger when stacks are applied (only on initial application). +`RequireModifierSuccessToTriggerCue` uses the flags enum `CueTriggerRequirement`, allowing you to define for which lifecycle events modifier success should be required before cues trigger. This provides granular control over visual, audio, and UI feedback. + **Example use cases:** - Visual effects for damage, healing, buffs. @@ -548,6 +578,32 @@ var cueEnabledEffectData = new EffectData( - Camera effects for important gameplay moments. - UI indicators for effect application/removal. +## Effect Activation Context + +Effects can be applied with custom activation context data using the generic `ApplyEffect` method on `EffectsManager`. This allows you to pass arbitrary, strongly-typed context through the effect pipeline to custom calculators and custom executions. + +### Applying Effects With Context Data + +Use `ApplyEffect(Effect effect, TData contextData)` to apply an effect and provide custom runtime data for advanced logic: + +```csharp +var contextData = new DamageContext(damage: 50, isCritical: true); +entity.EffectsManager.ApplyEffect(effect, contextData); +``` + +### Accessing Activation Context in Custom Calculators + +Custom context data provided at activation is accessible from `EffectEvaluatedData` by using the `TryGetContextData(out TData? data)` method. This method is designed for use within custom calculators and custom executions. + +```csharp +if (effectEvaluatedData.TryGetContextData(out DamageContext? data)) +{ + // Use data.Damage, data.IsCritical, etc. +} +``` + +Custom context data is most commonly consumed by [custom calculators and custom executions](calculators.md) for advanced behaviors. For thorough documentation and best practices on using effect activation context data, see the [Custom Calculators documentation](calculators.md#effect-activation-context). + ## Best Practices 1. **Reuse Effect Definitions**: Create a library of effect templates for consistency. diff --git a/docs/effects/calculators.md b/docs/effects/calculators.md index 7044134..6e2ae1c 100644 --- a/docs/effects/calculators.md +++ b/docs/effects/calculators.md @@ -1,6 +1,6 @@ # Custom Calculators -Custom Calculators in Forge enables developers to implement complex, dynamic calculations for effect modifiers. These calculators provide a powerful way to create game-specific logic that goes beyond the built-in [modifier types](modifiers.md#magnitude-calculation-types). +Custom Calculators in Forge enable developers to implement complex, dynamic calculations for effect modifiers. These calculators provide a powerful way to create game-specific logic that goes beyond the built-in [modifier types](modifiers.md#magnitude-calculation-types). ## Core Concepts @@ -16,6 +16,7 @@ public abstract class CustomCalculator AttributeCaptureDefinition capturedAttribute, Effect effect, IForgeEntity? target, + EffectEvaluatedData? effectEvaluatedData, AttributeCalculationType calculationType = AttributeCalculationType.CurrentValue, int finalChannel = 0); @@ -36,6 +37,18 @@ Forge offers two primary calculator types that inherit from `CustomCalculator`: ## Key Components +### EffectEvaluatedData + +Most custom calculator methods, such as `CalculateBaseMagnitude` and `EvaluateExecution`, take an `EffectEvaluatedData? effectEvaluatedData` parameter. This object encapsulates all the computed and contextual data for an effect application at that moment. + +**Why is EffectEvaluatedData important?** + +- It caches and provides all the results and inputs computed for the current effect application, including attribute values, modifiers, levels, stacks, durations, and more. +- It is the central place for storing and re-accessing the current state of the applied effect, which is crucial for accurate and consistent logic when recalculating due to non-snapshot attribute dependencies or stack/level changes. +- When any non-snapshot property (like a non-snapshotted AttributeCaptureDefinition) changes in the game, Forge will re-evaluate the effect and update its `EffectEvaluatedData` accordingly. + +Pass `effectEvaluatedData` to all helper and custom methods that need contextual evaluation data, so they always use the freshest relevant information and respect the proper state and dependencies of the effect system. This ensures correct operation and optimal performance in both realtime and turn-based games. + ### Attribute Capture The `AttributeCaptureDefinition` struct is central to retrieving attribute values from the [Attributes system](../attributes.md): @@ -92,11 +105,11 @@ public class MyCalculator : CustomModifierMagnitudeCalculator AttributesToCapture.Add(TargetArmor); } - public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target) + public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData) { // 4. Use CaptureAttributeMagnitude to safely get values - int health = CaptureAttributeMagnitude(SourceHealth, effect, target); - int armor = CaptureAttributeMagnitude(TargetArmor, effect, target); + int health = CaptureAttributeMagnitude(SourceHealth, effect, target, effectEvaluatedData); + int armor = CaptureAttributeMagnitude(TargetArmor, effect, target, effectEvaluatedData); // Your calculation logic... return health * (1.0f - (armor / 200.0f)); @@ -120,11 +133,14 @@ The `CaptureAttributeMagnitude` method: - `AttributeCalculationType.MagnitudeEvaluatedUpToChannel`: Gets the value calculated up to a specific channel (requires finalChannel parameter). Example with a specific calculation type: + +```csharp // Get the valid modifier value (total modifier without overflow) int validModifier = CaptureAttributeMagnitude( StrengthAttribute, effect, target, + effectEvaluatedData, AttributeCalculationType.ValidModifier); // Get magnitude calculated up to a specific channel @@ -132,9 +148,12 @@ int channelMagnitude = CaptureAttributeMagnitude( StrengthAttribute, effect, target, + effectEvaluatedData, AttributeCalculationType.MagnitudeEvaluatedUpToChannel, finalChannel: 2); -Even when not using non-snapshot functionality, it's recommended to follow this pattern to ensure consistent and safe attribute access. +``` + +Even when capturing with snapshot, it's recommended to follow this pattern to ensure consistent and safe attribute access. ### ModifierEvaluatedData @@ -222,9 +241,9 @@ public class DamageCalculator : CustomModifierMagnitudeCalculator CustomCueParameters.Add("cues.damage.amount", 0); } - public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target) + public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData) { - int strength = CaptureAttributeMagnitude(AttackerStrength, effect, target); + int strength = CaptureAttributeMagnitude(AttackerStrength, effect, target, effectEvaluatedData); // Check for critical hit bool isCritical = new Random().NextDouble() < 0.2; @@ -283,11 +302,11 @@ public class MyDamageCalculator : CustomModifierMagnitudeCalculator CustomCueParameters.Add("cues.damage.type", "physical"); } - public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target) + public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData) { // Get attribute values - int strength = CaptureAttributeMagnitude(StrengthAttribute, effect, target); - int agility = CaptureAttributeMagnitude(AgilityAttribute, effect, target); + int strength = CaptureAttributeMagnitude(StrengthAttribute, effect, target, effectEvaluatedData); + int agility = CaptureAttributeMagnitude(AgilityAttribute, effect, target, effectEvaluatedData); // Custom calculation logic float baseDamage = strength * 0.7f + agility * 0.3f; @@ -401,14 +420,14 @@ public class ManaDrainExecution : CustomExecution CustomCueParameters.Add("cues.spell.transfer_amount", 0f); } - public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target) + public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData) { var results = new List(); // Get attribute values - int targetMana = CaptureAttributeMagnitude(TargetCurrentMana, effect, target); - int magicResist = CaptureAttributeMagnitude(TargetMagicResist, effect, target); - int intelligence = CaptureAttributeMagnitude(SourceIntelligence, effect, target); + int targetMana = CaptureAttributeMagnitude(TargetCurrentMana, effect, target, effectEvaluatedData); + int magicResist = CaptureAttributeMagnitude(TargetMagicResist, effect, target, effectEvaluatedData); + int intelligence = CaptureAttributeMagnitude(SourceIntelligence, effect, target, effectEvaluatedData); // Calculate mana drain amount (reduced by magic resistance) float resistFactor = 1.0f - (magicResist / 200.0f); // 200 resist = 100% reduction @@ -499,9 +518,9 @@ public class QuestDamageCalculator : CustomModifierMagnitudeCalculator CustomCueParameters.Add("cues.quest.target_bonus", false); } - public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target) + public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData) { - float baseDamage = CaptureAttributeMagnitude(BaseDamage, effect, target); + float baseDamage = CaptureAttributeMagnitude(BaseDamage, effect, target, effectEvaluatedData); // Check if target is related to an active quest if (target is IQuestTarget questTarget && _questManager.IsTargetForActiveQuest(questTarget.QuestTargetId)) @@ -548,11 +567,11 @@ public class ComboAttackExecution : CustomExecution CustomCueParameters.Add("cues.combat.combo_damage", 0f); } - public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target) + public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData) { var results = new List(); - int strength = CaptureAttributeMagnitude(AttackerStrength, effect, target); + int strength = CaptureAttributeMagnitude(AttackerStrength, effect, target, effectEvaluatedData); int comboCount = _comboSystem.GetCurrentComboCount(effect.Ownership.Owner); // Calculate combo damage @@ -582,6 +601,79 @@ public class ComboAttackExecution : CustomExecution } ``` +## Effect Activation Context + +Custom activation context enables you to pass dynamic, strongly-typed data to your custom calculator or execution logic when applying an effect. This is especially useful for effects that depend on user input, situational gameplay, or real-time information beyond static effect definition. + +To provide context data, use the generic `ApplyEffect` method from the `EffectsManager`: + +```csharp +var hitLocationData = new HitLocationData(HitZone.Head); +entity.EffectsManager.ApplyEffect(effect, hitLocationData); +``` + +Inside your `CustomModifierMagnitudeCalculator` or `CustomExecution`, retrieve the context data using `effectEvaluatedData.TryGetContextData(out T? data)`: + +### Practical Example: Bonus Damage on Headshot + +Suppose you want a damage effect that doubles its damage when applied as a headshot. You could define the following context struct: + +```csharp +public readonly record struct HitLocationData(HitZone Zone); + +public enum HitZone { Head, Torso, Arm, Leg } +``` + +And a custom calculator like this: + +```csharp +public class HeadshotDamageCalculator : CustomModifierMagnitudeCalculator +{ + public AttributeCaptureDefinition SourceWeaponDamage { get; } + + public HeadshotDamageCalculator() + { + SourceWeaponDamage = new AttributeCaptureDefinition( + "WeaponAttributeSet.Damage", AttributeCaptureSource.Source, snapshot: true); + AttributesToCapture.Add(SourceWeaponDamage); + } + + public override float CalculateBaseMagnitude( + Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData) + { + int damage = CaptureAttributeMagnitude(SourceWeaponDamage, effect, target, effectEvaluatedData); + + // Default is single damage + float finalDamage = damage; + + // Check activation context for hit location + if (effectEvaluatedData?.TryGetContextData(out HitLocationData? hitLocation) + && hitLocation.Zone == HitZone.Head) + { + finalDamage *= 2.0f; // Double damage for headshots + CustomCueParameters["cues.damage.headshot"] = true; + } + else + { + CustomCueParameters["cues.damage.headshot"] = false; + } + + return -finalDamage; // Negative for damage + } +} +``` + +When applying the effect, specify the hit location: + +```csharp +// Apply the effect as a headshot +entity.EffectsManager.ApplyEffect(effect, new HitLocationData(HitZone.Head)); +// Apply the effect as a torso shot +entity.EffectsManager.ApplyEffect(effect, new HitLocationData(HitZone.Torso)); +``` + +This pattern ensures that your custom logic can access dynamic activation information whenever the effect is applied, enabling rich scenario-driven gameplay without muddying your effect definitions. + ## Debugging Custom Calculators When debugging issues with custom calculators: diff --git a/docs/effects/components.md b/docs/effects/components.md index 81b8b05..def46c2 100644 --- a/docs/effects/components.md +++ b/docs/effects/components.md @@ -27,8 +27,10 @@ To create a custom component, implement the `IEffectComponent` interface: ```csharp public interface IEffectComponent { + IEffectComponent CreateInstance(); bool CanApplyEffect(in IForgeEntity target, in Effect effect); bool OnActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData); + void OnPostActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData); void OnActiveEffectUnapplied(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData, bool removed); void OnActiveEffectChanged(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData); void OnEffectApplied(IForgeEntity target, in EffectEvaluatedData effectEvaluatedData); @@ -40,6 +42,31 @@ The interface provides default implementations for all methods, so you only need ### Component Lifecycle Methods +#### CreateInstance + +`CreateInstance` is called when the effect is applied and allows the component to provide either a shared (stateless) instance or return a new instance for per-application (stateful) data. + +Override this method when your component holds data that must be isolated per-effect application, such as event subscriptions or runtime counters. + +```csharp +public class ExampleComponent : IEffectComponent +{ + private int _someState; + + public IEffectComponent CreateInstance() + { + // Return a new instance so each effect application has its own state + return new ExampleComponent(); + } +} +``` + +Use cases: + +- Tracking data or resources that must not be shared across multiple effect instances. +- Managing event subscriptions or references tied to a specific application of an effect. +- Ensuring thread safety or isolation when effects are applied to different targets simultaneously. + #### CanApplyEffect Called during the validation phase to determine if an effect can be applied. Return `false` to block the application. @@ -76,6 +103,30 @@ Use cases: - Setting up event subscriptions. - Initializing effect-specific game state. +#### OnPostActiveEffectAdded + +`OnPostActiveEffectAdded` is called after all components’ `OnActiveEffectAdded` callbacks have completed, and the effect has finished its initial application logic. At this point, the effect is fully initialized. + +Override this method to perform actions that rely on other components being initialized, or when you need to trigger behaviors that should occur after the effect is completely active. + +```csharp +public void OnPostActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) +{ + // Logic here runs after all initialization and validation is complete + // For example, attempt activation if not inhibited + if (!activeEffectEvaluatedData.ActiveEffectHandle.IsInhibited) + { + // Custom post-activation logic + } +} +``` + +Use cases: + +- Conditionally activating abilities granted by earlier components. +- Synchronizing with other components after full effect application. +- Triggering animations, particles, or gameplay effects that should be delayed until the effect is stable. + #### OnActiveEffectUnapplied Called when an effect is unapplied or a stack is removed. @@ -160,9 +211,10 @@ Example custom component: // Component that tracks damage thresholds and applies additional effects public class DamageThresholdComponent : IEffectComponent { - private readonly Dictionary _accumulatedDamage = new(); private readonly float _threshold; private readonly Effect _thresholdEffect; + private float _accumulatedDamage; + private EventSubscriptionToken? _damageEventToken; public DamageThresholdComponent(float threshold, Effect thresholdEffect) { @@ -170,12 +222,26 @@ public class DamageThresholdComponent : IEffectComponent _thresholdEffect = thresholdEffect; } + // Guarantees each effect application has its own unique instance and state + public IEffectComponent CreateInstance() + { + return new DamageThresholdComponent(_threshold, _thresholdEffect); + } + public bool OnActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) { - _accumulatedDamage[activeEffectEvaluatedData.ActiveEffectHandle] = 0; - // Note: This is a simplified example. A real implementation would need a more robust way to subscribe/unsubscribe. - target.Attributes.GetAttribute("CombatAttributeSet.CurrentHealth").OnValueChanged += - (attribute, change) => TrackDamage(target, activeEffectEvaluatedData.ActiveEffectHandle, change); + _accumulatedDamage = 0f; + // Subscribe to an "events.combat.damage_taken" event using Forge's Events system + var damageTakenTag = Tag.RequestTag(target.TagsManager, "events.combat.damage_taken"); + _damageEventToken = target.Events.Subscribe(damageTakenTag, data => + { + _accumulatedDamage += data.EventMagnitude; + if (_accumulatedDamage >= _threshold) + { + _accumulatedDamage = 0; + target.EffectsManager.ApplyEffect(_thresholdEffect); + } + }); return true; } @@ -184,32 +250,58 @@ public class DamageThresholdComponent : IEffectComponent in ActiveEffectEvaluatedData activeEffectEvaluatedData, bool removed) { - if (removed) + if (removed && _damageEventToken is not null) { - _accumulatedDamage.Remove(activeEffectEvaluatedData.ActiveEffectHandle); - // Note: This is a simplified example. A real implementation would need a more robust way to subscribe/unsubscribe. - target.Attributes.GetAttribute("CombatAttributeSet.CurrentHealth").OnValueChanged -= - (attribute, change) => TrackDamage(target, activeEffectEvaluatedData.ActiveEffectHandle, change); + target.Events.Unsubscribe(_damageEventToken.Value); + _damageEventToken = null; } } +} +``` + +### Accessing Component Instances at Runtime + +When you apply a duration (non-instant) effect, you receive an `ActiveEffectHandle` from the `EffectsManager`. This handle provides access to the specific component instances that were created for this effect application. + +This is useful if you need to check runtime state, interact with a component that manages resources, or access data (such as granted abilities or custom counters) unique to this particular effect instance. + +#### Retrieving a Component Instance + +You can retrieve a component instance of a given type using the handle's generic `GetComponent()` method: - private void TrackDamage(IForgeEntity target, ActiveEffectHandle handle, int change) +```csharp +// Apply an effect and get the handle. +ActiveEffectHandle? handle = entity.EffectsManager.ApplyEffect(new Effect(effectData, ownership)); + +if (handle is not null) +{ + // Retrieve a specific component instance used by this effect. + var grantAbilityComponent = handle.GetComponent(); + if (grantAbilityComponent is not null) { - if (change < 0 && _accumulatedDamage.ContainsKey(handle)) - { - _accumulatedDamage[handle] += Math.Abs(change); + // Access runtime data exposed by the component + IReadOnlyList grantedAbilities = grantAbilityComponent.GrantedAbilities; + // ... use grantedAbilities as needed + } - if (_accumulatedDamage[handle] >= _threshold) - { - // Reset accumulation and apply threshold effect - _accumulatedDamage[handle] = 0; - target.EffectsManager.ApplyEffect(_thresholdEffect); - } - } + // You can also enumerate all component instances for additional logic + foreach (var component in handle.ComponentInstances) + { + // Inspect or interact with component instances } } ``` +- `GetComponent()` returns the first component instance of type `T` (or `null` if none exists). +- `ComponentInstances` exposes all component instances for this effect (may hold per-instance state). + +**Typical use cases:** +- Accessing granted ability handles from a `GrantAbilityEffectComponent`. +- Inspecting or updating internal state on a custom component. +- Coordinating follow-up logic or queries in gameplay systems. + +For more details on the structure of `ActiveEffectHandle`, see the [ActiveEffectHandle documentation](README.md#activeeffecthandle). + ### Advanced Component Integration Components can be used to implement complex systems that integrate with your game's mechanics: @@ -240,11 +332,13 @@ public class GrantAbilityEffectComponent(GrantAbilityConfig[] grantAbilityConfig ```csharp var grantConfig = new GrantAbilityConfig( - abilityData: fireballData, - abilityLevel: new ScalableInt(1), // Scales with effect level - removalPolicy: AbilityDeactivationPolicy.CancelImmediately, // Cancels running instances immediately when effect ends - inhibitionPolicy: AbilityDeactivationPolicy.CancelImmediately, // Cancels running instances immediately if effect is inhibited - levelOverridePolicy: LevelComparison.Higher // Update level if higher than existing grant + AbilityData: fireballData, + ScalableLevel: new ScalableInt(1), // Scales with effect level if a curve is defined + RemovalPolicy: AbilityDeactivationPolicy.CancelImmediately, // Cancels running instances immediately when effect ends + InhibitionPolicy: AbilityDeactivationPolicy.CancelImmediately, // Cancels running instances immediately if effect is inhibited + TryActivateOnGrant: false, // Do not try to activate automatically when granted + TryActivateOnEnable: false, // Do not try to activate automatically when enabled back from inhibition + LevelOverridePolicy: LevelComparison.Higher // Update level if higher than existing grant ); // Keep a reference to the component if you need to access the granted ability handles later @@ -265,7 +359,7 @@ AbilityHandle fireballHandle = grantComponent.GrantedAbilities[0]; Key points: -- **Direct Handle Access**: You can hold onto the component instance to access `GrantedAbilities`. This provides direct references to the `AbilityHandle`s created by this specific effect application, which is often more reliable than searching via `TryGetAbility`. +- **Direct Handle Access**: You can keep a reference to the component instance to access its `GrantedAbilities`, which contains the `AbilityHandle`s created by this specific effect application. Alternatively, use the effect handle's `GetComponent()` method to retrieve the runtime component instance when needed. - **Lifecycle Management**: Automatically handles granting, removing, and inhibiting abilities based on the effect's lifecycle and the configured policies. - **Permanent vs. Temporary**: - If used in an **Instant** effect, the ability is granted permanently. diff --git a/docs/effects/duration.md b/docs/effects/duration.md index 637c6b1..80ca2eb 100644 --- a/docs/effects/duration.md +++ b/docs/effects/duration.md @@ -167,7 +167,7 @@ When working with durations, several constraints apply to ensure effects behave "Invalid Effect", new DurationData(DurationType.Instant), [/*...*/], - snapshopLevel: false // Error + snapshotLevel: false // Error ); ``` diff --git a/docs/effects/modifiers.md b/docs/effects/modifiers.md index 38233a8..070cc31 100644 --- a/docs/effects/modifiers.md +++ b/docs/effects/modifiers.md @@ -1,6 +1,6 @@ # Effect Modifiers -Effect Modifiers in Forge provides a flexible way to modify entity [attributes](../attributes.md) through [effects](README.md). Modifiers define how an effect changes attribute values, with support for different operation types and magnitude calculations. +Effect Modifiers in Forge provide a flexible way to modify entity [attributes](../attributes.md) through [effects](README.md). Modifiers define how an effect changes attribute values, with support for different operation types and magnitude calculations. For a practical guide on using modifiers, see the [Quick Start Guide](../quick-start.md). @@ -10,10 +10,10 @@ At its core, a modifier represents a mathematical operation that changes the val ```csharp public readonly struct Modifier( - StringKey attribute, - ModifierOperation operation, - ModifierMagnitude magnitude, - int channel = 0) + StringKey Attribute, + ModifierOperation Operation, + ModifierMagnitude Magnitude, + int Channel = 0) { // Implementation... } @@ -136,17 +136,17 @@ When evaluated, the formula is: `BaseValue * ScalingCurve.Evaluate(level)`, or j ### AttributeBasedFloat -Calculates magnitude based on another attribute's value using a powerful formula: +`AttributeBasedFloat` computes its magnitude from another attribute (including snapshot logic for effect context). ```csharp public readonly struct AttributeBasedFloat( - AttributeCaptureDefinition backingAttribute, - AttributeCalculationType attributeCalculationType, - ScalableFloat coefficient, - ScalableFloat preMultiplyAdditiveValue, - ScalableFloat postMultiplyAdditiveValue, - int? finalChannel = null, - ICurve? lookupCurve = null) + AttributeCaptureDefinition BackingAttribute, + AttributeCalculationType AttributeCalculationType, + ScalableFloat Coefficient, + ScalableFloat PreMultiplyAdditiveValue, + ScalableFloat PostMultiplyAdditiveValue, + int? FinalChannel = null, + ICurve? LookupCurve = null) { // Implementation... } @@ -245,11 +245,11 @@ For complex calculations requiring custom logic, see the [Custom Calculators doc ```csharp public readonly struct CustomCalculationBasedFloat( - CustomModifierMagnitudeCalculator magnitudeCalculatorClass, - ScalableFloat coefficient, - ScalableFloat preMultiplyAdditiveValue, - ScalableFloat postMultiplyAdditiveValue, - ICurve? lookupCurve = null) + CustomModifierMagnitudeCalculator MagnitudeCalculatorClass, + ScalableFloat Coefficient, + ScalableFloat PreMultiplyAdditiveValue, + ScalableFloat PostMultiplyAdditiveValue, + ICurve? LookupCurve = null) { // Implementation... } @@ -258,7 +258,7 @@ public readonly struct CustomCalculationBasedFloat( The magnitude is calculated using the same formula as `AttributeBasedFloat`, but with a custom calculator providing the base magnitude: ``` -baseMagnitude = magnitudeCalculatorClass.CalculateBaseMagnitude() +baseMagnitude = magnitudeCalculatorClass.CalculateBaseMagnitude(effect, target, effectEvaluatedData) finalValue = (coefficient * (baseMagnitude + preMultiply)) + postMultiply ``` @@ -311,16 +311,25 @@ var missingHealthDamage = new Modifier( ### SetByCallerFloat -Allows the effect's magnitude to be set externally before it's applied: +`SetByCallerFloat` is a magnitude type that allows the caller to provide a custom value when applying an effect. ```csharp -public readonly struct SetByCallerFloat(Tag tag) +public readonly struct SetByCallerFloat(Tag tag, bool Snapshot = true) { // Implementation... } ``` -The `Tag` property is used as a key to look up the magnitude value that must be set before applying the effect: +#### Tag + +The `Tag` property is used as a key to look up the magnitude value that must be set before applying the effect. + +#### Snapshot + +The `Snapshot` parameter controls whether the provided value is captured at application time or evaluated dynamically for non-instant effects. + +- When `Snapshot` is set to `true`, the value associated with the tag is captured when the effect is applied and remains fixed for the lifetime of the effect. +- When `Snapshot` is set to `false`, the effect always uses the latest value associated with the tag, allowing the magnitude to change if the caller updates the value after the effect has already been applied. ```csharp // Magnitude will be set before the effect is applied @@ -330,7 +339,8 @@ var variableDamageModifier = new Modifier( new ModifierMagnitude( MagnitudeCalculationType.SetByCaller, setByCallerFloat: new SetByCallerFloat( - Tag.RequestTag(tagsManager, "damage.amount") + Tag.RequestTag(tagsManager, "damage.amount"), + Snapshot: true ) ) ); @@ -338,7 +348,7 @@ var variableDamageModifier = new Modifier( var effectData = new EffectData("Variable Damage", new DurationData(DurationType.Instant), [variableDamageModifier]); var effect = new Effect(effectData, new EffectOwnership(caster, caster)); -// Before applying the effect: +// Set the caller-provided magnitude before applying the effect: effect.SetSetByCallerMagnitude(Tag.RequestTag(tagsManager, "damage.amount"), 25.5f); target.EffectsManager.ApplyEffect(effect); ``` diff --git a/docs/effects/periodic.md b/docs/effects/periodic.md index e90a367..05de1ce 100644 --- a/docs/effects/periodic.md +++ b/docs/effects/periodic.md @@ -352,411 +352,3 @@ var bleedingEffectData = new EffectData( - In turn-based games, call `entity.EffectsManager.UpdateEffects(1.0f)` at the end of each turn. - Set period to exactly `1.0f` for effects that should trigger once per turn. - Use values like `2.0f` or `3.0f` for effects that trigger every few turns. -``` - -````markdown name=docs/effects/stacking.md url=https://github.com/gamesmiths-guild/forge/blob/37b442b3f13986dcda70ed54c1d2c93e69e01c83/docs/effects/stacking.md -# Effect Stacking - -Effect Stacking in Forge enables [effects](README.md) to accumulate on a target entity, allowing gameplay mechanics like poison stacks, buff/debuff stacks, or other cumulative effects. This powerful system offers extensive control over how effects combine, interact, and expire. - -For a practical guide on using stacking, see the [Quick Start Guide](../quick-start.md). - -## Core Components - -### StackingData - -`StackingData` defines how an effect behaves when multiple instances are applied to the same target: - -```csharp -public readonly struct StackingData( - ScalableInt stackLimit, - ScalableInt initialStack, - StackPolicy stackPolicy, - StackLevelPolicy stackLevelPolicy, - StackMagnitudePolicy magnitudePolicy, - StackOverflowPolicy overflowPolicy, - StackExpirationPolicy expirationPolicy, - StackOwnerDenialPolicy? ownerDenialPolicy = null, - StackOwnerOverridePolicy? ownerOverridePolicy = null, - StackOwnerOverrideStackCountPolicy? ownerOverrideStackCountPolicy = null, - LevelComparison? levelDenialPolicy = null, - LevelComparison? levelOverridePolicy = null, - StackLevelOverrideStackCountPolicy? levelOverrideStackCountPolicy = null, - StackApplicationRefreshPolicy? applicationRefreshPolicy = null, - StackApplicationResetPeriodPolicy? applicationResetPeriodPolicy = null, - bool? executeOnSuccessfulApplication = null) -{ - // Properties to access each parameter... -} -``` - -## Basic Stacking Parameters - -### Stack Limits and Counts - -- **StackLimit**: Maximum number of stacks that can be applied to a target. - ```csharp - public ScalableInt StackLimit { get; } - ``` - -- **InitialStack**: Number of stacks applied when the effect is first applied. - ```csharp - public ScalableInt InitialStack { get; } - ``` - -- **ExecuteOnSuccessfulApplication**: For [periodic effects](periodic.md), determines whether the periodic effect executes when a new stack is applied. - ```csharp - public bool? ExecuteOnSuccessfulApplication { get; } - ``` - -### Overflow Policy - -The `StackOverflowPolicy` controls what happens when a new stack application would exceed the stack limit: - -```csharp -public enum StackOverflowPolicy : byte -{ - AllowApplication = 0, // Apply the effect but maintain the stack limit - DenyApplication = 1 // Reject the application entirely -} -``` - -An "overflow" occurs when an effect has reached its maximum stack count (defined by `StackLimit`) and a new application attempts to add more stacks. The overflow policy determines how this situation is handled: - -- With `AllowApplication`, the new application is processed (refreshing duration, triggering events, etc.) but the stack count remains at the limit. -- With `DenyApplication`, the new application is completely rejected as if it never happened. - -## Key Stacking Policies - -### Stack Aggregation - -The `StackPolicy` determines how stacks are aggregated on a target: - -```csharp -public enum StackPolicy : byte -{ - AggregateBySource = 0, // Each source has its own stack on the target - AggregateByTarget = 1 // Target has only one stack, shared by all sources -} -``` - -### Stack Level Handling - -The `StackLevelPolicy` defines how effects of different levels interact: - -```csharp -public enum StackLevelPolicy : byte -{ - AggregateLevels = 0, // Combine effects of different levels - SegregateLevels = 1 // Keep effects of different levels separate -} -``` - -### Magnitude Policy - -The `StackMagnitudePolicy` controls how effect [magnitudes](modifiers.md) are calculated when stacked: - -```csharp -public enum StackMagnitudePolicy : byte -{ - DontStack = 0, // Each stack uses its original magnitude - Sum = 1 // Sum the magnitudes of all stacks -} -``` - -### Expiration Policy - -The `StackExpirationPolicy` determines what happens when an effect's [duration](duration.md) ends: - -```csharp -public enum StackExpirationPolicy : byte -{ - ClearEntireStack = 0, // Remove all stacks at once - RemoveSingleStackAndRefreshDuration = 1 // Remove one stack, refresh duration -} -``` - -### Owner Control Policies - -When using `StackPolicy.AggregateByTarget`, these policies control how different owners' effects interact: - -- **OwnerDenialPolicy**: Controls whether different owners can apply stacks. - ```csharp - public enum StackOwnerDenialPolicy : byte - { - AlwaysAllow = 0, // Any source can add stacks - DenyIfDifferent = 1 // Only the original source can add stacks - } - ``` - -- **OwnerOverridePolicy**: Controls whether effect ownership changes. - ```csharp - public enum StackOwnerOverridePolicy : byte - { - KeepCurrent = 0, // Original owner is always kept - Override = 1 // New applications change ownership - } - ``` - -- **OwnerOverrideStackCountPolicy**: Controls stack behavior when ownership changes. - ```csharp - public enum StackOwnerOverrideStackCountPolicy : byte - { - IncreaseStacks = 0, // Add to existing stack count - ResetStacks = 1 // Reset stack count to initial value - } - ``` - -### Application Policies - -- **ApplicationRefreshPolicy**: Controls how duration is handled when applying new stacks. - ```csharp - public enum StackApplicationRefreshPolicy : byte - { - RefreshOnSuccessfulApplication = 0, // Reset the duration when a stack is applied - NeverRefresh = 1 // Keep the current duration - } - ``` - -- **ApplicationResetPeriodPolicy**: For periodic effects, controls how the period timer is handled when a new stack is applied. - ```csharp - public enum StackApplicationResetPeriodPolicy : byte - { - ResetOnSuccessfulApplication = 0, // Reset period timer when a stack is applied - NeverReset = 1 // Keep the current period timer - } - ``` - -## Advanced Stacking Control - -### Level Comparison - -`LevelComparison` is a flags enum used to compare effect levels: - -```csharp -[Flags] -public enum LevelComparison : byte -{ - None = 0, - Equal = 1 << 0, // 1 - Higher = 1 << 1, // 2 - Lower = 1 << 2 // 4 -} -``` - -| Flag Combination | Value | Description | -|----------------------------|-------|----------------------------------------------| -| None | 0 | No comparison, ignores all levels | -| Equal | 1 | Only matches equal levels | -| Higher | 2 | Only matches higher levels | -| Lower | 4 | Only matches lower levels | -| Equal \| Higher | 3 | Matches equal or higher levels | -| Equal \| Lower | 5 | Matches equal or lower levels | -| Higher \| Lower | 6 | Matches higher or lower levels (not equal) | -| Equal \| Higher \| Lower | 7 | Matches all levels (rarely useful) | - -When used for: - -- **LevelDenialPolicy**: Denies application if the level relationship matches. -- **LevelOverridePolicy**: Overrides existing stack if the level relationship matches. - -### Level Override Stack Count Policy - -When a level override occurs, this policy controls what happens to the stack count: - -```csharp -public enum StackLevelOverrideStackCountPolicy : byte -{ - IncreaseStacks = 0, // Add to existing stack count - ResetStacks = 1 // Reset stack count to initial value -} -``` - -## Configuring Stacking Effects - -### Basic Stacking Effect - -```csharp -// Simple poison effect that stacks up to 5 times, each stack adds to the damage -var poisonEffectData = new EffectData( - "Poison", - new DurationData( - DurationType.HasDuration, - new ModifierMagnitude( - MagnitudeCalculationType.ScalableFloat, - new ScalableFloat(10.0f) - ) - ), - new[] { - new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-5))) - }, - new StackingData( - stackLimit: new ScalableInt(5), - initialStack: new ScalableInt(1), - stackPolicy: StackPolicy.AggregateBySource, - stackLevelPolicy: StackLevelPolicy.SegregateLevels, - magnitudePolicy: StackMagnitudePolicy.Sum, - overflowPolicy: StackOverflowPolicy.DenyApplication, - expirationPolicy: StackExpirationPolicy.ClearEntireStack, - applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication - ) -); -``` - -### Advanced Stacking with Level Control - -```csharp -// Buff that allows higher level applications to override lower ones -var hierarchicalBuffEffect = new EffectData( - "Strength Buff", - new DurationData( - DurationType.HasDuration, - new ModifierMagnitude( - MagnitudeCalculationType.ScalableFloat, - new ScalableFloat(30.0f)) - ), - new[] { - new Modifier("CombatAttributeSet.AttackPower", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) - }, - new StackingData( - stackLimit: new ScalableInt(3), - initialStack: new ScalableInt(1), - stackPolicy: StackPolicy.AggregateByTarget, - stackLevelPolicy: StackLevelPolicy.AggregateLevels, - magnitudePolicy: StackMagnitudePolicy.Sum, - overflowPolicy: StackOverflowPolicy.DenyApplication, - expirationPolicy: StackExpirationPolicy.RemoveSingleStackAndRefreshDuration, - // Control how different owners interact - ownerDenialPolicy: StackOwnerDenialPolicy.AlwaysAllow, - ownerOverridePolicy: StackOwnerOverridePolicy.Override, - ownerOverrideStackCountPolicy: StackOwnerOverrideStackCountPolicy.IncreaseStacks, - // Control how different levels interact - levelDenialPolicy: LevelComparison.None, - levelOverridePolicy: LevelComparison.Higher, - levelOverrideStackCountPolicy: StackLevelOverrideStackCountPolicy.ResetStacks, - applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication - ) -); -``` - -### Stacking with Periodic Effect - -```csharp -// Bleeding effect that ticks every 2 seconds and stacks up to 3 times -var bleedingEffectData = new EffectData( - "Bleeding", - new DurationData( - DurationType.HasDuration, - new ModifierMagnitude( - MagnitudeCalculationType.ScalableFloat, - new ScalableFloat(8.0f)) - ), - new[] { - new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-3))) - }, - new StackingData( - stackLimit: new ScalableInt(3), - initialStack: new ScalableInt(1), - stackPolicy: StackPolicy.AggregateBySource, - stackLevelPolicy: StackLevelPolicy.SegregateLevels, - magnitudePolicy: StackMagnitudePolicy.Sum, - overflowPolicy: StackOverflowPolicy.AllowApplication, - expirationPolicy: StackExpirationPolicy.ClearEntireStack, - applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication, - // Required for periodic effects - applicationResetPeriodPolicy: StackApplicationResetPeriodPolicy.ResetOnSuccessfulApplication, - executeOnSuccessfulApplication: true - ), - new PeriodicData( - period: new ScalableFloat(2.0f), - executeOnApplication: true, - periodInhibitionRemovedPolicy: PeriodInhibitionRemovedPolicy.ResetPeriod - ) -); -``` - -## Constraints and Relationships - -Stacking effects have several constraints and required relationships: - -1. **No Instant Stacking**: Stacks cannot be used with `DurationType.Instant`. - ```csharp - // INVALID - Instant effects can't stack - new EffectData( - "Invalid Effect", - new DurationData(DurationType.Instant), // Error with stacking data - [/*...*/], - new StackingData(/*...*/) - ); - ``` - -2. **Stack Limit and Initial Stack**: The initial stack count must be greater than 0 and less than or equal to the stack limit. - ```csharp - // VALID - Initial stack and limit relationship - new StackingData( - stackLimit: new ScalableInt(5), - initialStack: new ScalableInt(1) - // ... - ); - ``` - -3. **`ApplicationRefreshPolicy` Required**: For `HasDuration` effects with stacking. - ```csharp - // VALID - HasDuration requires ApplicationRefreshPolicy - new StackingData( - // ... - applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication - ); - ``` - -4. **Periodic Integration**: Stacking effects with `PeriodicData` must define: - - `ExecuteOnSuccessfulApplication` - - `ApplicationResetPeriodPolicy` - -5. **`AggregateByTarget` Requirements**: - - Must define `OwnerDenialPolicy`. - - If `OwnerDenialPolicy` is `AlwaysAllow`, must define `OwnerOverridePolicy`. - - If `OwnerOverridePolicy` is `Override`, must define `OwnerOverrideStackCountPolicy`. - -6. **`AggregateLevels` Requirements**: - - Must define `LevelDenialPolicy`. - - Must define `LevelOverridePolicy`. - - If `LevelOverridePolicy` is not `None`, must define `LevelOverrideStackCountPolicy`. - - `LevelDenialPolicy` and `LevelOverridePolicy` cannot have overlapping flags. - -## Best Practices - -1. **Use Clear Stack Limits**: - - Choose appropriate stack limits based on your game's balance. - - Consider using `ScalableInt` for level-based stack limits. - -2. **Choose Magnitude Policy Carefully**: - - `Sum`: Good for additive effects (damage, stat bonuses). - - `DontStack`: Good for status effects where you want duration benefits of stacking but not increased magnitude. - -3. **Consider Stack Expiration**: - - `ClearEntireStack`: Simple but can feel abrupt to players. - - `RemoveSingleStackAndRefreshDuration`: More gradual, better player experience. - -4. **Level Control Strategies**: - - Use `SegregateLevels` for simpler systems. - - Use `AggregateLevels` with careful level policies for more complex behaviors. - -5. **Owner Control**: - - `AggregateBySource`: Simpler, each source gets its own stack. - - `AggregateByTarget`: More complex, but prevents stacking abuse. - -6. **Create Unique Effects**: - - Use `StackPolicy.AggregateByTarget` with `StackLimit` of 1 to ensure only one instance of an effect exists on a target. - - Control replacement behavior with `OwnerDenialPolicy` and `LevelDenialPolicy`. - - Use `LevelOverridePolicy` to allow higher-level versions to replace lower ones. - -7. **Test Edge Cases**: - - Stack limit behavior. - - Stack expiration and duration refresh. - - Interactions with inhibitions. - - Effects from multiple owners and levels. - -8. **Document Your Stacking Rules**: - - Clearly explain to players how stacks work for key abilities. - - Use UI to communicate current stack counts. diff --git a/docs/effects/stacking.md b/docs/effects/stacking.md index f98dc79..272fdee 100644 --- a/docs/effects/stacking.md +++ b/docs/effects/stacking.md @@ -363,4 +363,44 @@ Stacking effects have several constraints and required relationships: - If `LevelOverridePolicy` is not `None`, must define `LevelOverrideStackCountPolicy`. - `LevelDenialPolicy` and `LevelOverridePolicy` cannot have overlapping flags. -7. **Duration Magnitude**: `DurationData` uses `ModifierMagnitude` (ScalableFloat, AttributeBased, CustomCalculatorClass, SetByCaller). For non-snapshot attribute captures or `SetByCaller` values, durations are re-evaluated at runtime. Stack refresh/reset behaviors (e.g., `ApplicationRefreshPolicy` or `RemoveSingleStackAndRefreshDuration`) use the current evaluated duration when they apply. +## Best Practices + +1. **Use Clear Stack Limits**: + - Choose appropriate stack limits based on your game's balance. + - Consider using `ScalableInt` for level-based stack limits. + +2. **Choose Magnitude Policy Carefully**: + - `Sum`: Good for additive effects (damage, stat bonuses). + - `DontStack`: Good for status effects where you want duration benefits of stacking but not increased magnitude. + +3. **Consider Stack Expiration**: + - `ClearEntireStack`: Simple but can feel abrupt to players. + - `RemoveSingleStackAndRefreshDuration`: More gradual, better player experience. + +4. **Level Control Strategies**: + - Use `SegregateLevels` for simpler systems. + - Use `AggregateLevels` with careful level policies for more complex behaviors. + +5. **Owner Control**: + - `AggregateBySource`: Simpler, each source gets its own stack. + - `AggregateByTarget`: More complex, but prevents stacking abuse. + +6. **Create Unique Effects**: + - Use `StackPolicy.AggregateByTarget` with `StackLimit` of 1 to ensure only one instance of an effect exists on a target. + - Control replacement behavior with `OwnerDenialPolicy` and `LevelDenialPolicy`. + - Use `LevelOverridePolicy` to allow higher-level versions to replace lower ones. + +7. **Test Edge Cases**: + - Stack limit behavior. + - Stack expiration and duration refresh. + - Interactions with inhibitions. + - Effects from multiple owners and levels. + +8. **Document Your Stacking Rules**: + - Clearly explain to players how stacks work for key abilities. + - Use UI to communicate current stack counts. + +9. **Duration Magnitude**: + - `DurationData` uses `ModifierMagnitude` (ScalableFloat, AttributeBased, CustomCalculatorClass, SetByCaller). + - For non-snapshot attribute captures or `SetByCaller` values, durations are re-evaluated at runtime. + - Stack refresh/reset behaviors (e.g., `ApplicationRefreshPolicy` or `RemoveSingleStackAndRefreshDuration`) use the current evaluated duration when they apply. diff --git a/docs/events.md b/docs/events.md index 7fe9671..d01c70d 100644 --- a/docs/events.md +++ b/docs/events.md @@ -1,13 +1,13 @@ # Events System -The Events system in Forge provides a flexible event bus for triggering gameplay reactions, driving ability activation, and propagating tagged event data. +The Events system in Forge provides a flexible event bus for triggering gameplay reactions, driving ability activation, and propagating tagged event data. ## Core Concepts -- Events carry tags for filtering `EventTags` plus optional source, target, magnitude, and payload data. +- Events carry tags for filtering `EventTags` plus optional source, target, magnitude, and payload data. - Handlers subscribe by tag and run in priority order (higher priority first). -- Generic events avoid boxing by using typed payloads. -- Generic raises do **not** forward to non-generic handlers. +- Generic events avoid boxing by using typed payloads. +- Generic raises do **not** forward to non-generic handlers. ## Event Data @@ -37,22 +37,22 @@ public readonly record struct EventData } ``` -- **EventTags**: Tag-based filtering key (uses `TagContainer. HasTag`). -- **Source/Target**: Originator and intended recipient of the event. -- **EventMagnitude**: Optional numeric intensity. +- **EventTags**: Tag-based filtering key (uses `TagContainer.HasTag`). +- **Source/Target**: Originator and intended recipient of the event. +- **EventMagnitude**: Optional numeric intensity. - **Payload**: Optional opaque object, or a typed payload for generic events. ## Event Manager -`EventManager` manages subscriptions and dispatch. While every `IForgeEntity` has an `EventManager` instance, you can create and manage additional instances for any purpose: global event buses, subsystem-specific channels, or custom scopes. +`EventManager` manages subscriptions and dispatch. While every `IForgeEntity` has an `EventManager` instance, you can create and manage additional instances for any purpose: global event buses, subsystem-specific channels, or custom scopes. ```csharp // Per-entity event manager (built-in) -entity.Events. Raise(eventData); +entity.Events.Raise(in eventData); // Custom event manager for a subsystem var combatEvents = new EventManager(); -combatEvents. Subscribe(damageTag, OnDamageDealt); +combatEvents.Subscribe(damageTag, OnDamageDealt); // Global event bus public static class GlobalEvents @@ -77,15 +77,16 @@ public sealed class EventManager ``` - Subscriptions are sorted by `priority` (higher first). -- A handler is invoked when `data.EventTags. HasTag(eventTag)` is true. -- Generic subscriptions are stored per `TPayload` type and are only invoked for matching generic raises. +- A handler is invoked when `data.EventTags.HasTag(eventTag)` is true. +- Generic subscriptions are stored per `TPayload` type and are only invoked for matching generic raises. ## Usage Examples ### Subscribe and Raise (non-generic) ```csharp -var eventTag = Tag.RequestTag(tagsManager, "events.combat. hit"); +var eventTag = Tag.RequestTag(tagsManager, "events.combat.hit"); + EventSubscriptionToken token = entity.Events.Subscribe(eventTag, data => { // React to combat hit @@ -95,7 +96,7 @@ EventSubscriptionToken token = entity.Events.Subscribe(eventTag, data => }); // Raise the event -entity.Events. Raise(new EventData +entity.Events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer(), Source = attacker, @@ -113,12 +114,12 @@ var hitTag = Tag.RequestTag(tagsManager, "events.combat.hit"); EventSubscriptionToken token = entity.Events.Subscribe(hitTag, data => { - HitPayload payload = data. Payload; - int damage = payload. Damage; + HitPayload payload = data.Payload; + int damage = payload.Damage; bool crit = payload.Critical; }); -entity.Events. Raise(new EventData +entity.Events.Raise(new EventData { EventTags = hitTag.GetSingleTagContainer(), Source = attacker, @@ -130,37 +131,39 @@ entity.Events. Raise(new EventData ### Unsubscribe +To unsubscribe from an event, call `Unsubscribe` using the corresponding `EventSubscriptionToken`. + ```csharp -entity.Events. Unsubscribe(token); +entity.Events.Unsubscribe(token); ``` ## Tagging and Filtering - Use dedicated event tags (e.g., `events.combat.hit`, `events.status.applied`) registered in `TagsManager`. -- Matching uses hierarchy: `EventTags.HasTag(subscriptionTag)` supports parent/child tag relationships. +- Matching uses hierarchy: `EventTags.HasTag(subscriptionTag)` supports parent/child tag relationships. ## Integration Notes - Abilities can use event tags as triggers (see ability trigger configurations in the Abilities system). -- Event tags can align with cues or effects to coordinate cross-system reactions. +- Event tags can align with cues or effects to coordinate cross-system reactions. - When an event should trigger visual feedback, raise the event for gameplay logic, then trigger the corresponding cue separately for presentation. ## Events vs Cues Events and [Cues](cues.md) serve distinct purposes: -- **Events** are part of the core simulation. They drive gameplay logic, trigger abilities, and propagate state changes. In a networked context (planned), events would require reliable replication. -- **Cues** are for the presentation layer. They handle visual effects, audio, and player feedback. In a networked context (planned), cues can use unreliable replication since they don't affect game state. +- **Events** are part of the core simulation. They drive gameplay logic, trigger abilities, and propagate state changes. In a networked context, events would require reliable replication. +- **Cues** are for the presentation layer. They handle visual effects, audio, and player feedback. In a networked context, cues can use unreliable replication since they don't affect game state. Use Events when the outcome affects game state or triggers other gameplay systems. Use Cues when you need to communicate changes to the player through feedback. ## Best Practices -1. **Define Tag Conventions**: Use consistent prefixes (e.g., `events.*`) for clarity. +1. **Define Tag Conventions**: Use consistent prefixes (e.g., `events.*`) for clarity. 2. **Prefer Typed Payloads**: Use `EventData` to avoid boxing and improve safety. -3. **Use Priorities Sparingly**: Reserve high priorities for critical handlers; keep most at default. -4. **Unsubscribe When Done**: Store tokens and call `Unsubscribe` to avoid stale handlers. -5. **Keep Handlers Lightweight**: Avoid heavy work inside handlers; defer long tasks if needed. -6. **Validate Tags**: Ensure tags are registered in `TagsManager` before use. +3. **Use Priorities Sparingly**: Reserve high priorities for critical handlers; keep most at default. +4. **Unsubscribe When Done**: Store tokens and call `Unsubscribe` to avoid stale handlers. +5. **Keep Handlers Lightweight**: Avoid heavy work inside handlers; defer long tasks if needed. +6. **Validate Tags**: Ensure tags are registered in `TagsManager` before use. 7. **Separate Events from Cues**: Use events for gameplay-affecting logic; trigger cues separately for presentation. 8. **Consider Scope**: Use entity-level `EventManager` for entity-specific events; create custom instances for broader or specialized scopes. diff --git a/docs/quick-start.md b/docs/quick-start.md index 7fb8907..959fffa 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -9,7 +9,7 @@ This guide will help you quickly get started with the Forge framework, showing y Install Forge via NuGet (recommended): ```shell -dotnet add package Gamesmiths.Forge --version 0.2.0 +dotnet add package Gamesmiths.Forge ``` For other installation methods, see the [main README](../README.md). @@ -676,7 +676,7 @@ public class HealthDrainExecution : CustomExecution AttributesToCapture.Add(SourceStrength); } - public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target, EffectEvaluatedData effectEvaluatedData) + public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData) { var results = new List(); From 63f5316a68a98089b3c16be1f8968c87706e0b86 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 25 Jan 2026 12:54:23 -0300 Subject: [PATCH 85/87] Changed validation workflow name --- .github/workflows/validate-project.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-project.yml b/.github/workflows/validate-project.yml index 9919d3f..d1bd0c1 100644 --- a/.github/workflows/validate-project.yml +++ b/.github/workflows/validate-project.yml @@ -1,4 +1,4 @@ -name: Validate Project +name: CI on: push: From 809cefe73679cbaaf471af1551475e5d636523ed Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 25 Jan 2026 17:09:38 -0300 Subject: [PATCH 86/87] Added a better quick-start sample for events --- Forge.Tests/Events/EventTests.cs | 21 +++++++++++-------- Forge.Tests/Samples/QuickStartTests.cs | 29 +++++++++++++++++--------- docs/quick-start.md | 14 ++++++++----- 3 files changed, 40 insertions(+), 24 deletions(-) diff --git a/Forge.Tests/Events/EventTests.cs b/Forge.Tests/Events/EventTests.cs index 769a080..b676247 100644 --- a/Forge.Tests/Events/EventTests.cs +++ b/Forge.Tests/Events/EventTests.cs @@ -57,27 +57,30 @@ public void Typed_event_subscription_receives_correct_payload() var entity = new TestEntity(tagsManager, cuesManager); var damageTag = Tag.RequestTag(tagsManager, "simple.tag"); - var logMessage = string.Empty; - var logValue = 0; + var value = 0; + DamageType damageType = DamageType.Physical; + var isCritical = false; // Subscribe with generic type - entity.Events.Subscribe(damageTag, x => + entity.Events.Subscribe(damageTag, x => { - logMessage = x.Payload.Message; - logValue = x.Payload.Value; + value = x.Payload.Value; + damageType = x.Payload.DamageType; + isCritical = x.Payload.IsCritical; }); // Raise with generic type - entity.Events.Raise(new EventData + entity.Events.Raise(new EventData { EventTags = damageTag.GetSingleTagContainer()!, Source = null, Target = entity, - Payload = new CombatLogPayload("Critical Hit", 9999), + Payload = new DamageInfo(500, DamageType.Magical, true), }); - logMessage.Should().Be("Critical Hit"); - logValue.Should().Be(9999); + value.Should().Be(500); + damageType.Should().Be(DamageType.Magical); + isCritical.Should().Be(true); } [Fact] diff --git a/Forge.Tests/Samples/QuickStartTests.cs b/Forge.Tests/Samples/QuickStartTests.cs index c90bbce..bc302eb 100644 --- a/Forge.Tests/Samples/QuickStartTests.cs +++ b/Forge.Tests/Samples/QuickStartTests.cs @@ -778,27 +778,30 @@ public void Strongly_typed_events() var player = new Player(tagsManager, cuesManager); var damageTag = Tag.RequestTag(tagsManager, "events.combat.damage"); - string logMessage = string.Empty; - int logValue = 0; + int value = 0; + DamageType damageType = DamageType.Magical; + bool isCritical = false; // Subscribe with generic type - player.Events.Subscribe(damageTag, eventData => + player.Events.Subscribe(damageTag, eventData => { - logMessage = eventData.Payload.Message; - logValue = eventData.Payload.Value; + value = eventData.Payload.Value; + damageType = eventData.Payload.DamageType; + isCritical = eventData.Payload.IsCritical; }); // Raise with generic type - player.Events.Raise(new EventData + player.Events.Raise(new EventData { EventTags = damageTag.GetSingleTagContainer(), Source = null, Target = player, - Payload = new CombatLogPayload("Critical Hit", 9999) + Payload = new DamageInfo(120, DamageType.Physical, true) }); - logMessage.Should().Be("Critical Hit"); - logValue.Should().Be(9999); + value.Should().Be(120); + damageType.Should().Be(DamageType.Physical); + isCritical.Should().Be(true); } [Fact] @@ -1309,5 +1312,11 @@ public void OnEnded(AbilityBehaviorContext context) } } - public record struct CombatLogPayload(string Message, int Value); + public enum DamageType + { + Physical, + Magical, + } + + public record struct DamageInfo(int Value, DamageType DamageType, bool IsCritical); } diff --git a/docs/quick-start.md b/docs/quick-start.md index 959fffa..3f177ef 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -18,7 +18,7 @@ For other installation methods, see the [main README](../README.md). ## Creating a Basic Entity -Let's create a simple player entity with three attributes: health, mana, strength, and speed. +Let's create a simple player entity with three attributes: health, mana, strength and speed. For that we need to first define an `AttributeSet` that will hold those attributes. @@ -901,23 +901,27 @@ You can optimize events to avoid boxing by using generic `EventData`. ```csharp // Define a strongly typed payload -public record struct CombatLogPayload(string Message, int Value); +public record struct DamageInfo(int Value, DamageType DamageType, bool IsCritical); var damageTag = Tag.RequestTag(tagsManager, "events.combat.damage"); // Subscribe using the specific payload type player.Events.Subscribe(damageTag, eventData => { - Console.WriteLine($"[Combat Log] {eventData.Payload.Message}: {eventData.Payload.Value}"); + Console.WriteLine( + $"[Combat Log] Damage: {eventData.Payload.Value}, " + + $"Type: {eventData.Payload.DamageType}, " + + $"Critical: {eventData.Payload.IsCritical}" + ); }); // Raise the event with the typed payload -player.Events.Raise(new EventData +player.Events.Raise(new EventData { EventTags = damageTag.GetSingleTagContainer(), Source = null, Target = player, - Payload = new CombatLogPayload("Critical Hit", 9999) + Payload = new DamageInfo(120, DamageType.Physical, true) }); ``` From 2ada1d050509b650fa01101a1ab5606d7a879137 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 25 Jan 2026 17:09:55 -0300 Subject: [PATCH 87/87] Small review fixes to documentation --- docs/abilities.md | 4 ++-- docs/effects/README.md | 2 +- docs/effects/duration.md | 8 ++++---- docs/events.md | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/abilities.md b/docs/abilities.md index f96de17..be4eeb6 100644 --- a/docs/abilities.md +++ b/docs/abilities.md @@ -363,7 +363,7 @@ if (entity.Abilities.TryGetAbility(abilityData, out AbilityHandle? handle)) - **IsValid**: Whether the handle still references a valid granted ability. - **Level**: The current level of the ability. - **Activate(out failureFlags, target?, magnitude?)**: Attempt to activate the ability with optional target and magnitude. -- **Activate(data, out failureFlags, target?, magnitude?)**: Attempt to activate the ability passing additional typed activation data. +- **Activate\(data, out failureFlags, target?, magnitude?)**: Attempt to activate the ability passing additional typed activation data. - **Cancel()**: Cancel all active instances. - **CommitAbility()**: Helper that calls both `CommitCooldown()` and `CommitCost()`. - **CommitCooldown()**: Apply the cooldown effects. @@ -573,7 +573,7 @@ public class FireballBehavior : IAbilityBehavior - **InstanceHandle**: Handle to this specific instance for ending it. - **Magnitude**: A numeric value associated with the activation attempt. -### Behavior Context `` +### Behavior Context \ In addition to the core fields, the generic behavior context also carries: diff --git a/docs/effects/README.md b/docs/effects/README.md index 3dd10be..f6645ee 100644 --- a/docs/effects/README.md +++ b/docs/effects/README.md @@ -110,7 +110,7 @@ if (buffHandle is not null) #### Public Methods - **SetInhibit(bool value)**: Sets the inhibition status of the effect (e.g., to temporarily pause its action without removing it). -- **GetComponent()**: Returns the first component instance of type `T` attached to this effect, or `null` if not found. +- **GetComponent\()**: Returns the first component instance of type `T` attached to this effect, or `null` if not found. Useful for retrieving a specific effect component's runtime state. diff --git a/docs/effects/duration.md b/docs/effects/duration.md index 80ca2eb..141b51c 100644 --- a/docs/effects/duration.md +++ b/docs/effects/duration.md @@ -175,10 +175,10 @@ When working with durations, several constraints apply to ensure effects behave ```csharp // INVALID - Instant effects can't apply modifier tags new EffectData( - "Invalid Effect", - new DurationData(DurationType.Instant), - [/*...*/], - effectComponents: new[] { new ModifierTagsEffectComponent(new TagContainer()) } // Error + "Invalid Effect", + new DurationData(DurationType.Instant), + [/*...*/], + effectComponents: new[] { new ModifierTagsEffectComponent(new TagContainer()) } // Error ); ``` diff --git a/docs/events.md b/docs/events.md index d01c70d..e9cb6fc 100644 --- a/docs/events.md +++ b/docs/events.md @@ -24,7 +24,7 @@ public readonly record struct EventData } ``` -### EventData +### EventData\ ```csharp public readonly record struct EventData