From 4a8b28e6340437f808298aa539d524498cd3c46a Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 15 Aug 2026 17:16:59 -0300 Subject: [PATCH 1/3] fix: grant effect inhibition cancel all instances --- Forge/Core/EntityAbilities.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index 0980058..b4ab855 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -750,7 +750,7 @@ private void InhibitAbilityBasedOnPolicy(Ability abilityToInhibit, AbilityDeacti case AbilityDeactivationPolicy.CancelImmediately: if (abilityToInhibit.IsActive) { - abilityToInhibit.End(); + abilityToInhibit.CancelAllInstances(); } InhibitAbility(abilityToInhibit); From 08831bc21edd292bbb5d211f2d49fead5dd9d5be Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 15 Aug 2026 17:17:37 -0300 Subject: [PATCH 2/3] test: add test cases --- Forge.Tests/Abilities/AbilitiesTests.cs | 95 +++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index 95a9a1b..f832a3f 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -1579,6 +1579,101 @@ [new ScalableFloat(3f)], abilityHandle.IsInhibited.Should().BeFalse(); } + [Fact] + [Trait("Inhibit ability", null)] + public void Inhibiting_the_grant_cancels_every_instance_of_a_per_execution_ability() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + TagContainer ownedTags = TagsOf("color.red"); + + AbilityData abilityData = CreateAbilityData( + "PerExecutionInhibition", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute5", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerExecution, + activationOwnedTags: ownedTags); + + // Kept apart from the activation owned tags, so starting an instance is not what inhibits the grant. + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "other.tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags))); + + abilityHandle.Should().NotBeNull(); + + for (int i = 0; i < 3; i++) + { + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + } + + abilityHandle!.IsActive.Should().BeTrue(); + entity.Tags.AllTags.HasAll(ownedTags).Should().BeTrue(); + + // Inhibit the granting effect. + CreateAndApplyTagEffect(entity, ignoreTags!); + + abilityHandle.IsInhibited.Should().BeTrue(); + + // Every instance must be cancelled, not just the most recent one. An inhibited ability that is still running + // contradicts CancelImmediately, and the survivors would hold their activation owned tags on the entity while + // nothing is allowed to activate the ability again. + abilityHandle.IsActive.Should().BeFalse(); + entity.Tags.AllTags.HasAny(ownedTags).Should().BeFalse(); + } + + [Fact] + [Trait("Inhibit ability", null)] + public void Inhibiting_the_grant_reports_the_ability_as_canceled() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "other.tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags))); + + abilityHandle.Should().NotBeNull(); + + AbilityEndedData? capturedData = null; + entity.Abilities.OnAbilityEnded += x => capturedData = x; + + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + // Inhibit the granting effect. + CreateAndApplyTagEffect(entity, ignoreTags!); + + // A grant inhibited under CancelImmediately tears the ability away rather than letting it finish, exactly as a + // removal does, so listeners have to be able to tell it apart from a natural ending. + capturedData.Should().NotBeNull(); + capturedData!.Value.WasCanceled.Should().BeTrue(); + } + [Fact] [Trait("Inhibit ability", null)] public void Inhibition_policy_RemoveOnEnd_inhibits_after_deactivation() From 2603751b6b3b2a532b9f63b2f2bf61ebdb80844d Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 15 Aug 2026 17:17:53 -0300 Subject: [PATCH 3/3] docs: update abilities docs --- docs/abilities.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/abilities.md b/docs/abilities.md index f52e4d4..916689c 100644 --- a/docs/abilities.md +++ b/docs/abilities.md @@ -253,7 +253,7 @@ entity.EffectsManager.ApplyEffect(grantEffect3); - **RemoveOnEnd**: Wait for all active instances to end before removing/inhibiting. - **Ignore**: The grant source ignores removal/inhibition requests entirely. -A removal under `CancelImmediately` cancels **every** active instance, which matters for [`PerExecution`](#perexecution) abilities running several at once, and reports `AbilityEndedData.WasCanceled == true` — the ability was torn away rather than allowed to finish. +A removal or an inhibition under `CancelImmediately` cancels **every** active instance, which matters for [`PerExecution`](#perexecution) abilities running several at once, and reports `AbilityEndedData.WasCanceled == true` — the ability was torn away rather than allowed to finish. ### Policy Interactions Between Grant Sources @@ -453,7 +453,7 @@ entity.Abilities.OnAbilityEnded += data => }; ``` -`WasCanceled` is `true` when the ability was canceled (via `AbilityHandle.Cancel()` or `CancelAbilities`) and `false` when it ended gracefully (reaching its natural end, or a Statescript Exit node). `AbilityEndedData` also carries `AbilityData`, captured before the handle can be freed, because an ability granted with `RemoveOnEnd` is removed by the very same call. +`WasCanceled` is `true` when the ability was canceled (via `AbilityHandle.Cancel()` or `CancelAbilities`, or by a grant removed or inhibited under [`CancelImmediately`](#deactivation-policies)) and `false` when it ended gracefully (reaching its natural end, or a Statescript Exit node). `AbilityEndedData` also carries `AbilityData`, captured before the handle can be freed, because an ability granted with `RemoveOnEnd` is removed by the very same call. **Failed activations.** Whoever calls the activation API already receives [`AbilityActivationFailures`](#activation-failures) as an out parameter. `OnAbilityActivationFailed` exists for the activations nobody holds the result of — those driven by [ability triggers](#ability-triggers) and by the Statescript activation nodes — which are otherwise completely silent: