diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs index 4e896e98..81e84fb7 100644 --- a/Forge.Tests/Abilities/AbilitiesTests.cs +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -44,7 +44,7 @@ [new ScalableFloat(3f)], abilityHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); } @@ -72,7 +72,7 @@ [new ScalableFloat(3f)], effectHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); @@ -81,7 +81,7 @@ [new ScalableFloat(3f)], entity.Abilities.GrantedAbilities.Should().BeEmpty(); abilityHandle.IsActive.Should().BeFalse(); - abilityHandle.Activate(out failureFlags).Should().BeFalse(); + abilityHandle.TryActivate(out failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.InvalidHandler); } @@ -110,7 +110,7 @@ [new ScalableFloat(3f)], effectHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); @@ -159,7 +159,7 @@ [new ScalableFloat(3f)], effectHandle2.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); @@ -208,7 +208,7 @@ [new ScalableFloat(3f)], effectHandle2.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); @@ -247,7 +247,7 @@ [new ScalableFloat(3f)], abilityHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); @@ -255,7 +255,7 @@ [new ScalableFloat(3f)], entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle.Activate(out failureFlags).Should().BeFalse(); + abilityHandle.TryActivate(out failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.Inhibited); abilityHandle.IsActive.Should().BeFalse(); abilityHandle.IsInhibited.Should().BeTrue(); @@ -264,7 +264,7 @@ [new ScalableFloat(3f)], entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle.Activate(out failureFlags).Should().BeTrue(); + abilityHandle.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); abilityHandle.IsInhibited.Should().BeFalse(); @@ -295,7 +295,7 @@ [new ScalableFloat(3f)], effectHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); @@ -753,7 +753,7 @@ [new ScalableFloat(3f)], abilityHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); @@ -825,10 +825,10 @@ [new ScalableFloat(3f)], inhibitedHandle!.IsInhibited.Should().BeTrue(); untouchedHandle!.IsInhibited.Should().BeFalse(); - inhibitedHandle.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + inhibitedHandle.TryActivate(out AbilityActivationFailures failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.Inhibited); - bool activated = untouchedHandle.Activate(out failureFlags); + bool activated = untouchedHandle.TryActivate(out failureFlags); failureFlags.Should().Be(AbilityActivationFailures.None); activated.Should().BeTrue(); } @@ -882,7 +882,7 @@ [new ScalableFloat(3f)], // The Ignore grant does not react to its effect being inhibited, so it keeps the ability enabled. abilityHandle!.IsInhibited.Should().BeFalse(); - abilityHandle.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); } @@ -957,7 +957,7 @@ [new ScalableFloat(3f)], abilityHandle.Should().NotBeNull(); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); @@ -1004,7 +1004,7 @@ [new ScalableFloat(3f)], abilityHandle.Should().NotBeNull(); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); // Inhibit the granting effect. @@ -1095,7 +1095,7 @@ [new ScalableFloat(3f)], abilityHandle2.Should().NotBeNull(); abilityHandle.Should().Be(abilityHandle2); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); abilityHandle2!.IsActive.Should().BeTrue(); @@ -1267,7 +1267,7 @@ [new ScalableFloat(3f)], entity1.Abilities.GrantedAbilities.Should().HaveCount(2); // Activate one and ensure the other is not affected - abilityHandle1!.Activate(out AbilityActivationFailures failureFlags1).Should().BeTrue(); + abilityHandle1!.TryActivate(out AbilityActivationFailures failureFlags1).Should().BeTrue(); failureFlags1.Should().Be(AbilityActivationFailures.None); abilityHandle1.IsActive.Should().BeTrue(); abilityHandle2!.IsActive.Should().BeFalse(); @@ -1292,24 +1292,24 @@ [new ScalableFloat(3f)], new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); - abilityHandle.CommitCooldown(); + abilityHandle.TryCommitCooldown(); abilityHandle.Cancel(); - abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + abilityHandle!.TryActivate(out failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.Cooldown); entity.EffectsManager.UpdateEffects(2f); - abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + abilityHandle!.TryActivate(out failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.Cooldown); entity.EffectsManager.UpdateEffects(1f); - abilityHandle!.Activate(out failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); } @@ -1332,24 +1332,24 @@ public void Ability_wont_activate_until_last_cooldown_effect_is_removed() new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); - abilityHandle.CommitCooldown(); + abilityHandle.TryCommitCooldown(); abilityHandle.Cancel(); - abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + abilityHandle!.TryActivate(out failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.Cooldown); entity.EffectsManager.UpdateEffects(2f); - abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + abilityHandle!.TryActivate(out failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.Cooldown); entity.EffectsManager.UpdateEffects(1f); - abilityHandle!.Activate(out failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); } @@ -1372,7 +1372,7 @@ public void GetCooldownData_and_GetRemainingCooldownTime_return_correct_values() new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); @@ -1393,10 +1393,10 @@ public void GetCooldownData_and_GetRemainingCooldownTime_return_correct_values() abilityHandle.GetRemainingCooldownTime(simpleTag).Should().Be(0f); abilityHandle.GetRemainingCooldownTime(tag).Should().Be(0f); - abilityHandle.CommitCooldown(); + abilityHandle.TryCommitCooldown(); abilityHandle.Cancel(); - abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + abilityHandle!.TryActivate(out failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.Cooldown); cooldownData = abilityHandle.GetCooldownData()!; @@ -1415,7 +1415,7 @@ public void GetCooldownData_and_GetRemainingCooldownTime_return_correct_values() entity.EffectsManager.UpdateEffects(0.5f); - abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + abilityHandle!.TryActivate(out failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.Cooldown); cooldownData = abilityHandle.GetCooldownData()!; @@ -1464,7 +1464,7 @@ public void GetCooldownData_and_GetRemainingCooldownTime_return_correct_values() abilityHandle.GetRemainingCooldownTime(simpleTag).Should().Be(0f); abilityHandle.GetRemainingCooldownTime(tag).Should().Be(0f); - abilityHandle!.Activate(out failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); } @@ -1490,16 +1490,141 @@ [new ScalableFloat(3f)], new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); - abilityHandle.CommitCost(); + abilityHandle.TryCommitCost(); - abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + abilityHandle!.TryActivate(out failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.InsufficientResources); } + [Fact] + [Trait("Cost", null)] + public void Commit_cost_fails_when_the_resource_can_no_longer_be_paid() + { + 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!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + abilityHandle.TryCommitCost().Should().BeTrue(); + entity.Attributes["TestAttributeSet.Attribute90"].CurrentValue.Should().Be(40); + + // A commit can land arbitrarily late, by which point the resources checked on activation may be gone. + abilityHandle.TryCommitCost().Should().BeFalse(); + entity.Attributes["TestAttributeSet.Attribute90"].CurrentValue.Should().Be(40); + } + + [Fact] + [Trait("Cooldown", null)] + public void Commit_cooldown_fails_while_the_cooldown_is_running() + { + TestEntity entity = new(_tagsManager, _cuesManager); + var cooldownTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + abilityHandle.TryCommitCooldown().Should().BeTrue(); + abilityHandle.TryCommitCooldown().Should().BeFalse(); + + entity.EffectsManager.UpdateEffects(3f); + abilityHandle.GetRemainingCooldownTime(cooldownTag).Should().Be(0f); + + abilityHandle.TryCommitCooldown().Should().BeTrue(); + } + + [Fact] + [Trait("TryCommitAbility", null)] + public void Commit_ability_pays_nothing_when_the_cost_can_no_longer_be_paid() + { + TestEntity entity = new(_tagsManager, _cuesManager); + var cooldownTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + 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!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + abilityHandle.TryCommitCost().Should().BeTrue(); + entity.Attributes["TestAttributeSet.Attribute90"].CurrentValue.Should().Be(40); + + // The cooldown could still be started, but the cost cannot be paid, so neither half is committed. + abilityHandle.TryCommitAbility().Should().BeFalse(); + entity.Attributes["TestAttributeSet.Attribute90"].CurrentValue.Should().Be(40); + abilityHandle.GetRemainingCooldownTime(cooldownTag).Should().Be(0f); + } + + [Fact] + [Trait("TryCommitAbility", null)] + public void Commit_fails_on_an_invalid_handle() + { + 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); + + abilityHandle!.IsValid.Should().BeTrue(); + + entity.EffectsManager.RemoveEffect(effectHandle!); + abilityHandle.IsValid.Should().BeFalse(); + + abilityHandle.TryCommitAbility().Should().BeFalse(); + abilityHandle.TryCommitCooldown().Should().BeFalse(); + abilityHandle.TryCommitCost().Should().BeFalse(); + } + [Fact] [Trait("OwnerTag requirements", null)] public void Ability_wont_activate_when_owner_missing_required_tag() @@ -1521,7 +1646,7 @@ [new ScalableFloat(3f)], new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.OwnerTagRequirements); abilityHandle.IsActive.Should().BeFalse(); } @@ -1547,7 +1672,7 @@ [new ScalableFloat(3f)], new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.OwnerTagRequirements); abilityHandle.IsActive.Should().BeFalse(); } @@ -1575,7 +1700,7 @@ [new ScalableFloat(3f)], out _, sourceEntity: source); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.SourceTagRequirements); abilityHandle.IsActive.Should().BeFalse(); } @@ -1603,7 +1728,7 @@ [new ScalableFloat(3f)], out _, sourceEntity: source); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.SourceTagRequirements); abilityHandle.IsActive.Should().BeFalse(); } @@ -1630,7 +1755,7 @@ [new ScalableFloat(3f)], out _, sourceEntity: null); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.SourceTagRequirements); abilityHandle.IsActive.Should().BeFalse(); } @@ -1657,7 +1782,7 @@ [new ScalableFloat(3f)], out _, sourceEntity: null); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); } @@ -1684,7 +1809,7 @@ [new ScalableFloat(3f)], new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags, target).Should().BeFalse(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags, target).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.TargetTagRequirements); abilityHandle.IsActive.Should().BeFalse(); } @@ -1711,7 +1836,7 @@ [new ScalableFloat(3f)], new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags, target).Should().BeFalse(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags, target).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.TargetTagRequirements); abilityHandle.IsActive.Should().BeFalse(); } @@ -1737,7 +1862,7 @@ [new ScalableFloat(3f)], new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.TargetTagRequirements); abilityHandle.IsActive.Should().BeFalse(); } @@ -1763,7 +1888,7 @@ [new ScalableFloat(3f)], new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); } @@ -1819,21 +1944,21 @@ [new ScalableFloat(3f)], new ScalableInt(1), out _); - blockerAbilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + blockerAbilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); blockerAbilityHandle.IsActive.Should().BeTrue(); - unblockedAbilityHandle!.Activate(out failureFlags).Should().BeTrue(); + unblockedAbilityHandle!.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); unblockedAbilityHandle.IsActive.Should().BeTrue(); - blockedAbilityHandle!.Activate(out failureFlags).Should().BeFalse(); + blockedAbilityHandle!.TryActivate(out failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.BlockedByTags); blockedAbilityHandle.IsActive.Should().BeFalse(); blockerAbilityHandle!.Cancel(); - blockedAbilityHandle!.Activate(out failureFlags).Should().BeTrue(); + blockedAbilityHandle!.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); blockedAbilityHandle.IsActive.Should().BeTrue(); } @@ -1856,12 +1981,12 @@ [new ScalableFloat(3f)], AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); // No retrigger, single instance. - handle!.Activate(out failureFlags).Should().BeFalse(); + handle!.TryActivate(out failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.PersistentInstanceActive); handle.IsActive.Should().BeTrue(); @@ -1887,12 +2012,12 @@ [new ScalableFloat(3f)], AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); // Retrigger replaces the running instance. - handle!.Activate(out AbilityActivationFailures failureFlags2).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags2).Should().BeTrue(); failureFlags2.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); @@ -1918,15 +2043,15 @@ [new ScalableFloat(3f)], AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); - handle!.Activate(out failureFlags).Should().BeTrue(); + handle!.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); - handle!.Activate(out failureFlags).Should().BeTrue(); + handle!.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); @@ -1952,11 +2077,11 @@ [new ScalableFloat(3f)], AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); - handle!.Activate(out failureFlags).Should().BeTrue(); + handle!.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); @@ -1982,15 +2107,15 @@ [new ScalableFloat(3f)], AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); - handle!.Activate(out failureFlags).Should().BeTrue(); + handle!.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); - handle!.Activate(out failureFlags).Should().BeTrue(); + handle!.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); @@ -2026,11 +2151,11 @@ [new ScalableFloat(3f)], AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); AbilityHandle? victimHandle = SetupAbility(entity, victim, new ScalableInt(1), out _); - victimHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + victimHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); victimHandle.IsActive.Should().BeTrue(); - cancellerHandle!.Activate(out failureFlags).Should().BeTrue(); + cancellerHandle!.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); cancellerHandle.IsActive.Should().BeTrue(); @@ -2064,11 +2189,11 @@ [new ScalableFloat(3f)], AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); AbilityHandle? unrelatedHandle = SetupAbility(entity, unrelated, new ScalableInt(1), out _); - unrelatedHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + unrelatedHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); unrelatedHandle.IsActive.Should().BeTrue(); - cancellerHandle!.Activate(out failureFlags).Should().BeTrue(); + cancellerHandle!.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); cancellerHandle.IsActive.Should().BeTrue(); @@ -2159,7 +2284,7 @@ public void Activating_an_ability_with_an_empty_cancel_container_cancels_nothing cancelAbilitiesWithTag: new TagContainer(_tagsManager)); AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); - cancellerHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + cancellerHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); redHandle.IsActive.Should().BeTrue(); @@ -2194,11 +2319,11 @@ [new ScalableFloat(3f)], AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); AbilityHandle? victimHandle = SetupAbility(entity, victim, new ScalableInt(1), out _); - victimHandle!.Activate(out AbilityActivationFailures failureFlagsA).Should().BeTrue(); + victimHandle!.TryActivate(out AbilityActivationFailures failureFlagsA).Should().BeTrue(); failureFlagsA.Should().Be(AbilityActivationFailures.None); victimHandle.IsActive.Should().BeTrue(); - cancellerHandle!.Activate(out AbilityActivationFailures failureFlagsB).Should().BeTrue(); + cancellerHandle!.TryActivate(out AbilityActivationFailures failureFlagsB).Should().BeTrue(); failureFlagsB.Should().Be(AbilityActivationFailures.None); cancellerHandle.IsActive.Should().BeTrue(); @@ -2224,7 +2349,7 @@ [new ScalableFloat(3f)], AbilityHandle? handle = SetupAbility(entity, selfCanceller, new ScalableInt(1), out _); - handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); } @@ -2257,22 +2382,22 @@ [new ScalableFloat(3f)], AbilityHandle? blockerHandle = SetupAbility(entity, blocker, new ScalableInt(1), out _); AbilityHandle? blockedHandle = SetupAbility(entity, blocked, new ScalableInt(1), out _); - blockerHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + blockerHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); blockerHandle.IsActive.Should().BeTrue(); - blockerHandle!.Activate(out failureFlags).Should().BeTrue(); + blockerHandle!.TryActivate(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 failureFlags).Should().BeFalse(); + blockedHandle!.TryActivate(out failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.BlockedByTags); blockedHandle.IsActive.Should().BeFalse(); // End all blocker instances. blockerHandle.Cancel(); - blockedHandle.Activate(out failureFlags).Should().BeTrue(); + blockedHandle.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); blockedHandle.IsActive.Should().BeTrue(); } @@ -2295,7 +2420,7 @@ [new ScalableFloat(3f)], AbilityHandle? handle = SetupAbility(entity, abilityWithOwned, new ScalableInt(1), out _); - handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); entity.Tags.AllTags.HasAll(ownedTags).Should().BeTrue(); handle.IsActive.Should().BeTrue(); @@ -2323,15 +2448,15 @@ [new ScalableFloat(3f)], AbilityHandle? handle = SetupAbility(entity, abilityWithOwned, new ScalableInt(1), out _); - handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); - handle!.Activate(out failureFlags).Should().BeTrue(); + handle!.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); - handle!.Activate(out failureFlags).Should().BeTrue(); + handle!.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); @@ -2369,20 +2494,20 @@ [new ScalableFloat(3f)], AbilityHandle? needsHandle = SetupAbility(entity, requiresBuff, new ScalableInt(1), out _); // Cannot activate without buff. - needsHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + needsHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.OwnerTagRequirements); needsHandle.IsActive.Should().BeFalse(); // Gain buff, then can activate. - giverHandle!.Activate(out failureFlags).Should().BeTrue(); + giverHandle!.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); - needsHandle.Activate(out failureFlags).Should().BeTrue(); + needsHandle.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); // Lose buff, then cannot activate again. giverHandle.Cancel(); needsHandle.Cancel(); - needsHandle.Activate(out failureFlags).Should().BeFalse(); + needsHandle.TryActivate(out failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.OwnerTagRequirements); } @@ -2413,9 +2538,9 @@ [new ScalableFloat(3f)], grantHandle.Should().NotBeNull(); // Activate twice to simulate two instances. - handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); - handle!.Activate(out failureFlags).Should().BeTrue(); + handle!.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); // Remove grant; ability should not be removed until all instances end. @@ -2446,13 +2571,13 @@ [new ScalableFloat(3f)], AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(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 failureFlags).Should().BeTrue(); + handle.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); } @@ -2487,7 +2612,7 @@ [new ScalableFloat(3f)], victimHandle!.IsActive.Should().BeFalse(); // Activating canceller should not affect inactive victim. - cancellerHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + cancellerHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); victimHandle.IsActive.Should().BeFalse(); entity.Abilities.GrantedAbilities.Should().Contain(victimHandle); @@ -2524,9 +2649,9 @@ [new ScalableFloat(3f)], AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); AbilityHandle? victimHandle = SetupAbility(entity, victim, new ScalableInt(1), out _); - victimHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + victimHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); - cancellerHandle!.Activate(out failureFlags).Should().BeTrue(); + cancellerHandle!.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); // Victim must be canceled; canceller remains active. @@ -2575,10 +2700,219 @@ public void Cost_effect_reports_configured_cost_and_applies_on_commit( abilityHandle.GetCostForAttribute("TestAttributeSet.Attribute90").Should().Be(expectedCost); - abilityHandle.CommitCost(); + abilityHandle.TryCommitCost().Should().BeTrue(); entity.Attributes["TestAttributeSet.Attribute90"].CurrentValue.Should().Be(finalValue); } + [Theory] + + // Attribute90 starts at 90 within bounds [0, 99]. Only a flat modifier's magnitude is the change it makes to the + // attribute; for the other operations the magnitude describes the result, so the affordability check has to derive + // the change before comparing it against the remaining headroom. + [InlineData(ModifierOperation.Override, 98f, true, 98)] + [InlineData(ModifierOperation.Override, -5f, false, 90)] + [InlineData(ModifierOperation.PercentBonus, 0.2f, false, 90)] + [InlineData(ModifierOperation.PercentBonus, -0.1f, true, 81)] + [Trait("Ability cost", null)] + public void Cost_affordability_accounts_for_the_modifier_operation( + ModifierOperation operation, float magnitude, bool affordable, 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 _); + + // Activation and commit share the affordability check, so both have to agree with the operation's real change. + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().Be(affordable); + failureFlags.Should().Be( + affordable ? AbilityActivationFailures.None : AbilityActivationFailures.InsufficientResources); + + abilityHandle.TryCommitCost().Should().Be(affordable); + entity.Attributes["TestAttributeSet.Attribute90"].CurrentValue.Should().Be(finalValue); + } + + [Fact] + [Trait("Ability cost", null)] + public void Cost_affordability_is_measured_against_the_value_a_commit_would_spend_from() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + // Attribute100 starts at 100 within bounds [0, 1000]. A buff raises the current value without moving the base + // value, and an instant cost spends from the base value. + var buffEffectData = new EffectData( + "Mana Buff", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute100", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(200))) + ]); + + entity.EffectsManager.ApplyEffect(new Effect(buffEffectData, new EffectOwnership(entity, entity))); + + entity.Attributes["TestAttributeSet.Attribute100"].BaseValue.Should().Be(100); + entity.Attributes["TestAttributeSet.Attribute100"].CurrentValue.Should().Be(300); + + var costEffectData = new EffectData( + "Fireball Cost", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute100", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-150))) + ]); + + AbilityData abilityData = new("Fireball", costEffectData); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + // The quoted cost stays the nominal ask even though the owner cannot cover it. + abilityHandle!.GetCostForAttribute("TestAttributeSet.Attribute100").Should().Be(-150); + + // 150 fits under the current value of 300 but not under the 100 a commit would actually spend from, and a + // commit that went ahead would clamp at 0 and quietly charge 100 instead of 150. + abilityHandle.TryActivate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.InsufficientResources); + + abilityHandle.TryCommitCost().Should().BeFalse(); + entity.Attributes["TestAttributeSet.Attribute100"].BaseValue.Should().Be(100); + entity.Attributes["TestAttributeSet.Attribute100"].CurrentValue.Should().Be(300); + } + + [Fact] + [Trait("Ability cost", null)] + public void Commit_fails_when_an_application_blocker_turns_the_cost_effect_away() + { + 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(-10))) + ]); + + AbilityData abilityData = new("Fireball", costEffectData); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + // An immunity registers itself as an application blocker, so the cost effect never reaches the attributes. + var immunityEffectData = new EffectData( + "Cost Immunity", + new DurationData(DurationType.Infinite), + effectComponents: [new ImmunityEffectComponent([new EffectQuery(costEffectData)])]); + + entity.EffectsManager.ApplyEffect(new Effect(immunityEffectData, new EffectOwnership(entity, entity))); + + // The cost is perfectly affordable; it just cannot land. Reporting success here would hand out a free cast. + abilityHandle!.TryCommitCost().Should().BeFalse(); + entity.Attributes["TestAttributeSet.Attribute90"].CurrentValue.Should().Be(90); + + abilityHandle.TryCommitAbility().Should().BeFalse(); + entity.Attributes["TestAttributeSet.Attribute90"].CurrentValue.Should().Be(90); + } + + [Fact] + [Trait("Ability cost", null)] + public void Cost_is_unaffordable_when_the_owner_has_no_attribute_to_charge_it_against() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var costEffectData = new EffectData( + "Fireball Cost", + new DurationData(DurationType.Instant), + [ + new Modifier( + "MissingAttributeSet.Mana", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-10))) + ]); + + AbilityData abilityData = new("Fireball", costEffectData); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + // Applying the effect would skip the modifier, so without this gate the ability would be cast for free. + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.InsufficientResources); + + abilityHandle.TryCommitCost().Should().BeFalse(); + + // There is no attribute to quote a cost against, so the cost data stays empty while the ability stays blocked. + abilityHandle.GetCostData().Should().BeEmpty(); + abilityHandle.GetCostForAttribute("MissingAttributeSet.Mana").Should().Be(0); + } + + [Fact] + [Trait("Ability cost", null)] + public void Cost_affordability_compounds_modifiers_charged_against_the_same_attribute() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + // Attribute90 starts at 90. Either half is affordable on its own; together they overdraw it. + var costEffectData = new EffectData( + "Fireball Cost", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute90", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-50))), + new Modifier( + "TestAttributeSet.Attribute90", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-50))) + ]); + + AbilityData abilityData = new("Fireball", costEffectData); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.GetCostForAttribute("TestAttributeSet.Attribute90").Should().Be(-100); + + abilityHandle.TryActivate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.InsufficientResources); + + abilityHandle.TryCommitCost().Should().BeFalse(); + entity.Attributes["TestAttributeSet.Attribute90"].CurrentValue.Should().Be(90); + } + [Fact] [Trait("Ability cost", null)] public void Cost_effect_with_multiple_modifiers_reports_configured_cost_and_applies_on_commit() @@ -2617,7 +2951,7 @@ public void Cost_effect_with_multiple_modifiers_reports_configured_cost_and_appl abilityHandle.GetCostForAttribute("TestAttributeSet.Attribute90").Should().Be(-9); - abilityHandle.CommitCost(); + abilityHandle.TryCommitCost().Should().BeTrue(); entity.Attributes["TestAttributeSet.Attribute90"].CurrentValue.Should().Be(81); } @@ -2766,7 +3100,7 @@ public void OnAbilityEnded_fires_when_ability_instance_is_canceled() }; // Activate the ability - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); abilityHandle.Cancel(); // Verify the event was fired exactly once, with the canceled flag set. @@ -2789,13 +3123,14 @@ [new ScalableFloat(3f)], "TestAttributeSet.Attribute90", new ScalableFloat(-1)); - AbilityHandle? abilityHandle = entity.Abilities.GrantAbilityAndActivateOnce( + entity.Abilities.TryGrantAbilityAndActivateOnce( abilityData, 1, LevelComparison.None, out AbilityActivationFailures failureFlags, + out AbilityHandle? abilityHandle, entity, - entity); + entity).Should().BeTrue(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); @@ -2819,19 +3154,77 @@ [new ScalableFloat(3f)], "TestAttributeSet.Attribute90", new ScalableFloat(-100)); - AbilityHandle? abilityHandle = entity.Abilities.GrantAbilityAndActivateOnce( + entity.Abilities.TryGrantAbilityAndActivateOnce( abilityData, 1, LevelComparison.None, out AbilityActivationFailures failureFlags, + out AbilityHandle? abilityHandle, entity, - entity); + entity).Should().BeFalse(); entity.Abilities.GrantedAbilities.Should().BeEmpty(); failureFlags.Should().Be(AbilityActivationFailures.InsufficientResources); abilityHandle.Should().BeNull(); } + [Fact] + [Trait("Grant ability", null)] + public void Ability_granted_and_activated_once_outputs_no_handle_when_another_grant_keeps_it_alive() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-100)); + + // A pre-existing grant of the same ability from the same source shares one handle, so removing the transient + // grant afterwards leaves that handle valid even though this activation never happened. + entity.Abilities.GrantAbilityPermanently(abilityData, 1, LevelComparison.None, sourceEntity: null); + + entity.Abilities.TryGrantAbilityAndActivateOnce( + abilityData, + 1, + LevelComparison.None, + out AbilityActivationFailures failureFlags, + out AbilityHandle? grantedAbility).Should().BeFalse(); + + failureFlags.Should().Be(AbilityActivationFailures.InsufficientResources); + grantedAbility.Should().BeNull(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_granted_and_activated_once_reports_success_even_when_it_ends_immediately() + { + TestEntity entity = new(_tagsManager, _cuesManager); + int activationCount = 0; + + AbilityData abilityData = new( + "Fireball", + behaviorFactory: () => new CountingAbilityBehavior(() => activationCount++)); + + entity.Abilities.TryGrantAbilityAndActivateOnce( + abilityData, + 1, + LevelComparison.None, + out AbilityActivationFailures failureFlags, + out AbilityHandle? abilityHandle, + entity, + entity).Should().BeTrue(); + + failureFlags.Should().Be(AbilityActivationFailures.None); + activationCount.Should().Be(1); + + // The behavior ended the instance inside the call, so the transient grant went with it. A null handle is not a + // failure signal — the return value is. + abilityHandle.Should().BeNull(); + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + } + [Fact] [Trait("Failure reason", null)] public void Failure_reason_contains_all_failureFlags_reasons() @@ -2852,13 +3245,13 @@ [new ScalableFloat(3f)], new ScalableInt(1), out _); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); - abilityHandle.CommitAbility(); + abilityHandle.TryCommitAbility(); - abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + abilityHandle!.TryActivate(out failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.InsufficientResources | AbilityActivationFailures.Cooldown); } @@ -2887,7 +3280,7 @@ [new ScalableFloat(3f)], abilityHandle.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); - abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); abilityHandle.IsActive.Should().BeTrue(); @@ -3524,7 +3917,7 @@ private AbilityHandle ActivateTaggedAbility(TestEntity entity, string abilityNam AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); diff --git a/Forge.Tests/Abilities/AbilityBehaviorTests.cs b/Forge.Tests/Abilities/AbilityBehaviorTests.cs index a8d67d60..ce47cc9d 100644 --- a/Forge.Tests/Abilities/AbilityBehaviorTests.cs +++ b/Forge.Tests/Abilities/AbilityBehaviorTests.cs @@ -30,7 +30,7 @@ public void Behavior_OnStarted_and_OnEnded_are_invoked_per_instance() AbilityHandle? handle = Grant(entity, data); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); behavior.StartCount.Should().Be(1); behavior.EndCount.Should().Be(0); @@ -58,9 +58,9 @@ public void PerExecution_creates_distinct_behavior_instances() AbilityHandle? handle = Grant(entity, data); handle.Should().NotBeNull(); - handle!.Activate(out _).Should().BeTrue(); - handle.Activate(out _).Should().BeTrue(); - handle.Activate(out _).Should().BeTrue(); + handle!.TryActivate(out _).Should().BeTrue(); + handle.TryActivate(out _).Should().BeTrue(); + handle.TryActivate(out _).Should().BeTrue(); behaviors.Should().HaveCount(3); behaviors.Sum(x => x.StartCount).Should().Be(3); behaviors.Sum(x => x.EndCount).Should().Be(0); @@ -96,23 +96,23 @@ public void Blocked_ability_tags_are_removed_only_after_last_instance_ends() AbilityHandle? blockerHandle = Grant(entity, blocker); AbilityHandle? blockedHandle = Grant(entity, blocked); - blockerHandle!.Activate(out _).Should().BeTrue(); - blockerHandle!.Activate(out _).Should().BeTrue(); + blockerHandle!.TryActivate(out _).Should().BeTrue(); + blockerHandle!.TryActivate(out _).Should().BeTrue(); // While any blocker instance active, blocked ability cannot activate. - blockedHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + blockedHandle!.TryActivate(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 failureFlags).Should().BeFalse(); + blockedHandle.TryActivate(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 failureFlags).Should().BeTrue(); + blockedHandle.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); blockedHandle.IsActive.Should().BeTrue(); } @@ -139,9 +139,9 @@ public void Activation_owned_tags_are_applied_on_activation_and_removed_after_la AbilityHandle? handle = Grant(entity, abilityWithOwned); - handle!.Activate(out _).Should().BeTrue(); - handle!.Activate(out _).Should().BeTrue(); - handle!.Activate(out _).Should().BeTrue(); + handle!.TryActivate(out _).Should().BeTrue(); + handle!.TryActivate(out _).Should().BeTrue(); + handle!.TryActivate(out _).Should().BeTrue(); entity.Tags.AllTags.HasAll(ownedTags).Should().BeTrue(); @@ -182,10 +182,10 @@ public void PerEntity_retrigger_invokes_previous_OnEnded_before_new_OnStarted() AbilityHandle? handle = Grant(entity, data); handle.Should().NotBeNull(); - handle!.Activate(out _).Should().BeTrue(); + handle!.TryActivate(out _).Should().BeTrue(); // Second activation retriggers: ability should call previous.OnEnded() before new.OnStarted(). - handle.Activate(out _).Should().BeTrue(); + handle.TryActivate(out _).Should().BeTrue(); endedBeforeNew.Should().BeTrue("the previous instance should have ended before the new one started"); } @@ -203,7 +203,7 @@ public void Context_provides_expected_values() AbilityHandle? handle = Grant(target, data, sourceEntity: source); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationFailures failureFlags, target).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags, target).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); captured.Should().NotBeNull(); captured!.Owner.Should().Be(target); @@ -225,17 +225,17 @@ public void Behavior_can_end_instance_during_OnStarted() AbilityHandle? handle = Grant(entity, data); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeFalse(); } [Fact] - [Trait("CommitAbility", null)] + [Trait("TryCommitAbility", null)] public void Behavior_commits_cooldown_and_cost_on_start() { var entity = new TestEntity(_tagsManager, _cuesManager); - var behavior = new CallbackBehavior(x => x.AbilityHandle.CommitAbility()); + var behavior = new CallbackBehavior(x => x.AbilityHandle.TryCommitAbility()); AbilityData data = CreateAbilityData( "CommitOnStart", @@ -247,18 +247,18 @@ public void Behavior_commits_cooldown_and_cost_on_start() AbilityHandle? handle = Grant(entity, data); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(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 failureFlags).Should().BeFalse(); + handle.TryActivate(out failureFlags).Should().BeFalse(); failureFlags.Should().Be(AbilityActivationFailures.Cooldown); // Advance time until cooldown expires. entity.EffectsManager.UpdateEffects(2f); - handle.Activate(out failureFlags).Should().BeTrue(); + handle.TryActivate(out failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); } @@ -274,7 +274,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 AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); handle.IsActive.Should().BeFalse(); behavior.StartAttempts.Should().Be(1); } @@ -290,7 +290,7 @@ public void Exception_in_OnEnded_does_not_prevent_deactivation() AbilityHandle? handle = Grant(entity, data); handle.Should().NotBeNull(); - handle!.Activate(out _).Should().BeTrue(); + handle!.TryActivate(out _).Should().BeTrue(); handle.IsActive.Should().BeTrue(); handle.Cancel(); @@ -313,7 +313,7 @@ public void Null_behavior_instance_is_ignored() AbilityHandle? handle = Grant(entity, data); handle.Should().NotBeNull(); - handle!.Activate(out _).Should().BeTrue(); + handle!.TryActivate(out _).Should().BeTrue(); handle.IsActive.Should().BeTrue(); handle.Cancel(); @@ -333,7 +333,7 @@ public void OnAbilityEnded_fires_when_ability_instance_ends() AbilityEndedData? capturedData = null; entity.Abilities.OnAbilityEnded += x => { capturedData = x; }; - handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); behavior.StartCount.Should().Be(1); behavior.EndCount.Should().Be(0); @@ -356,14 +356,17 @@ public void Ability_is_granted_and_activated_once() var behavior = new TrackingBehavior(); AbilityData data = CreateAbilityData("Tracked", behaviorFactory: () => behavior); - entity.Abilities.GrantAbilityAndActivateOnce( + entity.Abilities.TryGrantAbilityAndActivateOnce( data, 1, LevelComparison.None, - out AbilityActivationFailures failureFlags); + out AbilityActivationFailures failureFlags, + out AbilityHandle? grantedAbility).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); + // The behavior keeps the instance running, so the transient grant is still in place. + grantedAbility.Should().NotBeNull(); entity.Abilities.GrantedAbilities.Should().ContainSingle(); behavior.StartCount.Should().Be(1); behavior.EndCount.Should().Be(0); @@ -385,19 +388,22 @@ public void Ability_is_granted_and_activated_once_with_typed_data() "TypedProc", behaviorFactory: () => new TypedPayloadBehavior((_, x) => capturedData = x)); - entity.Abilities.GrantAbilityAndActivateOnce( + entity.Abilities.TryGrantAbilityAndActivateOnce( data, 1, LevelComparison.None, new TestActivationData("Proc", 7), - out AbilityActivationFailures failureFlags); + out AbilityActivationFailures failureFlags, + out AbilityHandle? grantedAbility).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); capturedData.Should().NotBeNull(); capturedData!.StringValue.Should().Be("Proc"); capturedData.IntValue.Should().Be(7); - // Transient grant: the behavior ends the instance, so the ability is removed again. + // Transient grant: the behavior ends the instance, so the ability is removed again and the handle is freed + // even though the activation succeeded. + grantedAbility.Should().BeNull(); entity.Abilities.GrantedAbilities.Should().BeEmpty(); } @@ -479,7 +485,7 @@ public void Generic_activate_creates_typed_context_with_payload() handle.Should().NotBeNull(); var activationData = new TestActivationData("TestValue", 42); - handle!.Activate(activationData, out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(activationData, out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); capturedContext.Should().NotBeNull(); @@ -504,7 +510,7 @@ public void Non_generic_activate_creates_base_context() AbilityHandle? handle = Grant(entity, data); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); capturedContext.Should().NotBeNull(); @@ -526,7 +532,7 @@ public void Value_type_payload_is_preserved_in_context() handle.Should().NotBeNull(); var activationData = new ValueTypeActivationData(1.5f, 2.5f, 3.5f); - handle!.Activate(activationData, out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(activationData, out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); capturedContext.Should().NotBeNull(); @@ -556,8 +562,8 @@ public void Context_data_is_passed_through_for_each_instance_in_PerExecution() var activationData1 = new TestActivationData("First", 1); var activationData2 = new TestActivationData("Second", 2); - handle!.Activate(activationData1, out _).Should().BeTrue(); - handle.Activate(activationData2, out _).Should().BeTrue(); + handle!.TryActivate(activationData1, out _).Should().BeTrue(); + handle.TryActivate(activationData2, out _).Should().BeTrue(); capturedContexts.Should().HaveCount(2); @@ -591,8 +597,8 @@ public void PerEntity_retrigger_passes_new_context_data() var activationData1 = new TestActivationData("First", 1); var activationData2 = new TestActivationData("Second", 2); - handle!.Activate(activationData1, out _).Should().BeTrue(); - handle.Activate(activationData2, out _).Should().BeTrue(); + handle!.TryActivate(activationData1, out _).Should().BeTrue(); + handle.TryActivate(activationData2, out _).Should().BeTrue(); // Both activations should have succeeded with their own context data capturedContexts.Should().HaveCount(2); @@ -712,7 +718,7 @@ public void Event_triggered_ability_with_value_type_payload_receives_payload() public void Event_triggered_ability_respects_cooldown() { var entity = new TestEntity(_tagsManager, _cuesManager); - var behavior = new CallbackBehavior(x => x.AbilityHandle.CommitAbility()); + var behavior = new CallbackBehavior(x => x.AbilityHandle.TryCommitAbility()); var eventTag = Tag.RequestTag(_tagsManager, "color.dark.red"); AbilityData data = CreateAbilityData( @@ -778,7 +784,7 @@ public void Context_contains_magnitude_when_activated_with_magnitude() AbilityHandle? handle = Grant(entity, data); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationFailures failureFlags, magnitude: 75.5f).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags, magnitude: 75.5f).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); capturedContext.Should().NotBeNull(); @@ -799,7 +805,7 @@ public void Context_magnitude_defaults_to_zero_when_not_specified() AbilityHandle? handle = Grant(entity, data); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); capturedContext.Should().NotBeNull(); @@ -884,7 +890,7 @@ public void Generic_activate_passes_both_data_and_magnitude() handle.Should().NotBeNull(); var activationData = new TestActivationData("TestValue", 42); - handle!.Activate(activationData, out AbilityActivationFailures failureFlags, magnitude: 100f).Should().BeTrue(); + handle!.TryActivate(activationData, out AbilityActivationFailures failureFlags, magnitude: 100f).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); capturedContext.Should().NotBeNull(); @@ -911,9 +917,9 @@ public void Magnitude_is_preserved_across_instances_in_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(); + handle!.TryActivate(out _, magnitude: 10f).Should().BeTrue(); + handle.TryActivate(out _, magnitude: 20f).Should().BeTrue(); + handle.TryActivate(out _, magnitude: 30f).Should().BeTrue(); capturedMagnitudes.Should().HaveCount(3); capturedMagnitudes.Should().HaveElementAt(0, 10f); @@ -936,8 +942,8 @@ public void PerEntity_retrigger_uses_new_magnitude() AbilityHandle? handle = Grant(entity, data); handle.Should().NotBeNull(); - handle!.Activate(out _, magnitude: 50f).Should().BeTrue(); - handle.Activate(out _, magnitude: 75f).Should().BeTrue(); + handle!.TryActivate(out _, magnitude: 50f).Should().BeTrue(); + handle.TryActivate(out _, magnitude: 75f).Should().BeTrue(); capturedMagnitudes.Should().HaveCount(2); capturedMagnitudes.Should().HaveElementAt(0, 50f); diff --git a/Forge.Tests/Abilities/EntityAbilitiesEventsTests.cs b/Forge.Tests/Abilities/EntityAbilitiesEventsTests.cs index 4e332133..fb3b3c5a 100644 --- a/Forge.Tests/Abilities/EntityAbilitiesEventsTests.cs +++ b/Forge.Tests/Abilities/EntityAbilitiesEventsTests.cs @@ -166,7 +166,7 @@ public void Activating_an_ability_reports_activated_then_ended() var log = new EventLog(entity.Abilities); - handle.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); log.Entries.Should().Equal("Activated"); @@ -192,7 +192,7 @@ public void A_behavior_that_finishes_synchronously_still_reports_activated_befor var log = new EventLog(entity.Abilities); - handle.Activate(out _).Should().BeTrue(); + handle.TryActivate(out _).Should().BeTrue(); log.Entries.Should().Equal("Activated", "Ended"); handle.IsActive.Should().BeFalse(); @@ -211,8 +211,8 @@ public void A_second_concurrent_instance_reports_nothing_until_the_last_one_ends var log = new EventLog(entity.Abilities); - handle.Activate(out _).Should().BeTrue(); - handle.Activate(out _).Should().BeTrue(); + handle.TryActivate(out _).Should().BeTrue(); + handle.TryActivate(out _).Should().BeTrue(); // Both events track the ability, not its instances, so the second concurrent instance is silent. log.Entries.Should().Equal("Activated"); @@ -235,7 +235,7 @@ public void A_refused_activation_reports_the_failure_flags() var log = new EventLog(entity.Abilities); - handle.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + handle.TryActivate(out AbilityActivationFailures failureFlags).Should().BeFalse(); log.Entries.Should().Equal("ActivationFailed"); log.Failures.Should().ContainSingle(); @@ -253,7 +253,7 @@ public void A_successful_activation_reports_no_failure() CreateAbilityData("Fireball"), 1, LevelComparison.Higher, null); var log = new EventLog(entity.Abilities); - handle.Activate(out _).Should().BeTrue(); + handle.TryActivate(out _).Should().BeTrue(); log.Failures.Should().BeEmpty(); } diff --git a/Forge.Tests/Effects/BlockAbilityTagsComponentTests.cs b/Forge.Tests/Effects/BlockAbilityTagsComponentTests.cs index a7710e89..2defd6a0 100644 --- a/Forge.Tests/Effects/BlockAbilityTagsComponentTests.cs +++ b/Forge.Tests/Effects/BlockAbilityTagsComponentTests.cs @@ -56,7 +56,7 @@ public void Blocked_ability_fails_to_actually_activate() entity.EffectsManager.ApplyEffect(CreateBlockingEffect(entity, ["item.equipment.weapon.axe"], 10f)); - ability.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + ability.TryActivate(out AbilityActivationFailures failureFlags).Should().BeFalse(); failureFlags.Should().HaveFlag(AbilityActivationFailures.BlockedByTags); ability.IsActive.Should().BeFalse(); } diff --git a/Forge.Tests/Effects/CancelAbilityTagsComponentTests.cs b/Forge.Tests/Effects/CancelAbilityTagsComponentTests.cs index 29979ef6..a0edeea1 100644 --- a/Forge.Tests/Effects/CancelAbilityTagsComponentTests.cs +++ b/Forge.Tests/Effects/CancelAbilityTagsComponentTests.cs @@ -93,7 +93,7 @@ public void Duration_effect_cancels_once_on_application_and_not_on_updates() red.IsActive.Should().BeFalse(); // Re-activating mid-effect must stick: OnApplication does not fire again on update. - red.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + red.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); entity.EffectsManager.UpdateEffects(5f); @@ -120,7 +120,7 @@ public void Periodic_effect_cancels_on_each_execution() // ExecuteOnApplication is on, so the first execution happens right away. red.IsActive.Should().BeFalse(); - red.Activate(out _).Should().BeTrue(); + red.TryActivate(out _).Should().BeTrue(); red.IsActive.Should().BeTrue(); // No execution yet, so the re-activated ability survives. @@ -150,7 +150,7 @@ public void On_application_policy_does_not_fire_on_periodic_executions() red.IsActive.Should().BeFalse(); - red.Activate(out _).Should().BeTrue(); + red.TryActivate(out _).Should().BeTrue(); entity.EffectsManager.UpdateEffects(3f); @@ -224,7 +224,7 @@ private AbilityHandle ActivateAbility(TestEntity entity, string abilityName, str AbilityHandle handle = entity.Abilities.GrantAbilityPermanently(abilityData, 1, LevelComparison.None, null); - handle.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); diff --git a/Forge.Tests/Helpers/StatescriptTestHelpers.cs b/Forge.Tests/Helpers/StatescriptTestHelpers.cs index 5dffe622..d2c69065 100644 --- a/Forge.Tests/Helpers/StatescriptTestHelpers.cs +++ b/Forge.Tests/Helpers/StatescriptTestHelpers.cs @@ -149,7 +149,7 @@ public static GraphContext CreateAbilityGraphContext( ?? throw new InvalidOperationException( "Failed to grant the resolver test ability and create an ability graph context."); - if (!handle.Activate(out _, target, magnitude)) + if (!handle.TryActivate(out _, target, magnitude)) { throw new InvalidOperationException( "Failed to activate the resolver test ability while creating an ability graph context." + @@ -186,7 +186,7 @@ public static GraphContext CreateAbilityGraphContext( ?? throw new InvalidOperationException( "Failed to grant the typed resolver test ability and create an ability graph context."); - if (!handle.Activate(activationData, out _, magnitude: magnitude)) + if (!handle.TryActivate(activationData, out _, magnitude: magnitude)) { throw new InvalidOperationException( "Failed to activate the typed resolver test ability while creating an ability graph context." + @@ -216,7 +216,7 @@ public static void ExecuteAbilityGraph( AbilityHandle handle = Grant(owner, abilityData, source, level) ?? throw new InvalidOperationException("Failed to grant the resolver execution test ability."); - if (!handle.Activate(out _, target, magnitude)) + if (!handle.TryActivate(out _, target, magnitude)) { throw new InvalidOperationException("Failed to activate the resolver execution test ability."); } diff --git a/Forge.Tests/Samples/QuickStartTests.cs b/Forge.Tests/Samples/QuickStartTests.cs index 8c11cbe0..48ec29d4 100644 --- a/Forge.Tests/Samples/QuickStartTests.cs +++ b/Forge.Tests/Samples/QuickStartTests.cs @@ -933,7 +933,7 @@ public void Granting_activating_and_removing_an_ability() // Retrieve handle directly from component as shown in docs var fireballAbilityHandle = grantEffectHandle.GetComponent().GrantedAbilities[0]; - bool successfulActivation = fireballAbilityHandle.Activate(out AbilityActivationFailures failures); + bool successfulActivation = fireballAbilityHandle.TryActivate(out AbilityActivationFailures failures); successfulActivation.Should().BeTrue(); failures.Should().Be(AbilityActivationFailures.None); @@ -1005,7 +1005,7 @@ public void Activating_an_ability_with_checks() costs.Should().Contain(c => c.Attribute == "PlayerAttributeSet.Mana" && c.Cost == -20); // Activate - bool success = handle.Activate(out AbilityActivationFailures failures); + bool success = handle.TryActivate(out AbilityActivationFailures failures); success.Should().BeTrue(); // Verify resources consumed and cooldown started @@ -1030,18 +1030,21 @@ public void Granting_an_ability_and_activating_once() behaviorFactory: () => new CustomAbilityBehavior("Fireball")); // Simulate using a scroll - AbilityHandle? handle = player.Abilities.GrantAbilityAndActivateOnce( + bool cast = player.Abilities.TryGrantAbilityAndActivateOnce( abilityData: fireballData, abilityLevel: 1, levelOverridePolicy: LevelComparison.None, out AbilityActivationFailures failureFlags, + out AbilityHandle? handle, targetEntity: player, // Target of the fireball sourceEntity: player // Source (e.g., the scroll item) ); - // Fireball ends instantly so handle is null - handle.Should().BeNull(); + cast.Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); + + // Fireball ends instantly so the handle is null even though the cast succeeded + handle.Should().BeNull(); } [Fact] @@ -1356,7 +1359,7 @@ private class CustomAbilityBehavior(string parameter) : IAbilityBehavior { public void OnStarted(AbilityBehaviorContext context) { - context.AbilityHandle.CommitAbility(); + context.AbilityHandle.TryCommitAbility(); // Instantiate a projectile here (omitted for brevity) Console.WriteLine($"{context.Owner} used ability ({parameter}) on target {context.Target}"); @@ -1374,7 +1377,7 @@ private class PersistentAbilityBehavior : IAbilityBehavior { public void OnStarted(AbilityBehaviorContext context) { - context.AbilityHandle.CommitAbility(); + context.AbilityHandle.TryCommitAbility(); // Does NOT call End() to simulate a persistent effect/aura } diff --git a/Forge.Tests/Statescript/GraphAbilityBehaviorTests.cs b/Forge.Tests/Statescript/GraphAbilityBehaviorTests.cs index a5274a18..0a6a3c5b 100644 --- a/Forge.Tests/Statescript/GraphAbilityBehaviorTests.cs +++ b/Forge.Tests/Statescript/GraphAbilityBehaviorTests.cs @@ -43,7 +43,7 @@ public void Action_only_graph_ends_ability_instance_on_start() AbilityHandle? handle = Grant(entity, abilityData); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); actionNode.ExecutionCount.Should().Be(1); @@ -70,7 +70,7 @@ public void Timer_graph_keeps_ability_active_until_timer_completes() AbilityHandle? handle = Grant(entity, abilityData); handle.Should().NotBeNull(); - handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); handle.IsActive.Should().BeTrue(); @@ -101,7 +101,7 @@ public void Canceling_ability_stops_graph() AbilityHandle? handle = Grant(entity, abilityData); handle.Should().NotBeNull(); - handle!.Activate(out _).Should().BeTrue(); + handle!.TryActivate(out _).Should().BeTrue(); handle.IsActive.Should().BeTrue(); behavior.Processor.GraphContext.IsActive.Should().BeTrue(); @@ -130,7 +130,7 @@ public void Graph_variables_are_initialized_from_definitions() AbilityHandle? handle = Grant(entity, abilityData); handle.Should().NotBeNull(); - handle!.Activate(out _).Should().BeTrue(); + handle!.TryActivate(out _).Should().BeTrue(); behavior.Processor.GraphContext.GraphVariables.TryGetVar("counter", out int value).Should().BeTrue(); value.Should().Be(1); @@ -155,7 +155,7 @@ public void Shared_variables_are_set_from_ability_context_owner() AbilityHandle? handle = Grant(entity, abilityData); handle.Should().NotBeNull(); - handle!.Activate(out _).Should().BeTrue(); + handle!.TryActivate(out _).Should().BeTrue(); behavior.Processor.GraphContext.SharedVariables.Should().BeSameAs(entity.SharedVariables); } @@ -195,7 +195,7 @@ public void Typed_data_binder_writes_activation_data_into_graph_variables() handle.Should().NotBeNull(); var activationData = new DamageData(50, 2.5); - handle!.Activate(activationData, out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle!.TryActivate(activationData, out AbilityActivationFailures failureFlags).Should().BeTrue(); failureFlags.Should().Be(AbilityActivationFailures.None); readDamage.LastReadValue.Should().Be(50); @@ -229,7 +229,7 @@ public void Exit_node_ends_ability_instance() AbilityHandle? handle = Grant(entity, abilityData); handle.Should().NotBeNull(); - handle!.Activate(out _).Should().BeTrue(); + handle!.TryActivate(out _).Should().BeTrue(); handle.IsActive.Should().BeFalse(); } @@ -252,7 +252,7 @@ public void Activation_context_contains_ability_behavior_context() AbilityHandle? handle = Grant(entity, abilityData); handle.Should().NotBeNull(); - handle!.Activate(out _).Should().BeTrue(); + handle!.TryActivate(out _).Should().BeTrue(); captureNode.CapturedActivationContext.Should().NotBeNull(); captureNode.CapturedActivationContext.Should().BeOfType(); @@ -281,7 +281,7 @@ public void TryGetActivationContext_returns_typed_ability_behavior_context() AbilityHandle? handle = Grant(entity, abilityData); handle.Should().NotBeNull(); - handle!.Activate(out _).Should().BeTrue(); + handle!.TryActivate(out _).Should().BeTrue(); typedCaptureNode.Found.Should().BeTrue(); typedCaptureNode.CapturedContext.Should().NotBeNull(); diff --git a/Forge.Tests/Statescript/Nodes/Action/CommitAbilityNodeTests.cs b/Forge.Tests/Statescript/Nodes/Action/CommitAbilityNodeTests.cs deleted file mode 100644 index 5ccbb882..00000000 --- a/Forge.Tests/Statescript/Nodes/Action/CommitAbilityNodeTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -// 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.Statescript; -using Gamesmiths.Forge.Statescript.Nodes; -using Gamesmiths.Forge.Statescript.Nodes.Action; -using Gamesmiths.Forge.Tags; -using Gamesmiths.Forge.Tests.Helpers; - -namespace Gamesmiths.Forge.Tests.Statescript.Nodes.Action; - -public class CommitAbilityNodeTests(TagsAndCuesFixture tagsAndCuesFixture) : IClassFixture -{ - private const string CostAttribute = "TestAttributeSet.Attribute90"; - - private readonly TagsManager _tagsManager = tagsAndCuesFixture.TagsManager; - private readonly CuesManager _cuesManager = tagsAndCuesFixture.CuesManager; - - [Theory] - [Trait("Graph", "CommitAbility")] - [InlineData(CommitAbilityOperation.CostAndCooldown, 85, true)] - [InlineData(CommitAbilityOperation.CooldownOnly, 90, true)] - [InlineData(CommitAbilityOperation.CostOnly, 85, false)] - public void Commit_ability_node_commits_the_configured_operation( - CommitAbilityOperation operation, - int expectedAttributeValue, - bool expectCooldown) - { - var owner = new TestEntity(_tagsManager, _cuesManager); - var cooldownTag = Tag.RequestTag(_tagsManager, "simple.tag"); - - var graph = new Graph(); - var commitNode = new CommitAbilityNode(operation); - graph.AddNode(commitNode); - graph.AddConnection(new Connection( - graph.EntryNode.OutputPorts[EntryNode.OutputPort], - commitNode.InputPorts[ActionNode.InputPort])); - - AbilityData abilityData = CreateAbilityData(graph, cooldownTag); - - AbilityHandle handle = owner.Abilities.GrantAbilityPermanently( - abilityData, - 1, - LevelComparison.None, - sourceEntity: null); - - handle.Activate(out AbilityActivationFailures failures).Should().BeTrue(); - failures.Should().Be(AbilityActivationFailures.None); - - owner.Attributes[CostAttribute].CurrentValue.Should().Be(expectedAttributeValue); - (handle.GetRemainingCooldownTime(cooldownTag) > 0).Should().Be(expectCooldown); - } - - [Fact] - [Trait("Graph", "CommitAbility")] - public void Commit_ability_node_does_nothing_without_an_ability_context() - { - var graph = new Graph(); - var commitNode = new CommitAbilityNode(); - graph.AddNode(commitNode); - graph.AddConnection(new Connection( - graph.EntryNode.OutputPorts[EntryNode.OutputPort], - commitNode.InputPorts[ActionNode.InputPort])); - - var processor = new GraphProcessor(graph); - - FluentActions.Invoking(() => processor.StartGraph()).Should().NotThrow(); - } - - private static AbilityData CreateAbilityData(Graph graph, Tag cooldownTag) - { - var costEffect = new EffectData( - "Cost", - new DurationData(DurationType.Instant), - [ - new Modifier( - CostAttribute, - ModifierOperation.FlatBonus, - new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-5))) - ]); - - var cooldownEffect = new EffectData( - "Cooldown", - new DurationData( - DurationType.HasDuration, - new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(5))), - effectComponents: [new ModifierTagsEffectComponent(cooldownTag.GetSingleTagContainer()!)]); - - return new AbilityData( - "Commit Test", - costEffect: costEffect, - cooldownEffects: [cooldownEffect], - behaviorFactory: () => new GraphAbilityBehavior(graph)); - } -} diff --git a/Forge.Tests/Statescript/Nodes/Condition/ExpressionNodeTests.cs b/Forge.Tests/Statescript/Nodes/Condition/ExpressionNodeTests.cs index efd039e6..37ca250f 100644 --- a/Forge.Tests/Statescript/Nodes/Condition/ExpressionNodeTests.cs +++ b/Forge.Tests/Statescript/Nodes/Condition/ExpressionNodeTests.cs @@ -418,7 +418,7 @@ public void Comparison_resolver_works_with_attribute_resolver() var grantEffect = new Effect(grantEffectData, new EffectOwnership(null, null)); _ = entity.EffectsManager.ApplyEffect(grantEffect); entity.Abilities.TryGetAbility(abilityData, out AbilityHandle? handle); - handle!.Activate(out _); + handle!.TryActivate(out _); trueAction.ExecutionCount.Should().Be(1); falseAction.ExecutionCount.Should().Be(0); diff --git a/Forge.Tests/Statescript/Nodes/Condition/TryCommitAbilityNodeTests.cs b/Forge.Tests/Statescript/Nodes/Condition/TryCommitAbilityNodeTests.cs new file mode 100644 index 00000000..17116e5f --- /dev/null +++ b/Forge.Tests/Statescript/Nodes/Condition/TryCommitAbilityNodeTests.cs @@ -0,0 +1,268 @@ +// 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.Statescript; +using Gamesmiths.Forge.Statescript.Nodes; +using Gamesmiths.Forge.Statescript.Nodes.Condition; +using Gamesmiths.Forge.Tags; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Statescript.Nodes.Condition; + +public class TryCommitAbilityNodeTests(TagsAndCuesFixture tagsAndCuesFixture) : IClassFixture +{ + private const string CostAttribute = "TestAttributeSet.Attribute90"; + + private readonly TagsManager _tagsManager = tagsAndCuesFixture.TagsManager; + private readonly CuesManager _cuesManager = tagsAndCuesFixture.CuesManager; + + [Theory] + [Trait("Graph", "TryCommitAbility")] + [InlineData(CommitAbilityOperation.CostAndCooldown, 85, true)] + [InlineData(CommitAbilityOperation.CooldownOnly, 90, true)] + [InlineData(CommitAbilityOperation.CostOnly, 85, false)] + public void Try_commit_ability_node_commits_the_configured_operation( + CommitAbilityOperation operation, + int expectedAttributeValue, + bool expectCooldown) + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var cooldownTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + var graph = new Graph(); + var commitNode = new TryCommitAbilityNode(operation); + var committedAction = new TrackingActionNode(); + var failedAction = new TrackingActionNode(); + graph.AddNode(commitNode); + graph.AddNode(committedAction); + graph.AddNode(failedAction); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + commitNode.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + commitNode.OutputPorts[ConditionNode.TruePort], + committedAction.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + commitNode.OutputPorts[ConditionNode.FalsePort], + failedAction.InputPorts[ActionNode.InputPort])); + + AbilityData abilityData = CreateAbilityData(graph, cooldownTag); + + AbilityHandle handle = owner.Abilities.GrantAbilityPermanently( + abilityData, + 1, + LevelComparison.None, + sourceEntity: null); + + handle.TryActivate(out AbilityActivationFailures failures).Should().BeTrue(); + failures.Should().Be(AbilityActivationFailures.None); + + committedAction.ExecutionCount.Should().Be(1); + failedAction.ExecutionCount.Should().Be(0); + owner.Attributes[CostAttribute].CurrentValue.Should().Be(expectedAttributeValue); + (handle.GetRemainingCooldownTime(cooldownTag) > 0).Should().Be(expectCooldown); + } + + [Fact] + [Trait("Graph", "TryCommitAbility")] + public void Try_commit_ability_node_routes_to_false_when_the_cooldown_is_already_running() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var cooldownTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + var graph = new Graph(); + var firstCommit = new TryCommitAbilityNode(CommitAbilityOperation.CooldownOnly); + var secondCommit = new TryCommitAbilityNode(CommitAbilityOperation.CooldownOnly); + var committedAction = new TrackingActionNode(); + var failedAction = new TrackingActionNode(); + graph.AddNode(firstCommit); + graph.AddNode(secondCommit); + graph.AddNode(committedAction); + graph.AddNode(failedAction); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + firstCommit.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + firstCommit.OutputPorts[ConditionNode.TruePort], + secondCommit.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + secondCommit.OutputPorts[ConditionNode.TruePort], + committedAction.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + secondCommit.OutputPorts[ConditionNode.FalsePort], + failedAction.InputPorts[ActionNode.InputPort])); + + AbilityData abilityData = CreateAbilityData(graph, cooldownTag); + + AbilityHandle handle = owner.Abilities.GrantAbilityPermanently( + abilityData, + 1, + LevelComparison.None, + sourceEntity: null); + + handle.TryActivate(out AbilityActivationFailures failures).Should().BeTrue(); + failures.Should().Be(AbilityActivationFailures.None); + + // The first commit started the cooldown, so the second one has nothing left to pay. + committedAction.ExecutionCount.Should().Be(0); + failedAction.ExecutionCount.Should().Be(1); + } + + [Fact] + [Trait("Graph", "TryCommitAbility")] + public void Try_commit_ability_node_routes_to_false_when_the_cost_is_no_longer_affordable() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var cooldownTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + var graph = new Graph(); + var firstCommit = new TryCommitAbilityNode(CommitAbilityOperation.CostOnly); + var secondCommit = new TryCommitAbilityNode(CommitAbilityOperation.CostOnly); + var committedAction = new TrackingActionNode(); + var failedAction = new TrackingActionNode(); + graph.AddNode(firstCommit); + graph.AddNode(secondCommit); + graph.AddNode(committedAction); + graph.AddNode(failedAction); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + firstCommit.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + firstCommit.OutputPorts[ConditionNode.TruePort], + secondCommit.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + secondCommit.OutputPorts[ConditionNode.TruePort], + committedAction.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + secondCommit.OutputPorts[ConditionNode.FalsePort], + failedAction.InputPorts[ActionNode.InputPort])); + + // A 50 point cost against the attribute's starting value of 90: affordable once, never twice. + AbilityData abilityData = CreateAbilityData(graph, cooldownTag, costMagnitude: -50); + + AbilityHandle handle = owner.Abilities.GrantAbilityPermanently( + abilityData, + 1, + LevelComparison.None, + sourceEntity: null); + + handle.TryActivate(out AbilityActivationFailures failures).Should().BeTrue(); + failures.Should().Be(AbilityActivationFailures.None); + + committedAction.ExecutionCount.Should().Be(0); + failedAction.ExecutionCount.Should().Be(1); + owner.Attributes[CostAttribute].CurrentValue.Should().Be(40); + } + + [Fact] + [Trait("Graph", "TryCommitAbility")] + public void Try_commit_ability_node_pays_nothing_when_only_one_of_cost_and_cooldown_can_be_committed() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var cooldownTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + var graph = new Graph(); + var costCommit = new TryCommitAbilityNode(CommitAbilityOperation.CostOnly); + var fullCommit = new TryCommitAbilityNode(CommitAbilityOperation.CostAndCooldown); + var committedAction = new TrackingActionNode(); + var failedAction = new TrackingActionNode(); + graph.AddNode(costCommit); + graph.AddNode(fullCommit); + graph.AddNode(committedAction); + graph.AddNode(failedAction); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + costCommit.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + costCommit.OutputPorts[ConditionNode.TruePort], + fullCommit.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + fullCommit.OutputPorts[ConditionNode.TruePort], + committedAction.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + fullCommit.OutputPorts[ConditionNode.FalsePort], + failedAction.InputPorts[ActionNode.InputPort])); + + AbilityData abilityData = CreateAbilityData(graph, cooldownTag, costMagnitude: -50); + + AbilityHandle handle = owner.Abilities.GrantAbilityPermanently( + abilityData, + 1, + LevelComparison.None, + sourceEntity: null); + + handle.TryActivate(out AbilityActivationFailures failures).Should().BeTrue(); + failures.Should().Be(AbilityActivationFailures.None); + + // The cooldown was free to start, but the cost was not affordable, so neither was applied. + committedAction.ExecutionCount.Should().Be(0); + failedAction.ExecutionCount.Should().Be(1); + owner.Attributes[CostAttribute].CurrentValue.Should().Be(40); + handle.GetRemainingCooldownTime(cooldownTag).Should().Be(0); + } + + [Fact] + [Trait("Graph", "TryCommitAbility")] + public void Try_commit_ability_node_routes_to_false_without_an_ability_context() + { + var graph = new Graph(); + var commitNode = new TryCommitAbilityNode(); + var committedAction = new TrackingActionNode(); + var failedAction = new TrackingActionNode(); + graph.AddNode(commitNode); + graph.AddNode(committedAction); + graph.AddNode(failedAction); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + commitNode.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + commitNode.OutputPorts[ConditionNode.TruePort], + committedAction.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + commitNode.OutputPorts[ConditionNode.FalsePort], + failedAction.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + + FluentActions.Invoking(() => processor.StartGraph()).Should().NotThrow(); + + committedAction.ExecutionCount.Should().Be(0); + failedAction.ExecutionCount.Should().Be(1); + } + + private static AbilityData CreateAbilityData(Graph graph, Tag cooldownTag, float costMagnitude = -5) + { + var costEffect = new EffectData( + "Cost", + new DurationData(DurationType.Instant), + [ + new Modifier( + CostAttribute, + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(costMagnitude))) + ]); + + var cooldownEffect = new EffectData( + "Cooldown", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(5))), + effectComponents: [new ModifierTagsEffectComponent(cooldownTag.GetSingleTagContainer()!)]); + + return new AbilityData( + "Commit Test", + costEffect: costEffect, + cooldownEffects: [cooldownEffect], + behaviorFactory: () => new GraphAbilityBehavior(graph)); + } +} diff --git a/Forge.Tests/Statescript/Nodes/GrantAbilityNodesTests.cs b/Forge.Tests/Statescript/Nodes/GrantAbilityNodesTests.cs index c95322c0..2227a10d 100644 --- a/Forge.Tests/Statescript/Nodes/GrantAbilityNodesTests.cs +++ b/Forge.Tests/Statescript/Nodes/GrantAbilityNodesTests.cs @@ -278,7 +278,7 @@ public void Cancel_abilities_by_tag_node_cancels_matching_active_abilities() 1, LevelComparison.None, sourceEntity: null); - handle.Activate(out _).Should().BeTrue(); + handle.TryActivate(out _).Should().BeTrue(); handle.IsActive.Should().BeTrue(); var graph = new Graph(); @@ -379,7 +379,7 @@ public void Cancel_ability_node_cancels_the_running_ability() 1, LevelComparison.None, sourceEntity: null); - handle.Activate(out _).Should().BeTrue(); + handle.TryActivate(out _).Should().BeTrue(); handle.IsActive.Should().BeTrue(); owner.Abilities.UpdateAbilities(1.0); @@ -408,10 +408,12 @@ public void Grant_ability_and_activate_once_node_procs_the_ability() var graph = new Graph(); graph.VariableDefinitions.DefineObjectVariable("abilityData", abilityData); graph.VariableDefinitions.DefineObjectVariable("entity", owner); + graph.VariableDefinitions.DefineObjectVariable("grantedAbility"); - var procNode = new GrantAbilityAndActivateOnceNode(); - procNode.BindInput(GrantAbilityAndActivateOnceNode.AbilityDataInput, "abilityData"); - procNode.BindInput(GrantAbilityAndActivateOnceNode.EntityInput, "entity"); + var procNode = new TryGrantAbilityAndActivateOnceNode(); + procNode.BindInput(TryGrantAbilityAndActivateOnceNode.AbilityDataInput, "abilityData"); + procNode.BindInput(TryGrantAbilityAndActivateOnceNode.EntityInput, "entity"); + procNode.BindOutput(TryGrantAbilityAndActivateOnceNode.AbilityOutput, "grantedAbility"); var onTrue = new TrackingActionNode(); @@ -430,9 +432,57 @@ public void Grant_ability_and_activate_once_node_procs_the_ability() onTrue.ExecutionCount.Should().Be(1); captureNode.CapturedGraphContext.Should().NotBeNull(); - // Transient grant: the ability is removed after it ends. + // Transient grant: the ability is removed after it ends, so there is no lingering handle to output even + // though the proc activated. owner.Abilities.TryGetAbility(abilityData, out AbilityHandle? handle); handle.Should().BeNull(); + + processor.GraphContext.GraphVariables.TryGetObject("grantedAbility", out object? outputHandle) + .Should().BeTrue(); + outputHandle.Should().BeNull(); + } + + [Fact] + [Trait("Graph", "GrantAbility")] + public void Try_grant_ability_and_activate_once_node_outputs_a_lingering_proc_handle() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + + // No behavior factory means nothing ends the instance, so the transient grant outlives the node. + var abilityData = new AbilityData("Lingering Proc"); + + var graph = new Graph(); + graph.VariableDefinitions.DefineObjectVariable("abilityData", abilityData); + graph.VariableDefinitions.DefineObjectVariable("entity", owner); + graph.VariableDefinitions.DefineObjectVariable("grantedAbility"); + + var procNode = new TryGrantAbilityAndActivateOnceNode(); + procNode.BindInput(TryGrantAbilityAndActivateOnceNode.AbilityDataInput, "abilityData"); + procNode.BindInput(TryGrantAbilityAndActivateOnceNode.EntityInput, "entity"); + procNode.BindOutput(TryGrantAbilityAndActivateOnceNode.AbilityOutput, "grantedAbility"); + + var onTrue = new TrackingActionNode(); + + graph.AddNode(procNode); + graph.AddNode(onTrue); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + procNode.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + procNode.OutputPorts[ConditionNode.TruePort], + onTrue.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + onTrue.ExecutionCount.Should().Be(1); + + owner.Abilities.TryGetAbility(abilityData, out AbilityHandle? handle).Should().BeTrue(); + handle!.IsActive.Should().BeTrue(); + + processor.GraphContext.GraphVariables.TryGetObject("grantedAbility", out object? outputHandle) + .Should().BeTrue(); + outputHandle.Should().Be(handle); } [Fact] @@ -557,10 +607,10 @@ public void Grant_ability_and_activate_once_node_passes_activation_data_to_the_a "activationData", new AbilityActivatorResolver(new ShoutProvider())); - var procNode = new GrantAbilityAndActivateOnceNode(); - procNode.BindInput(GrantAbilityAndActivateOnceNode.AbilityDataInput, "abilityData"); - procNode.BindInput(GrantAbilityAndActivateOnceNode.EntityInput, "entity"); - procNode.BindInput(GrantAbilityAndActivateOnceNode.ActivationDataInput, "activationData"); + var procNode = new TryGrantAbilityAndActivateOnceNode(); + procNode.BindInput(TryGrantAbilityAndActivateOnceNode.AbilityDataInput, "abilityData"); + procNode.BindInput(TryGrantAbilityAndActivateOnceNode.EntityInput, "entity"); + procNode.BindInput(TryGrantAbilityAndActivateOnceNode.ActivationDataInput, "activationData"); var onTrue = new TrackingActionNode(); @@ -610,7 +660,7 @@ private AbilityHandle GrantAndActivateChannelingAbility( LevelComparison.None, sourceEntity: null); - handle.Activate(out _).Should().BeTrue(); + handle.TryActivate(out _).Should().BeTrue(); handle.IsActive.Should().BeTrue(); return handle; diff --git a/Forge.Tests/Statescript/Nodes/State/ListenerNodesTests.cs b/Forge.Tests/Statescript/Nodes/State/ListenerNodesTests.cs index 3337fd63..3d64778a 100644 --- a/Forge.Tests/Statescript/Nodes/State/ListenerNodesTests.cs +++ b/Forge.Tests/Statescript/Nodes/State/ListenerNodesTests.cs @@ -140,7 +140,7 @@ public void Ability_end_listener_node_emits_when_abilities_end() var processor = new GraphProcessor(graph); processor.StartGraph(); - handle.Activate(out _).Should().BeTrue(); + handle.TryActivate(out _).Should().BeTrue(); onEnded.ExecutionCount.Should().Be(1); processor.GraphContext.GraphVariables.TryGetVar("wasCanceled", out bool wasCanceled).Should().BeTrue(); @@ -193,10 +193,10 @@ public void Ability_end_listener_node_filters_by_ability_data() var processor = new GraphProcessor(graph); processor.StartGraph(); - otherHandle.Activate(out _).Should().BeTrue(); + otherHandle.TryActivate(out _).Should().BeTrue(); onEnded.ExecutionCount.Should().Be(0); - watchedHandle.Activate(out _).Should().BeTrue(); + watchedHandle.TryActivate(out _).Should().BeTrue(); onEnded.ExecutionCount.Should().Be(1); } @@ -247,10 +247,10 @@ public void Ability_end_listener_node_filters_by_ability_data_when_granted_with_ var processor = new GraphProcessor(graph); processor.StartGraph(); - otherHandle.Activate(out _).Should().BeTrue(); + otherHandle.TryActivate(out _).Should().BeTrue(); onEnded.ExecutionCount.Should().Be(0); - watchedHandle.Activate(out _).Should().BeTrue(); + watchedHandle.TryActivate(out _).Should().BeTrue(); onEnded.ExecutionCount.Should().Be(1); } @@ -302,10 +302,10 @@ public void Ability_end_listener_node_filters_ability_granted_after_activation() sourceEntity: null); AbilityHandle otherHandle = GrantInstantAbility(owner, "Other"); - otherHandle.Activate(out _).Should().BeTrue(); + otherHandle.TryActivate(out _).Should().BeTrue(); onEnded.ExecutionCount.Should().Be(0); - watchedHandle.Activate(out _).Should().BeTrue(); + watchedHandle.TryActivate(out _).Should().BeTrue(); onEnded.ExecutionCount.Should().Be(1); } @@ -359,7 +359,7 @@ public void Ability_end_listener_node_matches_filter_when_ability_is_removed_on_ var processor = new GraphProcessor(graph); processor.StartGraph(); - watchedHandle!.Activate(out _).Should().BeTrue(); + watchedHandle!.TryActivate(out _).Should().BeTrue(); watchedHandle.IsActive.Should().BeTrue(); // Removing the grant while the ability is active defers removal to deactivation, which frees the handle before diff --git a/Forge.Tests/Statescript/Resolvers/AbilityActivatorResolverTests.cs b/Forge.Tests/Statescript/Resolvers/AbilityActivatorResolverTests.cs index 49f12a64..5153b3d7 100644 --- a/Forge.Tests/Statescript/Resolvers/AbilityActivatorResolverTests.cs +++ b/Forge.Tests/Statescript/Resolvers/AbilityActivatorResolverTests.cs @@ -147,11 +147,13 @@ public void Activator_grants_and_activates_once_with_the_typed_data() LevelComparison.None, null, null, - graphContext).Should().BeTrue(); + graphContext, + out AbilityHandle? grantedAbility).Should().BeTrue(); captured.Should().ContainSingle().Which.Volume.Should().Be(42); - // Transient grant: the ability is removed after it ends. + // Transient grant: the ability is removed after it ends, so the activation reports no lingering handle. + grantedAbility.Should().BeNull(); owner.Abilities.TryGetAbility(abilityData, out AbilityHandle? handle).Should().BeFalse(); handle.Should().BeNull(); } diff --git a/Forge.Tests/Statescript/Resolvers/AbilityDataResolverTests.cs b/Forge.Tests/Statescript/Resolvers/AbilityDataResolverTests.cs index ba522f3f..9495f231 100644 --- a/Forge.Tests/Statescript/Resolvers/AbilityDataResolverTests.cs +++ b/Forge.Tests/Statescript/Resolvers/AbilityDataResolverTests.cs @@ -35,7 +35,7 @@ public void Ability_cooldown_resolver_reads_the_committed_cooldown() new AbilityCooldownResolver(AbilityCooldownDataType.TotalTime) .Resolve(context).Get().Should().BeApproximately(5f, 0.001f); - handle.CommitCooldown(); + handle.TryCommitCooldown(); new AbilityCooldownResolver(AbilityCooldownDataType.RemainingTime) .Resolve(context).Get().Should().BeApproximately(5f, 0.001f); @@ -56,7 +56,7 @@ public void Ability_cooldown_resolver_filters_by_tag() { (GraphContext context, AbilityHandle handle, _) = CreateActivatedAbility(); - handle.CommitCooldown(); + handle.TryCommitCooldown(); var cooldownTag = Tag.RequestTag(_tagsManager, "simple.tag"); var unrelatedTag = Tag.RequestTag(_tagsManager, "color.red"); @@ -85,7 +85,7 @@ public void Can_activate_ability_resolver_reflects_cooldown_state() new CanActivateAbilityResolver().Resolve(context).AsBool().Should().BeTrue(); - handle.CommitCooldown(); + handle.TryCommitCooldown(); new CanActivateAbilityResolver().Resolve(context).AsBool().Should().BeFalse(); } @@ -114,7 +114,7 @@ public void Get_ability_handle_resolver_finds_granted_abilities() resolvedHandle.Should().Be(handle); // Cross-ability query: the resolved handle feeds other ability resolvers. - handle.CommitCooldown(); + handle.TryCommitCooldown(); new AbilityCooldownResolver( AbilityCooldownDataType.RemainingTime, handleResolver: new GetAbilityHandleResolver(abilityData)) @@ -185,7 +185,7 @@ public void Ability_resolvers_return_defaults_without_an_ability_context() LevelComparison.None, sourceEntity: null); - handle.Activate(out _).Should().BeTrue(); + handle.TryActivate(out _).Should().BeTrue(); captureNode.CapturedGraphContext.Should().NotBeNull(); return (captureNode.CapturedGraphContext!, handle, owner); diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 96cc5376..985562f9 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -1,6 +1,7 @@ // Copyright © Gamesmiths Guild. using System.Reflection; +using Gamesmiths.Forge.Attributes; using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Effects; using Gamesmiths.Forge.Effects.Calculator; @@ -18,6 +19,16 @@ internal sealed class Ability { private record struct BehaviorBinding(IAbilityBehavior Behavior, AbilityBehaviorContext Context); + /// + /// What applying the cost effect would do to one attribute: the net change it produces, and whether every step of + /// it lands inside the attribute's bounds. + /// + /// The attribute the cost is charged against. + /// The nominal net change the cost effect asks of the attribute, whether or not it fits. + /// Whether the cost lands without being clamped, which is what makes it affordable. + /// + private readonly record struct CostProjection(StringKey Attribute, int Cost, bool AppliesInFull); + private readonly Effect[]? _cooldownEffects; private readonly ActiveEffectHandle?[]? _activeCooldownHandles; @@ -166,35 +177,29 @@ internal bool TryActivateAbility( return false; } - internal void CommitAbility() + internal bool TryCommitAbility() { - CommitCooldown(); - CommitCost(); + // Both checks run before either application so the commit is all-or-nothing: an affordable cost must never be + // paid for a cooldown that is still running, and vice versa. + if (!CanCommitCooldown() || !CanCommitCost()) + { + return false; + } + + // The cost goes first because it is the half that cannot be undone. An effect component or a registered + // application blocker can still turn either of them away, and an instant attribute change cannot be rolled + // back, so a cost turned away leaves nothing spent and no cooldown running, while the reverse would not. + return ApplyCost() && ApplyCooldown(); } - internal void CommitCooldown() + internal bool TryCommitCooldown() { - if (_cooldownEffects is not null) - { - Validation.Assert( - _activeCooldownHandles is not null - && _activeCooldownHandles.Length == _cooldownEffects.Length, - "Active cooldown handles array should have been properly initialized."); - - for (int i = 0; i < _cooldownEffects.Length; i++) - { - Effect effect = _cooldownEffects[i]; - _activeCooldownHandles[i] = Owner.EffectsManager.ApplyEffect(effect); - } - } + return CanCommitCooldown() && ApplyCooldown(); } - internal void CommitCost() + internal bool TryCommitCost() { - if (_costEffect is not null) - { - Owner.EffectsManager.ApplyEffect(_costEffect); - } + return CanCommitCost() && ApplyCost(); } internal void End() @@ -355,21 +360,14 @@ internal bool CanActivate(IForgeEntity? abilityTarget, out AbilityActivationFail } // Check cooldown. - if (_cooldownEffects is not null) + if (!CanCommitCooldown()) { - foreach (Effect effect in _cooldownEffects) - { - if (effect?.CachedGrantedTags is not null && Owner.Tags.AllTags.HasAny(effect.CachedGrantedTags)) - { - failureFlags |= AbilityActivationFailures.Cooldown; - canActivate = false; - } - } + failureFlags |= AbilityActivationFailures.Cooldown; + canActivate = false; } // Check resources. - if (_costEffect is not null - && !Owner.EffectsManager.CanApplyEffect(_costEffect, Level)) + if (!CanCommitCost()) { failureFlags |= AbilityActivationFailures.InsufficientResources; canActivate = false; @@ -481,40 +479,15 @@ internal float GetRemainingCooldownTime(Tag tag) return null; } - ModifierEvaluatedData[] allModifiersEvaluatedData = EvaluateInstantModifiers(_costEffect, specificAttribute); + CostProjection[] projections = ProjectCost(specificAttribute); + var costData = new CostData[projections.Length]; - Dictionary costByAttribute = []; - - foreach (ModifierEvaluatedData modifierEvaluatedData in allModifiersEvaluatedData) + for (int i = 0; i < projections.Length; i++) { - if (!costByAttribute.TryGetValue(modifierEvaluatedData.Attribute.Key, out float value)) - { - value = 0f; - costByAttribute[modifierEvaluatedData.Attribute.Key] = value; - } - - float 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; - } + costData[i] = new CostData(projections[i].Attribute, projections[i].Cost); } - return [.. costByAttribute.Select(x => new CostData(x.Key, (int)x.Value))]; + return costData; } internal int GetCostForAttribute(StringKey attributeKey) @@ -537,6 +510,54 @@ internal int GetCostForAttribute(StringKey attributeKey) return 0; } + /// + /// Replays every modifier charged against one attribute the way an instant application would: in order, each one + /// building on the value the previous one produced, clamped to the attribute's bounds at every step. + /// + /// + /// A step landing outside the bounds is one the attribute could not absorb in full, which is exactly what + /// makes a cost unaffordable. The projection deliberately carries the unclamped value forward, so the cost + /// stays the nominal ask — a UI showing "150 mana" against a pool of 100 wants the 150, not the 100 the target + /// could scrape together. + /// When nothing falls out of bounds no clamping would happen either, so the projected cost is then exactly + /// the change an application would make. + /// + /// The attribute being charged. + /// Every evaluated modifier of the cost effect, in application order. + /// The projected outcome for this attribute. + private static CostProjection ProjectAttributeCost( + EntityAttribute attribute, + ModifierEvaluatedData[] evaluatedModifiers) + { + // An instant application mutates the base value, so that is the value the projection has to start from. + int value = attribute.BaseValue; + bool appliesInFull = true; + + foreach (ModifierEvaluatedData evaluatedModifier in evaluatedModifiers) + { + if (evaluatedModifier.Attribute.Key != attribute.Key) + { + continue; + } + + // Mirrors EntityAttribute.Execute*: the same truncation and the same rounding. + value = evaluatedModifier.ModifierOperation switch + { + ModifierOperation.FlatBonus => value + (int)evaluatedModifier.Magnitude, + ModifierOperation.PercentBonus => (int)(value * Math.Round(1 + evaluatedModifier.Magnitude, 6)), + ModifierOperation.Override => (int)evaluatedModifier.Magnitude, + _ => value, + }; + + if (value < attribute.Min || value > attribute.Max) + { + appliesInFull = false; + } + } + + return new CostProjection(attribute.Key, value - attribute.BaseValue, appliesInFull); + } + private static bool FailsRequiredTags(TagContainer? required, TagContainer? present) { return required is not null && (present?.HasAll(required) != true); @@ -569,6 +590,133 @@ private EventSubscriptionToken SubscribeTypedEventCore(Tag tag, int pr priority: priority); } + private bool CanCommitCooldown() + { + if (_cooldownEffects is null) + { + return true; + } + + foreach (Effect effect in _cooldownEffects) + { + if (effect?.CachedGrantedTags is not null && Owner.Tags.AllTags.HasAny(effect.CachedGrantedTags)) + { + return false; + } + } + + return true; + } + + private bool CanCommitCost() + { + if (_costEffect is null) + { + return true; + } + + // A cost charged against an attribute the owner does not have can never be paid. Applying the effect would + // quietly skip that modifier, so refusing here is what stops the ability from being cast for free — the check + // is deliberately stricter than the application on this one point. + foreach (Modifier modifier in _costEffect.EffectData.Modifiers) + { + if (!Owner.Attributes.ContainsAttribute(modifier.Attribute)) + { + return false; + } + } + + foreach (CostProjection projection in ProjectCost(specificAttribute: null)) + { + if (!projection.AppliesInFull) + { + return false; + } + } + + return true; + } + + /// + /// Projects what applying the cost effect would do, without touching a single attribute. + /// + /// + /// This is the one place the cost is computed. Both the number a player is shown through + /// and the affordability behind read it, so the cost quoted + /// and the cost charged cannot drift apart, and neither can drift from what an instant + /// application would really do. + /// Modifiers are grouped per attribute because that is the granularity the bounds apply at; several + /// modifiers charged against the same attribute compound, exactly as they would when executed. + /// + /// Restricts the projection to a single attribute, or for + /// every attribute the cost touches. + /// One projection per attribute the cost effect charges. + private CostProjection[] ProjectCost(StringKey? specificAttribute) + { + Validation.Assert(_costEffect is not null, "There is no cost to project without a cost effect."); + + ModifierEvaluatedData[] evaluatedModifiers = EvaluateInstantModifiers(_costEffect, specificAttribute); + + List projections = []; + + foreach (EntityAttribute attribute in evaluatedModifiers.Select(x => x.Attribute)) + { + if (projections.Exists(x => x.Attribute == attribute.Key)) + { + continue; + } + + projections.Add(ProjectAttributeCost(attribute, evaluatedModifiers)); + } + + return [.. projections]; + } + + /// + /// Applies the cooldown effects and reports whether every one of them landed. + /// + /// + /// Passing is not enough on its own: an effect component or a registered + /// application blocker — an immunity, typically — can still turn a cooldown effect away, and a commit must not + /// claim to have started a cooldown that never began. + /// + /// when there was nothing to apply or every cooldown effect was applied. + private bool ApplyCooldown() + { + if (_cooldownEffects is null) + { + return true; + } + + Validation.Assert( + _activeCooldownHandles is not null + && _activeCooldownHandles.Length == _cooldownEffects.Length, + "Active cooldown handles array should have been properly initialized."); + + bool applied = true; + + for (int i = 0; i < _cooldownEffects.Length; i++) + { + Effect effect = _cooldownEffects[i]; + applied &= Owner.EffectsManager.TryApplyEffect(effect, out ActiveEffectHandle? activeEffectHandle); + _activeCooldownHandles[i] = activeEffectHandle; + } + + return applied; + } + + /// + /// Applies the cost effect and reports whether it landed. + /// + /// + /// Affordability is only half the question — see for the other half. + /// + /// when there was no cost or the cost effect was applied. + private bool ApplyCost() + { + return _costEffect is null || Owner.EffectsManager.TryApplyEffect(_costEffect, out _); + } + private void Activate(IForgeEntity? abilityTarget, float magnitude) { AbilityInstance instance = CreateInstance(abilityTarget); diff --git a/Forge/Abilities/AbilityBehaviorContext.Data.cs b/Forge/Abilities/AbilityBehaviorContext.Data.cs index 7dc3110e..60908624 100644 --- a/Forge/Abilities/AbilityBehaviorContext.Data.cs +++ b/Forge/Abilities/AbilityBehaviorContext.Data.cs @@ -4,7 +4,7 @@ namespace Gamesmiths.Forge.Abilities; /// /// Context that carries strongly-typed additional data. -/// Created automatically when using . +/// Created automatically when using . /// /// The activation data type. public sealed class AbilityBehaviorContext : AbilityBehaviorContext diff --git a/Forge/Abilities/AbilityHandle.cs b/Forge/Abilities/AbilityHandle.cs index 23093af6..cd4b1d0a 100644 --- a/Forge/Abilities/AbilityHandle.cs +++ b/Forge/Abilities/AbilityHandle.cs @@ -38,14 +38,14 @@ internal AbilityHandle(Ability ability) } /// - /// Activates the ability associated with this handle. + /// Tries to activate the ability associated with this handle. /// /// Flags indicating the failure reasons 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( + public bool TryActivate( out AbilityActivationFailures failureFlags, IForgeEntity? target = null, float magnitude = 0) @@ -55,7 +55,7 @@ public bool Activate( } /// - /// Activates the ability associated with this handle with strongly-typed additional data. + /// Tries to activate the ability associated with this handle with strongly-typed additional data. /// /// The type of the data to pass to the ability behavior. /// Additional data to pass to the behavior. @@ -64,7 +64,7 @@ public bool Activate( /// Optional magnitude value for the ability activation. /// Return if the ability was successfully activated; otherwise, /// . - public bool Activate( + public bool TryActivate( TData data, out AbilityActivationFailures failureFlags, IForgeEntity? target = null, @@ -88,27 +88,38 @@ public void Cancel() } /// - /// Commits the ability cooldown and cost. + /// Tries to commit the ability cooldown and cost. /// - public void CommitAbility() + /// + /// A commit can happen long after activation, by which point the cooldown may have started or the resources may + /// have been spent elsewhere, so the cooldown and the cost are both re-checked here. The commit is + /// all-or-nothing: when either check fails, neither the cooldown nor the cost is applied. + /// + /// Return if both the cooldown and the cost were committed; otherwise, + /// . + public bool TryCommitAbility() { - Ability?.CommitAbility(); + return Ability?.TryCommitAbility() ?? false; } /// - /// Commits the ability cooldown. + /// Tries to commit the ability cooldown. /// - public void CommitCooldown() + /// Return if the cooldown was committed; when the ability + /// is already on cooldown or the handle is invalid. + public bool TryCommitCooldown() { - Ability?.CommitCooldown(); + return Ability?.TryCommitCooldown() ?? false; } /// - /// Commits the ability cost. + /// Tries to commit the ability cost. /// - public void CommitCost() + /// Return if the cost was committed; when the owner can no + /// longer afford it or the handle is invalid. + public bool TryCommitCost() { - Ability?.CommitCost(); + return Ability?.TryCommitCost() ?? false; } /// diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index b7604e39..8c518668 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -85,9 +85,9 @@ public class EntityAbilities(IForgeEntity owner) /// /// /// Read-only: the manager keeps this set in step with the grant sources behind each ability, so grant and removal - /// go through , GrantAbilityAndActivateOnce and the effect components - /// rather than through this set. The collection is live, so a handle removed while it is being enumerated - /// invalidates the enumeration; copy it first when the loop body can remove abilities. + /// go through , TryGrantAbilityAndActivateOnce and the effect + /// components rather than through this set. The collection is live, so a handle removed while it is being + /// enumerated invalidates the enumeration; copy it first when the loop body can remove abilities. /// public IReadOnlyCollection GrantedAbilities => _grantedAbilities; @@ -261,19 +261,27 @@ public bool TryActivateAbilitiesByTag( /// /// Grants an ability and activates it once. The ability grant will be removed if activation fails or after it ends. /// + /// + /// is only a handle to an ability that is still running. An ability that + /// activates and ends within the call — the common one-shot case — leaves it even though the + /// activation succeeded, so read the return value, not the handle, to know whether the ability activated. + /// /// 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. /// Flags indicating the failure reasons for the ability activation. + /// The handle of the granted ability while it remains active, or + /// once the grant has been removed. /// 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. + /// Returns if the ability was activated; otherwise, . /// - public AbilityHandle? GrantAbilityAndActivateOnce( + public bool TryGrantAbilityAndActivateOnce( AbilityData abilityData, int abilityLevel, LevelComparison levelOverridePolicy, out AbilityActivationFailures failureFlags, + out AbilityHandle? grantedAbility, IForgeEntity? targetEntity = null, IForgeEntity? sourceEntity = null) { @@ -284,11 +292,15 @@ public bool TryActivateAbilitiesByTag( sourceEntity, out TransientGrantSource grantSource); - abilityHandle.Activate(out failureFlags, targetEntity); + bool activated = abilityHandle.TryActivate(out failureFlags, targetEntity); RemoveGrantedAbility(abilityHandle, grantSource); - return abilityHandle.IsValid ? abilityHandle : null; + // A still-valid handle is not proof this proc is alive: the same ability granted from another source shares + // one handle, which outlives the transient grant. Only this activation still running earns the output. + grantedAbility = activated && abilityHandle.IsActive ? abilityHandle : null; + + return activated; } /// @@ -296,9 +308,13 @@ public bool TryActivateAbilitiesByTag( /// if activation fails or after it ends. /// /// - /// Unlike , the activated ability is known up front, so + /// Unlike , the activated ability is known up front, so /// can be matched to it. An ability whose behavior does not accept - /// still activates, ignoring the data. + /// still activates, ignoring the data. + /// is only a handle to an ability that is still running. An ability + /// that activates and ends within the call — the common one-shot case — leaves it even + /// though the activation succeeded, so read the return value, not the handle, to know whether the ability + /// activated. /// /// The type of the data to pass to the ability behavior. /// The configuration data of the ability to grant and activate. @@ -306,16 +322,19 @@ public bool TryActivateAbilitiesByTag( /// The policy for overriding the level of an existing granted ability. /// Additional data to pass to the behavior. /// Flags indicating the failure reasons for the ability activation. + /// The handle of the granted ability while it remains active, or + /// once the grant has been removed. /// 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. + /// Returns if the ability was activated; otherwise, . /// - public AbilityHandle? GrantAbilityAndActivateOnce( + public bool TryGrantAbilityAndActivateOnce( AbilityData abilityData, int abilityLevel, LevelComparison levelOverridePolicy, TData data, out AbilityActivationFailures failureFlags, + out AbilityHandle? grantedAbility, IForgeEntity? targetEntity = null, IForgeEntity? sourceEntity = null) { @@ -326,11 +345,15 @@ public bool TryActivateAbilitiesByTag( sourceEntity, out TransientGrantSource grantSource); - abilityHandle.Activate(data, out failureFlags, targetEntity); + bool activated = abilityHandle.TryActivate(data, out failureFlags, targetEntity); RemoveGrantedAbility(abilityHandle, grantSource); - return abilityHandle.IsValid ? abilityHandle : null; + // A still-valid handle is not proof this proc is alive: the same ability granted from another source shares + // one handle, which outlives the transient grant. Only this activation still running earns the output. + grantedAbility = activated && abilityHandle.IsActive ? abilityHandle : null; + + return activated; } /// diff --git a/Forge/Effects/Components/GrantAbilityEffectComponent.cs b/Forge/Effects/Components/GrantAbilityEffectComponent.cs index 305169bb..0980a63d 100644 --- a/Forge/Effects/Components/GrantAbilityEffectComponent.cs +++ b/Forge/Effects/Components/GrantAbilityEffectComponent.cs @@ -69,7 +69,7 @@ public void OnPostActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluate { if (_grantAbilityConfigs[i].TryActivateOnGrant) { - _grantedAbilities[i].Activate(out _); + _grantedAbilities[i].TryActivate(out _); } } } @@ -101,7 +101,7 @@ public void OnActiveEffectChanged(IForgeEntity target, in ActiveEffectEvaluatedD { if (_grantAbilityConfigs[i].TryActivateOnEnable) { - _grantedAbilities[i].Activate(out _); + _grantedAbilities[i].TryActivate(out _); } } } @@ -128,7 +128,7 @@ private void GrantAbilitiesPermanently(IForgeEntity target, in EffectEvaluatedDa { if (_grantAbilityConfigs[i].TryActivateOnGrant) { - _grantedAbilities[i].Activate(out _); + _grantedAbilities[i].TryActivate(out _); } } } diff --git a/Forge/Effects/EffectsManager.cs b/Forge/Effects/EffectsManager.cs index a4edef65..dfac06d2 100644 --- a/Forge/Effects/EffectsManager.cs +++ b/Forge/Effects/EffectsManager.cs @@ -4,7 +4,6 @@ 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; @@ -430,44 +429,29 @@ internal void RemoveActiveEffect_InternalCall(ActiveEffect effect) RemoveActiveEffect(effect, EffectRemovalReason.Expired); } - internal bool CanApplyEffect(Effect costEffect, int level) + internal ActiveEffectHandle? ApplyEffectInternal(Effect effect, EffectApplicationContext? applicationContext) { - foreach (Modifier modifier in costEffect.EffectData.Modifiers) - { - if (!modifier.CanApply(costEffect, Owner, level)) - { - return false; - } - } - - return true; + return ApplyEffectInternal(effect, applicationContext, out _); } - internal ActiveEffectHandle? ApplyEffectInternal(Effect effect, EffectApplicationContext? applicationContext) + /// + /// Applies an effect and reports whether it actually landed. + /// + /// + /// A returned handle cannot answer this on its own: an instant effect has no to hand + /// back, so it returns whether it executed or was turned away by a component's + /// or by a registered . + /// Callers that must know — an ability committing its cost, say — need this instead of re-running those checks up + /// front, which would fire the blocked notification twice and burn through a single-use immunity. + /// + /// The instance of the effect to be applied. + /// A handle to the applied effect when it became an . + /// + /// when the effect was applied. + internal bool TryApplyEffect(Effect effect, out ActiveEffectHandle? activeEffectHandle) { - // Applications can cascade: a component reacting to one effect landing can apply another, which can apply - // another. The chain is cut here rather than left to overflow the stack, so a build with validation disabled - // drops the offending application instead of taking the process down with it. - if (_applicationDepth >= MaxApplicationDepth) - { - Validation.Fail( - $"Effect application exceeded {MaxApplicationDepth} levels of nesting while applying " + - $"'{effect.EffectData.Name}', which means two or more effects are applying each other in a cycle. " + - "Break the cycle, usually by gating one of the applications on a tag the other grants."); - - return null; - } - - _applicationDepth++; - - try - { - return ApplyEffectUnguarded(effect, applicationContext); - } - finally - { - _applicationDepth--; - } + activeEffectHandle = ApplyEffectInternal(effect, applicationContext: null, out bool applied); + return applied; } private static bool MatchesStackPolicy(ActiveEffect existingEffect, Effect newEffect) @@ -505,8 +489,44 @@ private static EffectStackInstanceData CreateStackInstanceData(ActiveEffect effe evaluatedData.Stack); } - private ActiveEffectHandle? ApplyEffectUnguarded(Effect effect, EffectApplicationContext? applicationContext) + private ActiveEffectHandle? ApplyEffectInternal( + Effect effect, + EffectApplicationContext? applicationContext, + out bool applied) + { + // Applications can cascade: a component reacting to one effect landing can apply another, which can apply + // another. The chain is cut here rather than left to overflow the stack, so a build with validation disabled + // drops the offending application instead of taking the process down with it. + if (_applicationDepth >= MaxApplicationDepth) + { + Validation.Fail( + $"Effect application exceeded {MaxApplicationDepth} levels of nesting while applying " + + $"'{effect.EffectData.Name}', which means two or more effects are applying each other in a cycle. " + + "Break the cycle, usually by gating one of the applications on a tag the other grants."); + + applied = false; + return null; + } + + _applicationDepth++; + + try + { + return ApplyEffectUnguarded(effect, applicationContext, out applied); + } + finally + { + _applicationDepth--; + } + } + + private ActiveEffectHandle? ApplyEffectUnguarded( + Effect effect, + EffectApplicationContext? applicationContext, + out bool applied) { + applied = false; + if (!effect.CanApply(Owner)) { return null; @@ -537,11 +557,13 @@ private static EffectStackInstanceData CreateStackInstanceData(ActiveEffect effe OnEffectApplied?.Invoke(evaluatedData); Effect.Execute(in evaluatedData, componentInstances); + applied = true; return null; } if (!effect.EffectData.StackingData.HasValue) { + applied = true; return ApplyNewEffect(effect, applicationContext).Handle; } @@ -565,9 +587,12 @@ private static EffectStackInstanceData CreateStackInstanceData(ActiveEffect effe OnEffectStackDenied?.Invoke(effect, stackableEffect.Handle); } + // A denied stack hands back the existing handle but changed nothing, so it is not an application. + applied = successfulApplication; return stackableEffect.Handle; } + applied = true; return ApplyNewEffect(effect, applicationContext).Handle; } diff --git a/Forge/Effects/Modifiers/Modifier.cs b/Forge/Effects/Modifiers/Modifier.cs index 245d6ade..33e97712 100644 --- a/Forge/Effects/Modifiers/Modifier.cs +++ b/Forge/Effects/Modifiers/Modifier.cs @@ -1,6 +1,5 @@ // Copyright © Gamesmiths Guild. -using Gamesmiths.Forge.Attributes; using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Effects.Magnitudes; @@ -20,29 +19,4 @@ public readonly record struct Modifier( ModifierOperation Operation, ModifierMagnitude Magnitude, int Channel = 0, - AggregationMode AggregationMode = AggregationMode.Sum) -{ - internal bool CanApply(Effect effect, IForgeEntity target, int level) - { - if (!target.Attributes.ContainsAttribute(Attribute)) - { - return false; - } - - float 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; - } -} + AggregationMode AggregationMode = AggregationMode.Sum); diff --git a/Forge/Statescript/Nodes/Action/CommitAbilityNode.cs b/Forge/Statescript/Nodes/Action/CommitAbilityNode.cs deleted file mode 100644 index ba3c1e36..00000000 --- a/Forge/Statescript/Nodes/Action/CommitAbilityNode.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright © Gamesmiths Guild. - -using Gamesmiths.Forge.Abilities; - -namespace Gamesmiths.Forge.Statescript.Nodes.Action; - -/// -/// Commits the cost and/or cooldown of the ability driving the current graph. -/// -/// -/// Costs and cooldowns are only paid when explicitly committed. Place this node early in an ability graph -/// (typically right after the Entry node) to pay the ability's cost and start its cooldown, or later if the graph -/// tests conditions before committing. -/// The node reads the from the graph's activation context. When the graph -/// runs without an ability context (standalone execution), the node does nothing. -/// -/// What to commit when the node executes. -public class CommitAbilityNode(CommitAbilityOperation operation = CommitAbilityOperation.CostAndCooldown) : ActionNode -{ - private readonly CommitAbilityOperation _operation = operation; - - /// - public override string Description => "Commits the cost and/or cooldown of the ability driving the graph."; - - /// - protected override void Execute(GraphContext graphContext) - { - if (!graphContext.TryGetActivationContext(out AbilityBehaviorContext? abilityContext)) - { - return; - } - - switch (_operation) - { - case CommitAbilityOperation.CostAndCooldown: - abilityContext.AbilityHandle.CommitAbility(); - break; - - case CommitAbilityOperation.CooldownOnly: - abilityContext.AbilityHandle.CommitCooldown(); - break; - - case CommitAbilityOperation.CostOnly: - abilityContext.AbilityHandle.CommitCost(); - break; - } - } -} diff --git a/Forge/Statescript/Nodes/Action/CommitAbilityOperation.cs b/Forge/Statescript/Nodes/Condition/CommitAbilityOperation.cs similarity index 74% rename from Forge/Statescript/Nodes/Action/CommitAbilityOperation.cs rename to Forge/Statescript/Nodes/Condition/CommitAbilityOperation.cs index b17987ba..0ed0b321 100644 --- a/Forge/Statescript/Nodes/Action/CommitAbilityOperation.cs +++ b/Forge/Statescript/Nodes/Condition/CommitAbilityOperation.cs @@ -1,9 +1,9 @@ // Copyright © Gamesmiths Guild. -namespace Gamesmiths.Forge.Statescript.Nodes.Action; +namespace Gamesmiths.Forge.Statescript.Nodes.Condition; /// -/// Selects what a commits when it executes. +/// Selects what a commits when it executes. /// public enum CommitAbilityOperation : byte { diff --git a/Forge/Statescript/Nodes/Condition/TryActivateAbilityNode.cs b/Forge/Statescript/Nodes/Condition/TryActivateAbilityNode.cs index 90922b2d..8678335a 100644 --- a/Forge/Statescript/Nodes/Condition/TryActivateAbilityNode.cs +++ b/Forge/Statescript/Nodes/Condition/TryActivateAbilityNode.cs @@ -79,6 +79,6 @@ protected override bool Test(GraphContext graphContext) return activator is not null ? activator.Activate(handle, target, (float)magnitude, graphContext) - : handle.Activate(out _, target, (float)magnitude); + : handle.TryActivate(out _, target, (float)magnitude); } } diff --git a/Forge/Statescript/Nodes/Condition/TryCommitAbilityNode.cs b/Forge/Statescript/Nodes/Condition/TryCommitAbilityNode.cs new file mode 100644 index 00000000..d7c83123 --- /dev/null +++ b/Forge/Statescript/Nodes/Condition/TryCommitAbilityNode.cs @@ -0,0 +1,52 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Abilities; + +namespace Gamesmiths.Forge.Statescript.Nodes.Condition; + +/// +/// Tries to commit the cost and/or cooldown of the ability driving the current graph, routing to the True port when +/// the commit succeeds. +/// +/// +/// Costs and cooldowns are only paid when explicitly committed. Place this node early in an ability graph +/// (typically right after the Entry node) to pay the ability's cost and start its cooldown, or later if the graph +/// tests conditions before committing. +/// A graph can reach this node long after the ability activated, by which point the cooldown may have started or +/// the resources may have been spent elsewhere. The commit is therefore re-checked and can fail, which is what the +/// False port is for: route it to whatever the graph should do when the ability cannot pay, usually a +/// . +/// The node reads the from the graph's activation context. When the graph +/// runs without an ability context (standalone execution), there is nothing to commit and the node routes to +/// False. +/// +/// What to commit when the node executes. +public class TryCommitAbilityNode(CommitAbilityOperation operation = CommitAbilityOperation.CostAndCooldown) + : ConditionNode +{ + private readonly CommitAbilityOperation _operation = operation; + + /// + public override string Description => + "Tries to commit the cost and/or cooldown of the ability driving the graph; True when committed."; + + /// + protected override bool Test(GraphContext graphContext) + { + if (!graphContext.TryGetActivationContext(out AbilityBehaviorContext? abilityContext)) + { + return false; + } + + return _operation switch + { + CommitAbilityOperation.CooldownOnly => abilityContext.AbilityHandle.TryCommitCooldown(), + CommitAbilityOperation.CostOnly => abilityContext.AbilityHandle.TryCommitCost(), + + // CostAndCooldown, and equally anything a corrupted configuration deserializes into. Committing both is + // the node's default and, unlike a no-op, can never hand out a free cast; it also keeps the True/False + // result honest, since the port still reports a commit that was genuinely attempted. + _ => abilityContext.AbilityHandle.TryCommitAbility(), + }; + } +} diff --git a/Forge/Statescript/Nodes/Condition/GrantAbilityAndActivateOnceNode.cs b/Forge/Statescript/Nodes/Condition/TryGrantAbilityAndActivateOnceNode.cs similarity index 69% rename from Forge/Statescript/Nodes/Condition/GrantAbilityAndActivateOnceNode.cs rename to Forge/Statescript/Nodes/Condition/TryGrantAbilityAndActivateOnceNode.cs index ddfa17e1..8e3ab59b 100644 --- a/Forge/Statescript/Nodes/Condition/GrantAbilityAndActivateOnceNode.cs +++ b/Forge/Statescript/Nodes/Condition/TryGrantAbilityAndActivateOnceNode.cs @@ -7,7 +7,8 @@ namespace Gamesmiths.Forge.Statescript.Nodes.Condition; /// -/// Grants an ability transiently, activates it once, and routes to the True port when the activation succeeds. +/// Grants an ability transiently, tries to activate it once, and routes to the True port when the activation +/// succeeds. /// /// /// The granted ability is automatically removed when it ends, the one-shot "proc" pattern. @@ -19,10 +20,14 @@ namespace Gamesmiths.Forge.Statescript.Nodes.Condition; /// ) carrying strongly-typed data for the activation. Since the /// ability being activated is known up front, the provider's data type can be matched to it. When the input is unbound, /// the ability is activated without custom data. +/// The granted is written to the node's output variable, but only while the procced +/// ability is still running. A proc that ends as it activates takes its transient grant with it and leaves the +/// output despite routing to True, so use the output to reach a lingering proc — to cancel it, +/// say — not to test whether the activation succeeded. /// /// When the ability is already granted, which level relationships override the /// existing level. -public class GrantAbilityAndActivateOnceNode(LevelComparison levelOverridePolicy = LevelComparison.None) +public class TryGrantAbilityAndActivateOnceNode(LevelComparison levelOverridePolicy = LevelComparison.None) : ConditionNode { /// @@ -50,11 +55,16 @@ public class GrantAbilityAndActivateOnceNode(LevelComparison levelOverridePolicy /// public const byte ActivationDataInput = 4; + /// + /// Output variable index for the granted ability handle. + /// + public const byte AbilityOutput = 0; + private readonly LevelComparison _levelOverridePolicy = levelOverridePolicy; /// public override string Description => - "Grants an ability transiently and activates it once; True when the activation succeeds."; + "Grants an ability transiently and tries to activate it once; True when the activation succeeds."; /// protected override void DefineParameters(List inputProperties, List outputVariables) @@ -64,6 +74,7 @@ protected override void DefineParameters(List inputProperties, Li inputProperties.Add(new InputProperty("Level", typeof(int))); inputProperties.Add(new InputProperty("Target", typeof(IForgeEntity), IsOptional: true)); inputProperties.Add(new InputProperty("Activation Data", typeof(AbilityActivator), IsOptional: true)); + outputVariables.Add(new OutputVariable("Ability", typeof(AbilityHandle))); } /// @@ -95,27 +106,36 @@ protected override bool Test(GraphContext graphContext) graphContext, InputProperties[ActivationDataInput].BoundName); + bool activated; + AbilityHandle? grantedAbility; + if (activator is not null) { - return activator.GrantAndActivateOnce( + activated = activator.GrantAndActivateOnce( entity.Abilities, abilityData, level, _levelOverridePolicy, target, source: null, - graphContext); + graphContext, + out grantedAbility); + } + else + { + activated = entity.Abilities.TryGrantAbilityAndActivateOnce( + abilityData, + level, + _levelOverridePolicy, + out _, + out grantedAbility, + target); } - entity.Abilities.GrantAbilityAndActivateOnce( - abilityData, - level, - _levelOverridePolicy, - out AbilityActivationFailures failures, - target); + // Written on both outcomes so a failed proc clears a handle left by an earlier one instead of leaving it to be + // read as if it were still live. + AbilityNodeUtilities.WriteHandleOutput(graphContext, OutputVariables[AbilityOutput], grantedAbility); - // The returned handle is already freed when the ability completed (and was auto-removed) synchronously, so - // activation success is judged by the failure flags instead. - return failures == AbilityActivationFailures.None; + return activated; } } diff --git a/Forge/Statescript/Properties/AbilityActivator.cs b/Forge/Statescript/Properties/AbilityActivator.cs index 23266a29..d4d93794 100644 --- a/Forge/Statescript/Properties/AbilityActivator.cs +++ b/Forge/Statescript/Properties/AbilityActivator.cs @@ -76,6 +76,8 @@ public bool ActivateByTag( /// The optional activation target. /// The source entity of the granted ability, if any. /// The graph execution context the data is built from. + /// The handle of the granted ability while it remains active, or + /// once the transient grant has been removed. /// when the ability activated without failures. public bool GrantAndActivateOnce( EntityAbilities abilities, @@ -84,7 +86,8 @@ public bool GrantAndActivateOnce( LevelComparison levelOverridePolicy, IForgeEntity? target, IForgeEntity? source, - GraphContext graphContext) + GraphContext graphContext, + out AbilityHandle? grantedAbility) { return _provider.GrantAndActivateOnce( abilities, @@ -94,6 +97,7 @@ public bool GrantAndActivateOnce( target, source, graphContext, - _inputResolvers); + _inputResolvers, + out grantedAbility); } } diff --git a/Forge/Statescript/Properties/AbilityActivatorResolver.cs b/Forge/Statescript/Properties/AbilityActivatorResolver.cs index 451e2bdd..a431db4d 100644 --- a/Forge/Statescript/Properties/AbilityActivatorResolver.cs +++ b/Forge/Statescript/Properties/AbilityActivatorResolver.cs @@ -6,9 +6,10 @@ namespace Gamesmiths.Forge.Statescript.Properties; /// /// Object resolver that produces an for the optional activation-data input of -/// TryActivateAbilityNode, TryActivateAbilitiesByTagNode, and GrantAbilityAndActivateOnceNode. The -/// node uses the activator to build the bound 's typed data and pass it -/// through the generic (non-boxing) activation APIs. +/// TryActivateAbilityNode, TryActivateAbilitiesByTagNode, and +/// TryGrantAbilityAndActivateOnceNode. The node uses the activator to build the bound +/// 's typed data and pass it through the generic (non-boxing) activation +/// APIs. /// /// /// When the provider declares members, the matching resolve them on demand as the diff --git a/Forge/Statescript/Providers/AbilityActivationDataProvider.cs b/Forge/Statescript/Providers/AbilityActivationDataProvider.cs index aec06c23..4bc9f496 100644 --- a/Forge/Statescript/Providers/AbilityActivationDataProvider.cs +++ b/Forge/Statescript/Providers/AbilityActivationDataProvider.cs @@ -48,7 +48,7 @@ bool IAbilityActivationDataProvider.Activate( GraphContext graphContext, IReadOnlyDictionary inputResolvers) { - return handle.Activate(BuildData(graphContext, inputResolvers), out _, target, magnitude); + return handle.TryActivate(BuildData(graphContext, inputResolvers), out _, target, magnitude); } /// @@ -71,20 +71,18 @@ bool IAbilityActivationDataProvider.GrantAndActivateOnce( IForgeEntity? target, IForgeEntity? source, GraphContext graphContext, - IReadOnlyDictionary inputResolvers) + IReadOnlyDictionary inputResolvers, + out AbilityHandle? grantedAbility) { - abilities.GrantAbilityAndActivateOnce( + return abilities.TryGrantAbilityAndActivateOnce( abilityData, level, levelOverridePolicy, BuildData(graphContext, inputResolvers), - out AbilityActivationFailures failures, + out _, + out grantedAbility, target, source); - - // The returned handle is already freed when the ability completed (and was auto-removed) synchronously, so - // activation success is judged by the failure flags instead. - return failures == AbilityActivationFailures.None; } private TData BuildData( diff --git a/Forge/Statescript/Providers/IAbilityActivationDataProvider.cs b/Forge/Statescript/Providers/IAbilityActivationDataProvider.cs index 3b8ad5ee..ca163576 100644 --- a/Forge/Statescript/Providers/IAbilityActivationDataProvider.cs +++ b/Forge/Statescript/Providers/IAbilityActivationDataProvider.cs @@ -89,6 +89,8 @@ bool ActivateByTag( /// The source entity of the granted ability, if any. /// The graph execution context the data is built from. /// The resolvers for the provider's declared members, keyed by member name. + /// The handle of the granted ability while it remains active, or + /// once the transient grant has been removed. /// when the ability activated without failures. bool GrantAndActivateOnce( EntityAbilities abilities, @@ -98,5 +100,6 @@ bool GrantAndActivateOnce( IForgeEntity? target, IForgeEntity? source, GraphContext graphContext, - IReadOnlyDictionary inputResolvers); + IReadOnlyDictionary inputResolvers, + out AbilityHandle? grantedAbility); } diff --git a/docs/abilities.md b/docs/abilities.md index 6d953df5..5983b4a7 100644 --- a/docs/abilities.md +++ b/docs/abilities.md @@ -112,18 +112,17 @@ AbilityHandle handle = entity.Abilities.GrantAbilityPermanently( ### Granting and Activating Once -Use `GrantAbilityAndActivateOnce` to grant an ability temporarily and immediately attempt to activate it: +Use `TryGrantAbilityAndActivateOnce` to grant an ability temporarily and immediately attempt to activate it: ```csharp -AbilityHandle? handle = entity.Abilities.GrantAbilityAndActivateOnce( +if (entity.Abilities.TryGrantAbilityAndActivateOnce( abilityData: consumableAbility, abilityLevel: 1, levelOverridePolicy: LevelComparison.None, out AbilityActivationFailures failureFlags, + out AbilityHandle? handle, targetEntity: enemy, - sourceEntity: item); - -if (handle is not null) + sourceEntity: item)) { // Ability activated successfully (failureFlags == AbilityActivationFailures.None) // The grant will be removed automatically when the ability ends @@ -135,17 +134,20 @@ else } ``` -The ability grant is automatically removed when the ability ends. If activation fails, the grant is removed immediately and the method returns `null`. +The ability grant is automatically removed when the ability ends. If activation fails, the grant is removed immediately. + +`handle` is **not** a success flag: it is only non-`null` while the procced ability is still active, so you can cancel a lingering one. An ability that activates and ends within the call — the common one-shot case — leaves it `null` even though the return value is `true`. Pass `out _` when you do not need it. A generic overload passes strongly-typed activation data to the procced ability, the same data a behavior reads through `IAbilityBehavior.OnStarted`: ```csharp -AbilityHandle? handle = entity.Abilities.GrantAbilityAndActivateOnce( +entity.Abilities.TryGrantAbilityAndActivateOnce( abilityData: consumableAbility, abilityLevel: 1, levelOverridePolicy: LevelComparison.None, data: new ConsumeData(itemId, stackCount), out AbilityActivationFailures failureFlags, + out _, targetEntity: enemy, sourceEntity: item); ``` @@ -240,7 +242,7 @@ ActiveEffectHandle? effectHandle2 = entity.EffectsManager.ApplyEffect(grantEffec // Get the ability handle (both grants reference the same ability) entity.Abilities.TryGetAbility(abilityData, out AbilityHandle? handle); -handle.Activate(out _); +handle.TryActivate(out _); // Removing effect 1 (RemoveOnEnd): ability stays active, waits for end entity.EffectsManager.RemoveEffect(effectHandle1); @@ -288,7 +290,7 @@ Without a source, the lookup matches the ability regardless of who granted it (i if (entity.Abilities.TryGetAbility(fireballData, out AbilityHandle? handle)) { // Ability is granted (by any source), use the handle - handle.Activate(out AbilityActivationFailures failures); + handle.TryActivate(out AbilityActivationFailures failures); } // With a specific source entity @@ -430,7 +432,7 @@ Handlers run inside the ability pipeline, synchronously, so keep them cheap. The ```csharp if (entity.Abilities.TryGetAbility(abilityData, out AbilityHandle? handle)) { - if (handle.Activate(out AbilityActivationFailures failureFlags)) + if (handle.TryActivate(out AbilityActivationFailures failureFlags)) { // Ability activated successfully } @@ -456,12 +458,12 @@ if (entity.Abilities.TryGetAbility(abilityData, out AbilityHandle? handle)) - **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, 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. +- **TryActivate(out failureFlags, target?, magnitude?)**: Attempt to activate the ability with optional target and magnitude. Returns whether it activated. +- **TryActivate\(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. +- **TryCommitAbility()**: Attempt to commit both the cooldown and the cost. All-or-nothing: if either cannot be committed, neither is applied. Returns whether it committed. +- **TryCommitCooldown()**: Attempt to apply the cooldown effects. Fails when the ability is already on cooldown. +- **TryCommitCost()**: Attempt to apply the cost effect. Fails when the owner can no longer afford it. - **GetCooldownData()**: Get information about all cooldowns. - **GetRemainingCooldownTime(tag)**: Get remaining time for a specific cooldown. - **GetCostData()**: Get information about all costs. @@ -522,9 +524,9 @@ var abilityData = new AbilityData( instancingPolicy: AbilityInstancingPolicy.PerExecution); // Each activation creates a new instance -handle.Activate(out _); // Instance 1 -handle.Activate(out _); // Instance 2 -handle.Activate(out _); // Instance 3 +handle.TryActivate(out _); // Instance 1 +handle.TryActivate(out _); // Instance 2 +handle.TryActivate(out _); // Instance 3 // Cancel ends all instances handle.Cancel(); @@ -612,7 +614,7 @@ foreach (CooldownData cd in cooldowns) float remainingTime = handle.GetRemainingCooldownTime(cooldownTag); ``` -Cooldowns are checked during activation but only applied when `CommitCooldown()` or `CommitAbility()` is called. +Cooldowns are checked during activation but only applied when `TryCommitCooldown()` or `TryCommitAbility()` is called. The check is repeated at commit time, so committing a cooldown that is already running returns `false` and applies nothing. ## Costs @@ -624,11 +626,17 @@ 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 **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?) +A cost is affordable when applying it would land inside the attribute's configured min/max bounds — that is, when it applies **in full**, with nothing clamped away: +- A **negative** modifier (consumption) tests against the attribute's **Minimum Value**. (e.g., Do I have enough Mana to pay -30 without going below 0?) +- A **positive** modifier (restoration) tests against the attribute's **Maximum Value**. (e.g., Is my Health low enough to receive +50 healing without exceeding Max Health?) + +Two details follow from the cost being an *instant* effect, and they are what `GetCostData()` reports and what the affordability check measures — the two are computed by the same projection, so the cost you show a player is always the cost that gets charged: -You can add multiple modifiers to the single `CostEffect`, allowing an ability to consume multiple different attributes (e.g., Mana and Health). +- **Costs are spent from `BaseValue`, not `CurrentValue`.** Instant effects modify the base value, so a duration-based buff raising an attribute's current value does not make more of it spendable. An entity with 100 base Mana and a +200 Mana buff shows 300 but can still only pay 100. +- **Modifiers on the same attribute compound.** They are validated in application order, each building on the value the previous one produced, so two -50 modifiers against 90 Mana cost 100 and are rejected together rather than passing individually. +- **A cost the owner has no attribute for is never affordable.** Applying the effect would silently skip that modifier, so the check refuses instead — otherwise an ability whose cost names an attribute the entity does not have would be cast for free. This is the one point where the check is deliberately stricter than the application. + +You can add multiple modifiers to the single `CostEffect`, allowing an ability to consume multiple different attributes (e.g., Mana and Health). Each attribute is validated independently. ```csharp var costEffect = new EffectData( @@ -644,7 +652,7 @@ var abilityData = new AbilityData( costEffect: costEffect); ``` -Cost is checked during activation but only applied when `CommitCost()` or `CommitAbility()` is called. +Cost is checked during activation but only applied when `TryCommitCost()` or `TryCommitAbility()` is called. The check is repeated at commit time, so a cost the owner can no longer afford returns `false` and consumes nothing. ## Ability Behavior @@ -653,10 +661,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()`. +2. **Committing**: Resources and Cooldowns are not applied automatically. You must call `context.AbilityHandle.TryCommitAbility()` (or `TryCommitCost` / `TryCommitCooldown` separately). + - `TryCommitAbility()` commits both the cost and the cooldown, and only if both can be committed. - Do **not** call all three; it is redundant. - Deferring commits allows for mechanics like "free cast if cancelled early." + - Because a commit can run at any point after activation — the resources checked on activation may already be spent — every commit re-checks and returns `false` instead of paying. Handle that result: a behavior that cannot pay usually ends or cancels its instance. **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. @@ -673,9 +682,12 @@ public class FireballBehavior : IAbilityBehavior AbilityHandle abilityHandle = context.AbilityHandle; AbilityInstanceHandle instanceHandle = context.InstanceHandle; - // Commit cooldown and cost - // This calls both CommitCooldown() and CommitCost() - abilityHandle.CommitAbility(); + // Commit cooldown and cost together; nothing is paid unless both can be + if (!abilityHandle.TryCommitAbility()) + { + instanceHandle.End(); + return; + } // Spawn projectile, start animation, etc. SpawnFireball(owner, target, level); @@ -718,7 +730,7 @@ public class InstantAbilityBehavior : IAbilityBehavior { public void OnStarted(AbilityBehaviorContext context) { - context.AbilityHandle.CommitAbility(); + context.AbilityHandle.TryCommitAbility(); // Do the instant effect ApplyDamage(context.Target); @@ -898,7 +910,7 @@ Forge exposes this data through the ability behavior context during activation. `Magnitude` is a numeric value associated with an activation attempt. -- It can be passed explicitly when calling `AbilityHandle.Activate(...)`. +- It can be passed explicitly when calling `AbilityHandle.TryActivate(...)`. - It is automatically populated when abilities are triggered by **Event Triggers**. - It is accessible via `context.Magnitude` inside the behavior. @@ -911,7 +923,7 @@ For cases where a numeric magnitude is not sufficient, abilities can receive str This is done using the generic activation method: ```csharp -handle.Activate( +handle.TryActivate( new HitLocationData(HitZone.Head), out AbilityActivationFailures failures, target: enemy); @@ -931,7 +943,7 @@ public sealed class HitReactionBehavior : IAbilityBehavior { public void OnStarted(AbilityBehaviorContext context, HitLocationData data) { - context.AbilityHandle.CommitAbility(); + context.AbilityHandle.TryCommitAbility(); switch (data.Zone) { @@ -1040,7 +1052,7 @@ For detailed documentation on Statescript concepts, see the [Statescript documen 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. +3. **Commit Explicitly**: Call `TryCommitAbility()` (or individual commits) inside your behavior, and act on the `false` result — a late commit can find the cooldown running or the resources already spent. 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`. diff --git a/docs/quick-start.md b/docs/quick-start.md index 5b805375..9bf40e35 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -1050,8 +1050,13 @@ public class FireballBehavior : IAbilityBehavior { public void OnStarted(AbilityBehaviorContext context) { - // Apply costs and cooldowns - context.AbilityHandle.CommitAbility(); + // Apply costs and cooldowns; a commit can fail if the resources went away + if (!context.AbilityHandle.TryCommitAbility()) + { + context.InstanceHandle.End(); + return; + } + Console.WriteLine("Fireball cast!"); context.InstanceHandle.End(); } @@ -1138,7 +1143,7 @@ if (player.Abilities.TryGetAbility(fireballData, out AbilityHandle? handle)) } // Try to activate - if (handle.Activate(out AbilityActivationFailures failures)) + if (handle.TryActivate(out AbilityActivationFailures failures)) { Console.WriteLine("Activation successful"); } @@ -1178,16 +1183,17 @@ In some cases you just want a quick way to activate an ability on a target witho 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( +bool cast = player.Abilities.TryGrantAbilityAndActivateOnce( abilityData: fireballData, abilityLevel: 1, levelOverridePolicy: LevelComparison.None, out AbilityActivationFailures failureFlags, + out AbilityHandle? handle, targetEntity: enemy, // The target of the fireball sourceEntity: scrollItem // The source (e.g., the scroll item) ); -if (handle is not null) +if (cast) { Console.WriteLine("Scroll used successfully! Fireball cast."); } @@ -1197,6 +1203,8 @@ else } ``` +The `handle` is only set while the procced ability is **still running**. A fireball that finishes inside the call leaves it `null` even though the cast succeeded, so read the return value — not the handle — to know whether the ability activated. + --- ### Triggering an Ability via Events @@ -1315,7 +1323,7 @@ var abilityData = new AbilityData( AbilityHandle handle = player.Abilities.GrantAbilityPermanently( abilityData, 1, LevelComparison.None, player); -handle.Activate(out AbilityActivationFailures failureFlags); +handle.TryActivate(out AbilityActivationFailures failureFlags); ``` Activating the ability starts the graph at its Entry node; `UpdateAbilities` ticks it each frame, and the ability instance ends automatically when the graph finishes. Everything you already know about costs, cooldowns, tag requirements and triggers applies unchanged — the graph replaces the behavior, not the ability. diff --git a/docs/statescript/ability-integration.md b/docs/statescript/ability-integration.md index 04896b7a..7a86f3c9 100644 --- a/docs/statescript/ability-integration.md +++ b/docs/statescript/ability-integration.md @@ -90,7 +90,7 @@ var abilityData = new AbilityData( When activated with typed data: ```csharp -handle.Activate(new DashData(10.0f, 5.0f), out AbilityActivationFailures failures); +handle.TryActivate(new DashData(10.0f, 5.0f), out AbilityActivationFailures failures); ``` If you need to rename fields, precompute values, or convert unsupported payload types into graph-friendly values, use the data-binder overload instead: @@ -132,20 +132,21 @@ The activated ability then receives the value through `IAbilityBehavior(out var context)) + if (graphContext.TryGetActivationContext(out var context) + && context.Owner.Attributes["Health.Current"].CurrentValue < 10) { - context.AbilityHandle.CommitAbility(); + context.InstanceHandle.End(); } } } @@ -233,20 +234,21 @@ This is especially useful for `PerExecution` instancing policies where the same ### Commit Cost and Cooldown from Graph +The built-in [TryCommitAbilityNode](nodes/condition/try-commit-ability-node.md) does this. Wire it after the Entry to commit the ability's cost and cooldown at the start of execution: + ```csharp -public class CommitAbilityActionNode : ActionNode -{ - protected override void Execute(GraphContext graphContext) - { - if (graphContext.TryGetActivationContext(out var context)) - { - context.AbilityHandle.CommitAbility(); - } - } -} +var commit = new TryCommitAbilityNode(); +graph.AddNode(commit); + +graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + commit.InputPorts[ConditionNode.InputPort])); +graph.AddConnection(new Connection( + commit.OutputPorts[ConditionNode.TruePort], + castNode.InputPorts[ActionNode.InputPort])); ``` -Wire this node after the Entry to commit the ability's cost and cooldown at the start of execution. +It is a condition node rather than an action node because the commit can fail: a graph may reach it long after activation, by which point the cooldown may be running or the resources may be gone. Route the **False** port to whatever should happen when the ability cannot pay. ### Apply Effects from Graph @@ -305,7 +307,7 @@ A complete example of an ability that applies a buff, waits, then cleans up: var graph = new Graph(); graph.VariableDefinitions.DefineVariable("buffDuration", 5.0); -var commitNode = new CommitAbilityActionNode(); +var commitNode = new TryCommitAbilityNode(); var applyBuff = new ApplyEffectToOwnerNode(buffEffectData); var timer = new TimerNode(); timer.BindInput(TimerNode.DurationInput, "buffDuration"); @@ -316,12 +318,12 @@ graph.AddNode(applyBuff); graph.AddNode(timer); graph.AddNode(removeBuff); -// Entry → commit → apply buff → timer +// Entry → commit → apply buff → timer (nothing runs when the commit fails) graph.AddConnection(new Connection( graph.EntryNode.OutputPorts[EntryNode.OutputPort], - commitNode.InputPorts[ActionNode.InputPort])); + commitNode.InputPorts[ConditionNode.InputPort])); graph.AddConnection(new Connection( - commitNode.OutputPorts[ActionNode.OutputPort], + commitNode.OutputPorts[ConditionNode.TruePort], applyBuff.InputPorts[ActionNode.InputPort])); graph.AddConnection(new Connection( applyBuff.OutputPorts[ActionNode.OutputPort], @@ -341,7 +343,7 @@ var abilityData = new AbilityData( ## Best Practices -1. **Remember to Commit**: Usually you want to place a `CommitAbility` action node early in the graph to apply costs and cooldowns, but you may want to test conditions first. Make sure to commit at some point to activate cooldowns and deduct costs. +1. **Remember to Commit**: Usually you want to place a `TryCommitAbilityNode` early in the graph to apply costs and cooldowns, but you may want to test conditions first. Make sure to commit at some point to activate cooldowns and deduct costs, and handle its **False** port — the later the commit, the likelier it is to fail. 2. **Use Subgraphs for Cleanup**: Connect ongoing effects and state to Subgraph ports so they are automatically cleaned up when the parent deactivates or the ability is canceled. 3. **Leverage Property Resolvers**: Use `AttributeResolver`, `TagQueryResolver`, and `ComparisonResolver` for data-driven conditions instead of writing custom condition nodes. 4. **Use Shared Variables for Cross-Ability State**: When multiple abilities need to communicate and [Attributes](../attributes.md) or [Tags](../tags.md) are not sufficient, use entity-level shared variables rather than external state. diff --git a/docs/statescript/nodes/action/README.md b/docs/statescript/nodes/action/README.md index dd755b08..0948fe55 100644 --- a/docs/statescript/nodes/action/README.md +++ b/docs/statescript/nodes/action/README.md @@ -56,7 +56,6 @@ public class ApplyEffectActionNode : ActionNode | [ApplyEffectNode](apply-effect-node.md) | Applies one or more effects to one or more targets. | | [CancelAbilitiesNode](cancel-abilities-node.md) | Cancels active abilities on an entity, selected by the ability tags they carry. | | [CancelAbilityNode](cancel-ability-node.md) | Cancels the ability driving the current graph. | -| [CommitAbilityNode](commit-ability-node.md) | Commits the cost and/or cooldown of the ability driving the graph. | | [ExecuteCueNode](execute-cue-node.md) | Executes one or more one-shot cues on one or more targets. | | [GrantAbilityPermanentlyNode](grant-ability-permanently-node.md) | Permanently grants an ability to an entity (cannot be revoked). | | [RaiseEventNode](raise-event-node.md) | Raises an event on one or more target entities' event buses. | diff --git a/docs/statescript/nodes/action/commit-ability-node.md b/docs/statescript/nodes/action/commit-ability-node.md deleted file mode 100644 index 7bcb3946..00000000 --- a/docs/statescript/nodes/action/commit-ability-node.md +++ /dev/null @@ -1,61 +0,0 @@ -# CommitAbilityNode - -> **Type:** Action Node -> **Class:** `Gamesmiths.Forge.Statescript.Nodes.Action.CommitAbilityNode` - -Commits the cost and/or cooldown of the ability driving the current graph, then continues execution. - -Costs and cooldowns are only paid when explicitly committed. Place this node early in an ability graph (typically right after Entry) to pay the ability's cost and start its cooldown, or later if the graph tests conditions before committing. - -## Ports - -**Input Ports:** - -| Index | Name | Description | -|-------|------|-------------| -| 0 | Input | Triggers the commit. | - -**Output Ports:** - -| Index | Name | Type | Description | -|-------|------|------|-------------| -| 0 | Output | Event | Emits after the commit. | - -## Constructor - -```csharp -new CommitAbilityNode(operation = CommitAbilityOperation.CostAndCooldown) -``` - -| Parameter | Type | Description | -|-----------|------|-------------| -| operation | `CommitAbilityOperation` | What to commit. Defaults to `CostAndCooldown`. | - -### `CommitAbilityOperation` values - -- `CostAndCooldown`: commits both cost and cooldown (`AbilityHandle.CommitAbility()`). -- `CooldownOnly`: commits only the cooldown (`CommitCooldown()`). -- `CostOnly`: commits only the cost (`CommitCost()`). - -## Behavior - -1. Reads the `AbilityBehaviorContext` from the graph's activation context. -2. Commits the configured operation on that ability's handle. -3. When the graph runs without an ability context (standalone execution), the node does nothing. - -## Usage - -```csharp -var commit = new CommitAbilityNode(); // cost + cooldown - -graph.AddConnection(new Connection( - graph.EntryNode.OutputPorts[EntryNode.OutputPort], - commit.InputPorts[ActionNode.InputPort])); -``` - -## See Also - -- [Action Nodes Overview](README.md) -- [Ability Integration](../../ability-integration.md) -- [CanActivateAbilityResolver](../../resolvers/can-activate-ability-resolver.md) -- [AbilityCooldownResolver](../../resolvers/ability-cooldown-resolver.md) diff --git a/docs/statescript/nodes/action/grant-ability-permanently-node.md b/docs/statescript/nodes/action/grant-ability-permanently-node.md index feeba400..0feed7b4 100644 --- a/docs/statescript/nodes/action/grant-ability-permanently-node.md +++ b/docs/statescript/nodes/action/grant-ability-permanently-node.md @@ -71,5 +71,5 @@ graph.AddConnection(new Connection( - [Action Nodes Overview](README.md) - [GrantAbilityNode](../state/grant-ability-node.md) -- [GrantAbilityAndActivateOnceNode](../condition/grant-ability-and-activate-once-node.md) +- [TryGrantAbilityAndActivateOnceNode](../condition/try-grant-ability-and-activate-once-node.md) - [GetAbilityHandleResolver](../../resolvers/get-ability-handle-resolver.md) diff --git a/docs/statescript/nodes/condition/README.md b/docs/statescript/nodes/condition/README.md index 7017610d..b884c896 100644 --- a/docs/statescript/nodes/condition/README.md +++ b/docs/statescript/nodes/condition/README.md @@ -45,7 +45,8 @@ public class HasTargetConditionNode : ConditionNode | Node | Description | |------|-------------| | [ExpressionNode](expression-node.md) | Evaluates a boolean input property to choose the output. | -| [GrantAbilityAndActivateOnceNode](grant-ability-and-activate-once-node.md) | Grants an ability transiently and activates it once; True when the activation succeeds. | | [RandomBranchNode](random-branch-node.md) | Routes to True with a resolved probability. | | [TryActivateAbilitiesByTagNode](try-activate-abilities-by-tag-node.md) | Tries to activate abilities matching the given tags; True when any activated. | | [TryActivateAbilityNode](try-activate-ability-node.md) | Tries to activate an ability through its handle; True when activated. | +| [TryCommitAbilityNode](try-commit-ability-node.md) | Tries to commit the cost and/or cooldown of the ability driving the graph; True when committed. | +| [TryGrantAbilityAndActivateOnceNode](try-grant-ability-and-activate-once-node.md) | Grants an ability transiently and tries to activate it once; True when the activation succeeds. Writes the still-running proc's `AbilityHandle` to an output variable. | diff --git a/docs/statescript/nodes/condition/try-activate-ability-node.md b/docs/statescript/nodes/condition/try-activate-ability-node.md index 08c850dd..395a442c 100644 --- a/docs/statescript/nodes/condition/try-activate-ability-node.md +++ b/docs/statescript/nodes/condition/try-activate-ability-node.md @@ -34,7 +34,7 @@ Tries to activate an ability through its `AbilityHandle`, routing to the **True* ## Behavior 1. Resolves the `AbilityHandle` (typically from a [GetAbilityHandleResolver](../../resolvers/get-ability-handle-resolver.md) or the output of a grant node), the optional target, and the magnitude. -2. Calls `AbilityHandle.Activate(out _, target, magnitude)`, or `AbilityHandle.Activate(data, out _, target, magnitude)` when the **Activation Data** input is bound. +2. Calls `AbilityHandle.TryActivate(out _, target, magnitude)`, or `AbilityHandle.TryActivate(data, out _, target, magnitude)` when the **Activation Data** input is bound. 3. Routes to **True** when it activated, otherwise **False**. ## Usage diff --git a/docs/statescript/nodes/condition/try-commit-ability-node.md b/docs/statescript/nodes/condition/try-commit-ability-node.md new file mode 100644 index 00000000..99ab73cf --- /dev/null +++ b/docs/statescript/nodes/condition/try-commit-ability-node.md @@ -0,0 +1,82 @@ +# TryCommitAbilityNode + +> **Type:** Condition Node +> **Class:** `Gamesmiths.Forge.Statescript.Nodes.Condition.TryCommitAbilityNode` + +Tries to commit the cost and/or cooldown of the ability driving the current graph, routing to the **True** port when the commit succeeds. + +Costs and cooldowns are only paid when explicitly committed. Place this node early in an ability graph (typically right after Entry) to pay the ability's cost and start its cooldown, or later if the graph tests conditions before committing. + +The commit is re-checked when the node runs, which is why this is a condition node: a graph can reach it long after the ability activated, by which point the cooldown may have started or the resources may have been spent elsewhere. + +## Ports + +**Input Ports:** + +| Index | Name | Description | +|-------|------|-------------| +| 0 | Input | Triggers the commit. | + +**Output Ports:** + +| Index | Name | Type | Description | +|-------|------|------|-------------| +| 0 | True | Event | Emits when the commit succeeded. | +| 1 | False | Event | Emits when it did not, having paid nothing. | + +## Constructor + +```csharp +new TryCommitAbilityNode(operation = CommitAbilityOperation.CostAndCooldown) +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| operation | `CommitAbilityOperation` | What to commit. Defaults to `CostAndCooldown`. | + +### `CommitAbilityOperation` values + +- `CostAndCooldown`: commits both cost and cooldown (`AbilityHandle.TryCommitAbility()`). +- `CooldownOnly`: commits only the cooldown (`TryCommitCooldown()`). +- `CostOnly`: commits only the cost (`TryCommitCost()`). + +## Behavior + +1. Reads the `AbilityBehaviorContext` from the graph's activation context. +2. Tries the configured operation on that ability's handle. +3. Routes to **True** when it committed, otherwise **False**. +4. When the graph runs without an ability context (standalone execution), there is nothing to commit and the node routes to **False**. + +`CostAndCooldown` is all-or-nothing: if either half cannot be committed, neither is applied and the node routes to **False**. + +## Usage + +```csharp +var commit = new TryCommitAbilityNode(); // cost + cooldown +var cancel = new CancelAbilityNode(); + +graph.AddNode(commit); +graph.AddNode(cancel); + +graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + commit.InputPorts[ConditionNode.InputPort])); + +// Paid: run the ability +graph.AddConnection(new Connection( + commit.OutputPorts[ConditionNode.TruePort], + castNode.InputPorts[ActionNode.InputPort])); + +// Could not pay: drop the ability instead of casting for free +graph.AddConnection(new Connection( + commit.OutputPorts[ConditionNode.FalsePort], + cancel.InputPorts[ActionNode.InputPort])); +``` + +## See Also + +- [Condition Nodes Overview](README.md) +- [CancelAbilityNode](../action/cancel-ability-node.md) +- [Ability Integration](../../ability-integration.md) +- [CanActivateAbilityResolver](../../resolvers/can-activate-ability-resolver.md) +- [AbilityCooldownResolver](../../resolvers/ability-cooldown-resolver.md) diff --git a/docs/statescript/nodes/condition/grant-ability-and-activate-once-node.md b/docs/statescript/nodes/condition/try-grant-ability-and-activate-once-node.md similarity index 54% rename from docs/statescript/nodes/condition/grant-ability-and-activate-once-node.md rename to docs/statescript/nodes/condition/try-grant-ability-and-activate-once-node.md index 1a7d2eaf..5009f196 100644 --- a/docs/statescript/nodes/condition/grant-ability-and-activate-once-node.md +++ b/docs/statescript/nodes/condition/try-grant-ability-and-activate-once-node.md @@ -1,9 +1,9 @@ -# GrantAbilityAndActivateOnceNode +# TryGrantAbilityAndActivateOnceNode > **Type:** Condition Node -> **Class:** `Gamesmiths.Forge.Statescript.Nodes.Condition.GrantAbilityAndActivateOnceNode` +> **Class:** `Gamesmiths.Forge.Statescript.Nodes.Condition.TryGrantAbilityAndActivateOnceNode` -Grants an ability transiently, activates it once, and routes to the **True** port when the activation succeeds. The granted ability is automatically removed when it ends, the one-shot "proc" pattern. +Grants an ability transiently, tries to activate it once, and routes to the **True** port when the activation succeeds. The granted ability is automatically removed when it ends, the one-shot "proc" pattern. ## Ports @@ -23,7 +23,7 @@ Grants an ability transiently, activates it once, and routes to the **True** por ## Constructor ```csharp -new GrantAbilityAndActivateOnceNode(levelOverridePolicy = LevelComparison.None) +new TryGrantAbilityAndActivateOnceNode(levelOverridePolicy = LevelComparison.None) ``` | Parameter | Type | Description | @@ -42,19 +42,30 @@ new GrantAbilityAndActivateOnceNode(levelOverridePolicy = LevelComparison.None) | 3 | Target | `IForgeEntity` | Optional. Passed as the activation target; leave unbound to activate with no target. | | 4 | Activation Data | `AbilityActivator` | Optional. Custom activation data passed to the ability; leave unbound to activate without custom data. | +**Output Variables:** + +| Index | Label | Type | Description | +|-------|-------|------|-------------| +| 0 | Ability | `AbilityHandle` | Optional. The procced ability's handle while it is still running, otherwise `null`. | + ## Behavior 1. Resolves the ability data, entity (default owner), level, and optional target. -2. Calls `EntityAbilities.GrantAbilityAndActivateOnce(...)`, or `EntityAbilities.GrantAbilityAndActivateOnce(...)` when the **Activation Data** input is bound. -3. Routes to **True** when the activation succeeded (judged by the returned failure flags), otherwise **False**. +2. Calls `EntityAbilities.TryGrantAbilityAndActivateOnce(...)`, or `EntityAbilities.TryGrantAbilityAndActivateOnce(...)` when the **Activation Data** input is bound. +3. Writes the granted `AbilityHandle` to the **Ability** output when bound, on both outcomes — so a failed proc clears a handle left by an earlier one. +4. Routes to **True** when the call reports the ability activated, otherwise **False**. An ability that activates and ends immediately still routes to **True**. + +The **Ability** output is not a success signal. A one-shot proc that finishes as it activates takes its transient grant with it, leaving the output `null` on the **True** branch. Bind it when the graph needs to reach a proc that keeps running — to cancel it later, or to read its cooldown — and branch on True/False for everything else. ## Usage ```csharp graph.VariableDefinitions.DefineObjectVariable("procAbility", counterAttackData); +graph.VariableDefinitions.DefineObjectVariable("procHandle"); -var proc = new GrantAbilityAndActivateOnceNode(); -proc.BindInput(GrantAbilityAndActivateOnceNode.AbilityDataInput, "procAbility"); +var proc = new TryGrantAbilityAndActivateOnceNode(); +proc.BindInput(TryGrantAbilityAndActivateOnceNode.AbilityDataInput, "procAbility"); +proc.BindOutput(TryGrantAbilityAndActivateOnceNode.AbilityOutput, "procHandle"); graph.AddConnection(new Connection( graph.EntryNode.OutputPorts[EntryNode.OutputPort], @@ -64,6 +75,8 @@ graph.AddConnection(new Connection( onProcNode.InputPorts[ActionNode.InputPort])); ``` +`procHandle` now holds the procced ability for as long as it runs, so later nodes and the handle-taking resolvers ([AbilityCooldownResolver](../../resolvers/ability-cooldown-resolver.md), [AbilityCostResolver](../../resolvers/ability-cost-resolver.md), [AbilityStateResolver](../../resolvers/ability-state-resolver.md)) can act on that specific proc rather than on the ability driving the graph. + ## Passing custom activation data Bind the **Activation Data** input to an [AbilityActivatorResolver](../../resolvers/ability-activator-resolver.md) to hand the procced ability a strongly-typed value built from the current graph state: @@ -72,7 +85,7 @@ Bind the **Activation Data** input to an [AbilityActivatorResolver](../../resolv graph.VariableDefinitions.DefineObjectProperty("counterData", new AbilityActivatorResolver(new CounterAttackDataProvider())); -proc.BindInput(GrantAbilityAndActivateOnceNode.ActivationDataInput, "counterData"); +proc.BindInput(TryGrantAbilityAndActivateOnceNode.ActivationDataInput, "counterData"); ``` Unlike [TryActivateAbilitiesByTagNode](try-activate-abilities-by-tag-node.md), the ability being activated is fixed by the **Ability Data** input, so the provider's data type can be matched to it up front. An ability that does not implement `IAbilityBehavior` still activates and simply ignores the data. diff --git a/docs/statescript/nodes/state/grant-ability-node.md b/docs/statescript/nodes/state/grant-ability-node.md index 2e487e5d..f1cbb64d 100644 --- a/docs/statescript/nodes/state/grant-ability-node.md +++ b/docs/statescript/nodes/state/grant-ability-node.md @@ -69,4 +69,4 @@ graph.AddConnection(new Connection( - [State Nodes Overview](README.md) - [GrantAbilityPermanentlyNode](../action/grant-ability-permanently-node.md) -- [GrantAbilityAndActivateOnceNode](../condition/grant-ability-and-activate-once-node.md) +- [TryGrantAbilityAndActivateOnceNode](../condition/try-grant-ability-and-activate-once-node.md) diff --git a/docs/statescript/resolvers/ability-activator-resolver.md b/docs/statescript/resolvers/ability-activator-resolver.md index 85aeb940..19644299 100644 --- a/docs/statescript/resolvers/ability-activator-resolver.md +++ b/docs/statescript/resolvers/ability-activator-resolver.md @@ -3,7 +3,7 @@ > **Type:** `Gamesmiths.Forge.Statescript.Properties.AbilityActivatorResolver` > **Output Type:** `AbilityActivator` -Produces the custom activation data a graph passes when it activates an ability. It delegates to an `IAbilityActivationDataProvider`, which builds a strongly-typed value from the current graph state and hands it to the generic activation APIs. Bind it to the optional **Activation Data** input of [TryActivateAbilityNode](../nodes/condition/try-activate-ability-node.md), [TryActivateAbilitiesByTagNode](../nodes/condition/try-activate-abilities-by-tag-node.md), and [GrantAbilityAndActivateOnceNode](../nodes/condition/grant-ability-and-activate-once-node.md). +Produces the custom activation data a graph passes when it activates an ability. It delegates to an `IAbilityActivationDataProvider`, which builds a strongly-typed value from the current graph state and hands it to the generic activation APIs. Bind it to the optional **Activation Data** input of [TryActivateAbilityNode](../nodes/condition/try-activate-ability-node.md), [TryActivateAbilitiesByTagNode](../nodes/condition/try-activate-abilities-by-tag-node.md), and [TryGrantAbilityAndActivateOnceNode](../nodes/condition/try-grant-ability-and-activate-once-node.md). This is the send end of the channel [AbilityActivationDataResolver](ability-activation-data-resolver.md) reads from: a provider builds typed data *from* the graph and feeds it *into* the activation, where the activated ability reads members back *out*. **Both ends are driven by the same `IAbilityActivationDataProvider`**, so one implementation per activation-data type covers the whole round trip. It is the ability counterpart of [EffectContextDataResolver](effect-context-data-resolver.md) and [EventPayloadResolver](event-payload-resolver.md). @@ -81,7 +81,7 @@ On the **reading** side the same member is offered as a bindable field of [Abili ## Behavior - The resolver returns the same `AbilityActivator` on every resolve; the *data* is rebuilt from the current graph state on each activation. Declared inputs are resolved lazily from the bag as the provider reads them. -- Each node calls the activator entry point matching its own operation, which forwards to the generic ability API (`AbilityHandle.Activate`, `EntityAbilities.TryActivateAbilitiesByTag`, `EntityAbilities.GrantAbilityAndActivateOnce`). +- Each node calls the activator entry point matching its own operation, which forwards to the generic ability API (`AbilityHandle.TryActivate`, `EntityAbilities.TryActivateAbilitiesByTag`, `EntityAbilities.TryGrantAbilityAndActivateOnce`). - **Mismatched data is never an error.** An ability whose behavior does not implement `IAbilityBehavior` still activates; it just starts through the untyped path and ignores the data. This matters most for [TryActivateAbilitiesByTagNode](../nodes/condition/try-activate-abilities-by-tag-node.md), where one tag can select several abilities that do not share an activation-data type. - When the **Activation Data** input is unbound, the node activates without custom data. @@ -105,4 +105,4 @@ tryActivate.BindInput(TryActivateAbilityNode.ActivationDataInput, "throwData"); - [EventPayloadResolver](event-payload-resolver.md) - [TryActivateAbilityNode](../nodes/condition/try-activate-ability-node.md) - [TryActivateAbilitiesByTagNode](../nodes/condition/try-activate-abilities-by-tag-node.md) -- [GrantAbilityAndActivateOnceNode](../nodes/condition/grant-ability-and-activate-once-node.md) +- [TryGrantAbilityAndActivateOnceNode](../nodes/condition/try-grant-ability-and-activate-once-node.md) diff --git a/docs/statescript/resolvers/can-activate-ability-resolver.md b/docs/statescript/resolvers/can-activate-ability-resolver.md index 1ae90085..dfb3c1e1 100644 --- a/docs/statescript/resolvers/can-activate-ability-resolver.md +++ b/docs/statescript/resolvers/can-activate-ability-resolver.md @@ -51,5 +51,5 @@ graph.VariableDefinitions.DefineProperty("shouldProc", ## See Also - [Resolvers Overview](README.md) -- [CommitAbilityNode](../nodes/action/commit-ability-node.md) +- [TryCommitAbilityNode](../nodes/condition/try-commit-ability-node.md) - [AbilityCooldownResolver](ability-cooldown-resolver.md)