Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions Forge.Tests/Abilities/AbilitiesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion Forge/Core/EntityAbilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -750,7 +750,7 @@ private void InhibitAbilityBasedOnPolicy(Ability abilityToInhibit, AbilityDeacti
case AbilityDeactivationPolicy.CancelImmediately:
if (abilityToInhibit.IsActive)
{
abilityToInhibit.End();
abilityToInhibit.CancelAllInstances();
}

InhibitAbility(abilityToInhibit);
Expand Down
4 changes: 2 additions & 2 deletions docs/abilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:

Expand Down
Loading