diff --git a/.github/workflows/validate-project.yml b/.github/workflows/validate-project.yml index 9919d3f..d1bd0c1 100644 --- a/.github/workflows/validate-project.yml +++ b/.github/workflows/validate-project.yml @@ -1,4 +1,4 @@ -name: Validate Project +name: CI on: push: diff --git a/Forge.Tests/Abilities/AbilitiesTests.cs b/Forge.Tests/Abilities/AbilitiesTests.cs new file mode 100644 index 0000000..f1142eb --- /dev/null +++ b/Forge.Tests/Abilities/AbilitiesTests.cs @@ -0,0 +1,3273 @@ +// 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.Effects.Periodic; +using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Tags; +using Gamesmiths.Forge.Tests.Core; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Abilities; + +public class AbilitiesTests(TagsAndCuesFixture tagsAndCuesFixture) : IClassFixture +{ + private readonly TagsManager _tagsManager = tagsAndCuesFixture.TagsManager; + private readonly CuesManager _cuesManager = tagsAndCuesFixture.CuesManager; + + [Fact] + [Trait("Grant ability", null)] + public void Ability_is_granted_successfully() + { + 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 _); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("Remove ability", null)] + public void Removed_ability_is_deactivated_immediately() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle); + + abilityHandle.Should().NotBeNull(); + effectHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + + entity.EffectsManager.RemoveEffect(effectHandle!); + + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + abilityHandle.IsActive.Should().BeFalse(); + + abilityHandle.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.InvalidHandler); + } + + [Fact] + [Trait("Remove ability", null)] + public void Ability_is_removed_only_after_deactivation_when_granted_with_RemoveOnEnd() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle, + AbilityDeactivationPolicy.RemoveOnEnd, + AbilityDeactivationPolicy.RemoveOnEnd); + + abilityHandle.Should().NotBeNull(); + effectHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + + entity.EffectsManager.RemoveEffect(effectHandle!); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle.Cancel(); + + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Remove ability", null)] + public void Grants_with_different_remove_policies_work_independently() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle, + AbilityDeactivationPolicy.RemoveOnEnd, + AbilityDeactivationPolicy.RemoveOnEnd); + + AbilityHandle? abilityHandle2 = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle2, + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.RemoveOnEnd); + + abilityHandle.Should().NotBeNull(); + abilityHandle2.Should().Be(abilityHandle); + effectHandle.Should().NotBeNull(); + effectHandle2.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + + entity.EffectsManager.RemoveEffect(effectHandle!); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + abilityHandle.IsActive.Should().BeTrue(); + + entity.EffectsManager.RemoveEffect(effectHandle2!); + + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Remove ability", null)] + public void CancelImmediately_takes_precedence_and_removes_the_ability_immediately() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle, + AbilityDeactivationPolicy.RemoveOnEnd, + AbilityDeactivationPolicy.Ignore); + + AbilityHandle? abilityHandle2 = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle2, + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.Ignore); + + abilityHandle.Should().NotBeNull(); + abilityHandle2.Should().Be(abilityHandle); + effectHandle.Should().NotBeNull(); + effectHandle2.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + + entity.EffectsManager.RemoveEffect(effectHandle!); + entity.EffectsManager.RemoveEffect(effectHandle2!); + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Inhibit ability", null)] + public void Ability_gets_inhibited_temporarily_while_granting_effect_is_inhibited() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags))); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + + ActiveEffectHandle? tagEffectHandle = CreateAndApplyTagEffect(entity, ignoreTags!); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.Inhibited); + abilityHandle.IsActive.Should().BeFalse(); + abilityHandle.IsInhibited.Should().BeTrue(); + + entity.EffectsManager.RemoveEffect(tagEffectHandle!); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + abilityHandle.IsInhibited.Should().BeFalse(); + } + + [Fact] + [Trait("Remove ability", null)] + public void Granted_ability_is_not_removed_when_deactivation_policy_is_ignore() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle, + AbilityDeactivationPolicy.Ignore, + AbilityDeactivationPolicy.Ignore); + + abilityHandle.Should().NotBeNull(); + effectHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + + entity.EffectsManager.RemoveEffect(effectHandle!); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle.Cancel(); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_granted_by_multiple_effects_is_removed_only_when_all_granting_effects_are_removed() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle1 = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle1); + + AbilityHandle? abilityHandle2 = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle2); + + abilityHandle1.Should().NotBeNull(); + effectHandle1.Should().NotBeNull(); + abilityHandle2.Should().NotBeNull(); + effectHandle2.Should().NotBeNull(); + abilityHandle1.Should().Be(abilityHandle2); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + entity.EffectsManager.RemoveEffect(effectHandle1!); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + entity.EffectsManager.RemoveEffect(effectHandle2!); + + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_is_not_granted_if_target_has_blocking_tags() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? blockingTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + blockingTags.Should().NotBeNull(); + + var grantAbilityConfig = new GrantAbilityConfig( + abilityData, + new ScalableInt(1), + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.CancelImmediately); + + var grantAbilityEffectData = new EffectData( + "Grant Fireball", + new DurationData(DurationType.Infinite), + effectComponents: + [ + new GrantAbilityEffectComponent([grantAbilityConfig]), + new TargetTagRequirementsEffectComponent(applicationTagRequirements: new TagRequirements(IgnoreTags: blockingTags)) + ]); + + var grantAbilityEffect = new Effect( + grantAbilityEffectData, + new EffectOwnership(entity, null)); + + CreateAndApplyTagEffect(entity, blockingTags!); + + entity.EffectsManager.ApplyEffect(grantAbilityEffect); + + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_granted_by_instant_effect_is_permanent() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + // Grant ability with an instant effect, making it permanent. + AbilityHandle? permanentAbilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + durationData: new DurationData(DurationType.Instant)); + + // Grant the same ability with a non-instant effect. + AbilityHandle? temporaryAbilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? temporaryEffectHandle); + + permanentAbilityHandle.Should().NotBeNull(); + temporaryAbilityHandle.Should().NotBeNull(); + temporaryEffectHandle.Should().NotBeNull(); + permanentAbilityHandle.Should().Be(temporaryAbilityHandle); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + // Remove the temporary effect. + entity.EffectsManager.RemoveEffect(temporaryEffectHandle!); + + // The ability should still be granted because of the initial permanent grant. + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_granted_by_same_EffectData_to_two_entities_are_different_instances() + { + TestEntity entity1 = new(_tagsManager, _cuesManager); + TestEntity entity2 = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + GrantAbilityConfig grantAbilityConfig = new( + abilityData, + new ScalableInt(1), + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.CancelImmediately, + false, + false, + LevelComparison.None); + + var grantAbilityEffectData = new EffectData( + "GrantFireball", + new DurationData(DurationType.Instant), + effectComponents: [new GrantAbilityEffectComponent([grantAbilityConfig])]); + + var effect1 = new Effect( + grantAbilityEffectData, + new EffectOwnership(null, entity1)); + + var effect2 = new Effect( + grantAbilityEffectData, + new EffectOwnership(null, entity2)); + + entity1.EffectsManager.ApplyEffect(effect1); + entity2.EffectsManager.ApplyEffect(effect2); + + entity1.Abilities.TryGetAbility(abilityData, out AbilityHandle? abilityHandle1, entity1); + entity2.Abilities.TryGetAbility(abilityData, out AbilityHandle? abilityHandle2, entity2); + + abilityHandle1.Should().NotBeNull(); + abilityHandle2.Should().NotBeNull(); + abilityHandle1.Should().NotBe(abilityHandle2); + entity1.Abilities.GrantedAbilities.Should().ContainSingle(); + entity2.Abilities.GrantedAbilities.Should().ContainSingle(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_granted_by_late_instant_effect_is_permanent() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + // Grant the same ability with a non-instant effect. + AbilityHandle? temporaryAbilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? temporaryEffectHandle); + + temporaryAbilityHandle.Should().NotBeNull(); + temporaryEffectHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + // Grant ability with an instant effect, making it permanent. + AbilityHandle? permanentAbilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + durationData: new DurationData(DurationType.Instant)); + + permanentAbilityHandle.Should().Be(temporaryAbilityHandle); + + // Remove the temporary effect. + entity.EffectsManager.RemoveEffect(temporaryEffectHandle!); + + // The ability should still be granted because of the initial permanent grant. + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + } + + [Fact] + [Trait("Inhibit ability", null)] + public void Ability_granted_by_instant_effect_is_not_inhibited() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + // Grant ability with an instant effect, making it permanent. + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + durationData: new DurationData(DurationType.Instant)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + // Grant the same ability with a non-instant, inhibitable effect. + SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags))); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + // Inhibit the temporary effect by adding the tag. + CreateAndApplyTagEffect(entity, ignoreTags!); + + // The ability should not be inhibited because it was granted permanently. + abilityHandle!.IsInhibited.Should().BeFalse(); + } + + [Fact] + [Trait("Inhibit ability", null)] + public void Ability_granted_by_late_instant_effect_is_not_inhibited() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + // Grant the same ability with a non-instant, inhibitable effect. + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags))); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + // Inhibit the temporary effect by adding the tag. + CreateAndApplyTagEffect(entity, ignoreTags!); + + // The ability should now be inhibited. + abilityHandle!.IsInhibited.Should().BeTrue(); + + // Grant ability with an instant effect, making it permanent and removing inhibition. + SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + durationData: new DurationData(DurationType.Instant)); + + // The ability should no longer be inhibited. + abilityHandle.IsInhibited.Should().BeFalse(); + } + + [Fact] + [Trait("Inhibit ability", null)] + public void Ability_is_inhibited_only_when_all_granting_effects_are_inhibited() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags1 = Tag.RequestTag(_tagsManager, "Simple.Tag").GetSingleTagContainer(); + TagContainer? ignoreTags2 = Tag.RequestTag(_tagsManager, "Other.Tag").GetSingleTagContainer(); + ignoreTags1.Should().NotBeNull(); + ignoreTags2.Should().NotBeNull(); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags1))); + + SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags2))); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + + // Inhibit the first effect. + CreateAndApplyTagEffect(entity, ignoreTags1!); + + // Ability should not be inhibited yet, but it should be deactivated. + abilityHandle.IsInhibited.Should().BeFalse(); + abilityHandle.IsActive.Should().BeFalse(); + + // Inhibit the second effect. + CreateAndApplyTagEffect(entity, ignoreTags2!); + + // Now the ability should be fully inhibited. + abilityHandle.IsInhibited.Should().BeTrue(); + } + + [Fact] + [Trait("Inhibit ability", null)] + public void Inhibited_ability_becomes_active_if_new_non_inhibited_source_is_added() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags))); + + abilityHandle.Should().NotBeNull(); + + // Inhibit the ability. + CreateAndApplyTagEffect(entity, ignoreTags!); + + abilityHandle!.IsInhibited.Should().BeTrue(); + + // Add a new, non-inhibited source for the same ability. + SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + // The ability should no longer be inhibited. + abilityHandle.IsInhibited.Should().BeFalse(); + } + + [Fact] + [Trait("Inhibit ability", null)] + public void Inhibition_policy_RemoveOnEnd_inhibits_after_deactivation() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + grantedAbilityInhibitionPolicy: AbilityDeactivationPolicy.RemoveOnEnd, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags))); + + abilityHandle.Should().NotBeNull(); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + + // Inhibit the granting effect. + CreateAndApplyTagEffect(entity, ignoreTags!); + + // With RemoveOnEnd policy, the ability is not inhibited while active. + abilityHandle.IsInhibited.Should().BeFalse(); + abilityHandle.IsActive.Should().BeTrue(); + + // End the ability. + abilityHandle.Cancel(); + + // Now that it's no longer active, it should become inhibited. + abilityHandle.IsActive.Should().BeFalse(); + abilityHandle.IsInhibited.Should().BeTrue(); + } + + [Fact] + [Trait("Inhibit ability", null)] + public void Inhibition_policy_Ignore_prevents_inhibition() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + grantedAbilityInhibitionPolicy: AbilityDeactivationPolicy.Ignore, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags))); + + abilityHandle.Should().NotBeNull(); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + // Inhibit the granting effect. + CreateAndApplyTagEffect(entity, ignoreTags!); + + // With Ignore policy, the ability is never inhibited. + abilityHandle.IsInhibited.Should().BeFalse(); + abilityHandle.IsActive.Should().BeTrue(); + + abilityHandle.Cancel(); + + abilityHandle.IsInhibited.Should().BeFalse(); + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Inhibit ability", null)] + public void Effect_inhibited_at_application_grant_inhibited_abilities() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + + CreateAndApplyTagEffect(entity, ignoreTags!); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags))); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + abilityHandle!.IsInhibited.Should().BeTrue(); + } + + [Fact] + [Trait("Inhibit ability", null)] + public void Grants_with_different_inhibit_policies_work_independently() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + grantedAbilityInhibitionPolicy: AbilityDeactivationPolicy.RemoveOnEnd, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags))); + + abilityHandle.Should().NotBeNull(); + + TagContainer? ignoreTags2 = Tag.RequestTag(_tagsManager, "simple.tag").GetSingleTagContainer(); + ignoreTags2.Should().NotBeNull(); + + AbilityHandle? abilityHandle2 = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + grantedAbilityInhibitionPolicy: AbilityDeactivationPolicy.CancelImmediately, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements( + IgnoreTags: ignoreTags2))); + + abilityHandle2.Should().NotBeNull(); + abilityHandle.Should().Be(abilityHandle2); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + abilityHandle2!.IsActive.Should().BeTrue(); + + // Inhibit the granting effect with RemoveOnEnd policy. + CreateAndApplyTagEffect(entity, ignoreTags!); + + // With RemoveOnEnd policy, the ability is not inhibited while active. + abilityHandle.IsInhibited.Should().BeFalse(); + abilityHandle.IsActive.Should().BeTrue(); + abilityHandle2!.IsInhibited.Should().BeFalse(); + abilityHandle2.IsActive.Should().BeTrue(); + + // Inhibit the second granting effect with CancelImmediately policy. + CreateAndApplyTagEffect(entity, ignoreTags2!); + + // Now that it's no longer active, it should become inhibited. + abilityHandle.IsActive.Should().BeFalse(); + abilityHandle.IsInhibited.Should().BeTrue(); + abilityHandle2.IsActive.Should().BeFalse(); + abilityHandle2.IsInhibited.Should().BeTrue(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_level_is_set_correctly() + { + 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(5), // Grant at level 5 + out _); + + abilityHandle.Should().NotBeNull(); + abilityHandle!.Level.Should().Be(5); + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_level_scales_with_curve() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var levelCurve = new Curve( + [ + new CurveKey(1, 1f), // Effect level 1 -> Ability level 1 + new CurveKey(2, 3f), // Effect level 2 -> Ability level 3 + new CurveKey(3, 5f), // Effect level 3 -> Ability level 5 + ]); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + // Grant ability with a scaling level based on the effect's level + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1, levelCurve), + out _, + effectLevel: 2); // Granting effect is level 2 + + abilityHandle.Should().NotBeNull(); + abilityHandle!.Level.Should().Be(3); // Curve should evaluate to 3 + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_level_override_policy_works_correctly() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + // Grant at level 2 + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(2), + out _); + + abilityHandle.Should().NotBeNull(); + abilityHandle!.Level.Should().Be(2); + + // Grant again at level 3 with override policy for higher levels + SetupAbility( + entity, + abilityData, + new ScalableInt(3), + out _, + levelOverridePolicy: LevelComparison.Higher); + + abilityHandle.Level.Should().Be(3); + + // Grant again at level 1 with the same policy; level should not change + SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + levelOverridePolicy: LevelComparison.Higher); + + abilityHandle.Level.Should().Be(3); + + // Grant again at level 5 with override policy for lower or equal levels + SetupAbility( + entity, + abilityData, + new ScalableInt(5), + out _, + levelOverridePolicy: LevelComparison.Lower | LevelComparison.Equal); + + abilityHandle.Level.Should().Be(3); + } + + [Fact] + [Trait("Grant ability", null)] + public void Abilities_with_different_sources_are_separate_instances() + { + TestEntity entity1 = new(_tagsManager, _cuesManager); + TestEntity entity2 = new(_tagsManager, _cuesManager); + + // Create two different AbilityData instances (differ by name) + AbilityData abilityData1 = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + // Grant the first ability + AbilityHandle? abilityHandle1 = SetupAbility( + entity1, + abilityData1, + new ScalableInt(1), + out _, + sourceEntity: entity1); + + // Grant the second ability + AbilityHandle? abilityHandle2 = SetupAbility( + entity1, + abilityData1, + new ScalableInt(1), + out _, + sourceEntity: entity2); + + abilityHandle1.Should().NotBeNull(); + abilityHandle2.Should().NotBeNull(); + + // The handles should be for different ability instances + abilityHandle1.Should().NotBe(abilityHandle2); + entity1.Abilities.GrantedAbilities.Should().HaveCount(2); + + // Activate one and ensure the other is not affected + abilityHandle1!.Activate(out AbilityActivationFailures failureFlags1).Should().BeTrue(); + failureFlags1.Should().Be(AbilityActivationFailures.None); + abilityHandle1.IsActive.Should().BeTrue(); + abilityHandle2!.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Cooldown", null)] + public void Ability_wont_activate_while_cooldown_is_active() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + + abilityHandle.CommitCooldown(); + abilityHandle.Cancel(); + + abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.Cooldown); + + entity.EffectsManager.UpdateEffects(2f); + + abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.Cooldown); + + entity.EffectsManager.UpdateEffects(1f); + + abilityHandle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + } + + [Fact] + [Trait("Cooldown", null)] + public void Ability_wont_activate_until_last_cooldown_effect_is_removed() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f), new ScalableFloat(1f)], + ["simple.tag", "tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + + abilityHandle.CommitCooldown(); + abilityHandle.Cancel(); + + abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.Cooldown); + + entity.EffectsManager.UpdateEffects(2f); + + abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.Cooldown); + + entity.EffectsManager.UpdateEffects(1f); + + abilityHandle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + } + + [Fact] + [Trait("Cooldown", null)] + public void GetCooldownData_and_GetRemainingCooldownTime_return_correct_values() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f), new ScalableFloat(1f)], + ["simple.tag", "tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + + CooldownData[]? cooldownData = abilityHandle.GetCooldownData()!; + cooldownData.Should().HaveCount(2); + + var simpleTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var tag = Tag.RequestTag(_tagsManager, "tag"); + + cooldownData[0].TotalTime.Should().Be(3f); + cooldownData[0].RemainingTime.Should().Be(0f); + cooldownData[0].CooldownTags.Should().Contain(simpleTag); + + cooldownData[1].TotalTime.Should().Be(1f); + cooldownData[1].RemainingTime.Should().Be(0f); + cooldownData[1].CooldownTags.Should().Contain(tag); + + abilityHandle.GetRemainingCooldownTime(simpleTag).Should().Be(0f); + abilityHandle.GetRemainingCooldownTime(tag).Should().Be(0f); + + abilityHandle.CommitCooldown(); + abilityHandle.Cancel(); + + abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.Cooldown); + + cooldownData = abilityHandle.GetCooldownData()!; + cooldownData.Should().HaveCount(2); + + cooldownData[0].TotalTime.Should().Be(3f); + cooldownData[0].RemainingTime.Should().Be(3f); + cooldownData[0].CooldownTags.Should().Contain(simpleTag); + + cooldownData[1].TotalTime.Should().Be(1f); + cooldownData[1].RemainingTime.Should().Be(1f); + cooldownData[1].CooldownTags.Should().Contain(tag); + + abilityHandle.GetRemainingCooldownTime(simpleTag).Should().Be(3f); + abilityHandle.GetRemainingCooldownTime(tag).Should().Be(1f); + + entity.EffectsManager.UpdateEffects(0.5f); + + abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.Cooldown); + + cooldownData = abilityHandle.GetCooldownData()!; + cooldownData.Should().HaveCount(2); + + cooldownData[0].TotalTime.Should().Be(3f); + cooldownData[0].RemainingTime.Should().Be(2.5f); + cooldownData[0].CooldownTags.Should().Contain(simpleTag); + + cooldownData[1].TotalTime.Should().Be(1f); + cooldownData[1].RemainingTime.Should().Be(0.5f); + cooldownData[1].CooldownTags.Should().Contain(tag); + + abilityHandle.GetRemainingCooldownTime(simpleTag).Should().Be(2.5f); + abilityHandle.GetRemainingCooldownTime(tag).Should().Be(0.5f); + + entity.EffectsManager.UpdateEffects(1f); + + cooldownData = abilityHandle.GetCooldownData()!; + cooldownData.Should().HaveCount(2); + + cooldownData[0].TotalTime.Should().Be(3f); + cooldownData[0].RemainingTime.Should().Be(1.5f); + cooldownData[0].CooldownTags.Should().Contain(simpleTag); + + cooldownData[1].TotalTime.Should().Be(1f); + cooldownData[1].RemainingTime.Should().Be(0f); + cooldownData[1].CooldownTags.Should().Contain(tag); + + abilityHandle.GetRemainingCooldownTime(simpleTag).Should().Be(1.5f); + abilityHandle.GetRemainingCooldownTime(tag).Should().Be(0f); + + entity.EffectsManager.UpdateEffects(2f); + + cooldownData = abilityHandle.GetCooldownData()!; + cooldownData.Should().HaveCount(2); + + cooldownData[0].TotalTime.Should().Be(3f); + cooldownData[0].RemainingTime.Should().Be(0f); + cooldownData[0].CooldownTags.Should().Contain(simpleTag); + + cooldownData[1].TotalTime.Should().Be(1f); + cooldownData[1].RemainingTime.Should().Be(0f); + cooldownData[1].CooldownTags.Should().Contain(tag); + + abilityHandle.GetRemainingCooldownTime(simpleTag).Should().Be(0f); + abilityHandle.GetRemainingCooldownTime(tag).Should().Be(0f); + + abilityHandle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + } + + [Theory] + [Trait("Cost", null)] + [InlineData(5)] + [InlineData(-50)] + public void Ability_wont_activate_if_cant_afford_cost(int cost) + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(cost), + retriggerInstancedAbility: true); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + + abilityHandle.CommitCost(); + + abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.InsufficientResources); + } + + [Fact] + [Trait("OwnerTag requirements", null)] + public void Ability_wont_activate_when_owner_missing_required_tag() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + activationRequiredTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.OwnerTagRequirements); + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("OwnerTag requirements", null)] + public void Ability_wont_activate_when_owner_has_blocked_tag() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + activationBlockedTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["color.green"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.OwnerTagRequirements); + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("SourceTag requirements", null)] + public void Ability_wont_activate_when_source_missing_required_tag() + { + TestEntity entity = new(_tagsManager, _cuesManager); + TestEntity source = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + sourceRequiredTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + sourceEntity: source); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.SourceTagRequirements); + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("SourceTag requirements", null)] + public void Ability_wont_activate_when_source_has_blocked_tag() + { + TestEntity entity = new(_tagsManager, _cuesManager); + TestEntity source = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + sourceBlockedTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["color.green"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + sourceEntity: source); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.SourceTagRequirements); + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("SourceTag requirements", null)] + public void Ability_wont_activate_when_source_is_missing_but_has_required_tags() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + sourceRequiredTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + sourceEntity: null); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.SourceTagRequirements); + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("SourceTag requirements", null)] + public void Ability_activates_when_source_is_missing_and_has_blocked_tags() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + sourceBlockedTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["color.green"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + sourceEntity: null); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("TargetTag requirements", null)] + public void Ability_wont_activate_when_target_missing_required_tag() + { + TestEntity entity = new(_tagsManager, _cuesManager); + TestEntity target = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + targetRequiredTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags, target).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.TargetTagRequirements); + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("TargetTag requirements", null)] + public void Ability_wont_activate_when_target_has_blocked_tag() + { + TestEntity entity = new(_tagsManager, _cuesManager); + TestEntity target = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + targetBlockedTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["color.green"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags, target).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.TargetTagRequirements); + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("TargetTag requirements", null)] + public void Ability_wont_activate_when_target_is_missing_but_has_required_tags() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + targetRequiredTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.TargetTagRequirements); + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("TargetTag requirements", null)] + public void Ability_activates_when_target_is_missing_and_has_blocked_tags() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + targetBlockedTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["color.green"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("BlockAbilitiesWithTag", null)] + public void Ability_activation_blocks_other_abilities_with_blocked_tags() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData blockerAbilityData = CreateAbilityData( + "Blocker ability", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + blockAbilitiesWithTag: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"]))); + + AbilityData unblockedAbilityData = CreateAbilityData( + "Unblocked ability", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + abilityTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["color.blue"]))); + + AbilityData blockedAbilityData = CreateAbilityData( + "Blocked ability", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + abilityTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"]))); + + AbilityHandle? blockerAbilityHandle = SetupAbility( + entity, + blockerAbilityData, + new ScalableInt(1), + out _); + + AbilityHandle? unblockedAbilityHandle = SetupAbility( + entity, + unblockedAbilityData, + new ScalableInt(1), + out _); + + AbilityHandle? blockedAbilityHandle = SetupAbility( + entity, + blockedAbilityData, + new ScalableInt(1), + out _); + + blockerAbilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + blockerAbilityHandle.IsActive.Should().BeTrue(); + + unblockedAbilityHandle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + unblockedAbilityHandle.IsActive.Should().BeTrue(); + + blockedAbilityHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.BlockedByTags); + blockedAbilityHandle.IsActive.Should().BeFalse(); + + blockerAbilityHandle!.Cancel(); + + blockedAbilityHandle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + blockedAbilityHandle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("Instancing", null)] + public void Ability_PerEntity_no_retrigger_activated_twice_does_not_start_second_instance() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "PerEntity_NoRetrigger", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute1", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerEntity, + retriggerInstancedAbility: false); + + AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + handle.IsActive.Should().BeTrue(); + + // No retrigger, single instance. + handle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.PersistentInstanceActive); + handle.IsActive.Should().BeTrue(); + + handle.Cancel(); + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Instancing", null)] + public void Ability_PerEntity_retrigger_restarts_instance_and_fires_deactivated_once() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "PerEntity_Retrigger", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute2", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerEntity, + retriggerInstancedAbility: true); + + AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); + handle.Should().NotBeNull(); + + handle!.Activate(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(); + failureFlags2.Should().Be(AbilityActivationFailures.None); + handle.IsActive.Should().BeTrue(); + + // One End should fully deactivate because retrigger replaced the instance instead of stacking. + handle.Cancel(); + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Instancing", null)] + public void Ability_per_execution_multiple_activations_create_multiple_instances() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "PerExecution", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute3", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerExecution); + + AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + handle.IsActive.Should().BeTrue(); + + handle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + handle.IsActive.Should().BeTrue(); + + handle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + handle.IsActive.Should().BeTrue(); + + // Cancel ends all instances. + handle.Cancel(); + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Instancing", null)] + public void Ability_Cancel_ends_all_instances() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "EndsMostRecent", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute5", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerExecution); + + AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + handle.IsActive.Should().BeTrue(); + + handle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + handle.IsActive.Should().BeTrue(); + + // One Cancel should fully deactivate if multiple instances exist. + handle.Cancel(); + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Instancing", null)] + public void Ability_CancelAbility_cancels_all_active_instances() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "CancelAllInstances", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute5", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerExecution); + + AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + handle.IsActive.Should().BeTrue(); + + handle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + handle.IsActive.Should().BeTrue(); + + handle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + handle.IsActive.Should().BeTrue(); + + handle.Cancel(); + + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("CancelAbilitiesWithTag", null)] + public void CancelAbilitiesWithTag_cancels_all_matching_active_abilities_on_activation() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var cancelTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])); + + AbilityData canceller = CreateAbilityData( + "Canceller", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute3", + new ScalableFloat(-1), + cancelAbilitiesWithTag: cancelTags); + + AbilityData victim = CreateAbilityData( + "Victim", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute5", + new ScalableFloat(-1), + abilityTags: new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"]))); + + AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); + AbilityHandle? victimHandle = SetupAbility(entity, victim, new ScalableInt(1), out _); + + victimHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + victimHandle.IsActive.Should().BeTrue(); + + cancellerHandle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + cancellerHandle.IsActive.Should().BeTrue(); + + victimHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("CancelAbilitiesWithTag", null)] + public void CancelAbilitiesWithTag_does_not_cancel_unrelated_abilities() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var cancelTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])); + + AbilityData canceller = CreateAbilityData( + "Canceller2", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute3", + new ScalableFloat(-1), + cancelAbilitiesWithTag: cancelTags); + + AbilityData unrelated = CreateAbilityData( + "Unrelated", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute5", + new ScalableFloat(-1), + abilityTags: new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.blue"]))); + + AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); + AbilityHandle? unrelatedHandle = SetupAbility(entity, unrelated, new ScalableInt(1), out _); + + unrelatedHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + unrelatedHandle.IsActive.Should().BeTrue(); + + cancellerHandle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + cancellerHandle.IsActive.Should().BeTrue(); + + unrelatedHandle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("CancelAbilitiesWithTag", null)] + public void CancelAbilitiesWithTag_cancels_all_instances_of_matching_abilities() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var cancelTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])); + + AbilityData canceller = CreateAbilityData( + "Canceller3", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute1", + new ScalableFloat(-1), + cancelAbilitiesWithTag: cancelTags); + + AbilityData victim = CreateAbilityData( + "VictimMulti", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute2", + new ScalableFloat(-1), + abilityTags: new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])), + instancingPolicy: AbilityInstancingPolicy.PerExecution); + + AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); + AbilityHandle? victimHandle = SetupAbility(entity, victim, new ScalableInt(1), out _); + + victimHandle!.Activate(out AbilityActivationFailures failureFlagsA).Should().BeTrue(); + failureFlagsA.Should().Be(AbilityActivationFailures.None); + victimHandle.IsActive.Should().BeTrue(); + + cancellerHandle!.Activate(out AbilityActivationFailures failureFlagsB).Should().BeTrue(); + failureFlagsB.Should().Be(AbilityActivationFailures.None); + cancellerHandle.IsActive.Should().BeTrue(); + + victimHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("CancelAbilitiesWithTag", null)] + public void CancelAbilitiesWithTag_does_not_cancel_self_on_activation() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var redTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])); + + AbilityData selfCanceller = CreateAbilityData( + "SelfCanceller", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute1", + new ScalableFloat(-1), + abilityTags: redTags, + cancelAbilitiesWithTag: redTags); + + AbilityHandle? handle = SetupAbility(entity, selfCanceller, new ScalableInt(1), out _); + + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + handle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("BlockAbilitiesWithTag", null)] + public void Blocked_ability_tags_are_removed_after_all_instance_ends() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var redTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])); + + AbilityData blocker = CreateAbilityData( + "BlockerMulti", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute1", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerExecution, + blockAbilitiesWithTag: redTags); + + AbilityData blocked = CreateAbilityData( + "BlockedRed", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute5", + new ScalableFloat(-1), + abilityTags: redTags); + + AbilityHandle? blockerHandle = SetupAbility(entity, blocker, new ScalableInt(1), out _); + AbilityHandle? blockedHandle = SetupAbility(entity, blocked, new ScalableInt(1), out _); + + blockerHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + blockerHandle.IsActive.Should().BeTrue(); + + blockerHandle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + blockerHandle.IsActive.Should().BeTrue(); + + // While any blocker instance active, blocked ability cannot activate. + blockedHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.BlockedByTags); + blockedHandle.IsActive.Should().BeFalse(); + + // End all blocker instances. + blockerHandle.Cancel(); + blockedHandle.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + blockedHandle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("ActivationOwnedTags", null)] + public void Activation_owned_tags_are_applied_on_activation_and_removed_on_Cancel() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var ownedTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"])); + + AbilityData abilityWithOwned = CreateAbilityData( + "OwnedTagsAbility", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute5", + new ScalableFloat(-1), + activationOwnedTags: ownedTags); + + AbilityHandle? handle = SetupAbility(entity, abilityWithOwned, new ScalableInt(1), out _); + + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + entity.Tags.CombinedTags.HasAll(ownedTags).Should().BeTrue(); + handle.IsActive.Should().BeTrue(); + + handle.Cancel(); + entity.Tags.CombinedTags.HasAny(ownedTags).Should().BeFalse(); + } + + [Fact] + [Trait("ActivationOwnedTags", null)] + public void Activation_owned_tags_are_applied_on_activation_and_removed_when_all_instances_ends() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var ownedTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"])); + + AbilityData abilityWithOwned = CreateAbilityData( + "OwnedTagsAbility", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute5", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerExecution, + activationOwnedTags: ownedTags); + + AbilityHandle? handle = SetupAbility(entity, abilityWithOwned, new ScalableInt(1), out _); + + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + handle.IsActive.Should().BeTrue(); + + handle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + handle.IsActive.Should().BeTrue(); + + handle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + handle.IsActive.Should().BeTrue(); + + entity.Tags.CombinedTags.HasAll(ownedTags).Should().BeTrue(); + + handle.Cancel(); + entity.Tags.CombinedTags.HasAny(ownedTags).Should().BeFalse(); + } + + [Fact] + [Trait("ActivationOwnedTags", null)] + public void Activation_owned_tags_enable_other_ability_only_while_active() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var buffTag = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"])); + + AbilityData giver = CreateAbilityData( + "Giver", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute1", + new ScalableFloat(-1), + activationOwnedTags: buffTag); + + AbilityData requiresBuff = CreateAbilityData( + "NeedsBuff", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute5", + new ScalableFloat(-1), + activationRequiredTags: buffTag); + + AbilityHandle? giverHandle = SetupAbility(entity, giver, new ScalableInt(1), out _); + AbilityHandle? needsHandle = SetupAbility(entity, requiresBuff, new ScalableInt(1), out _); + + // Cannot activate without buff. + needsHandle!.Activate(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(); + failureFlags.Should().Be(AbilityActivationFailures.None); + needsHandle.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + // Lose buff, then cannot activate again. + giverHandle.Cancel(); + needsHandle.Cancel(); + needsHandle.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.OwnerTagRequirements); + } + + [Fact] + [Trait("Bookkeeping", null)] + public void Granted_ability_is_removed_when_all_instances_end() + { + // Proxy via RemoveOnEnd semantics: ability is only removed after each instance ends once. + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "RemoveOnEndProxy", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute1", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerExecution); + + AbilityHandle? handle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? grantHandle, + AbilityDeactivationPolicy.RemoveOnEnd, + AbilityDeactivationPolicy.RemoveOnEnd); + + handle.Should().NotBeNull(); + grantHandle.Should().NotBeNull(); + + // Activate twice to simulate two instances. + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + handle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + // Remove grant; ability should not be removed until all instances end. + entity.EffectsManager.RemoveEffect(grantHandle!); + + // Still present because policy is RemoveOnEnd and still active. + entity.Abilities.GrantedAbilities.Should().Contain(handle); + + // End all instances, remove grant. + handle.Cancel(); + entity.Abilities.GrantedAbilities.Should().NotContain(handle); + } + + [Fact] + [Trait("Instancing", null)] + public void Persistent_instance_reference_is_cleared_on_Cancel() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "PersistentCleared", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute3", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerEntity); + + AbilityHandle? handle = SetupAbility(entity, abilityData, new ScalableInt(1), out _); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + handle.Cancel(); + handle.IsActive.Should().BeFalse(); + + // Should be able to activate again, implying the persistent instance was cleared. + handle.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + } + + [Fact] + [Trait("CancelAbilitiesWithTag", null)] + public void CancelAbilitiesWithTag_with_no_active_abilities_does_nothing() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var cancelTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])); + + AbilityData canceller = CreateAbilityData( + "Canceller", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute2", + new ScalableFloat(-1), + cancelAbilitiesWithTag: cancelTags); + + AbilityData victim = CreateAbilityData( + "Victim", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute3", + new ScalableFloat(-1), + abilityTags: new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"]))); + + AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); + AbilityHandle? victimHandle = SetupAbility(entity, victim, new ScalableInt(1), out _); + + // Victim is granted but inactive. + victimHandle!.IsActive.Should().BeFalse(); + + // Activating canceller should not affect inactive victim. + cancellerHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + victimHandle.IsActive.Should().BeFalse(); + entity.Abilities.GrantedAbilities.Should().Contain(victimHandle); + } + + [Fact] + [Trait("CancelAbilitiesWithTag", null)] + public void CancelAbilitiesWithTag_executes_before_applying_blocking_or_activation_tags() + { + // Approximated: ensure canceller activates and cancels victim reliably. + TestEntity entity = new(_tagsManager, _cuesManager); + + var redTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])); + var blockBlue = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.blue"])); + + AbilityData canceller = CreateAbilityData( + "CancellerOrder", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute1", + new ScalableFloat(-1), + cancelAbilitiesWithTag: redTags, + blockAbilitiesWithTag: blockBlue, + activationOwnedTags: new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"]))); + + AbilityData victim = CreateAbilityData( + "VictimOrder", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute2", + new ScalableFloat(-1), + abilityTags: redTags); + + AbilityHandle? cancellerHandle = SetupAbility(entity, canceller, new ScalableInt(1), out _); + AbilityHandle? victimHandle = SetupAbility(entity, victim, new ScalableInt(1), out _); + + victimHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + cancellerHandle!.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + // Victim must be canceled; canceller remains active. + victimHandle.IsActive.Should().BeFalse(); + cancellerHandle.IsActive.Should().BeTrue(); + } + + [Theory] + [InlineData(ModifierOperation.FlatBonus, -1f, -1, 89)] + [InlineData(ModifierOperation.FlatBonus, 1f, 1, 91)] + [InlineData(ModifierOperation.PercentBonus, -0.1f, -9, 81)] + [InlineData(ModifierOperation.PercentBonus, 0.1f, 9, 99)] + [InlineData(ModifierOperation.Override, 1f, -89, 1)] + [InlineData(ModifierOperation.Override, 98f, 8, 98)] + [Trait("Ability cost", null)] + public void Cost_effect_reports_configured_cost_and_applies_on_commit( + ModifierOperation operation, float magnitude, int expectedCost, int finalValue) + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var costEffectData = new EffectData( + "Fireball Cost", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute90", + operation, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(magnitude))) + ]); + + AbilityData abilityData = new("Fireball", costEffectData); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + CostData[]? costData = abilityHandle!.GetCostData(); + costData.Should().ContainSingle(); + costData![0].Attribute.Should().Be("TestAttributeSet.Attribute90"); + costData![0].Cost.Should().Be(expectedCost); + + abilityHandle.GetCostForAttribute("TestAttributeSet.Attribute90").Should().Be(expectedCost); + + abilityHandle.CommitCost(); + entity.Attributes["TestAttributeSet.Attribute90"].CurrentValue.Should().Be(finalValue); + } + + [Fact] + [Trait("Ability cost", null)] + public void Cost_effect_with_multiple_modifiers_reports_configured_cost_and_applies_on_commit() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var costEffectData = new EffectData( + "Fireball Cost", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute90", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(1f))), + new Modifier( + "TestAttributeSet.Attribute90", + ModifierOperation.PercentBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-0.1f))) + ]); + + AbilityData abilityData = new("Fireball", costEffectData); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + CostData[]? costData = abilityHandle!.GetCostData(); + costData.Should().ContainSingle(); + costData![0].Attribute.Should().Be("TestAttributeSet.Attribute90"); + costData![0].Cost.Should().Be(-9); + + abilityHandle.GetCostForAttribute("TestAttributeSet.Attribute90").Should().Be(-9); + + abilityHandle.CommitCost(); + entity.Attributes["TestAttributeSet.Attribute90"].CurrentValue.Should().Be(81); + } + + [Fact] + [Trait("Event", null)] + public void Ability_gets_activated_by_proper_event_with_tag() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var triggerTag = Tag.RequestTag(_tagsManager, "color.red"); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + abilityTriggerData: AbilityTriggerData.ForEvent(triggerTag)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + var nonActivatingEventData = new EventData + { + EventTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.blue"])), + Source = entity, + Target = entity, + }; + + entity.Events.Raise(in nonActivatingEventData); + abilityHandle!.IsActive.Should().BeFalse(); + + var activatingEventData = new EventData + { + EventTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])), + Source = entity, + Target = entity, + }; + + entity.Events.Raise(in activatingEventData); + abilityHandle.IsActive.Should().BeTrue(); + + abilityHandle.Cancel(); + abilityHandle!.IsActive.Should().BeFalse(); + + entity.Events.Raise(in activatingEventData); + abilityHandle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("Event", null)] + public void Ability_gets_activated_by_tag_addition() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var triggerTag = Tag.RequestTag(_tagsManager, "color.red"); + TagContainer? triggerTagContainer = triggerTag.GetSingleTagContainer(); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + abilityTriggerData: AbilityTriggerData.ForTagAdded(triggerTag)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.IsActive.Should().BeFalse(); + + CreateAndApplyTagEffect(entity, triggerTagContainer!); + + abilityHandle!.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("Event", null)] + public void Ability_gets_activated_while_tag_present() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var triggerTag = Tag.RequestTag(_tagsManager, "color.red"); + TagContainer? triggerTagContainer = triggerTag.GetSingleTagContainer(); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + abilityTriggerData: AbilityTriggerData.ForTagPresent(triggerTag)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.IsActive.Should().BeFalse(); + + ActiveEffectHandle? effectHandle = CreateAndApplyTagEffect(entity, triggerTagContainer!); + + abilityHandle!.IsActive.Should().BeTrue(); + + entity.EffectsManager.RemoveEffect(effectHandle!); + + abilityHandle!.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Ability Ended Event", null)] + public void OnAbilityEnded_fires_when_ability_instance_is_canceled() + { + var targetEntity = new TestEntity(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Test Ability", + [], + [], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + instancingPolicy: AbilityInstancingPolicy.PerEntity); + + AbilityHandle? abilityHandle = SetupAbility( + targetEntity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle.Should().NotBeNull(); + + AbilityEndedData? capturedData = null; + + targetEntity.Abilities.OnAbilityEnded += x => { capturedData = x; }; + + // Activate the ability + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + abilityHandle.Cancel(); + + // Verify event was fired + capturedData.Should().NotBeNull(); + capturedData!.Value.Ability.Should().Be(abilityHandle); + capturedData.Value.WasCanceled.Should().BeTrue(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_is_granted_and_activated_once() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = entity.Abilities.GrantAbilityAndActivateOnce( + abilityData, + 1, + LevelComparison.None, + out AbilityActivationFailures failureFlags, + entity, + entity); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle!.IsActive.Should().BeTrue(); + + abilityHandle.Cancel(); + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + } + + [Fact] + [Trait("Grant ability", null)] + public void Ability_fails_to_be_granted_and_activated_once() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-100)); + + AbilityHandle? abilityHandle = entity.Abilities.GrantAbilityAndActivateOnce( + abilityData, + 1, + LevelComparison.None, + out AbilityActivationFailures failureFlags, + entity, + entity); + + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + failureFlags.Should().Be(AbilityActivationFailures.InsufficientResources); + abilityHandle.Should().BeNull(); + } + + [Fact] + [Trait("Failure reason", null)] + public void Failure_reason_contains_all_failureFlags_reasons() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-50), + retriggerInstancedAbility: true); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + + abilityHandle.CommitAbility(); + + abilityHandle!.Activate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.InsufficientResources | + AbilityActivationFailures.Cooldown); + } + + [Fact] + [Trait("Grant ability", null)] + public void Periodic_effects_grants_ability_while_active() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out ActiveEffectHandle? effectHandle, + durationData: new DurationData(DurationType.Infinite), + periodicData: new PeriodicData(new ScalableFloat(1f), true, PeriodInhibitionRemovedPolicy.ResetPeriod)); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + abilityHandle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + abilityHandle.IsActive.Should().BeTrue(); + + entity.EffectsManager.UpdateEffects(10f); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + entity.EffectsManager.RemoveEffect(effectHandle!); + + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("TryActivateOnGrant", null)] + public void Duration_effect_with_TryActivateOnGrant_activates_ability_immediately() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + tryActivateOnGrant: true); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + abilityHandle!.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("TryActivateOnGrant", null)] + public void Instant_effect_with_TryActivateOnGrant_activates_ability_immediately() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + durationData: new DurationData(DurationType.Instant), + tryActivateOnGrant: true); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + abilityHandle!.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("TryActivateOnGrant", null)] + public void Effect_inhibited_at_grant_does_not_try_to_activate_ability() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + // Apply the inhibiting tag before granting the ability + CreateAndApplyTagEffect(entity, ignoreTags!); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements(IgnoreTags: ignoreTags)), + tryActivateOnGrant: true); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + abilityHandle!.IsInhibited.Should().BeTrue(); + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("TryActivateOnEnable", null)] + public void Effect_enabled_from_inhibition_with_TryActivateOnEnable_activates_ability() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + // Apply the inhibiting tag before granting the ability + ActiveEffectHandle? tagEffectHandle = CreateAndApplyTagEffect(entity, ignoreTags!); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements(IgnoreTags: ignoreTags)), + tryActivateOnEnable: true); + + abilityHandle.Should().NotBeNull(); + abilityHandle!.IsInhibited.Should().BeTrue(); + abilityHandle.IsActive.Should().BeFalse(); + + // Remove the inhibiting tag to enable the effect + entity.EffectsManager.RemoveEffect(tagEffectHandle!); + + abilityHandle.IsInhibited.Should().BeFalse(); + abilityHandle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("TryActivateOnEnable", null)] + public void Effect_enabled_from_inhibition_without_TryActivateOnEnable_does_not_activate_ability() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + // Apply the inhibiting tag before granting the ability + ActiveEffectHandle? tagEffectHandle = CreateAndApplyTagEffect(entity, ignoreTags!); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements(IgnoreTags: ignoreTags)), + tryActivateOnEnable: false); + + abilityHandle.Should().NotBeNull(); + abilityHandle!.IsInhibited.Should().BeTrue(); + abilityHandle.IsActive.Should().BeFalse(); + + // Remove the inhibiting tag to enable the effect + entity.EffectsManager.RemoveEffect(tagEffectHandle!); + + abilityHandle.IsInhibited.Should().BeFalse(); + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("TryActivateOnGrant", null)] + public void TryActivateOnGrant_does_not_activate_if_CanActivate_fails() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + // Create ability that requires a tag that the entity doesn't have + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + activationRequiredTags: new TagContainer( + _tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"]))); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + tryActivateOnGrant: true); + + abilityHandle.Should().NotBeNull(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + + // Ability should be granted but not active because CanActivate fails (required tag) + abilityHandle!.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("TryActivateOnGrant", null)] + public void Both_TryActivateOnGrant_and_TryActivateOnEnable_work_together() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1)); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + // Grant ability without inhibition should activate on grant + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements(IgnoreTags: ignoreTags)), + tryActivateOnGrant: true, + tryActivateOnEnable: true); + + abilityHandle.Should().NotBeNull(); + abilityHandle!.IsActive.Should().BeTrue(); + + // Cancel the ability + abilityHandle.Cancel(); + abilityHandle.IsActive.Should().BeFalse(); + + // Inhibit the effect + ActiveEffectHandle? tagEffectHandle = CreateAndApplyTagEffect(entity, ignoreTags!); + abilityHandle.IsInhibited.Should().BeTrue(); + + // Remove inhibition - should activate on enable + entity.EffectsManager.RemoveEffect(tagEffectHandle!); + abilityHandle.IsInhibited.Should().BeFalse(); + abilityHandle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("TryActivateOnEnable", null)] + public void TryActivateOnEnable_does_not_activate_if_CanActivate_fails() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + TagContainer? requiredTag = Tag.RequestTag(_tagsManager, "other.tag").GetSingleTagContainer(); + requiredTag.Should().NotBeNull(); + + // Create ability that requires a tag that the entity doesn't have + AbilityData abilityData = CreateAbilityData( + "Fireball", + [new ScalableFloat(3f)], + ["simple.tag"], + "TestAttributeSet.Attribute90", + new ScalableFloat(-1), + activationRequiredTags: requiredTag); + + TagContainer? ignoreTags = Tag.RequestTag(_tagsManager, "Tag").GetSingleTagContainer(); + ignoreTags.Should().NotBeNull(); + + // Apply the inhibiting tag before granting the ability + ActiveEffectHandle? tagEffectHandle = CreateAndApplyTagEffect(entity, ignoreTags!); + + AbilityHandle? abilityHandle = SetupAbility( + entity, + abilityData, + new ScalableInt(1), + out _, + extraComponent: new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements(IgnoreTags: ignoreTags)), + tryActivateOnEnable: true); + + abilityHandle.Should().NotBeNull(); + abilityHandle!.IsInhibited.Should().BeTrue(); + abilityHandle.IsActive.Should().BeFalse(); + + // Remove the inhibiting tag to enable the effect + entity.EffectsManager.RemoveEffect(tagEffectHandle!); + + // Ability should not activate because CanActivate fails (missing required tag) + abilityHandle.IsInhibited.Should().BeFalse(); + abilityHandle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Cleanup", null)] + public void Removed_ability_with_TagAdded_trigger_does_not_activate_after_removal() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var triggerTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + var activationCount = 0; + + AbilityData abilityData = new( + "Triggered Ability", + instancingPolicy: AbilityInstancingPolicy.PerEntity, + abilityTriggerData: AbilityTriggerData.ForTagAdded(triggerTag), + behaviorFactory: () => new CountingAbilityBehavior(() => activationCount++)); + + // Grant the ability via effect + var grantConfig = new GrantAbilityConfig( + abilityData, + new ScalableInt(1), + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.CancelImmediately); + + var grantEffectData = new EffectData( + "Grant Ability", + new DurationData(DurationType.Infinite), + effectComponents: [new GrantAbilityEffectComponent([grantConfig])]); + + var effect = new Effect(grantEffectData, new EffectOwnership(entity, entity)); + ActiveEffectHandle? effectHandle = entity.EffectsManager.ApplyEffect(effect); + + AbilityHandle abilityHandle = effectHandle!.GetComponent()!.GrantedAbilities[0]; + abilityHandle.IsValid.Should().BeTrue(); + + // Create an effect that adds the trigger tag + TagContainer? triggerTagContainer = triggerTag.GetSingleTagContainer(); + var tagEffectData = new EffectData( + "Add Trigger Tag", + new DurationData(DurationType.Infinite), + effectComponents: [new ModifierTagsEffectComponent(triggerTagContainer!)]); + + var tagEffect = new Effect(tagEffectData, new EffectOwnership(entity, entity)); + + // Add tag to trigger the ability + ActiveEffectHandle? tagEffectHandle = entity.EffectsManager.ApplyEffect(tagEffect); + activationCount.Should().Be(1); + + // Remove the tag + entity.EffectsManager.RemoveEffect(tagEffectHandle!); + + // Remove the granting effect (removes the ability) + entity.EffectsManager.RemoveEffect(effectHandle!); + abilityHandle.IsValid.Should().BeFalse(); + + // Add tag again, this should NOT trigger the ability since it's been removed + entity.EffectsManager.ApplyEffect(tagEffect); + activationCount.Should().Be(1); // Should still be 1, not 2 + } + + [Fact] + [Trait("Cleanup", null)] + public void Removed_ability_with_TagPresent_trigger_does_not_activate_after_removal() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var triggerTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + var activationCount = 0; + + AbilityData abilityData = new( + "Triggered Ability", + instancingPolicy: AbilityInstancingPolicy.PerEntity, + abilityTriggerData: AbilityTriggerData.ForTagPresent(triggerTag), + behaviorFactory: () => new CountingAbilityBehavior(() => activationCount++)); + + // Grant the ability via effect + var grantConfig = new GrantAbilityConfig( + abilityData, + new ScalableInt(1), + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.CancelImmediately); + + var grantEffectData = new EffectData( + "Grant Ability", + new DurationData(DurationType.Infinite), + effectComponents: [new GrantAbilityEffectComponent([grantConfig])]); + + var effect = new Effect(grantEffectData, new EffectOwnership(entity, entity)); + ActiveEffectHandle? effectHandle = entity.EffectsManager.ApplyEffect(effect); + + AbilityHandle abilityHandle = effectHandle!.GetComponent()!.GrantedAbilities[0]; + abilityHandle.IsValid.Should().BeTrue(); + + // Create an effect that adds the trigger tag + TagContainer? triggerTagContainer = triggerTag.GetSingleTagContainer(); + var tagEffectData = new EffectData( + "Add Trigger Tag", + new DurationData(DurationType.Infinite), + effectComponents: [new ModifierTagsEffectComponent(triggerTagContainer!)]); + + var tagEffect = new Effect(tagEffectData, new EffectOwnership(entity, entity)); + + // Add tag to trigger the ability + ActiveEffectHandle? tagEffectHandle = entity.EffectsManager.ApplyEffect(tagEffect); + activationCount.Should().Be(1); + + // Remove the tag (this will cancel the ability for TagPresent) + entity.EffectsManager.RemoveEffect(tagEffectHandle!); + + // Remove the granting effect (removes the ability) + entity.EffectsManager.RemoveEffect(effectHandle!); + abilityHandle.IsValid.Should().BeFalse(); + + // Add tag again, this should NOT trigger the ability since it's been removed + entity.EffectsManager.ApplyEffect(tagEffect); + activationCount.Should().Be(1); // Should still be 1, not 2 + } + + [Fact] + [Trait("Cleanup", null)] + public void Removed_ability_with_Event_trigger_does_not_activate_after_removal() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + var activationCount = 0; + + AbilityData abilityData = new( + "Triggered Ability", + instancingPolicy: AbilityInstancingPolicy.PerEntity, + abilityTriggerData: AbilityTriggerData.ForEvent(eventTag), + behaviorFactory: () => new CountingAbilityBehavior(() => activationCount++)); + + // Grant the ability via effect + var grantConfig = new GrantAbilityConfig( + abilityData, + new ScalableInt(1), + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.CancelImmediately); + + var grantEffectData = new EffectData( + "Grant Ability", + new DurationData(DurationType.Infinite), + effectComponents: [new GrantAbilityEffectComponent([grantConfig])]); + + var effect = new Effect(grantEffectData, new EffectOwnership(entity, entity)); + ActiveEffectHandle? effectHandle = entity.EffectsManager.ApplyEffect(effect); + + AbilityHandle abilityHandle = effectHandle!.GetComponent()!.GrantedAbilities[0]; + abilityHandle.IsValid.Should().BeTrue(); + + // Raise event to trigger the ability + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Source = entity, + Target = entity, + }); + activationCount.Should().Be(1); + + // Remove the granting effect (removes the ability) + entity.EffectsManager.RemoveEffect(effectHandle!); + abilityHandle.IsValid.Should().BeFalse(); + + // Raise event again, this should NOT trigger the ability since it's been removed + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Source = entity, + Target = entity, + }); + + activationCount.Should().Be(1); // Should still be 1, not 2 + } + + [Fact] + [Trait("Cleanup", null)] + public void Removed_ability_with_typed_Event_trigger_does_not_activate_after_removal() + { + TestEntity entity = new(_tagsManager, _cuesManager); + + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + var activationCount = 0; + + AbilityData abilityData = new( + "Triggered Ability", + instancingPolicy: AbilityInstancingPolicy.PerEntity, + abilityTriggerData: AbilityTriggerData.ForEvent(eventTag), + behaviorFactory: () => new CountingAbilityBehavior(() => activationCount++)); + + // Grant the ability via effect + var grantConfig = new GrantAbilityConfig( + abilityData, + new ScalableInt(1), + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.CancelImmediately); + + var grantEffectData = new EffectData( + "Grant Ability", + new DurationData(DurationType.Infinite), + effectComponents: [new GrantAbilityEffectComponent([grantConfig])]); + + var effect = new Effect(grantEffectData, new EffectOwnership(entity, entity)); + ActiveEffectHandle? effectHandle = entity.EffectsManager.ApplyEffect(effect); + + AbilityHandle abilityHandle = effectHandle!.GetComponent()!.GrantedAbilities[0]; + abilityHandle.IsValid.Should().BeTrue(); + + // Raise typed event to trigger the ability + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Source = entity, + Target = entity, + Payload = 42, + }); + activationCount.Should().Be(1); + + // Remove the granting effect (removes the ability) + entity.EffectsManager.RemoveEffect(effectHandle!); + abilityHandle.IsValid.Should().BeFalse(); + + // Raise typed event again, this should NOT trigger the ability since it's been removed + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Source = entity, + Target = entity, + Payload = 100, + }); + + activationCount.Should().Be(1); // Should still be 1, not 2 + } + + private static AbilityHandle? SetupAbility( + TestEntity targetEntity, + AbilityData abilityData, + ScalableInt abilityLevelScaling, + out ActiveEffectHandle? effectHandle, + AbilityDeactivationPolicy grantedAbilityRemovalPolicy = AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy grantedAbilityInhibitionPolicy = AbilityDeactivationPolicy.CancelImmediately, + IForgeEntity? sourceEntity = null, + DurationData? durationData = null, + PeriodicData? periodicData = null, + IEffectComponent? extraComponent = null, + int effectLevel = 1, + bool tryActivateOnGrant = false, + bool tryActivateOnEnable = false, + LevelComparison levelOverridePolicy = LevelComparison.Higher) + { + GrantAbilityConfig grantAbilityConfig = new( + abilityData, + abilityLevelScaling, + grantedAbilityRemovalPolicy, + grantedAbilityInhibitionPolicy, + tryActivateOnGrant, + tryActivateOnEnable, + levelOverridePolicy); + + Effect grantAbilityEffect = CreateAbilityApplierEffect( + "Grant Ability Effect", + grantAbilityConfig, + sourceEntity, + durationData, + periodicData, + extraComponent, + effectLevel); + + effectHandle = targetEntity.EffectsManager.ApplyEffect(grantAbilityEffect); + + targetEntity.Abilities.TryGetAbility(abilityData, out AbilityHandle? abilityHandle, sourceEntity); + return abilityHandle; + } + + private static Effect CreateAbilityApplierEffect( + string effectName, + GrantAbilityConfig grantAbilityConfig, + IForgeEntity? sourceEntity, + DurationData? durationData, + PeriodicData? periodicData, + IEffectComponent? extraComponent, + int effectLevel) + { + durationData ??= new DurationData(DurationType.Infinite); + + List effectComponents = [new GrantAbilityEffectComponent([grantAbilityConfig])]; + + if (extraComponent is not null) + { + effectComponents.Add(extraComponent); + } + + var grantAbilityEffectData = new EffectData( + effectName, + durationData.Value, + periodicData: periodicData, + effectComponents: [.. effectComponents]); + + return new Effect( + grantAbilityEffectData, + new EffectOwnership(null, sourceEntity), + effectLevel); + } + + private static ActiveEffectHandle? CreateAndApplyTagEffect(TestEntity entity, TagContainer tags) + { + var tagEffectData = new EffectData( + "Tag Effect", + new DurationData(DurationType.Infinite), + effectComponents: [new ModifierTagsEffectComponent(tags)]); + + var tagEffect = new Effect( + tagEffectData, + new EffectOwnership(entity, null)); + + return entity.EffectsManager.ApplyEffect(tagEffect); + } + + private AbilityData CreateAbilityData( + string abilityName, + ScalableFloat[] cooldownDurations, + string[] cooldownTags, + string costAttribute, + ScalableFloat costAmount, + TagContainer? abilityTags = null, + AbilityInstancingPolicy instancingPolicy = AbilityInstancingPolicy.PerEntity, + bool retriggerInstancedAbility = false, + AbilityTriggerData? abilityTriggerData = null, + TagContainer? cancelAbilitiesWithTag = null, + TagContainer? blockAbilitiesWithTag = null, + TagContainer? activationOwnedTags = null, + TagContainer? activationRequiredTags = null, + TagContainer? activationBlockedTags = null, + TagContainer? sourceRequiredTags = null, + TagContainer? sourceBlockedTags = null, + TagContainer? targetRequiredTags = null, + TagContainer? targetBlockedTags = null) + { + var cooldownEffectData = new EffectData[cooldownDurations.Length]; + + for (var i = 0; i < cooldownDurations.Length; i++) + { + cooldownEffectData[i] = new EffectData( + "Fireball Cooldown", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, cooldownDurations[i])), + effectComponents: + [ + new ModifierTagsEffectComponent( + new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, [cooldownTags[i]]))) + ]); + } + + var costEffectData = new EffectData( + "Fireball Cost", + new DurationData(DurationType.Instant), + [ + new Modifier( + costAttribute, + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, costAmount)) + ]); + + return new( + abilityName, + costEffectData, + cooldownEffectData, + abilityTags: abilityTags, + instancingPolicy: instancingPolicy, + retriggerInstancedAbility: retriggerInstancedAbility, + abilityTriggerData: abilityTriggerData, + cancelAbilitiesWithTag: cancelAbilitiesWithTag, + blockAbilitiesWithTag: blockAbilitiesWithTag, + activationOwnedTags: activationOwnedTags, + activationRequiredTags: activationRequiredTags, + activationBlockedTags: activationBlockedTags, + sourceRequiredTags: sourceRequiredTags, + sourceBlockedTags: sourceBlockedTags, + targetRequiredTags: targetRequiredTags, + targetBlockedTags: targetBlockedTags); + } + + private sealed class CountingAbilityBehavior(Action onStarted) : IAbilityBehavior + { + private readonly Action _onStarted = onStarted; + + public void OnStarted(AbilityBehaviorContext context) + { + _onStarted(); + context.InstanceHandle.End(); + } + + public void OnEnded(AbilityBehaviorContext context) + { + } + } +} diff --git a/Forge.Tests/Abilities/AbilityBehaviorTests.cs b/Forge.Tests/Abilities/AbilityBehaviorTests.cs new file mode 100644 index 0000000..e040ef2 --- /dev/null +++ b/Forge.Tests/Abilities/AbilityBehaviorTests.cs @@ -0,0 +1,1033 @@ +// 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.Events; +using Gamesmiths.Forge.Tags; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Abilities; + +public class AbilityBehaviorTests(TagsAndCuesFixture fixture) : IClassFixture +{ + private readonly TagsManager _tagsManager = fixture.TagsManager; + private readonly CuesManager _cuesManager = fixture.CuesManager; + + [Fact] + [Trait("Lifecycle", null)] + public void Behavior_OnStarted_and_OnEnded_are_invoked_per_instance() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new TrackingBehavior(); + AbilityData data = CreateAbilityData("Tracked", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + behavior.StartCount.Should().Be(1); + behavior.EndCount.Should().Be(0); + + handle.Cancel(); + behavior.StartCount.Should().Be(1); + behavior.EndCount.Should().Be(1); + } + + [Fact] + [Trait("Multiple Instances", null)] + public void PerExecution_creates_distinct_behavior_instances() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var behaviors = new List(); + AbilityData data = CreateAbilityData( + "Multi", + behaviorFactory: () => + { + var trackingBehavior = new TrackingBehavior(); + behaviors.Add(trackingBehavior); + return trackingBehavior; + }, + instancingPolicy: AbilityInstancingPolicy.PerExecution); + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out _).Should().BeTrue(); + handle.Activate(out _).Should().BeTrue(); + handle.Activate(out _).Should().BeTrue(); + behaviors.Should().HaveCount(3); + behaviors.Sum(x => x.StartCount).Should().Be(3); + behaviors.Sum(x => x.EndCount).Should().Be(0); + + handle.Cancel(); + behaviors.Sum(x => x.EndCount).Should().Be(3); + } + + [Fact] + [Trait("Multiple Instances", null)] + public void Blocked_ability_tags_are_removed_only_after_last_instance_ends() + { + TestEntity entity = new(_tagsManager, _cuesManager); + var behaviors = new List(); + + var redTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["color.red"])); + + AbilityData blocker = CreateAbilityData( + "BlockerMulti", + behaviorFactory: () => + { + var trackingBehavior = new TrackingBehavior(); + behaviors.Add(trackingBehavior); + return trackingBehavior; + }, + instancingPolicy: AbilityInstancingPolicy.PerExecution, + blockAbilitiesWithTag: redTags); + + AbilityData blocked = CreateAbilityData( + "BlockedRed", + abilityTags: redTags); + + AbilityHandle? blockerHandle = Grant(entity, blocker); + AbilityHandle? blockedHandle = Grant(entity, blocked); + + blockerHandle!.Activate(out _).Should().BeTrue(); + blockerHandle!.Activate(out _).Should().BeTrue(); + + // While any blocker instance active, blocked ability cannot activate. + blockedHandle!.Activate(out 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(); + failureFlags.Should().Be(AbilityActivationFailures.BlockedByTags); + blockedHandle.IsActive.Should().BeFalse(); + + // End last blocker instance; now unblocked. + behaviors[1].End(); + blockedHandle.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + blockedHandle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("Multiple Instances", null)] + public void Activation_owned_tags_are_applied_on_activation_and_removed_after_last_instance_ends() + { + TestEntity entity = new(_tagsManager, _cuesManager); + var behaviors = new List(); + + var ownedTags = new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["tag"])); + + AbilityData abilityWithOwned = CreateAbilityData( + "OwnedTagsAbility", + behaviorFactory: () => + { + var trackingBehavior = new TrackingBehavior(); + behaviors.Add(trackingBehavior); + return trackingBehavior; + }, + instancingPolicy: AbilityInstancingPolicy.PerExecution, + activationOwnedTags: ownedTags); + + AbilityHandle? handle = Grant(entity, abilityWithOwned); + + handle!.Activate(out _).Should().BeTrue(); + handle!.Activate(out _).Should().BeTrue(); + handle!.Activate(out _).Should().BeTrue(); + + entity.Tags.CombinedTags.HasAll(ownedTags).Should().BeTrue(); + + behaviors[0].End(); + entity.Tags.CombinedTags.HasAll(ownedTags).Should().BeTrue(); + + behaviors[1].End(); + behaviors[2].End(); + entity.Tags.CombinedTags.HasAny(ownedTags).Should().BeFalse(); + } + + [Fact] + [Trait("Retrigger", null)] + public void PerEntity_retrigger_invokes_previous_OnEnded_before_new_OnStarted() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var endedBeforeNew = false; + TrackingBehavior? previous = null; + + AbilityData data = CreateAbilityData( + "Retriggered", + behaviorFactory: () => + { + var behavior = new TrackingBehavior(() => + { + if (previous?.EndCount == 1) + { + endedBeforeNew = true; + } + }); + + previous ??= behavior; + return behavior; + }, + instancingPolicy: AbilityInstancingPolicy.PerEntity, + retriggerInstancedAbility: true); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out _).Should().BeTrue(); + + // Second activation retriggers: ability should call previous.OnEnded() before new.OnStarted(). + handle.Activate(out _).Should().BeTrue(); + + endedBeforeNew.Should().BeTrue("the previous instance should have ended before the new one started"); + } + + [Fact] + [Trait("Context", null)] + public void Context_provides_expected_values() + { + var source = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + AbilityBehaviorContext? captured = null; + var behavior = new CallbackBehavior(x => captured = x); + + AbilityData data = CreateAbilityData("ContextTest", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(target, data, sourceEntity: source); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationFailures failureFlags, target).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + captured.Should().NotBeNull(); + captured!.Owner.Should().Be(target); + captured.Source.Should().Be(source); + captured.Target.Should().Be(target); + captured.Level.Should().Be(handle.Level); + captured.AbilityHandle.Should().Be(handle); + captured.InstanceHandle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("EndInsideStart", null)] + public void Behavior_can_end_instance_during_OnStarted() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new CallbackBehavior(x => x.InstanceHandle.End()); + + AbilityData data = CreateAbilityData("EndInsideStart", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("CommitAbility", null)] + public void Behavior_commits_cooldown_and_cost_on_start() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new CallbackBehavior(x => x.AbilityHandle.CommitAbility()); + + AbilityData data = CreateAbilityData( + "CommitOnStart", + behaviorFactory: () => behavior, + instancingPolicy: AbilityInstancingPolicy.PerExecution, + cooldownSeconds: 2f, + costMagnitude: -5f); + var baseBefore = entity.Attributes["TestAttributeSet.Attribute90"].BaseValue; + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out 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(); + failureFlags.Should().Be(AbilityActivationFailures.Cooldown); + + // Advance time until cooldown expires. + entity.EffectsManager.UpdateEffects(2f); + handle.Activate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + } + + [Fact] + [Trait("ExceptionStart", null)] + public void Exception_in_OnStarted_cancels_instance_and_does_not_crash() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new ExceptionBehaviorOnStart(); + + AbilityData data = CreateAbilityData("ThrowStart", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + // Activation returns success (instance created then canceled). + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + handle.IsActive.Should().BeFalse(); + behavior.StartAttempts.Should().Be(1); + } + + [Fact] + [Trait("ExceptionEnd", null)] + public void Exception_in_OnEnded_does_not_prevent_deactivation() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new ExceptionBehaviorOnEnd(); + + AbilityData data = CreateAbilityData("ThrowEnd", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out _).Should().BeTrue(); + handle.IsActive.Should().BeTrue(); + + handle.Cancel(); + behavior.EndAttempts.Should().Be(1); + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("NullFactoryReturn", null)] + public void Null_behavior_instance_is_ignored() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + AbilityData data = CreateAbilityData("NullBehavior", behaviorFactory: () => + { +#pragma warning disable CS8603 // Possible null reference return. + return null; +#pragma warning restore CS8603 // Possible null reference return. + }); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out _).Should().BeTrue(); + handle.IsActive.Should().BeTrue(); + + handle.Cancel(); + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Ability Ended Event", null)] + public void OnAbilityEnded_fires_when_ability_instance_ends() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new TrackingBehavior(); + AbilityData data = CreateAbilityData("Tracked", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + AbilityEndedData? capturedData = null; + entity.Abilities.OnAbilityEnded += x => { capturedData = x; }; + + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + behavior.StartCount.Should().Be(1); + behavior.EndCount.Should().Be(0); + + behavior.End(); + behavior.StartCount.Should().Be(1); + behavior.EndCount.Should().Be(1); + + // Verify event was fired + capturedData.Should().NotBeNull(); + capturedData!.Value.Ability.Should().Be(handle); + capturedData.Value.WasCanceled.Should().BeFalse(); + } + + [Fact] + [Trait("Ability Ended Event", null)] + public void Ability_is_granted_and_activated_once() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new TrackingBehavior(); + + AbilityData data = CreateAbilityData("Tracked", behaviorFactory: () => behavior); + entity.Abilities.GrantAbilityAndActivateOnce( + data, + 1, + LevelComparison.None, + out AbilityActivationFailures failureFlags); + + failureFlags.Should().Be(AbilityActivationFailures.None); + + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + behavior.StartCount.Should().Be(1); + behavior.EndCount.Should().Be(0); + + behavior.End(); + behavior.StartCount.Should().Be(1); + behavior.EndCount.Should().Be(1); + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + } + + [Fact] + [Trait("CustomContext", null)] + public void Generic_activate_creates_typed_context_with_payload() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + AbilityBehaviorContext? capturedContext = null; + + AbilityData data = CreateAbilityData( + "TypedContextAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedContext = ctx)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + var activationData = new TestActivationData("TestValue", 42); + handle!.Activate(activationData, out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + capturedContext.Should().NotBeNull(); + capturedContext.Should().BeOfType>(); + + var typedContext = (AbilityBehaviorContext)capturedContext!; + typedContext.Data.StringValue.Should().Be("TestValue"); + typedContext.Data.IntValue.Should().Be(42); + } + + [Fact] + [Trait("CustomContext", null)] + public void Non_generic_activate_creates_base_context() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + AbilityBehaviorContext? capturedContext = null; + + AbilityData data = CreateAbilityData( + "NoContextFactoryAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedContext = ctx)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + capturedContext.Should().NotBeNull(); + capturedContext.Should().BeOfType(); + } + + [Fact] + [Trait("CustomContext", null)] + public void Value_type_payload_is_preserved_in_context() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + AbilityBehaviorContext? capturedContext = null; + + AbilityData data = CreateAbilityData( + "ValueTypeContextAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedContext = ctx)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + var activationData = new ValueTypeActivationData(1.5f, 2.5f, 3.5f); + handle!.Activate(activationData, out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + capturedContext.Should().NotBeNull(); + capturedContext.Should().BeOfType>(); + + var typedContext = (AbilityBehaviorContext)capturedContext!; + typedContext.Data.X.Should().Be(1.5f); + typedContext.Data.Y.Should().Be(2.5f); + typedContext.Data.Z.Should().Be(3.5f); + } + + [Fact] + [Trait("CustomContext", null)] + public void Context_data_is_passed_through_for_each_instance_in_PerExecution() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var capturedContexts = new List(); + + AbilityData data = CreateAbilityData( + "PerExecutionContextAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedContexts.Add(ctx)), + instancingPolicy: AbilityInstancingPolicy.PerExecution); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + var activationData1 = new TestActivationData("First", 1); + var activationData2 = new TestActivationData("Second", 2); + + handle!.Activate(activationData1, out _).Should().BeTrue(); + handle.Activate(activationData2, out _).Should().BeTrue(); + + capturedContexts.Should().HaveCount(2); + + capturedContexts[0].Should().BeOfType>(); + capturedContexts[1].Should().BeOfType>(); + + var context1 = (AbilityBehaviorContext)capturedContexts[0]; + var context2 = (AbilityBehaviorContext)capturedContexts[1]; + + context1.Data.StringValue.Should().Be("First"); + context1.Data.IntValue.Should().Be(1); + context2.Data.StringValue.Should().Be("Second"); + context2.Data.IntValue.Should().Be(2); + } + + [Fact] + [Trait("CustomContext", null)] + public void PerEntity_retrigger_passes_new_context_data() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var capturedContexts = new List(); + + AbilityData data = CreateAbilityData( + "RetriggerContextAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedContexts.Add(ctx)), + retriggerInstancedAbility: true); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + var activationData1 = new TestActivationData("First", 1); + var activationData2 = new TestActivationData("Second", 2); + + handle!.Activate(activationData1, out _).Should().BeTrue(); + handle.Activate(activationData2, out _).Should().BeTrue(); + + // Both activations should have succeeded with their own context data + capturedContexts.Should().HaveCount(2); + + var context1 = (AbilityBehaviorContext)capturedContexts[0]; + var context2 = (AbilityBehaviorContext)capturedContexts[1]; + + context1.Data.StringValue.Should().Be("First"); + context2.Data.StringValue.Should().Be("Second"); + } + + [Fact] + [Trait("EventTrigger", null)] + public void Event_triggered_ability_activates_on_event() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new TrackingBehavior(); + var eventTag = Tag.RequestTag(_tagsManager, "color.red"); + + AbilityData data = CreateAbilityData( + "EventTriggered", + behaviorFactory: () => behavior, + abilityTriggerData: AbilityTriggerData.ForEvent(eventTag)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + behavior.StartCount.Should().Be(0); + + // Raise the event + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + }); + + behavior.StartCount.Should().Be(1); + handle!.IsActive.Should().BeTrue(); + + handle.Cancel(); + behavior.EndCount.Should().Be(1); + } + + [Fact] + [Trait("EventTrigger", null)] + public void Event_triggered_ability_with_typed_payload_receives_payload() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + TestEventPayload? capturedPayload = null; + AbilityBehaviorContext? capturedContext = null; + + var eventTag = Tag.RequestTag(_tagsManager, "color.green"); + + AbilityData data = CreateAbilityData( + "TypedEventTriggered", + behaviorFactory: () => new TypedPayloadBehavior((ctx, payload) => + { + capturedContext = ctx; + capturedPayload = payload; + }), + abilityTriggerData: AbilityTriggerData.ForEvent(eventTag)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + var expectedPayload = new TestEventPayload("Damage received", 42, true); + + // Raise the typed event + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Payload = expectedPayload, + }); + + capturedContext.Should().NotBeNull(); + capturedPayload.Should().NotBeNull(); + capturedPayload!.Message.Should().Be("Damage received"); + capturedPayload.Value.Should().Be(42); + capturedPayload.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("EventTrigger", null)] + public void Event_triggered_ability_with_value_type_payload_receives_payload() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + ValueTypeActivationData capturedPayload = default; + + var eventTag = Tag.RequestTag(_tagsManager, "color.blue"); + + AbilityData data = CreateAbilityData( + "ValueTypeEventTriggered", + behaviorFactory: () => new TypedPayloadBehavior((_, payload) => + { + capturedPayload = payload; + }), + abilityTriggerData: AbilityTriggerData.ForEvent(eventTag)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + var expectedPayload = new ValueTypeActivationData(1.5f, 2.5f, 3.5f); + + // Raise the typed event + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Payload = expectedPayload, + }); + + capturedPayload.X.Should().Be(1.5f); + capturedPayload.Y.Should().Be(2.5f); + capturedPayload.Z.Should().Be(3.5f); + } + + [Fact] + [Trait("EventTrigger", null)] + public void Event_triggered_ability_respects_cooldown() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new CallbackBehavior(x => x.AbilityHandle.CommitAbility()); + var eventTag = Tag.RequestTag(_tagsManager, "color.dark.red"); + + AbilityData data = CreateAbilityData( + "EventWithCooldown", + behaviorFactory: () => behavior, + cooldownSeconds: 5f, + abilityTriggerData: AbilityTriggerData.ForEvent(eventTag)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + // First event should activate + entity.Events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + handle!.IsActive.Should().BeTrue(); + handle.Cancel(); + + // Second event should fail due to cooldown + entity.Events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + handle.IsActive.Should().BeFalse(); + + // After cooldown expires, should work again + entity.EffectsManager.UpdateEffects(5f); + entity.Events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + handle.IsActive.Should().BeTrue(); + } + + [Fact] + [Trait("EventTrigger", null)] + public void Event_triggered_ability_multiple_events_with_PerExecution() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var activationCount = 0; + var eventTag = Tag.RequestTag(_tagsManager, "color.dark.green"); + + AbilityData data = CreateAbilityData( + "MultiEventPerExecution", + behaviorFactory: () => new CallbackBehavior(_ => activationCount++), + instancingPolicy: AbilityInstancingPolicy.PerExecution, + abilityTriggerData: AbilityTriggerData.ForEvent(eventTag)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + // Raise multiple events + entity.Events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + entity.Events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + entity.Events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + + activationCount.Should().Be(3); + } + + [Fact] + [Trait("Magnitude", null)] + public void Context_contains_magnitude_when_activated_with_magnitude() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + AbilityBehaviorContext? capturedContext = null; + + AbilityData data = CreateAbilityData( + "MagnitudeAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedContext = ctx)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationFailures failureFlags, magnitude: 75.5f).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + capturedContext.Should().NotBeNull(); + capturedContext!.Magnitude.Should().Be(75.5f); + } + + [Fact] + [Trait("Magnitude", null)] + public void Context_magnitude_defaults_to_zero_when_not_specified() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + AbilityBehaviorContext? capturedContext = null; + + AbilityData data = CreateAbilityData( + "NoMagnitudeAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedContext = ctx)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + capturedContext.Should().NotBeNull(); + capturedContext!.Magnitude.Should().Be(0f); + } + + [Fact] + [Trait("Magnitude", null)] + public void Event_triggered_ability_receives_magnitude_from_event() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var capturedMagnitude = 0f; + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + AbilityData data = CreateAbilityData( + "EventMagnitudeAbility", + behaviorFactory: () => new CallbackBehavior(ctx => + { + capturedMagnitude = ctx.Magnitude; + }), + abilityTriggerData: AbilityTriggerData.ForEvent(eventTag)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + EventMagnitude = 123.5f, + }); + + capturedMagnitude.Should().Be(123.5f); + handle!.Cancel(); + } + + [Fact] + [Trait("Magnitude", null)] + public void Typed_event_triggered_ability_receives_both_magnitude_and_data() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var capturedMagnitude = 0f; + TestEventPayload? capturedData = null; + var eventTag = Tag.RequestTag(_tagsManager, "tag"); + + AbilityData data = CreateAbilityData( + "TypedEventMagnitudeAbility", + behaviorFactory: () => new TypedPayloadBehavior((ctx, payload) => + { + capturedMagnitude = ctx.Magnitude; + capturedData = payload; + }), + abilityTriggerData: AbilityTriggerData.ForEvent(eventTag)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + EventMagnitude = 50f, + Payload = new TestEventPayload("Test", 42, true), + }); + + capturedMagnitude.Should().Be(50f); + capturedData.Should().NotBeNull(); + capturedData!.Message.Should().Be("Test"); + capturedData.Value.Should().Be(42); + } + + [Fact] + [Trait("Magnitude", null)] + public void Generic_activate_passes_both_data_and_magnitude() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + AbilityBehaviorContext? capturedContext = null; + + AbilityData data = CreateAbilityData( + "DataAndMagnitudeAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedContext = ctx)); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + var activationData = new TestActivationData("TestValue", 42); + handle!.Activate(activationData, out AbilityActivationFailures failureFlags, magnitude: 100f).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + capturedContext.Should().NotBeNull(); + capturedContext.Should().BeOfType>(); + + var typedContext = (AbilityBehaviorContext)capturedContext!; + typedContext.Magnitude.Should().Be(100f); + typedContext.Data.StringValue.Should().Be("TestValue"); + typedContext.Data.IntValue.Should().Be(42); + } + + [Fact] + [Trait("Magnitude", null)] + public void Magnitude_is_preserved_across_instances_in_PerExecution() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var capturedMagnitudes = new List(); + + AbilityData data = CreateAbilityData( + "MultiMagnitudeAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedMagnitudes.Add(ctx.Magnitude)), + instancingPolicy: AbilityInstancingPolicy.PerExecution); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out _, magnitude: 10f).Should().BeTrue(); + handle.Activate(out _, magnitude: 20f).Should().BeTrue(); + handle.Activate(out _, magnitude: 30f).Should().BeTrue(); + + capturedMagnitudes.Should().HaveCount(3); + capturedMagnitudes.Should().HaveElementAt(0, 10f); + capturedMagnitudes.Should().HaveElementAt(1, 20f); + capturedMagnitudes.Should().HaveElementAt(2, 30f); + } + + [Fact] + [Trait("Magnitude", null)] + public void PerEntity_retrigger_uses_new_magnitude() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var capturedMagnitudes = new List(); + + AbilityData data = CreateAbilityData( + "RetriggerMagnitudeAbility", + behaviorFactory: () => new CallbackBehavior(ctx => capturedMagnitudes.Add(ctx.Magnitude)), + retriggerInstancedAbility: true); + + AbilityHandle? handle = Grant(entity, data); + handle.Should().NotBeNull(); + + handle!.Activate(out _, magnitude: 50f).Should().BeTrue(); + handle.Activate(out _, magnitude: 75f).Should().BeTrue(); + + capturedMagnitudes.Should().HaveCount(2); + capturedMagnitudes.Should().HaveElementAt(0, 50f); + capturedMagnitudes.Should().HaveElementAt(1, 75f); + } + + private static AbilityHandle? Grant( + TestEntity target, + AbilityData data, + IForgeEntity? sourceEntity = null) + { + var grantConfig = new GrantAbilityConfig( + data, + new ScalableInt(1), + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.CancelImmediately, + false, + false, + LevelComparison.Higher); + + Effect grantEffect = CreateGrantEffect("Grant", grantConfig, sourceEntity); + _ = target.EffectsManager.ApplyEffect(grantEffect); + target.Abilities.TryGetAbility(data, out AbilityHandle? handle, sourceEntity); + return handle; + } + + private static Effect CreateGrantEffect( + string name, + GrantAbilityConfig config, + IForgeEntity? sourceEntity) + { + var data = new EffectData( + name, + new DurationData(DurationType.Infinite), + effectComponents: [new GrantAbilityEffectComponent([config])]); + + return new Effect(data, new EffectOwnership(null, sourceEntity)); + } + + private AbilityData CreateAbilityData( + string name, + IAbilityBehavior? behavior = null, + Func? behaviorFactory = null, + AbilityInstancingPolicy instancingPolicy = AbilityInstancingPolicy.PerEntity, + bool retriggerInstancedAbility = false, + float cooldownSeconds = 3f, + float costMagnitude = -1f, + TagContainer? abilityTags = null, + TagContainer? blockAbilitiesWithTag = null, + TagContainer? activationOwnedTags = null, + AbilityTriggerData? abilityTriggerData = null) + { + EffectData[] cooldownEffectData = [new EffectData( + $"{name} Cooldown", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + scalableFloatMagnitude: new ScalableFloat(cooldownSeconds))), + effectComponents: + [ + new ModifierTagsEffectComponent( + new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["simple.tag"]))) + ])]; + + var costEffectData = new EffectData( + $"{name} Cost", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute90", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + scalableFloatMagnitude: new ScalableFloat(costMagnitude))) + ]); + + return new AbilityData( + name, + costEffectData, + cooldownEffectData, + abilityTags: abilityTags, + instancingPolicy: instancingPolicy, + retriggerInstancedAbility: retriggerInstancedAbility, + abilityTriggerData: abilityTriggerData, + blockAbilitiesWithTag: blockAbilitiesWithTag, + activationOwnedTags: activationOwnedTags, + behaviorFactory: behaviorFactory ?? (() => behavior!)); + } + + private sealed record TestActivationData(string StringValue, int IntValue); + + private readonly record struct ValueTypeActivationData(float X, float Y, float Z); + + private sealed record TestEventPayload(string Message, int Value, bool IsActive); + + private sealed class TrackingBehavior(Action? onStartExtra = null) : IAbilityBehavior + { + private readonly Action? _onStartExtra = onStartExtra; + private AbilityBehaviorContext? _context; + + public int StartCount { get; private set; } + + public int EndCount { get; private set; } + + public void OnStarted(AbilityBehaviorContext context) + { + _context = context; + StartCount++; + _onStartExtra?.Invoke(); + } + + public void OnEnded(AbilityBehaviorContext context) + { + EndCount++; + } + + public void End() + { + _context?.InstanceHandle.End(); + } + } + + private sealed class CallbackBehavior(Action callback) : IAbilityBehavior + { + public void OnStarted(AbilityBehaviorContext context) + { + callback(context); + } + + public void OnEnded(AbilityBehaviorContext context) + { + // No-op + } + } + + private sealed class TypedPayloadBehavior( + Action callback) : IAbilityBehavior + { + public void OnStarted(AbilityBehaviorContext context, TPayload data) + { + callback(context, data); + context.InstanceHandle.End(); + } + + public void OnEnded(AbilityBehaviorContext context) + { + // No-op + } + } + + private sealed class ExceptionBehaviorOnStart : IAbilityBehavior + { + public int StartAttempts { get; private set; } + + public void OnStarted(AbilityBehaviorContext context) + { + StartAttempts++; + throw new InvalidOperationException("Start failure"); + } + + public void OnEnded(AbilityBehaviorContext context) + { + // No-op + } + } + + private sealed class ExceptionBehaviorOnEnd : IAbilityBehavior + { + public int EndAttempts { get; private set; } + + public void OnStarted(AbilityBehaviorContext context) + { + // No-op + } + + public void OnEnded(AbilityBehaviorContext context) + { + EndAttempts++; + throw new InvalidOperationException("End failure"); + } + } +} diff --git a/Forge.Tests/Cues/CueTests.cs b/Forge.Tests/Cues/CueTests.cs index 548226f..a89ea19 100644 --- a/Forge.Tests/Cues/CueTests.cs +++ b/Forge.Tests/Cues/CueTests.cs @@ -40,7 +40,7 @@ private enum TestCueExecutionType { new object[] { "TestAttributeSet.Attribute1", 3f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -61,7 +61,7 @@ private enum TestCueExecutionType { new object[] { "TestAttributeSet.Attribute1", 3f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -86,7 +86,7 @@ private enum TestCueExecutionType new object[] { "TestAttributeSet.Attribute1", 3f }, new object[] { "TestAttributeSet.Attribute1", 2f }, }, - true, + CueTriggerRequirement.OnExecute, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -110,7 +110,7 @@ private enum TestCueExecutionType { new object[] { "TestAttributeSet.Attribute1", 99f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -134,7 +134,7 @@ private enum TestCueExecutionType { new object[] { "TestAttributeSet.Attribute1", 99f }, }, - true, + CueTriggerRequirement.OnExecute, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -159,7 +159,7 @@ private enum TestCueExecutionType new object[] { "TestAttributeSet.Attribute1", 1f }, new object[] { "TestAttributeSet.Attribute2", 2f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -178,7 +178,7 @@ private enum TestCueExecutionType })] [InlineData( new object[] { }, - true, + CueTriggerRequirement.OnExecute, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -202,7 +202,7 @@ private enum TestCueExecutionType { new object[] { "TestAttributeSet.Attribute1", 3f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "Invalid.Attribute" }, @@ -224,7 +224,7 @@ private enum TestCueExecutionType new object[] { "TestAttributeSet.Attribute1", 3f }, new object[] { "TestAttributeSet.Attribute1", 2f }, }, - true, + CueTriggerRequirement.OnExecute, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "Invalid.Attribute1" }, @@ -245,7 +245,7 @@ private enum TestCueExecutionType })] [InlineData( new object[] { }, - true, + CueTriggerRequirement.OnExecute, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "Invalid.Attribute1" }, @@ -270,7 +270,7 @@ private enum TestCueExecutionType new object[] { "TestAttributeSet.Attribute1", 1f }, new object[] { "Invalid.Attribute", 2f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -292,7 +292,7 @@ private enum TestCueExecutionType { new object[] { "Invalid.Attribute", 3f }, }, - true, + CueTriggerRequirement.OnExecute, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -313,7 +313,7 @@ private enum TestCueExecutionType { new object[] { "TestAttributeSet.Attribute1", 3f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeBaseValue, "TestAttributeSet.Attribute1" }, @@ -328,25 +328,25 @@ private enum TestCueExecutionType })] public void Instant_effect_triggers_execute_cues_with_expected_results( object[] modifiersData, - bool requireModifierSuccessToTriggerCue, - object[] cueDatas, - object[] cueTestDatas1, - object[] cueTestDatas2) + CueTriggerRequirement requireModifierSuccessToTriggerCue, + object[] cueData, + object[] cueTestData1, + object[] cueTestData2) { var entity = new TestEntity(_tagsManager, _cuesManager); EffectData effectData = CreateInstantEffectData( CreateModifiers(modifiersData), requireModifierSuccessToTriggerCue, - CreateCueDatas(cueDatas)); + CreateCueData(cueData)); var effect = new Effect(effectData, new EffectOwnership(entity, entity)); ResetCues(); entity.EffectsManager.ApplyEffect(effect); - TestCueExecutionData(TestCueExecutionType.Execution, cueTestDatas1); + TestCueExecutionData(TestCueExecutionType.Execution, cueTestData1); effect.LevelUp(); entity.EffectsManager.ApplyEffect(effect); - TestCueExecutionData(TestCueExecutionType.Execution, cueTestDatas2); + TestCueExecutionData(TestCueExecutionType.Execution, cueTestData2); } [Theory] @@ -356,7 +356,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "TestAttributeSet.Attribute1", 5f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -377,7 +377,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "TestAttributeSet.Attribute1", 5f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute2" }, @@ -395,7 +395,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( })] [InlineData( new object[] { }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -413,7 +413,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( })] [InlineData( new object[] { }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -434,7 +434,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "TestAttributeSet.Attribute1", 5f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "Invalid.Attribute" }, @@ -455,7 +455,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "Invalid.Attribute", 5f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -476,7 +476,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "TestAttributeSet.Attribute90", 20f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 100, CueMagnitudeType.AttributeModifier, "TestAttributeSet.Attribute90" }, @@ -494,7 +494,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "TestAttributeSet.Attribute90", 20f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 100, CueMagnitudeType.AttributeOverflow, "TestAttributeSet.Attribute90" }, @@ -512,7 +512,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "TestAttributeSet.Attribute90", 20f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 100, CueMagnitudeType.AttributeValidModifier, "TestAttributeSet.Attribute90" }, @@ -530,7 +530,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "TestAttributeSet.Attribute5", 20f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 100, CueMagnitudeType.AttributeMin, "TestAttributeSet.Attribute90" }, @@ -548,7 +548,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "TestAttributeSet.Attribute5", 20f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] { 0, 0, 100, CueMagnitudeType.AttributeMax, "TestAttributeSet.Attribute90" }, @@ -566,7 +566,7 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( { new object[] { "TestAttributeSet.Attribute5", 20f }, }, - true, + CueTriggerRequirement.OnApply, new object[] { new object[] @@ -588,26 +588,25 @@ public void Instant_effect_triggers_execute_cues_with_expected_results( })] public void Infinite_effect_triggers_apply_and_remove_cues_with_expected_results( object[] modifiersData, - bool requireModifierSuccessToTriggerCue, - object[] cueDatas, - object[] cueTestDatas1, - object[] cueTestDatas2) + CueTriggerRequirement requireModifierSuccessToTriggerCue, + object[] cueData, + object[] cueTestData1, + object[] cueTestData2) { var entity = new TestEntity(_tagsManager, _cuesManager); EffectData effectData = CreateInfiniteEffectData( CreateModifiers(modifiersData), true, requireModifierSuccessToTriggerCue, - CreateCueDatas(cueDatas)); + CreateCueData(cueData)); var effect = new Effect(effectData, new EffectOwnership(entity, entity)); ResetCues(); ActiveEffectHandle? activeEffectHandler = entity.EffectsManager.ApplyEffect(effect); - TestCueExecutionData(TestCueExecutionType.Application, cueTestDatas1); + TestCueExecutionData(TestCueExecutionType.Application, cueTestData1); - Validation.Assert(activeEffectHandler is not null, "Effect should not be null here."); - entity.EffectsManager.UnapplyEffect(activeEffectHandler); - TestCueExecutionData(TestCueExecutionType.Application, cueTestDatas2); + entity.EffectsManager.RemoveEffect(activeEffectHandler!); + TestCueExecutionData(TestCueExecutionType.Application, cueTestData2); } [Theory] @@ -620,7 +619,7 @@ public void Infinite_effect_triggers_apply_and_remove_cues_with_expected_results { new object[] { "TestAttributeSet.Attribute1", 1f }, }, - false, + CueTriggerRequirement.None, 5f, 5f, new object[] @@ -670,7 +669,7 @@ public void Infinite_effect_triggers_apply_and_remove_cues_with_expected_results new object[] { "TestAttributeSet.Attribute1", 1f }, new object[] { "TestAttributeSet.Attribute2", 2f }, }, - false, + CueTriggerRequirement.None, 5f, 6f, new object[] @@ -720,7 +719,7 @@ public void Infinite_effect_triggers_apply_and_remove_cues_with_expected_results new object[] { "TestAttributeSet.Attribute1", 1f }, new object[] { "TestAttributeSet.Attribute2", 2f }, }, - true, + CueTriggerRequirement.OnApply | CueTriggerRequirement.OnExecute, 5f, 6f, new object[] @@ -770,7 +769,7 @@ public void Infinite_effect_triggers_apply_and_remove_cues_with_expected_results new object[] { "TestAttributeSet.Attribute1", 1f }, new object[] { "TestAttributeSet.Attribute2", 2f }, }, - true, + CueTriggerRequirement.OnApply | CueTriggerRequirement.OnExecute, 5f, 6f, new object[] @@ -819,7 +818,7 @@ public void Infinite_effect_triggers_apply_and_remove_cues_with_expected_results { new object[] { "TestAttributeSet.Attribute1", 1f }, }, - false, + CueTriggerRequirement.None, 5f, 5f, new object[] @@ -860,21 +859,99 @@ public void Infinite_effect_triggers_apply_and_remove_cues_with_expected_results true, true, true)] + [InlineData( + 10f, + 1f, + true, + new object[] { }, + CueTriggerRequirement.OnExecute, + 5f, + 5f, + new object[] + { + new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, + }, + new object[] + { + new object[] { 0, 1, 0, 0f, true }, + }, + new object[] + { + new object[] { 0, 0, 0, 0f, true }, + }, + new object[] + { + new object[] { 0, 1, 0, 0f, true }, + }, + new object[] + { + new object[] { 0, 0, 0, 0f, true }, + }, + new object[] + { + new object[] { 0, 1, 0, 0f, false }, + }, + new object[] + { + new object[] { 0, 0, 0, 0f, false }, + }, + true, + false, + true)] + [InlineData( + 10f, + 1f, + true, + new object[] { }, + CueTriggerRequirement.OnApply, + 5f, + 5f, + new object[] + { + new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, + }, + new object[] + { + new object[] { 0, 0, 0, 0f, false }, + }, + new object[] + { + new object[] { 0, 1, 0, 0f, false }, + }, + new object[] + { + new object[] { 0, 0, 0, 0f, false }, + }, + new object[] + { + new object[] { 0, 6, 0, 0f, false }, + }, + new object[] + { + new object[] { 0, 0, 0, 0f, false }, + }, + new object[] + { + new object[] { 0, 11, 0, 0f, false }, + }, + false, + true, + false)] public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected_results( float duration, float period, bool executeOnApplication, object[] modifiersData, - bool requireModifierSuccessToTriggerCue, + CueTriggerRequirement requireModifierSuccessToTriggerCue, float firstDeltaUpdate, float secondDeltaUpdate, - object[] cueDatas, - object[] applicationCueTestDatas1, - object[] executionCueTestDatas1, - object[] applicationCueTestDatas2, - object[] executionCueTestDatas2, - object[] applicationCueTestDatas3, - object[] executionCueTestDatas3, + object[] cueData, + object[] applicationCueTestData1, + object[] executionCueTestData1, + object[] applicationCueTestData2, + object[] executionCueTestData2, + object[] applicationCueTestData3, + object[] executionCueTestData3, bool applyTriggered, bool executeTriggered, bool isFiredInCorrectOrder) @@ -886,7 +963,7 @@ public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected executeOnApplication, CreateModifiers(modifiersData), requireModifierSuccessToTriggerCue, - CreateCueDatas(cueDatas)); + CreateCueData(cueData)); var effect = new Effect(effectData, new EffectOwnership(entity, entity)); var firstTriggered = new ManualResetEventSlim(false); @@ -909,18 +986,18 @@ public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected secondTriggered.IsSet.Should().Be(executeTriggered); isOrderCorrect.Should().Be(isFiredInCorrectOrder); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas1); - TestCueExecutionData(TestCueExecutionType.Execution, executionCueTestDatas1); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData1); + TestCueExecutionData(TestCueExecutionType.Execution, executionCueTestData1); entity.EffectsManager.UpdateEffects(firstDeltaUpdate); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas2); - TestCueExecutionData(TestCueExecutionType.Execution, executionCueTestDatas2); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData2); + TestCueExecutionData(TestCueExecutionType.Execution, executionCueTestData2); entity.EffectsManager.UpdateEffects(secondDeltaUpdate); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas3); - TestCueExecutionData(TestCueExecutionType.Execution, executionCueTestDatas3); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData3); + TestCueExecutionData(TestCueExecutionType.Execution, executionCueTestData3); } [Theory] @@ -931,7 +1008,7 @@ public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected new object[] { "TestAttributeSet.Attribute1", 1f }, }, false, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -973,7 +1050,7 @@ public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected new object[] { "TestAttributeSet.Attribute1", 1f }, }, false, - true, + CueTriggerRequirement.OnApply | CueTriggerRequirement.OnUpdate, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1015,7 +1092,7 @@ public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected new object[] { "TestAttributeSet.Attribute1", 1f }, }, true, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1057,7 +1134,7 @@ public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected new object[] { "TestAttributeSet.Attribute1", 99f }, }, false, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1099,7 +1176,7 @@ public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected new object[] { "TestAttributeSet.Attribute1", 99f }, }, false, - true, + CueTriggerRequirement.OnApply | CueTriggerRequirement.OnUpdate, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1141,7 +1218,7 @@ public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected new object[] { "TestAttributeSet.Attribute1", 1f }, }, false, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "Invalid.Attribute" }, @@ -1180,36 +1257,35 @@ public void Periodic_effect_triggers_apply_remove_and_execute_cues_with_expected public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_results( object[] modifiersData, bool snapshotLevel, - bool requireModifierSuccessToTriggerCue, - object[] cueDatas, - object[] applicationCueTestDatas1, - object[] updateCueTestDatas1, - object[] applicationCueTestDatas2, - object[] updateCueTestDatas2, - object[] applicationCueTestDatas3, - object[] updateCueTestDatas3) + CueTriggerRequirement requireModifierSuccessToTriggerCue, + object[] cueData, + object[] applicationCueTestData1, + object[] updateCueTestData1, + object[] applicationCueTestData2, + object[] updateCueTestData2, + object[] applicationCueTestData3, + object[] updateCueTestData3) { var entity = new TestEntity(_tagsManager, _cuesManager); EffectData effectData = CreateInfiniteEffectData( CreateModifiers(modifiersData), snapshotLevel, requireModifierSuccessToTriggerCue, - CreateCueDatas(cueDatas)); + CreateCueData(cueData)); var effect = new Effect(effectData, new EffectOwnership(entity, entity)); ResetCues(); ActiveEffectHandle? activeEffectHandler = entity.EffectsManager.ApplyEffect(effect); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas1); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas1); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData1); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData1); effect.LevelUp(); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas2); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas2); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData2); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData2); - Validation.Assert(activeEffectHandler is not null, "Effect should not be null here."); - entity.EffectsManager.UnapplyEffect(activeEffectHandler); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas3); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas3); + entity.EffectsManager.RemoveEffect(activeEffectHandler!); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData3); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData3); } [Theory] @@ -1224,7 +1300,7 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res new object[] { "TestAttributeSet.Attribute2", 1f }, new object[] { "TestAttributeSet.Attribute2", 1f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1269,7 +1345,7 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res { new object[] { "TestAttributeSet.Attribute2", 1f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1314,7 +1390,7 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res { new object[] { "TestAttributeSet.Attribute2", 1f }, }, - true, + CueTriggerRequirement.OnApply | CueTriggerRequirement.OnUpdate, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1359,7 +1435,7 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res { new object[] { "TestAttributeSet.Attribute90", 2f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1404,7 +1480,7 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res { new object[] { "TestAttributeSet.Attribute90", 2f }, }, - false, + CueTriggerRequirement.None, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1449,7 +1525,7 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res { new object[] { "TestAttributeSet.Attribute90", 2f }, }, - true, + CueTriggerRequirement.OnApply | CueTriggerRequirement.OnUpdate, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1494,7 +1570,7 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res { new object[] { "TestAttributeSet.Attribute90", 2f }, }, - true, + CueTriggerRequirement.OnApply | CueTriggerRequirement.OnUpdate, new object[] { new object[] { 0, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1" }, @@ -1530,47 +1606,46 @@ public void Infinite_effect_triggers_update_cues_when_level_up_with_expected_res new object[] { 0, 0, 0, 0f, true }, new object[] { 1, 0, 0, 0f, true }, })] - public void Attributre_based_modifiers_triggers_update_cues_when_attribute_changes_with_expected_results( + public void Attribute_based_modifiers_triggers_update_cues_when_attribute_changes_with_expected_results( object[] attributeBasedModifiersData, object[] modifiersData, - bool requireModifierSuccessToTriggerCue, - object[] cueDatas, - object[] applicationCueTestDatas1, - object[] updateCueTestDatas1, - object[] applicationCueTestDatas2, - object[] updateCueTestDatas2, - object[] applicationCueTestDatas3, - object[] updateCueTestDatas3) + CueTriggerRequirement requireModifierSuccessToTriggerCue, + object[] cueData, + object[] applicationCueTestData1, + object[] updateCueTestData1, + object[] applicationCueTestData2, + object[] updateCueTestData2, + object[] applicationCueTestData3, + object[] updateCueTestData3) { var entity = new TestEntity(_tagsManager, _cuesManager); EffectData effectData1 = CreateInfiniteEffectData( CreateAttributeBasedModifiers(attributeBasedModifiersData), true, requireModifierSuccessToTriggerCue, - CreateCueDatas(cueDatas)); + CreateCueData(cueData)); var effect1 = new Effect(effectData1, new EffectOwnership(entity, entity)); EffectData effectData2 = CreateInfiniteEffectData( CreateModifiers(modifiersData), true, requireModifierSuccessToTriggerCue, - CreateCueDatas([])); + CreateCueData([])); var effect2 = new Effect(effectData2, new EffectOwnership(entity, entity)); ResetCues(); entity.EffectsManager.ApplyEffect(effect1); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas1); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas1); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData1); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData1); ActiveEffectHandle? activeEffectHandler = entity.EffectsManager.ApplyEffect(effect2); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas2); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas2); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData2); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData2); - Validation.Assert(activeEffectHandler is not null, "Effect should not be null here."); - entity.EffectsManager.UnapplyEffect(activeEffectHandler); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas3); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas3); + entity.EffectsManager.RemoveEffect(activeEffectHandler!); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData3); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData3); } [Theory] @@ -1583,7 +1658,7 @@ public void Attributre_based_modifiers_triggers_update_cues_when_attribute_chang 10f, 1, 3, - false, + CueTriggerRequirement.None, false, new object[] { @@ -1661,7 +1736,7 @@ public void Attributre_based_modifiers_triggers_update_cues_when_attribute_chang 10f, 2, 3, - false, + CueTriggerRequirement.None, false, new object[] { @@ -1739,7 +1814,7 @@ public void Attributre_based_modifiers_triggers_update_cues_when_attribute_chang 10f, 2, 3, - false, + CueTriggerRequirement.None, false, new object[] { @@ -1817,7 +1892,7 @@ public void Attributre_based_modifiers_triggers_update_cues_when_attribute_chang 10f, 1, 3, - false, + CueTriggerRequirement.None, false, new object[] { @@ -1895,7 +1970,7 @@ public void Attributre_based_modifiers_triggers_update_cues_when_attribute_chang 10f, 3, 3, - false, + CueTriggerRequirement.None, false, new object[] { @@ -1973,7 +2048,7 @@ public void Attributre_based_modifiers_triggers_update_cues_when_attribute_chang 10f, 3, 3, - true, + CueTriggerRequirement.OnApply | CueTriggerRequirement.OnUpdate, false, new object[] { @@ -2051,7 +2126,7 @@ public void Attributre_based_modifiers_triggers_update_cues_when_attribute_chang 10f, 3, 3, - false, + CueTriggerRequirement.None, true, new object[] { @@ -2118,7 +2193,7 @@ public void Attributre_based_modifiers_triggers_update_cues_when_attribute_chang 10f, 3, 3, - false, + CueTriggerRequirement.None, true, new object[] { @@ -2182,21 +2257,21 @@ public void Stackable_effect_triggers_update_cues_when_attribute_changes_with_ex float duration, int initialStack, int stackLimit, - bool requireModifierSuccessToTriggerCue, + CueTriggerRequirement requireModifierSuccessToTriggerCue, bool suppressStackingCues, - object[] cueDatas, + object[] cueData, float deltaUpdate1, float deltaUpdate2, - object[] applicationCueTestDatas1, - object[] updateCueTestDatas1, - object[] applicationCueTestDatas2, - object[] updateCueTestDatas2, - object[] applicationCueTestDatas3, - object[] updateCueTestDatas3, - object[] applicationCueTestDatas4, - object[] updateCueTestDatas4, - object[] applicationCueTestDatas5, - object[] updateCueTestDatas5) + object[] applicationCueTestData1, + object[] updateCueTestData1, + object[] applicationCueTestData2, + object[] updateCueTestData2, + object[] applicationCueTestData3, + object[] updateCueTestData3, + object[] applicationCueTestData4, + object[] updateCueTestData4, + object[] applicationCueTestData5, + object[] updateCueTestData5) { var entity = new TestEntity(_tagsManager, _cuesManager); EffectData effectData1 = CreateDurationStackableEffectData( @@ -2206,41 +2281,41 @@ public void Stackable_effect_triggers_update_cues_when_attribute_changes_with_ex CreateModifiers(attributeBasedModifiersData), requireModifierSuccessToTriggerCue, suppressStackingCues, - CreateCueDatas(cueDatas)); + CreateCueData(cueData)); var effect1 = new Effect(effectData1, new EffectOwnership(entity, entity)); ResetCues(); entity.EffectsManager.ApplyEffect(effect1); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas1); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas1); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData1); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData1); entity.EffectsManager.UpdateEffects(deltaUpdate1); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas2); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas2); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData2); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData2); effect1.LevelUp(); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas3); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas3); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData3); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData3); entity.EffectsManager.ApplyEffect(effect1); entity.EffectsManager.ApplyEffect(effect1); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas4); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas4); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData4); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData4); entity.EffectsManager.UpdateEffects(deltaUpdate2); - TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestDatas5); - TestCueExecutionData(TestCueExecutionType.Update, updateCueTestDatas5); + TestCueExecutionData(TestCueExecutionType.Application, applicationCueTestData5); + TestCueExecutionData(TestCueExecutionType.Update, updateCueTestData5); } [Fact] [Trait("Invalid cue", null)] - public void Invalid_cue_fails_gracefullys() + public void Invalid_cue_fails_gracefully() { var entity = new TestEntity(_tagsManager, _cuesManager); EffectData effectData = CreateInstantEffectData( CreateModifiers([new object[] { "TestAttributeSet.Attribute1", 3f }]), - false, + CueTriggerRequirement.OnExecute, [new CueData( Tag.RequestTag(_tagsManager, "invalid.tag", false).GetSingleTagContainer(), 0, @@ -2317,13 +2392,17 @@ public void Custom_executions_sets_custom_cues_parameters_correctly() var owner = new TestEntity(_tagsManager, _cuesManager); var target = new TestEntity(_tagsManager, _cuesManager); - var customCalculatorClass = new CustomTestExecutionClass(); + var customCalculatorClass = new CustomTestExecutionClass(false); var effectData = new EffectData( "Test Effect", new DurationData(DurationType.Instant), customExecutions: [customCalculatorClass], - cues: [new CueData(Tag.RequestTag(_tagsManager, "Test.Cue1").GetSingleTagContainer(), 0, 10, CueMagnitudeType.EffectLevel)]); + cues: [new CueData( + Tag.RequestTag(_tagsManager, "Test.Cue1").GetSingleTagContainer(), + 0, + 10, + CueMagnitudeType.EffectLevel)]); var effect = new Effect(effectData, new EffectOwnership(owner, owner)); @@ -2332,7 +2411,7 @@ public void Custom_executions_sets_custom_cues_parameters_correctly() target.EffectsManager.ApplyEffect(effect); TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [89, 89, 0, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 16, 0, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 10, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 11, 0, 0]); _testCues[0].ExecuteData.Count.Should().Be(1); _testCues[0].ExecuteData.CustomParameters.Should().NotBeNull(); @@ -2341,7 +2420,7 @@ public void Custom_executions_sets_custom_cues_parameters_correctly() target.EffectsManager.ApplyEffect(effect); TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [88, 88, 0, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [31, 31, 0, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [18, 18, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [35, 35, 0, 0]); _testCues[0].ExecuteData.Count.Should().Be(2); _testCues[0].ExecuteData.CustomParameters.Should().NotBeNull(); @@ -2350,7 +2429,7 @@ public void Custom_executions_sets_custom_cues_parameters_correctly() target.EffectsManager.ApplyEffect(effect); TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [87, 87, 0, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [46, 46, 0, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [26, 26, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [74, 74, 0, 0]); _testCues[0].ExecuteData.Count.Should().Be(3); _testCues[0].ExecuteData.CustomParameters.Should().NotBeNull(); @@ -2372,7 +2451,7 @@ public void Effect_triggers_multiple_cues_with_expected_results() var entity = new TestEntity(_tagsManager, _cuesManager); EffectData effectData = CreateInstantEffectData( CreateModifiers([new object[] { "TestAttributeSet.Attribute1", 3f }]), - true, + CueTriggerRequirement.OnExecute, [new CueData(tagContainer, 0, 10, CueMagnitudeType.AttributeValueChange, "TestAttributeSet.Attribute1")]); var effect = new Effect(effectData, new EffectOwnership(entity, entity)); @@ -2387,9 +2466,116 @@ public void Effect_triggers_multiple_cues_with_expected_results() ]); } + [Fact] + [Trait("Custom cues", null)] + public void Custom_calculator_class_sets_custom_update_cues_parameters_correctly() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customCalculatorClass = new CustomMagnitudeCalculator( + "TestAttributeSet.Attribute1", + AttributeCaptureSource.Source, + 1); + + var effectData = new EffectData( + "Level Up", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.CustomCalculatorClass, + customCalculationBasedFloat: new CustomCalculationBasedFloat( + customCalculatorClass, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))) + ], + snapshotLevel: false, + cues: [new CueData( + Tag.RequestTag(_tagsManager, "Test.Cue1").GetSingleTagContainer(), + 0, + 10, + CueMagnitudeType.EffectLevel)]); + + var effect = new Effect( + effectData, + new EffectOwnership( + new TestEntity(_tagsManager, _cuesManager), + owner)); + + target.EffectsManager.ApplyEffect(effect); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [2, 1, 1, 0]); + + _testCues[0].ApplyData.CustomParameters.Should().NotBeNull(); + _testCues[0].ApplyData.CustomParameters.Should().Contain("test", 1); + _testCues[0].UpdateData.CustomParameters.Should().BeNull(); + + effect.LevelUp(); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [2, 1, 1, 0]); + + _testCues[0].UpdateData.CustomParameters.Should().NotBeNull(); + _testCues[0].UpdateData.CustomParameters.Should().Contain("test", 1); + } + + [Fact] + [Trait("Custom cues", null)] + public void Custom_executions_sets_custom_update_cues_parameters_correctly() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customCalculatorClass = new CustomTestExecutionClass(false); + + var effectData = new EffectData( + "Test Effect", + new DurationData(DurationType.Infinite), + snapshotLevel: false, + customExecutions: [customCalculatorClass], + cues: [new CueData( + Tag.RequestTag(_tagsManager, "Test.Cue1").GetSingleTagContainer(), + 0, + 10, + CueMagnitudeType.EffectLevel)]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + ResetCues(); + + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [89, 90, -1, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); + + _testCues[0].ApplyData.Count.Should().Be(1); + _testCues[0].ApplyData.CustomParameters.Should().NotBeNull(); + + // 1 * (3 + 5) = 8 + _testCues[0].ApplyData.CustomParameters.Should().Contain("custom.parameter", 8); + + target.EffectsManager.ApplyEffect(effect); + + // 2 * (3 + 5) = 16 + _testCues[0].ApplyData.CustomParameters.Should().Contain("custom.parameter", 16); + effect.LevelUp(); + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [88, 90, -2, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [31, 1, 30, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [35, 2, 33, 0]); + + _testCues[0].UpdateData.Count.Should().Be(2); + _testCues[0].UpdateData.CustomParameters.Should().NotBeNull(); + + // Updated twice because we have two instances of the effect, so 4 * (3 + 5) = 32 + _testCues[0].UpdateData.CustomParameters.Should().Contain("custom.parameter", 32); + } + private static EffectData CreateInstantEffectData( Modifier[] modifiers, - bool requireModifierSuccessToTriggerCue, + CueTriggerRequirement requireModifierSuccessToTriggerCue, CueData[] cues) { return new EffectData( @@ -2403,14 +2589,14 @@ private static EffectData CreateInstantEffectData( private static EffectData CreateInfiniteEffectData( Modifier[] modifiers, bool snapshotLevel, - bool requireModifierSuccessToTriggerCue, + CueTriggerRequirement requireModifierSuccessToTriggerCue, CueData[] cues) { return new EffectData( "Infinite Effect", new DurationData(DurationType.Infinite), modifiers, - snapshopLevel: snapshotLevel, + snapshotLevel: snapshotLevel, requireModifierSuccessToTriggerCue: requireModifierSuccessToTriggerCue, cues: cues); } @@ -2420,12 +2606,14 @@ private static EffectData CreateDurationPeriodicEffectData( float period, bool executeOnApplication, Modifier[] modifiers, - bool requireModifierSuccessToTriggerCue, + CueTriggerRequirement requireModifierSuccessToTriggerCue, CueData[] cues) { return new EffectData( "Infinite Effect", - new DurationData(DurationType.HasDuration, new ScalableFloat(duration)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(duration))), modifiers, periodicData: new PeriodicData( new ScalableFloat(period), @@ -2440,13 +2628,15 @@ private static EffectData CreateDurationStackableEffectData( int initialStack, int stackLimit, Modifier[] modifiers, - bool requireModifierSuccessToTriggerCue, + CueTriggerRequirement requireModifierSuccessToTriggerCue, bool suppressStackingCues, CueData[] cues) { return new EffectData( "Infinite Effect", - new DurationData(DurationType.HasDuration, new ScalableFloat(duration)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(duration))), modifiers, new StackingData( new ScalableInt(stackLimit), @@ -2460,7 +2650,7 @@ private static EffectData CreateDurationStackableEffectData( LevelOverridePolicy: LevelComparison.Lower | LevelComparison.Equal | LevelComparison.Higher, LevelOverrideStackCountPolicy: StackLevelOverrideStackCountPolicy.IncreaseStacks, ApplicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication), - snapshopLevel: false, + snapshotLevel: false, requireModifierSuccessToTriggerCue: requireModifierSuccessToTriggerCue, suppressStackingCues: suppressStackingCues, cues: cues); @@ -2527,13 +2717,13 @@ private static Modifier[] CreateAttributeBasedModifiers(object[] modifiersData) return result; } - private CueData[] CreateCueDatas(object[] cueDatas) + private CueData[] CreateCueData(object[] cueDataArray) { - var result = new CueData[cueDatas.Length]; + var result = new CueData[cueDataArray.Length]; - for (var i = 0; i < cueDatas.Length; i++) + for (var i = 0; i < cueDataArray.Length; i++) { - var cueData = (object[])cueDatas[i]; + var cueData = (object[])cueDataArray[i]; result[i] = new CueData( Tag.RequestTag(_tagsManager, $"Test.Cue{(int)cueData[0] + 1}").GetSingleTagContainer(), @@ -2546,11 +2736,11 @@ private CueData[] CreateCueDatas(object[] cueDatas) return result; } - private void TestCueExecutionData(TestCueExecutionType cueDataType, object[] cueTestDatas) + private void TestCueExecutionData(TestCueExecutionType cueDataType, object[] cueTestDataArray) { - for (var i = 0; i < cueTestDatas.Length; i++) + for (var i = 0; i < cueTestDataArray.Length; i++) { - var cueTestData = (object[])cueTestDatas[i]; + var cueTestData = (object[])cueTestDataArray[i]; TestCue testCue = _testCues[(int)cueTestData[0]]; CueExecutionData cueExecutionData = cueDataType switch @@ -2594,10 +2784,15 @@ public CustomMagnitudeCalculator(StringKey attribute, AttributeCaptureSource cap _exponent = exponent; } - public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target) + public override float CalculateBaseMagnitude( + Effect effect, + IForgeEntity target, + EffectEvaluatedData? effectEvaluatedData) { - CustomCueParameters.Add("test", _exponent); - return (float)Math.Pow(CaptureAttributeMagnitude(Attribute1, effect, target), _exponent); + CustomCueParameters["test"] = _exponent; + return (float)Math.Pow( + CaptureAttributeMagnitude(Attribute1, effect, target, effectEvaluatedData), + _exponent); } } } diff --git a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs index 9c5a6ab..12bbd23 100644 --- a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs +++ b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs @@ -8,6 +8,7 @@ using Gamesmiths.Forge.Effects.Duration; using Gamesmiths.Forge.Effects.Magnitudes; using Gamesmiths.Forge.Effects.Modifiers; +using Gamesmiths.Forge.Events; using Gamesmiths.Forge.Tags; using Gamesmiths.Forge.Tests.Core; using Gamesmiths.Forge.Tests.Helpers; @@ -44,6 +45,7 @@ public void Custom_calculator_class_magnitude_modifies_attribute_accordingly( var customCalculatorClass = new CustomMagnitudeCalculator( customMagnitudeCalculatorAttribute, AttributeCaptureSource.Source, + false, customMagnitudeCalculatorExponent); var effectData = new EffectData( @@ -98,6 +100,7 @@ public void Custom_calculator_class_magnitude_with_curve_modifies_attribute_acco var customCalculatorClass = new CustomMagnitudeCalculator( customMagnitudeCalculatorAttribute, AttributeCaptureSource.Source, + false, customMagnitudeCalculatorExponent); var effectData = new EffectData( @@ -263,6 +266,7 @@ public void Custom_calculator_class_non_snapshot_modifies_attribute_accordingly( var customCalculatorClass = new CustomMagnitudeCalculator( customMagnitudeCalculatorAttribute, captureSource, + false, customMagnitudeCalculatorExponent); var effectData = new EffectData( @@ -315,8 +319,7 @@ public void Custom_calculator_class_non_snapshot_modifies_attribute_accordingly( TestUtils.TestAttribute(target, targetAttribute, expectedResults2); - Validation.Assert(effectHandler is not null, "effectHandler should never be null here"); - effect2Target.EffectsManager.UnapplyEffect(effectHandler); + effect2Target.EffectsManager.RemoveEffect(effectHandler!); TestUtils.TestAttribute(target, targetAttribute, expectedResults1); } @@ -328,7 +331,7 @@ public void Custom_executions_modifies_attribute_accordingly() var owner = new TestEntity(_tagsManager, _cuesManager); var target = new TestEntity(_tagsManager, _cuesManager); - var customCalculatorClass = new CustomTestExecutionClass(); + var customCalculatorClass = new CustomTestExecutionClass(false); var effectData = new EffectData( "Test Effect", @@ -347,17 +350,17 @@ public void Custom_executions_modifies_attribute_accordingly() target.EffectsManager.ApplyEffect(effect); TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [89, 89, 0, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 16, 0, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 10, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 11, 0, 0]); target.EffectsManager.ApplyEffect(effect); TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [88, 88, 0, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [31, 31, 0, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [18, 18, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [35, 35, 0, 0]); target.EffectsManager.ApplyEffect(effect); TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [87, 87, 0, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [46, 46, 0, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [26, 26, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [74, 74, 0, 0]); } [Fact] @@ -367,7 +370,7 @@ public void Custom_executions_modifies_update_with_non_snapshot_attributes() var owner = new TestEntity(_tagsManager, _cuesManager); var target = new TestEntity(_tagsManager, _cuesManager); - var customCalculatorClass = new CustomTestExecutionClass(); + var customCalculatorClass = new CustomTestExecutionClass(false); var effectData = new EffectData( "Test Effect", @@ -423,29 +426,27 @@ public void Custom_executions_modifies_update_with_non_snapshot_attributes() TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [89, 90, -1, 0]); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 2, 8, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); ActiveEffectHandle? effectHandler1 = owner.EffectsManager.ApplyEffect(effect2); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [21, 1, 20, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [12, 2, 10, 0]); ActiveEffectHandle? effectHandler2 = owner.EffectsManager.ApplyEffect(effect3); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [29, 1, 28, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [13, 2, 11, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [14, 2, 12, 0]); - Validation.Assert(effectHandler2 is not null, "effectHandler2 should never be null here"); - owner.EffectsManager.UnapplyEffect(effectHandler2); + owner.EffectsManager.RemoveEffect(effectHandler2!); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [21, 1, 20, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [12, 2, 10, 0]); - Validation.Assert(effectHandler1 is not null, "effectHandler1 should never be null here"); - owner.EffectsManager.UnapplyEffect(effectHandler1); + owner.EffectsManager.RemoveEffect(effectHandler1!); TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); - TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [10, 2, 8, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); } [Fact] @@ -455,7 +456,7 @@ public void Custom_execution_without_valid_attributes_applies_with_no_attribute_ var owner = new NoAttributesEntity(_tagsManager, _cuesManager); var target = new NoAttributesEntity(_tagsManager, _cuesManager); - var customCalculatorClass = new CustomTestExecutionClass(); + var customCalculatorClass = new CustomTestExecutionClass(false); var effectData = new EffectData( "Test Effect", @@ -485,7 +486,7 @@ public void Custom_execution_without_valid_owner_attributes_applies_with_no_attr var owner = new NoAttributesEntity(_tagsManager, _cuesManager); var target = new TestEntity(_tagsManager, _cuesManager); - var customCalculatorClass = new CustomTestExecutionClass(); + var customCalculatorClass = new CustomTestExecutionClass(false); var effectData = new EffectData( "Test Effect", @@ -521,7 +522,7 @@ public void Custom_execution_without_valid_target_attributes_applies_with_no_att var owner = new TestEntity(_tagsManager, _cuesManager); var target = new NoAttributesEntity(_tagsManager, _cuesManager); - var customCalculatorClass = new CustomTestExecutionClass(); + var customCalculatorClass = new CustomTestExecutionClass(false); var effectData = new EffectData( "Test Effect", @@ -547,6 +548,110 @@ public void Custom_execution_without_valid_target_attributes_applies_with_no_att TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [90, 90, 0, 0]); } + [Fact] + [Trait("Execution", null)] + public void Custom_execution_captures_pending_modifiers_from_same_effect() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customCalculatorClass = new CustomTestExecutionClass(false); + + var effectData = new EffectData( + "Test Effect", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(5))), + ], + customExecutions: + [ + customCalculatorClass + ]); + + var effect = new Effect( + effectData, + new EffectOwnership( + owner, + owner)); + + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [89, 89, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [21, 21, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [16, 16, 0, 0]); + + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [88, 88, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [41, 41, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [50, 50, 0, 0]); + + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [87, 87, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [61, 61, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [99, 99, 0, 0]); + } + + [Fact] + [Trait("Execution", null)] + public void Custom_execution_considers_previously_applied_modifiers_on_different_channels() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var buffEffectData = new EffectData( + "Test Buff Effect", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.PercentBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(0.5f)), + 1), + ]); + + var buffEffect = new Effect( + buffEffectData, + new EffectOwnership( + owner, + owner)); + + var customCalculatorClass = new CustomTestExecutionClass(false); + + var effectData = new EffectData( + "Test Effect", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.Override, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(20))), + ], + customExecutions: + [ + customCalculatorClass + ]); + + var effect = new Effect( + effectData, + new EffectOwnership( + owner, + owner)); + + target.EffectsManager.ApplyEffect(buffEffect); + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [89, 89, 0, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [52, 35, 17, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [40, 40, 0, 0]); + } + [Fact] [Trait("Instant", null)] public void Custom_calculator_class_with_invalid_ownership_applies_with_no_attribute_changes() @@ -556,6 +661,7 @@ public void Custom_calculator_class_with_invalid_ownership_applies_with_no_attri var customCalculatorClass = new CustomMagnitudeCalculator( "TestAttributeSet.Attribute1", AttributeCaptureSource.Source, + false, 1); var effectData = new EffectData( @@ -589,7 +695,7 @@ public void Custom_executions_with_invalid_ownership_applies_with_no_attribute_c { var target = new TestEntity(_tagsManager, _cuesManager); - var customCalculatorClass = new CustomTestExecutionClass(); + var customCalculatorClass = new CustomTestExecutionClass(false); var effectData = new EffectData( "Test Effect", @@ -662,6 +768,7 @@ public void Custom_calculator_class_magnitude_captures_magnitude_correctly( var customCalculatorClass = new CustomMagnitudeCalculator( customMagnitudeCalculatorAttribute, AttributeCaptureSource.Source, + false, 1, attributeCalculationType); @@ -704,6 +811,161 @@ public void Custom_calculator_class_magnitude_captures_magnitude_correctly( TestUtils.TestAttribute(target, targetAttribute, expectedResults); } + [Fact] + [Trait("Snapshot", null)] + public void Custom_calculator_class_snapshot_does_not_update_value_with_effect_level_up() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customCalculatorClass = new CustomMagnitudeCalculator( + "TestAttributeSet.Attribute1", + AttributeCaptureSource.Source, + true, + 1); + + var effectData = new EffectData( + "Test Effect", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.CustomCalculatorClass, + customCalculationBasedFloat: new CustomCalculationBasedFloat( + customCalculatorClass, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))) + ], + snapshotLevel: false); + + var effectData2 = new EffectData( + "Backing Attribute Effect", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(1))) + ]); + + var effect = new Effect( + effectData, + new EffectOwnership( + owner, + owner)); + + var effect2 = new Effect( + effectData2, + new EffectOwnership( + owner, + owner)); + + target.EffectsManager.ApplyEffect(effect); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [2, 1, 1, 0]); + + owner.EffectsManager.ApplyEffect(effect2); + effect.LevelUp(); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [2, 1, 1, 0]); + } + + [Fact] + [Trait("Snapshot", null)] + public void Custom_executions_does_not_update_with_snapshot_attributes_when_effect_level_up() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customCalculatorClass = new CustomTestExecutionClass(true); + + var effectData = new EffectData( + "Test Effect", + new DurationData(DurationType.Infinite), + snapshotLevel: false, + customExecutions: + [ + customCalculatorClass + ]); + + var effectData2 = new EffectData( + "Backing Attribute Effect", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute3", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(1))) + ]); + + var effectData3 = new EffectData( + "Backing Attribute Effect", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute5", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(2))) + ]); + + var effect = new Effect( + effectData, + new EffectOwnership( + owner, + owner)); + + var effect2 = new Effect( + effectData2, + new EffectOwnership( + owner, + owner)); + + var effect3 = new Effect( + effectData3, + new EffectOwnership( + owner, + owner)); + + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute90", [89, 90, -1, 0]); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); + + ActiveEffectHandle? effectHandler1 = owner.EffectsManager.ApplyEffect(effect2); + effect.LevelUp(); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); + + ActiveEffectHandle? effectHandler2 = owner.EffectsManager.ApplyEffect(effect3); + effect.LevelUp(); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); + + owner.EffectsManager.RemoveEffect(effectHandler2!); + effect.LevelUp(); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); + + owner.EffectsManager.RemoveEffect(effectHandler1!); + effect.LevelUp(); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 1, 15, 0]); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute2", [11, 2, 9, 0]); + } + private sealed class CustomMagnitudeCalculator : CustomModifierMagnitudeCalculator { private readonly float _exponent; @@ -714,10 +976,11 @@ private sealed class CustomMagnitudeCalculator : CustomModifierMagnitudeCalculat public CustomMagnitudeCalculator( StringKey attribute, AttributeCaptureSource captureSource, + bool snapshot, float exponent, AttributeCalculationType attributeCalculationType = AttributeCalculationType.CurrentValue) { - Attribute1 = new AttributeCaptureDefinition(attribute, captureSource, false); + Attribute1 = new AttributeCaptureDefinition(attribute, captureSource, snapshot); AttributesToCapture.Add(Attribute1); @@ -725,9 +988,17 @@ public CustomMagnitudeCalculator( _attributeCalculationType = attributeCalculationType; } - public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target) + public override float CalculateBaseMagnitude( + Effect effect, + IForgeEntity target, + EffectEvaluatedData? effectEvaluatedData) { - var capturedMagnitude = CaptureAttributeMagnitude(Attribute1, effect, target, _attributeCalculationType); + var capturedMagnitude = CaptureAttributeMagnitude( + Attribute1, + effect, + target, + effectEvaluatedData, + _attributeCalculationType); return (float)Math.Pow(capturedMagnitude, _exponent); } @@ -741,11 +1012,17 @@ private sealed class NoAttributesEntity : IForgeEntity public EffectsManager EffectsManager { get; } + public EntityAbilities Abilities { get; } + + public EventManager Events { get; } + public NoAttributesEntity(TagsManager tagsManager, CuesManager cuesManager) { EffectsManager = new(this, cuesManager); Attributes = new(); Tags = new(new TagContainer(tagsManager)); + Abilities = new(this); + Events = new(); } } } diff --git a/Forge.Tests/Effects/EffectApplicationContextTests.cs b/Forge.Tests/Effects/EffectApplicationContextTests.cs new file mode 100644 index 0000000..19497e4 --- /dev/null +++ b/Forge.Tests/Effects/EffectApplicationContextTests.cs @@ -0,0 +1,421 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Attributes; +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Cues; +using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Effects.Calculator; +using Gamesmiths.Forge.Effects.Duration; +using Gamesmiths.Forge.Effects.Magnitudes; +using Gamesmiths.Forge.Effects.Modifiers; +using Gamesmiths.Forge.Tags; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Effects; + +public class EffectApplicationContextTests(TagsAndCuesFixture tagsAndCuesFixture) : IClassFixture +{ + private enum HealType + { + Instant = 0, + OverTime = 1, + } + + private readonly TagsManager _tagsManager = tagsAndCuesFixture.TagsManager; + private readonly CuesManager _cuesManager = tagsAndCuesFixture.CuesManager; + + [Fact] + [Trait("Context", "Instant")] + public void Custom_execution_can_access_context_data_from_instant_effect() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customExecution = new ContextAwareExecution(); + + var effectData = new EffectData( + "Context Effect", + new DurationData(DurationType.Instant), + customExecutions: [customExecution]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + var contextData = new DamageContext(25.5f, true, ["Head", "Torso"]); + + target.EffectsManager.ApplyEffect(effect, contextData); + + customExecution.ReceivedContext.Should().NotBeNull(); + customExecution.ReceivedDamage.Should().Be(25.5f); + customExecution.ReceivedIsCritical.Should().BeTrue(); + customExecution.ReceivedHitLocations.Should().BeEquivalentTo("Head", "Torso"); + } + + [Fact] + [Trait("Context", "Instant")] + public void Custom_execution_receives_null_context_when_applied_without_context() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customExecution = new ContextAwareExecution(); + + var effectData = new EffectData( + "Context Effect", + new DurationData(DurationType.Instant), + customExecutions: [customExecution]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + target.EffectsManager.ApplyEffect(effect); + + customExecution.ReceivedContext.Should().BeNull(); + customExecution.WasExecuted.Should().BeTrue(); + } + + [Fact] + [Trait("Context", "Infinite")] + public void Custom_execution_can_access_context_data_from_infinite_effect() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customExecution = new ContextAwareExecution(); + + var effectData = new EffectData( + "Context Effect", + new DurationData(DurationType.Infinite), + customExecutions: [customExecution]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + var contextData = new DamageContext(100f, false, ["Arm"]); + + target.EffectsManager.ApplyEffect(effect, contextData); + + customExecution.ReceivedContext.Should().NotBeNull(); + customExecution.ReceivedDamage.Should().Be(100f); + customExecution.ReceivedIsCritical.Should().BeFalse(); + customExecution.ReceivedHitLocations.Should().BeEquivalentTo("Arm"); + } + + [Fact] + [Trait("Context", "Calculator")] + public void Custom_magnitude_calculator_can_access_context_data() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customCalculator = new ContextAwareMagnitudeCalculator(); + + var effectData = new EffectData( + "Context Effect", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.CustomCalculatorClass, + customCalculationBasedFloat: new CustomCalculationBasedFloat( + customCalculator, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))) + ]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + var contextData = new DamageContext(10f, true, []); + + target.EffectsManager.ApplyEffect(effect, contextData); + + // Base magnitude is contextData.Damage * 2 (if critical) = 10 * 2 = 20 + // Attribute1 base is 1, so result should be 1 + 20 = 21 + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [21, 21, 0, 0]); + } + + [Fact] + [Trait("Context", "Calculator")] + public void Custom_magnitude_calculator_uses_base_damage_when_not_critical() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customCalculator = new ContextAwareMagnitudeCalculator(); + + var effectData = new EffectData( + "Context Effect", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.CustomCalculatorClass, + customCalculationBasedFloat: new CustomCalculationBasedFloat( + customCalculator, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))) + ]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + var contextData = new DamageContext(15f, false, []); + + target.EffectsManager.ApplyEffect(effect, contextData); + + // Base magnitude is contextData.Damage = 15 (not critical, no multiplier) + // Attribute1 base is 1, so result should be 1 + 15 = 16 + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [16, 16, 0, 0]); + } + + [Fact] + [Trait("Context", "Calculator")] + public void Custom_magnitude_calculator_returns_zero_when_no_context() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customCalculator = new ContextAwareMagnitudeCalculator(); + + var effectData = new EffectData( + "Context Effect", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.CustomCalculatorClass, + customCalculationBasedFloat: new CustomCalculationBasedFloat( + customCalculator, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))) + ]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + target.EffectsManager.ApplyEffect(effect); + + // No context, so magnitude should be 0 + // Attribute1 base is 1, so result should be 1 + 0 = 1 + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); + } + + [Fact] + [Trait("Context", "TryGetData")] + public void EffectApplicationContext_TryGetData_returns_true_for_matching_type() + { + var context = new EffectApplicationContext(new DamageContext(50f, true, ["Leg"])); + + var result = context.TryGetData(out DamageContext? data); + + result.Should().BeTrue(); + data.Should().NotBeNull(); + data!.Damage.Should().Be(50f); + data.IsCritical.Should().BeTrue(); + data.HitLocations.Should().BeEquivalentTo("Leg"); + } + + [Fact] + [Trait("Context", "TryGetData")] + public void EffectApplicationContext_TryGetData_returns_false_for_mismatched_type() + { + var context = new EffectApplicationContext(new DamageContext(50f, true, ["Leg"])); + + var result = context.TryGetData(out HealingContext? data); + + result.Should().BeFalse(); + data.Should().BeNull(); + } + + [Fact] + [Trait("Context", "MultipleHits")] + public void Context_data_can_carry_multiple_hit_locations_for_shotgun_style_effects() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var customExecution = new MultiHitExecution(); + + var effectData = new EffectData( + "Shotgun Blast", + new DurationData(DurationType.Instant), + customExecutions: [customExecution]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + // Simulate a shotgun hitting 5 different locations + var contextData = new DamageContext( + Damage: 10f, + IsCritical: false, + HitLocations: ["Head", "Torso", "LeftArm", "RightArm", "Leg"]); + + target.EffectsManager.ApplyEffect(effect, contextData); + + // Each hit location adds 10 damage = 5 * 10 = 50 total + // Attribute1 base is 1, so result should be 1 + 50 = 51 + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [51, 51, 0, 0]); + } + + [Fact] + [Trait("Context", "DifferentDataTypes")] + public void Different_context_types_can_be_used_for_different_effects() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var damageExecution = new ContextAwareExecution(); + var healingExecution = new HealingExecution(); + + var damageEffectData = new EffectData( + "Damage Effect", + new DurationData(DurationType.Instant), + customExecutions: [damageExecution]); + + var healingEffectData = new EffectData( + "Healing Effect", + new DurationData(DurationType.Instant), + customExecutions: [healingExecution]); + + var damageEffect = new Effect(damageEffectData, new EffectOwnership(owner, owner)); + var healingEffect = new Effect(healingEffectData, new EffectOwnership(owner, owner)); + + target.EffectsManager.ApplyEffect(damageEffect, new DamageContext(25f, true, ["Head"])); + target.EffectsManager.ApplyEffect(healingEffect, new HealingContext(50f, HealType.OverTime)); + + damageExecution.ReceivedDamage.Should().Be(25f); + healingExecution.ReceivedHealAmount.Should().Be(50f); + healingExecution.ReceivedHealType.Should().Be(HealType.OverTime); + } + + private sealed record DamageContext(float Damage, bool IsCritical, string[] HitLocations); + + private sealed record HealingContext(float HealAmount, HealType Type); + + /// + /// Test custom execution that accesses context data. + /// + private sealed class ContextAwareExecution : CustomExecution + { + public DamageContext? ReceivedContext { get; private set; } + + public float ReceivedDamage { get; private set; } + + public bool ReceivedIsCritical { get; private set; } + + public string[] ReceivedHitLocations { get; private set; } = []; + + public bool WasExecuted { get; private set; } + + public override ModifierEvaluatedData[] EvaluateExecution( + Effect effect, + IForgeEntity target, + EffectEvaluatedData? effectEvaluatedData) + { + WasExecuted = true; + + if (effectEvaluatedData?.TryGetContextData(out DamageContext? damageContext) == true) + { + ReceivedContext = damageContext; + ReceivedDamage = damageContext.Damage; + ReceivedIsCritical = damageContext.IsCritical; + ReceivedHitLocations = damageContext.HitLocations; + } + + return []; + } + } + + /// + /// Test custom magnitude calculator that uses context data. + /// + private sealed class ContextAwareMagnitudeCalculator : CustomModifierMagnitudeCalculator + { + public override float CalculateBaseMagnitude( + Effect effect, + IForgeEntity target, + EffectEvaluatedData? effectEvaluatedData) + { + if (effectEvaluatedData?.TryGetContextData(out DamageContext? damageContext) != true) + { + return 0f; + } + + // Double damage if critical + return damageContext!.IsCritical ? damageContext.Damage * 2 : damageContext.Damage; + } + } + + /// + /// Test execution that applies damage per hit location. + /// + private sealed class MultiHitExecution : CustomExecution + { + private readonly AttributeCaptureDefinition _targetAttribute; + + public MultiHitExecution() + { + _targetAttribute = new AttributeCaptureDefinition( + "TestAttributeSet.Attribute1", + AttributeCaptureSource.Target, + true); + + AttributesToCapture.Add(_targetAttribute); + } + + public override ModifierEvaluatedData[] EvaluateExecution( + Effect effect, + IForgeEntity target, + EffectEvaluatedData? effectEvaluatedData) + { + if (effectEvaluatedData?.TryGetContextData(out DamageContext? damageContext) != true) + { + return []; + } + + if (!_targetAttribute.TryGetAttribute(target, out EntityAttribute? attribute)) + { + return []; + } + + // Apply damage for each hit location + var totalDamage = damageContext!.Damage * damageContext.HitLocations.Length; + + return + [ + new ModifierEvaluatedData( + attribute, + ModifierOperation.FlatBonus, + totalDamage) + ]; + } + } + + /// + /// Test execution for healing context. + /// + private sealed class HealingExecution : CustomExecution + { + public float ReceivedHealAmount { get; private set; } + + public HealType ReceivedHealType { get; private set; } + + public override ModifierEvaluatedData[] EvaluateExecution( + Effect effect, + IForgeEntity target, + EffectEvaluatedData? effectEvaluatedData) + { + if (effectEvaluatedData?.TryGetContextData(out HealingContext? healingContext) == true) + { + ReceivedHealAmount = healingContext.HealAmount; + ReceivedHealType = healingContext.Type; + } + + return []; + } + } +} diff --git a/Forge.Tests/Effects/EffectsTests.cs b/Forge.Tests/Effects/EffectsTests.cs index 46a1d76..feeeae8 100644 --- a/Forge.Tests/Effects/EffectsTests.cs +++ b/Forge.Tests/Effects/EffectsTests.cs @@ -4,6 +4,7 @@ using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Cues; using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Effects.Calculator; using Gamesmiths.Forge.Effects.Duration; using Gamesmiths.Forge.Effects.Magnitudes; using Gamesmiths.Forge.Effects.Modifiers; @@ -64,7 +65,7 @@ public void Instant_effect_modifies_attribute_base_value( [InlineData("TestAttributeSet.Attribute90", "TestAttributeSet.Attribute5", -1, 5, 2, 82)] [InlineData("TestAttributeSet.Attribute3", "TestAttributeSet.Attribute5", 1.5f, 2.2f, 3.5f, 17)] [InlineData("Invalid.Attribute", "Invalid.Attribute", 2, 1, 2, 0)] - [InlineData("TestAttributeSet.Attribute1", "Invalid.Attribute", 2, 1, 2, 1)] + [InlineData("TestAttributeSet.Attribute1", "Invalid.Attribute", 2, 1, 2, 5)] [InlineData("Invalid.Attribute", "TestAttributeSet.Attribute1", 2, 1, 2, 0)] public void Attribute_based_effect_modifies_values_based_on_source_attribute( string targetAttribute, @@ -110,7 +111,7 @@ public void Attribute_based_effect_modifies_values_based_on_source_attribute( [InlineData("TestAttributeSet.Attribute3", "TestAttributeSet.Attribute5", 1.5f, 2.2f, 3.5f, 4)] [InlineData("TestAttributeSet.Attribute90", "TestAttributeSet.Attribute5", -1, 5, 2, 94)] [InlineData("Invalid.Attribute", "Invalid.Attribute", 2, 1, 2, 0)] - [InlineData("TestAttributeSet.Attribute1", "Invalid.Attribute", 2, 1, 2, 1)] + [InlineData("TestAttributeSet.Attribute1", "Invalid.Attribute", 2, 1, 2, 5)] [InlineData("Invalid.Attribute", "TestAttributeSet.Attribute1", 2, 1, 2, 0)] public void Attribute_based_effect_with_curve_modifies_values_based_on_curve_lookup( string targetAttribute, @@ -155,12 +156,62 @@ public void Attribute_based_effect_with_curve_modifies_values_based_on_curve_loo TestUtils.TestAttribute(target, targetAttribute, [expectedResult, expectedResult, 0, 0]); } + [Fact] + [Trait("Duration", null)] + public void Multiple_instant_effects_of_different_operations_modify_base_value_accordingly_2() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var effectData = new EffectData( + "Level Up", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute1000", + ModifierOperation.PercentBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(-0.8f)), + 1) + ]); + + var effect = new Effect( + effectData, + new EffectOwnership(owner, new TestEntity(_tagsManager, _cuesManager))); + + target.EffectsManager.ApplyEffect(effect); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1000", [0, 0, 0, 0]); + + var effectData2 = new EffectData( + "Rank Up", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute1000", + ModifierOperation.Override, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(100))) + ]); + + var effect2 = new Effect( + effectData2, + new EffectOwnership(owner, new TestEntity(_tagsManager, _cuesManager))); + + target.EffectsManager.ApplyEffect(effect2); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1000", [20, 0, 20, 0]); + } + [Theory] [Trait("Instant", null)] [InlineData("TestAttributeSet.Attribute1", 4, 5, 4, 25, -0.66f, 8, 42, 42)] [InlineData("TestAttributeSet.Attribute2", 8, 10, 2, 30, -0.66f, 10, 99, 99)] [InlineData("TestAttributeSet.Attribute3", 20, 23, 0.5f, 34, 1, 68, -10, 0)] [InlineData("TestAttributeSet.Attribute90", 90, 99, 0.3f, 99, 0f, 99, 100, 99)] + [InlineData("TestAttributeSet.Attribute1000", 100, 100, -0.8f, 20, 0.2f, 24, 200, 200)] [InlineData("Invalid.Attribute", 4, 0, 4, 0, -0.66f, 0, 42, 0)] public void Multiple_instant_effects_of_different_operations_modify_base_value_accordingly( string targetAttribute, @@ -363,11 +414,10 @@ public void Override_values_are_applied_temporarily( TestUtils.TestAttribute(target, targetAttribute, firstExpectedResults); ActiveEffectHandle? activeEffect2handle = target.EffectsManager.ApplyEffect(effect2); - Validation.Assert(activeEffect2handle is not null, "Effect handle should have a value."); TestUtils.TestAttribute(target, targetAttribute, secondExpectedResult); - target.EffectsManager.UnapplyEffect(activeEffect2handle); + target.EffectsManager.RemoveEffect(activeEffect2handle!); TestUtils.TestAttribute(target, targetAttribute, firstExpectedResults); } @@ -466,39 +516,34 @@ public void Multiple_override_values_are_applied_and_removed_correctly( TestUtils.TestAttribute(target, targetAttribute, expectedResults1); // 1 ActiveEffectHandle? activeEffect2Handle1 = target.EffectsManager.ApplyEffect(effect2); - Validation.Assert(activeEffect2Handle1 is not null, "Effect handle should have a value."); TestUtils.TestAttribute(target, targetAttribute, expectedResults2); // 1,2 ActiveEffectHandle? activeEffect3Handle = target.EffectsManager.ApplyEffect(effect3); - Validation.Assert(activeEffect3Handle is not null, "Effect handle should have a value."); TestUtils.TestAttribute(target, targetAttribute, expectedResults3); // 1,2,3 ActiveEffectHandle? activeEffect4Handle = target.EffectsManager.ApplyEffect(effect4); - Validation.Assert(activeEffect4Handle is not null, "Effect handle should have a value."); TestUtils.TestAttribute(target, targetAttribute, expectedResults4); // 1,2,3,4 ActiveEffectHandle? activeEffect1Handle = target.EffectsManager.ApplyEffect(effect1); - Validation.Assert(activeEffect1Handle is not null, "Effect handle should have a value."); TestUtils.TestAttribute(target, targetAttribute, expectedResults1); // 1,2,3,4,1 - target.EffectsManager.UnapplyEffect(activeEffect1Handle); + target.EffectsManager.RemoveEffect(activeEffect1Handle!); TestUtils.TestAttribute(target, targetAttribute, expectedResults4); // 1,2,3,4 - target.EffectsManager.UnapplyEffect(activeEffect2Handle1); + target.EffectsManager.RemoveEffect(activeEffect2Handle1!); TestUtils.TestAttribute(target, targetAttribute, expectedResults4); // 1,3,4 - target.EffectsManager.UnapplyEffect(activeEffect4Handle); + target.EffectsManager.RemoveEffect(activeEffect4Handle!); TestUtils.TestAttribute(target, targetAttribute, expectedResults3); // 1,3 ActiveEffectHandle? activeEffect2Handle2 = target.EffectsManager.ApplyEffect(effect2); - Validation.Assert(activeEffect2Handle2 is not null, "Effect handle should have a value."); TestUtils.TestAttribute(target, targetAttribute, expectedResults2); // 1,3,2 - target.EffectsManager.UnapplyEffect(activeEffect3Handle); + target.EffectsManager.RemoveEffect(activeEffect3Handle!); TestUtils.TestAttribute(target, targetAttribute, expectedResults2); // 1,2 - target.EffectsManager.UnapplyEffect(activeEffect2Handle2); + target.EffectsManager.RemoveEffect(activeEffect2Handle2!); TestUtils.TestAttribute(target, targetAttribute, expectedResults1); // 1 static EffectData CreateOverrideEffect( @@ -557,7 +602,7 @@ public void Non_snapshot_level_effect_updates_value_on_level_up( new CurveKey(2, modifierLevel2Multiplier), ])))) ], - snapshopLevel: false); + snapshotLevel: false); var effect = new Effect( effectData, @@ -621,7 +666,7 @@ public void Non_snapshot_level_periodic_effect_updates_scalable_float_values_on_ ])), true, PeriodInhibitionRemovedPolicy.NeverReset), - snapshopLevel: false); + snapshotLevel: false); var effect = new Effect( effectData, @@ -678,7 +723,7 @@ public void Periodic_effect_with_invalid_period_throws_exception( ])), true, PeriodInhibitionRemovedPolicy.NeverReset), - snapshopLevel: false); + snapshotLevel: false); var effect = new Effect( effectData, @@ -702,7 +747,7 @@ public void Periodic_effect_with_invalid_period_throws_exception( [InlineData("TestAttributeSet.Attribute5", 20, 33f, 999f, new int[] { 25, 5, 20, 0 })] [InlineData("TestAttributeSet.Attribute90", 100, 1f, 60f, new int[] { 99, 90, 100, 91 })] [InlineData("Invalid.Attribute", 100, 1f, 60f, new int[] { })] - public void Inifinite_effect_modify_attribute_modifier_value( + public void Infinite_effect_modify_attribute_modifier_value( string targetAttribute, float modifierBaseMagnitude, float simulatedFPS, @@ -1012,7 +1057,9 @@ public void Duration_effect_modifies_attribute_modifier_value_and_expire_after_d var effectData = new EffectData( "Buff", - new DurationData(DurationType.HasDuration, new ScalableFloat(baseDuration)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(baseDuration))), [ new Modifier( targetAttribute, @@ -1240,17 +1287,17 @@ public void Snapshot_periodic_effect_modifies_base_attribute_with_same_value_eve 1f, 2f, 1f, - 1, + 5, 1f, - 1, + 9, 2f, 0, 1f, - 1, + 13, 0, - 1, + 13, 1f, - 1)] + 17)] [InlineData( "Invalid.Attribute", "TestAttributeSet.Attribute2", @@ -1269,7 +1316,7 @@ public void Snapshot_periodic_effect_modifies_base_attribute_with_same_value_eve 0, 1f, 0)] - public void Non_snapshot_priodic_effect_with_attribute_based_magnitude_should_update_when_attribute_updates( + public void Non_snapshot_periodic_effect_with_attribute_based_magnitude_should_update_when_attribute_updates( string targetAttribute, string backingAttribute, float coefficient, @@ -1424,12 +1471,12 @@ public void Non_snapshot_priodic_effect_with_attribute_based_magnitude_should_up 2f, 1f, 2f, - new int[] { 1, 1, 0, 0 }, + new int[] { 5, 1, 4, 0 }, 2f, 0, - new int[] { 1, 1, 0, 0 }, + new int[] { 5, 1, 4, 0 }, 0, - new int[] { 1, 1, 0, 0 })] + new int[] { 5, 1, 4, 0 })] [InlineData( "Invalid.Attribute", "TestAttributeSet.Attribute2", @@ -1537,7 +1584,9 @@ public void Periodic_effect_modifies_base_attribute_value_and_expire_after_durat var effectData = new EffectData( "Buff", - new DurationData(DurationType.HasDuration, new ScalableFloat(duration)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(duration))), [ new Modifier( targetAttribute, @@ -1788,7 +1837,7 @@ public void Stackable_effect_from_two_different_sources_gives_expected_stack_dat [Theory] [Trait("Stackable", null)] - // Effects with stack level policy Seggregate don't stack. + // Effects with stack level policy Segregate don't stack. [InlineData( new int[] { 6, 1, 5, 0 }, new int[] { 16, 1, 15, 0 }, @@ -2733,7 +2782,7 @@ public void Stackable_periodic_effect_with_duration_updates_correctly( object[] thirdExpectedStackData, int fourthExpectedStackDataCount, object[] fourthExpectedStackData, - float effectDutation, + float effectDuration, string targetAttribute, int modifierMagnitude, float effectPeriod, @@ -2761,7 +2810,9 @@ public void Stackable_periodic_effect_with_duration_updates_correctly( var effectData = new EffectData( "Buff", - new DurationData(DurationType.HasDuration, new ScalableFloat(effectDutation)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(effectDuration))), [ new Modifier( targetAttribute, @@ -2977,7 +3028,7 @@ public void Infinite_stackable_effect_unnaplies_correctly( int modifierMagnitude, int stackLimit, int initialStack, - bool forceUnapply, + bool forceRemoval, StackPolicy stackPolicy, StackLevelPolicy stackLevelPolicy, StackMagnitudePolicy magnitudePolicy, @@ -3030,7 +3081,6 @@ public void Infinite_stackable_effect_unnaplies_correctly( new EffectOwnership(owner, owner)); ActiveEffectHandle? effectHandle = target.EffectsManager.ApplyEffect(effect); - Validation.Assert(effectHandle is not null, "Effect handle should not be null."); TestUtils.TestAttribute(target, targetAttribute, firstExpectedResults); @@ -3041,7 +3091,7 @@ public void Infinite_stackable_effect_unnaplies_correctly( owner, target); - target.EffectsManager.UnapplyEffect(effectHandle, forceUnapply); + target.EffectsManager.RemoveEffect(effectHandle!, forceRemoval); TestUtils.TestAttribute(target, targetAttribute, secondExpectedResults); @@ -3052,7 +3102,7 @@ public void Infinite_stackable_effect_unnaplies_correctly( owner, target); - target.EffectsManager.UnapplyEffect(effectHandle, forceUnapply); + target.EffectsManager.RemoveEffect(effectHandle!, forceRemoval); TestUtils.TestAttribute(target, targetAttribute, thirdExpectedResults); @@ -3063,7 +3113,7 @@ public void Infinite_stackable_effect_unnaplies_correctly( owner, target); - target.EffectsManager.UnapplyEffect(effectHandle, forceUnapply); + target.EffectsManager.RemoveEffect(effectHandle!, forceRemoval); TestUtils.TestAttribute(target, targetAttribute, fourthExpectedResults); @@ -3107,7 +3157,7 @@ public void Infinite_stackable_effect_unnaplies_correctly( 80f, new int[] { }, new int[] { })] - public void Unapply_duration_effect_restores_original_attribute_values( + public void Remove_duration_effect_restores_original_attribute_values( string targetAttribute, float effectMagnitude, int[] firstExpectedResults, @@ -3147,20 +3197,18 @@ public void Unapply_duration_effect_restores_original_attribute_values( } ActiveEffectHandle? activeEffectHandle1 = target.EffectsManager.ApplyEffect(effect); - Validation.Assert(activeEffectHandle1 is not null, "Effect handle should not be null."); TestUtils.TestAttribute(target, targetAttribute, firstExpectedResults); ActiveEffectHandle? activeEffectHandle2 = target.EffectsManager.ApplyEffect(effect); - Validation.Assert(activeEffectHandle2 is not null, "Effect handle should not be null."); TestUtils.TestAttribute(target, targetAttribute, secondExpectedResults); - target.EffectsManager.UnapplyEffect(activeEffectHandle1); + target.EffectsManager.RemoveEffect(activeEffectHandle1!); TestUtils.TestAttribute(target, targetAttribute, firstExpectedResults); - target.EffectsManager.UnapplyEffect(activeEffectHandle2); + target.EffectsManager.RemoveEffect(activeEffectHandle2!); TestUtils.TestAttribute( target, @@ -3269,4 +3317,484 @@ public void Attribute_based_modifier_with_null_ownership_does_not_apply_changes( TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); } + + [Fact] + [Trait("Duration", null)] + public void Attribute_based_duration_uses_source_attribute_to_set_expiration() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + // Owner TestAttributeSet.Attribute5 base is 5. Duration = 5 * 2 = 10 seconds. + var durationMagnitude = new ModifierMagnitude( + MagnitudeCalculationType.AttributeBased, + attributeBasedFloat: new AttributeBasedFloat( + new AttributeCaptureDefinition("TestAttributeSet.Attribute5", AttributeCaptureSource.Source), + AttributeCalculationType.BaseValue, + new ScalableFloat(2f), // coefficient + new ScalableFloat(0f), // pre-add + new ScalableFloat(0f))); // post-add + + var effectData = new EffectData( + "AB Duration", + new DurationData(DurationType.HasDuration, durationMagnitude), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) + ]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + // Apply and verify modifier is applied + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + // Before expiration + target.EffectsManager.UpdateEffects(9.9f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + // Cross expiration (10s total) + target.EffectsManager.UpdateEffects(10f - 9.9f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); + } + + [Fact] + [Trait("Duration", null)] + public void Set_by_caller_duration_sets_expiration_from_runtime_value() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var durationTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + var durationMagnitude = new ModifierMagnitude( + MagnitudeCalculationType.SetByCaller, + setByCallerFloat: new SetByCallerFloat(durationTag)); + + var effectData = new EffectData( + "SBC Duration", + new DurationData(DurationType.HasDuration, durationMagnitude), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) + ]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + // Set duration to 0.5 seconds at runtime + effect.SetSetByCallerMagnitude(durationTag, 0.5f); + + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + // Before expiration + target.EffectsManager.UpdateEffects(0.49f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + // Cross expiration + target.EffectsManager.UpdateEffects(0.5f - 0.49f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); + } + + [Fact] + [Trait("Duration", null)] + public void Duration_uses_custom_calculator_to_set_expiration() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var durationMagnitude = new ModifierMagnitude( + MagnitudeCalculationType.CustomCalculatorClass, + customCalculationBasedFloat: new CustomCalculationBasedFloat( + new DurationFromSourceAttributeCalculator(), + new ScalableFloat(1f), // coefficient + new ScalableFloat(0f), // pre-add + new ScalableFloat(0f))); // post-add + + var effectData = new EffectData( + "CC Duration", + new DurationData(DurationType.HasDuration, durationMagnitude), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) + ]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + // Before expiration (1.0s) + target.EffectsManager.UpdateEffects(0.99f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + // Cross expiration + target.EffectsManager.UpdateEffects(1f - 0.99f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); + } + + [Fact] + [Trait("Duration", null)] + public void Attribute_based_duration_updates_duration_with_attribute_changes() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + // Owner TestAttributeSet.Attribute5 base is 5. Duration = 5 * 2 = 10 seconds. + var durationMagnitude = new ModifierMagnitude( + MagnitudeCalculationType.AttributeBased, + attributeBasedFloat: new AttributeBasedFloat( + new AttributeCaptureDefinition("TestAttributeSet.Attribute5", AttributeCaptureSource.Source, false), + AttributeCalculationType.BaseValue, + new ScalableFloat(2f), // coefficient + new ScalableFloat(0f), // pre-add + new ScalableFloat(0f))); // post-add + + var effectData = new EffectData( + "AB Duration", + new DurationData(DurationType.HasDuration, durationMagnitude), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) + ]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + var buffEffectData = new EffectData( + "Buff Attribute5", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute5", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(5))) + ]); + + var buffEffect = new Effect(buffEffectData, new EffectOwnership(owner, owner)); + + // Apply and verify modifier is applied + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + // Before expiration + target.EffectsManager.UpdateEffects(9.9f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + owner.EffectsManager.ApplyEffect(buffEffect); + + // Cross expiration (10s total) + target.EffectsManager.UpdateEffects(10f - 9.9f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + // Before expiration + target.EffectsManager.UpdateEffects(10f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); + } + + [Fact] + [Trait("Duration", null)] + public void Attribute_based_duration_finishes_with_negative_attribute_changes() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + // Owner TestAttributeSet.Attribute5 base is 5. Duration = 5 * 2 = 10 seconds. + var durationMagnitude = new ModifierMagnitude( + MagnitudeCalculationType.AttributeBased, + attributeBasedFloat: new AttributeBasedFloat( + new AttributeCaptureDefinition("TestAttributeSet.Attribute5", AttributeCaptureSource.Source, false), + AttributeCalculationType.BaseValue, + new ScalableFloat(2f), // coefficient + new ScalableFloat(0f), // pre-add + new ScalableFloat(0f))); // post-add + + var effectData = new EffectData( + "AB Duration", + new DurationData(DurationType.HasDuration, durationMagnitude), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) + ]); + + var effect = new Effect(effectData, new EffectOwnership(owner, owner)); + + var buffEffectData = new EffectData( + "Buff Attribute5", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute5", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-3))) + ]); + + var debuffEffect = new Effect(buffEffectData, new EffectOwnership(owner, owner)); + + // Apply and verify modifier is applied + target.EffectsManager.ApplyEffect(effect); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + // Before expiration + target.EffectsManager.UpdateEffects(9.0f); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); + + owner.EffectsManager.ApplyEffect(debuffEffect); + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); + } + + [Fact] + [Trait("Periodic", null)] + public void Set_by_caller_magnitude_updates_periodic_application_value() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var setByCallerTag = Tag.RequestTag(_tagsManager, "tag"); + + var effectData = new EffectData( + "Level Up", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.SetByCaller, + setByCallerFloat: new SetByCallerFloat(setByCallerTag, false))) + ], + periodicData: new PeriodicData(new ScalableFloat(1f), true, PeriodInhibitionRemovedPolicy.NeverReset), + snapshotLevel: false); + + var effect = new Effect( + effectData, + new EffectOwnership( + new TestEntity(_tagsManager, _cuesManager), + owner)); + + effect.SetSetByCallerMagnitude(setByCallerTag, 1); + + target.EffectsManager.ApplyEffect(effect); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [2, 2, 0, 0]); + + effect.SetSetByCallerMagnitude(setByCallerTag, 2); + + target.EffectsManager.UpdateEffects(1f); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [4, 4, 0, 0]); + } + + [Fact] + [Trait("Periodic", null)] + public void Snapshot_attribute_based_magnitude_does_not_update_modifiers_when_attribute_updates() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var effectData = new EffectData( + "Buff", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.AttributeBased, + attributeBasedFloat: new AttributeBasedFloat( + new AttributeCaptureDefinition("TestAttributeSet.Attribute2", AttributeCaptureSource.Source, true), + AttributeCalculationType.BaseValue, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))) + ], + snapshotLevel: false); + + var effect = new Effect( + effectData, + new EffectOwnership(owner, new TestEntity(_tagsManager, _cuesManager))); + + target.EffectsManager.ApplyEffect(effect); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [3, 1, 2, 0]); + + var effectData2 = new EffectData( + "Buff2", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute2", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(2))) + ]); + + var effect2 = new Effect( + effectData2, + new EffectOwnership(owner, new TestEntity(_tagsManager, _cuesManager))); + + owner.EffectsManager.ApplyEffect(effect2); + effect.LevelUp(); + + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute2", [4, 4, 0, 0]); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [3, 1, 2, 0]); + + owner.EffectsManager.ApplyEffect(effect2); + effect.LevelUp(); + + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute2", [6, 6, 0, 0]); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [3, 1, 2, 0]); + } + + [Fact] + [Trait("Periodic", null)] + public void Only_one_modifier_update_modifiers_when_attribute_updates() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var effectData = new EffectData( + "Buff", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.AttributeBased, + attributeBasedFloat: new AttributeBasedFloat( + new AttributeCaptureDefinition("TestAttributeSet.Attribute2", AttributeCaptureSource.Source, true), + AttributeCalculationType.BaseValue, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))), + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.AttributeBased, + attributeBasedFloat: new AttributeBasedFloat( + new AttributeCaptureDefinition("TestAttributeSet.Attribute2", AttributeCaptureSource.Source, false), + AttributeCalculationType.BaseValue, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))) + ], + snapshotLevel: false); + + var effect = new Effect( + effectData, + new EffectOwnership(owner, new TestEntity(_tagsManager, _cuesManager))); + + target.EffectsManager.ApplyEffect(effect); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [5, 1, 4, 0]); + + var effectData2 = new EffectData( + "Buff2", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute2", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(2))) + ]); + + var effect2 = new Effect( + effectData2, + new EffectOwnership(owner, new TestEntity(_tagsManager, _cuesManager))); + + owner.EffectsManager.ApplyEffect(effect2); + + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute2", [4, 4, 0, 0]); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [7, 1, 6, 0]); + + owner.EffectsManager.ApplyEffect(effect2); + + TestUtils.TestAttribute(owner, "TestAttributeSet.Attribute2", [6, 6, 0, 0]); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [9, 1, 8, 0]); + } + + [Fact] + [Trait("Periodic", null)] + public void Set_by_caller_magnitude_does_not_update_periodic_application_value() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var setByCallerTag = Tag.RequestTag(_tagsManager, "tag"); + + var effectData = new EffectData( + "Level Up", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute1", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.SetByCaller, + setByCallerFloat: new SetByCallerFloat(setByCallerTag, true))) + ], + periodicData: new PeriodicData(new ScalableFloat(1f), true, PeriodInhibitionRemovedPolicy.NeverReset), + snapshotLevel: false); + + var effect = new Effect( + effectData, + new EffectOwnership( + new TestEntity(_tagsManager, _cuesManager), + owner)); + + effect.SetSetByCallerMagnitude(setByCallerTag, 1); + + target.EffectsManager.ApplyEffect(effect); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [2, 2, 0, 0]); + + effect.SetSetByCallerMagnitude(setByCallerTag, 2); + effect.LevelUp(); + + target.EffectsManager.UpdateEffects(1f); + + TestUtils.TestAttribute(target, "TestAttributeSet.Attribute1", [3, 3, 0, 0]); + } + + private sealed class DurationFromSourceAttributeCalculator : CustomModifierMagnitudeCalculator + { + private readonly AttributeCaptureDefinition _sourceAttr; + + public DurationFromSourceAttributeCalculator() + { + // Use owner's Attribute2 (base 2), duration = captured * 0.5 => 1.0 second + _sourceAttr = new AttributeCaptureDefinition( + "TestAttributeSet.Attribute2", + AttributeCaptureSource.Source, + Snapshot: false); + AttributesToCapture.Add(_sourceAttr); + } + + public override float CalculateBaseMagnitude( + Effect effect, + IForgeEntity target, + EffectEvaluatedData? effectEvaluatedData) + { + var value = CaptureAttributeMagnitude(_sourceAttr, effect, target, effectEvaluatedData); + return value * 0.5f; // 2 * 0.5 = 1.0 + } + } } diff --git a/Forge.Tests/Effects/ModifierTagsComponentTests.cs b/Forge.Tests/Effects/ModifierTagsComponentTests.cs index 0df6e5d..2800dbe 100644 --- a/Forge.Tests/Effects/ModifierTagsComponentTests.cs +++ b/Forge.Tests/Effects/ModifierTagsComponentTests.cs @@ -1,7 +1,6 @@ // Copyright © Gamesmiths Guild. using FluentAssertions; -using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Cues; using Gamesmiths.Forge.Effects; using Gamesmiths.Forge.Effects.Components; @@ -118,7 +117,6 @@ public void Manual_removal_removes_tags_instantly(string[] tagKeys) var validationContainer = new TagContainer(_tagsManager, validationTags); ActiveEffectHandle? activeEffectHandle = entity.EffectsManager.ApplyEffect(effect); - Validation.Assert(activeEffectHandle is not null, "Effect handle should have a value."); entity.Tags.CombinedTags.Equals(validationContainer).Should().BeTrue(); entity.Tags.ModifierTags.Equals(modifierTagsContainer).Should().BeTrue(); @@ -127,7 +125,7 @@ public void Manual_removal_removes_tags_instantly(string[] tagKeys) entity.Tags.CombinedTags.Equals(validationContainer).Should().BeTrue(); entity.Tags.ModifierTags.Equals(modifierTagsContainer).Should().BeTrue(); - entity.EffectsManager.UnapplyEffect(activeEffectHandle); + entity.EffectsManager.RemoveEffect(activeEffectHandle!); entity.Tags.CombinedTags.Equals(baseTagsContainer).Should().BeTrue(); entity.Tags.ModifierTags.IsEmpty.Should().BeTrue(); } @@ -246,19 +244,18 @@ public void Stackable_effects_keep_tags_until_completely_removed(string[] tagKey var validationContainer = new TagContainer(_tagsManager, validationTags); ActiveEffectHandle? activeEffectHandle = entity.EffectsManager.ApplyEffect(effect); - Validation.Assert(activeEffectHandle is not null, "Effect handle should have a value."); entity.Tags.CombinedTags.Equals(validationContainer).Should().BeTrue(); entity.Tags.ModifierTags.Equals(modifierTagsContainer).Should().BeTrue(); for (var i = 0; i < stacks - 1; i++) { - entity.EffectsManager.UnapplyEffect(activeEffectHandle); + entity.EffectsManager.RemoveEffect(activeEffectHandle!); entity.Tags.CombinedTags.Equals(validationContainer).Should().BeTrue(); entity.Tags.ModifierTags.Equals(modifierTagsContainer).Should().BeTrue(); } - entity.EffectsManager.UnapplyEffect(activeEffectHandle); + entity.EffectsManager.RemoveEffect(activeEffectHandle!); entity.Tags.CombinedTags.Equals(baseTagsContainer).Should().BeTrue(); entity.Tags.ModifierTags.IsEmpty.Should().BeTrue(); } @@ -284,12 +281,11 @@ public void Stackable_effects_removes_tags_when_forcibly_removed(string[] tagKey var validationContainer = new TagContainer(_tagsManager, validationTags); ActiveEffectHandle? activeEffectHandle = entity.EffectsManager.ApplyEffect(effect); - Validation.Assert(activeEffectHandle is not null, "Effect handle should have a value."); entity.Tags.CombinedTags.Equals(validationContainer).Should().BeTrue(); entity.Tags.ModifierTags.Equals(modifierTagsContainer).Should().BeTrue(); - entity.EffectsManager.UnapplyEffect(activeEffectHandle, true); + entity.EffectsManager.RemoveEffect(activeEffectHandle!, true); entity.Tags.CombinedTags.Equals(baseTagsContainer).Should().BeTrue(); entity.Tags.ModifierTags.IsEmpty.Should().BeTrue(); } @@ -300,7 +296,9 @@ private EffectData CreateDurationEffectData(string[] tagKeys, float duration) return new EffectData( "Test Effect", - new DurationData(DurationType.HasDuration, new ScalableFloat(duration)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(duration))), effectComponents: [ new ModifierTagsEffectComponent(new TagContainer(_tagsManager, tags)) diff --git a/Forge.Tests/Effects/TargetTagRequirementsComponentTests.cs b/Forge.Tests/Effects/TargetTagRequirementsComponentTests.cs index 3d9e03d..535a788 100644 --- a/Forge.Tests/Effects/TargetTagRequirementsComponentTests.cs +++ b/Forge.Tests/Effects/TargetTagRequirementsComponentTests.cs @@ -1,6 +1,5 @@ // Copyright © Gamesmiths Guild. -using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Cues; using Gamesmiths.Forge.Effects; using Gamesmiths.Forge.Effects.Components; @@ -299,7 +298,6 @@ public void Effect_gets_removed_after_modifier_tag_is_removed( var modifierTagEffect = new Effect(modifierTagEffectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? activeModifierEffectHandle = entity.EffectsManager.ApplyEffect(modifierTagEffect); - Validation.Assert(activeModifierEffectHandle is not null, "Effect handle should have a value."); entity.EffectsManager.ApplyEffect(effect); @@ -310,7 +308,7 @@ public void Effect_gets_removed_after_modifier_tag_is_removed( entity, entity); - entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle); + entity.EffectsManager.RemoveEffect(activeModifierEffectHandle!); TestUtils.TestStackData( entity.EffectsManager.GetEffectInfo(effectData), @@ -430,7 +428,6 @@ public void Effect_with_ongoing_requirement_initializes_normally( var effect = new Effect(effectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? activeEffectHandle = entity.EffectsManager.ApplyEffect(effect); - Validation.Assert(activeEffectHandle is not null, "Effect handle should have a value."); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); TestUtils.TestStackData( @@ -440,7 +437,7 @@ public void Effect_with_ongoing_requirement_initializes_normally( entity, entity); - entity.EffectsManager.UnapplyEffect(activeEffectHandle); + entity.EffectsManager.RemoveEffect(activeEffectHandle!); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); TestUtils.TestStackData( @@ -475,7 +472,6 @@ public void Effect_without_ongoing_requirement_initializes_inhibited( var effect = new Effect(effectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? activeEffectHandle = entity.EffectsManager.ApplyEffect(effect); - Validation.Assert(activeEffectHandle is not null, "Effect handle should have a value."); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); TestUtils.TestStackData( @@ -485,7 +481,7 @@ public void Effect_without_ongoing_requirement_initializes_inhibited( entity, entity); - entity.EffectsManager.UnapplyEffect(activeEffectHandle); + entity.EffectsManager.RemoveEffect(activeEffectHandle!); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); TestUtils.TestStackData( @@ -531,11 +527,10 @@ public void Effect_with_ongoing_requirement_gets_inhibited_after_modifier_tag_ap TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); ActiveEffectHandle? activeModifierEffectHandle = entity.EffectsManager.ApplyEffect(modifierTagEffect); - Validation.Assert(activeModifierEffectHandle is not null, "Effect handle should have a value."); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); - entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle); + entity.EffectsManager.RemoveEffect(activeModifierEffectHandle!); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); } @@ -571,12 +566,11 @@ public void Effect_with_ongoing_requirement_gets_inhibited_after_modifier_tag_re var modifierTagEffect = new Effect(modifierTagEffectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? activeModifierEffectHandle = entity.EffectsManager.ApplyEffect(modifierTagEffect); - Validation.Assert(activeModifierEffectHandle is not null, "Effect handle should have a value."); entity.EffectsManager.ApplyEffect(effect); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [11, 1, 10, 0]); - entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle); + entity.EffectsManager.RemoveEffect(activeModifierEffectHandle!); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); } @@ -605,7 +599,6 @@ public void Periodic_effect_with_ongoing_requirement_executes_normally( var effect = new Effect(effectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? activeEffectHandle = entity.EffectsManager.ApplyEffect(effect); - Validation.Assert(activeEffectHandle is not null, "Effect handle should have a value."); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [11, 11, 0, 0]); TestUtils.TestStackData( @@ -619,7 +612,7 @@ public void Periodic_effect_with_ongoing_requirement_executes_normally( TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [41, 41, 0, 0]); - entity.EffectsManager.UnapplyEffect(activeEffectHandle); + entity.EffectsManager.RemoveEffect(activeEffectHandle!); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [41, 41, 0, 0]); TestUtils.TestStackData( @@ -655,7 +648,6 @@ public void Periodic_effect_without_ongoing_requirement_does_not_execute( var effect = new Effect(effectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? activeEffectHandle = entity.EffectsManager.ApplyEffect(effect); - Validation.Assert(activeEffectHandle is not null, "Effect handle should have a value."); entity.EffectsManager.UpdateEffects(100f); @@ -667,7 +659,7 @@ public void Periodic_effect_without_ongoing_requirement_does_not_execute( entity, entity); - entity.EffectsManager.UnapplyEffect(activeEffectHandle); + entity.EffectsManager.RemoveEffect(activeEffectHandle!); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", [1, 1, 0, 0]); TestUtils.TestStackData( @@ -781,12 +773,11 @@ public void Periodic_effect_with_ongoing_requirement_gets_inhibited_after_modifi TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", secondExpectedResults); ActiveEffectHandle? activeModifierEffectHandle = entity.EffectsManager.ApplyEffect(modifierTagEffect); - Validation.Assert(activeModifierEffectHandle is not null, "Effect handle should have a value."); entity.EffectsManager.UpdateEffects(secondUpdatePeriod); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", thirdExpectedResults); - entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle); + entity.EffectsManager.RemoveEffect(activeModifierEffectHandle!); entity.EffectsManager.UpdateEffects(thirdUpdatePeriod); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", fourthExpectedResults); } @@ -888,7 +879,6 @@ public void Periodic_effect_with_ongoing_requirement_gets_inhibited_after_modifi var modifierTagEffect = new Effect(modifierTagEffectData, new EffectOwnership(entity, entity)); ActiveEffectHandle? activeModifierEffectHandle = entity.EffectsManager.ApplyEffect(modifierTagEffect); - Validation.Assert(activeModifierEffectHandle is not null, "Effect handle should have a value."); entity.EffectsManager.ApplyEffect(effect); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", firstExpectedResults); @@ -896,7 +886,7 @@ public void Periodic_effect_with_ongoing_requirement_gets_inhibited_after_modifi entity.EffectsManager.UpdateEffects(firstUpdatePeriod); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", secondExpectedResults); - entity.EffectsManager.UnapplyEffect(activeModifierEffectHandle); + entity.EffectsManager.RemoveEffect(activeModifierEffectHandle!); entity.EffectsManager.UpdateEffects(secondUpdatePeriod); TestUtils.TestAttribute(entity, "TestAttributeSet.Attribute1", thirdExpectedResults); diff --git a/Forge.Tests/Events/EventTests.cs b/Forge.Tests/Events/EventTests.cs new file mode 100644 index 0000000..b676247 --- /dev/null +++ b/Forge.Tests/Events/EventTests.cs @@ -0,0 +1,594 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Cues; +using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Tags; +using Gamesmiths.Forge.Tests.Helpers; +using static Gamesmiths.Forge.Tests.Samples.QuickStartTests; + +namespace Gamesmiths.Forge.Tests.Events; + +public class EventTests(TagsAndCuesFixture tagsAndCueFixture) : IClassFixture +{ + private readonly TagsManager _tagsManager = tagsAndCueFixture.TagsManager; + private readonly CuesManager _cuesManager = tagsAndCueFixture.CuesManager; + + [Fact] + [Trait("Simple", null)] + public void Subscribe_and_raise_triggers_handler_with_correct_magnitude() + { + // Initialize managers + TagsManager tagsManager = _tagsManager; + CuesManager cuesManager = _cuesManager; + + var entity = new TestEntity(tagsManager, cuesManager); + var damageTag = Tag.RequestTag(tagsManager, "simple.tag"); + + var receivedDamage = 0f; + var eventFired = false; + + entity.Events.Subscribe(damageTag, x => + { + eventFired = true; + receivedDamage = x.EventMagnitude; + }); + + entity.Events.Raise(new EventData + { + EventTags = damageTag.GetSingleTagContainer()!, + Source = null, + Target = entity, + EventMagnitude = 50f, + }); + + eventFired.Should().BeTrue(); + receivedDamage.Should().Be(50f); + } + + [Fact] + [Trait("Typed", null)] + public void Typed_event_subscription_receives_correct_payload() + { + // Initialize managers + TagsManager tagsManager = _tagsManager; + CuesManager cuesManager = _cuesManager; + + var entity = new TestEntity(tagsManager, cuesManager); + var damageTag = Tag.RequestTag(tagsManager, "simple.tag"); + + var value = 0; + DamageType damageType = DamageType.Physical; + var isCritical = false; + + // Subscribe with generic type + entity.Events.Subscribe(damageTag, x => + { + value = x.Payload.Value; + damageType = x.Payload.DamageType; + isCritical = x.Payload.IsCritical; + }); + + // Raise with generic type + entity.Events.Raise(new EventData + { + EventTags = damageTag.GetSingleTagContainer()!, + Source = null, + Target = entity, + Payload = new DamageInfo(500, DamageType.Magical, true), + }); + + value.Should().Be(500); + damageType.Should().Be(DamageType.Magical); + isCritical.Should().Be(true); + } + + [Fact] + [Trait("Standalone", null)] + public void EventManager_can_be_created_without_entity() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var eventFired = false; + + events.Subscribe(eventTag, _ => eventFired = true); + + events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + }); + + eventFired.Should().BeTrue(); + } + + [Fact] + [Trait("Standalone", null)] + public void Standalone_EventManager_supports_typed_events() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var capturedPayload = string.Empty; + + events.Subscribe(eventTag, x => capturedPayload = x.Payload); + + events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Payload = "Hello from standalone", + }); + + capturedPayload.Should().Be("Hello from standalone"); + } + + [Fact] + [Trait("Priority", null)] + public void Higher_priority_handlers_are_invoked_first() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var invocationOrder = new List(); + + events.Subscribe(eventTag, _ => invocationOrder.Add("low"), priority: 0); + events.Subscribe(eventTag, _ => invocationOrder.Add("high"), priority: 100); + events.Subscribe(eventTag, _ => invocationOrder.Add("medium"), priority: 50); + + events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + }); + + invocationOrder.Should().ContainInOrder("high", "medium", "low"); + } + + [Fact] + [Trait("Priority", null)] + public void Typed_events_respect_priority_ordering() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var invocationOrder = new List(); + + events.Subscribe(eventTag, _ => invocationOrder.Add(1), priority: 10); + events.Subscribe(eventTag, _ => invocationOrder.Add(2), priority: 50); + events.Subscribe(eventTag, _ => invocationOrder.Add(3), priority: 30); + + events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Payload = 42, + }); + + invocationOrder.Should().ContainInOrder(2, 3, 1); + } + + [Fact] + [Trait("Priority", null)] + public void Negative_priority_handlers_are_invoked_last() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var invocationOrder = new List(); + + events.Subscribe(eventTag, _ => invocationOrder.Add("negative"), priority: -100); + events.Subscribe(eventTag, _ => invocationOrder.Add("zero"), priority: 0); + events.Subscribe(eventTag, _ => invocationOrder.Add("positive"), priority: 100); + + events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + }); + + invocationOrder.Should().ContainInOrder("positive", "zero", "negative"); + } + + [Fact] + [Trait("Unsubscribe", null)] + public void Unsubscribe_prevents_handler_from_being_called() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var callCount = 0; + + EventSubscriptionToken token = events.Subscribe(eventTag, _ => callCount++); + + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + callCount.Should().Be(1); + + var unsubscribed = events.Unsubscribe(token); + unsubscribed.Should().BeTrue(); + + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + callCount.Should().Be(1, "handler should not be called after un-subscription"); + } + + [Fact] + [Trait("Unsubscribe", null)] + public void Unsubscribe_typed_event_prevents_handler_from_being_called() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var callCount = 0; + + EventSubscriptionToken token = events.Subscribe(eventTag, _ => callCount++); + + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()!, Payload = 1 }); + callCount.Should().Be(1); + + var unsubscribed = events.Unsubscribe(token); + unsubscribed.Should().BeTrue(); + + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()!, Payload = 2 }); + callCount.Should().Be(1, "handler should not be called after un-subscription"); + } + + [Fact] + [Trait("Unsubscribe", null)] + public void Unsubscribe_with_invalid_token_returns_false() + { + var events = new EventManager(); + var invalidToken = new EventSubscriptionToken(Guid.NewGuid()); + + var result = events.Unsubscribe(invalidToken); + + result.Should().BeFalse(); + } + + [Fact] + [Trait("Unsubscribe", null)] + public void Double_unsubscribe_returns_false_on_second_call() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + EventSubscriptionToken token = events.Subscribe(eventTag, _ => { }); + + events.Unsubscribe(token).Should().BeTrue(); + events.Unsubscribe(token).Should().BeFalse(); + } + + [Fact] + [Trait("Multiple", null)] + public void Multiple_handlers_on_same_tag_all_receive_event() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var handler1Called = false; + var handler2Called = false; + var handler3Called = false; + + events.Subscribe(eventTag, _ => handler1Called = true); + events.Subscribe(eventTag, _ => handler2Called = true); + events.Subscribe(eventTag, _ => handler3Called = true); + + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + + handler1Called.Should().BeTrue(); + handler2Called.Should().BeTrue(); + handler3Called.Should().BeTrue(); + } + + [Fact] + [Trait("Multiple", null)] + public void Handlers_on_different_tags_only_receive_matching_events() + { + var events = new EventManager(); + var redTag = Tag.RequestTag(_tagsManager, "color.red"); + var blueTag = Tag.RequestTag(_tagsManager, "color.blue"); + var redCalled = false; + var blueCalled = false; + + events.Subscribe(redTag, _ => redCalled = true); + events.Subscribe(blueTag, _ => blueCalled = true); + + events.Raise(new EventData { EventTags = redTag.GetSingleTagContainer()! }); + + redCalled.Should().BeTrue(); + blueCalled.Should().BeFalse(); + } + + [Fact] + [Trait("Multiple", null)] + public void Unsubscribing_one_handler_does_not_affect_others() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var handler1Count = 0; + var handler2Count = 0; + + EventSubscriptionToken token1 = events.Subscribe(eventTag, _ => handler1Count++); + events.Subscribe(eventTag, _ => handler2Count++); + + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + handler1Count.Should().Be(1); + handler2Count.Should().Be(1); + + events.Unsubscribe(token1); + + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + handler1Count.Should().Be(1, "unsubscribed handler should not be called"); + handler2Count.Should().Be(2, "remaining handler should still be called"); + } + + [Fact] + [Trait("TagHierarchy", null)] + public void Parent_tag_subscription_receives_child_tag_events() + { + var events = new EventManager(); + var colorTag = Tag.RequestTag(_tagsManager, "color"); + var redTag = Tag.RequestTag(_tagsManager, "color.red"); + var colorEventReceived = false; + + events.Subscribe(colorTag, _ => colorEventReceived = true); + + // Raise event with child tag + events.Raise(new EventData { EventTags = redTag.GetSingleTagContainer()! }); + + colorEventReceived.Should().BeTrue("parent tag subscription should receive child tag events"); + } + + [Fact] + [Trait("TagHierarchy", null)] + public void Child_tag_subscription_does_not_receive_parent_tag_events() + { + var events = new EventManager(); + var colorTag = Tag.RequestTag(_tagsManager, "color"); + var redTag = Tag.RequestTag(_tagsManager, "color.red"); + var redEventReceived = false; + + events.Subscribe(redTag, _ => redEventReceived = true); + + // Raise event with parent tag + events.Raise(new EventData { EventTags = colorTag.GetSingleTagContainer()! }); + + redEventReceived.Should().BeFalse("child tag subscription should not receive parent tag events"); + } + + [Fact] + [Trait("TagHierarchy", null)] + public void Parent_tag_receives_events_from_multiple_child_tags() + { + var events = new EventManager(); + var colorTag = Tag.RequestTag(_tagsManager, "color"); + var redTag = Tag.RequestTag(_tagsManager, "color.red"); + var blueTag = Tag.RequestTag(_tagsManager, "color.blue"); + var greenTag = Tag.RequestTag(_tagsManager, "color.green"); + var eventsReceived = new List(); + + events.Subscribe(colorTag, x => + { + if (x.EventTags.HasTagExact(redTag)) + { + eventsReceived.Add("red"); + } + + if (x.EventTags.HasTagExact(blueTag)) + { + eventsReceived.Add("blue"); + } + + if (x.EventTags.HasTagExact(greenTag)) + { + eventsReceived.Add("green"); + } + }); + + events.Raise(new EventData { EventTags = redTag.GetSingleTagContainer()! }); + events.Raise(new EventData { EventTags = blueTag.GetSingleTagContainer()! }); + events.Raise(new EventData { EventTags = greenTag.GetSingleTagContainer()! }); + + eventsReceived.Should().ContainInOrder("red", "blue", "green"); + } + + [Fact] + [Trait("TagHierarchy", null)] + public void Deep_hierarchy_parent_receives_deeply_nested_child_events() + { + var events = new EventManager(); + var itemTag = Tag.RequestTag(_tagsManager, "item"); + var swordTag = Tag.RequestTag(_tagsManager, "item.equipment.weapon.sword"); + var itemEventReceived = false; + + events.Subscribe(itemTag, _ => itemEventReceived = true); + + events.Raise(new EventData { EventTags = swordTag.GetSingleTagContainer()! }); + + itemEventReceived.Should().BeTrue("deeply nested child should trigger parent subscription"); + } + + [Fact] + [Trait("MultipleTags", null)] + public void Event_with_multiple_tags_triggers_all_matching_subscriptions() + { + var events = new EventManager(); + var redTag = Tag.RequestTag(_tagsManager, "color.red"); + var enemyTag = Tag.RequestTag(_tagsManager, "enemy.undead.zombie"); + var redCalled = false; + var enemyCalled = false; + + events.Subscribe(redTag, _ => redCalled = true); + events.Subscribe(enemyTag, _ => enemyCalled = true); + + // Create event with multiple tags + var multiTagContainer = new TagContainer(_tagsManager, [redTag, enemyTag]); + events.Raise(new EventData { EventTags = multiTagContainer }); + + redCalled.Should().BeTrue(); + enemyCalled.Should().BeTrue(); + } + + [Fact] + [Trait("MultipleTags", null)] + public void Single_handler_called_once_even_with_multiple_matching_tags() + { + var events = new EventManager(); + var colorTag = Tag.RequestTag(_tagsManager, "color"); + var redTag = Tag.RequestTag(_tagsManager, "color.red"); + var blueTag = Tag.RequestTag(_tagsManager, "color.blue"); + var callCount = 0; + + // Subscribe to parent tag + events.Subscribe(colorTag, _ => callCount++); + + // Raise event with multiple child tags + var multiTagContainer = new TagContainer(_tagsManager, [redTag, blueTag]); + events.Raise(new EventData { EventTags = multiTagContainer }); + + // Handler should only be called once per Raise, not per matching tag + callCount.Should().Be(1); + } + + [Fact] + [Trait("Isolation", null)] + public void Generic_raise_does_not_trigger_non_generic_handlers() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var nonGenericCalled = false; + var genericCalled = false; + + events.Subscribe(eventTag, _ => nonGenericCalled = true); + events.Subscribe(eventTag, _ => genericCalled = true); + + // Raise generic event + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()!, Payload = 42 }); + + nonGenericCalled.Should().BeFalse("non-generic handler should not be called by generic raise"); + genericCalled.Should().BeTrue(); + } + + [Fact] + [Trait("Isolation", null)] + public void Non_generic_raise_does_not_trigger_generic_handlers() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var nonGenericCalled = false; + var genericCalled = false; + + events.Subscribe(eventTag, _ => nonGenericCalled = true); + events.Subscribe(eventTag, _ => genericCalled = true); + + // Raise non-generic event + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + + nonGenericCalled.Should().BeTrue(); + genericCalled.Should().BeFalse("generic handler should not be called by non-generic raise"); + } + + [Fact] + [Trait("Isolation", null)] + public void Different_generic_types_are_isolated() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var intCalled = false; + var stringCalled = false; + + events.Subscribe(eventTag, _ => intCalled = true); + events.Subscribe(eventTag, _ => stringCalled = true); + + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()!, Payload = 42 }); + + intCalled.Should().BeTrue(); + stringCalled.Should().BeFalse("string handler should not receive int events"); + } + + [Fact] + [Trait("EdgeCase", null)] + public void Raising_event_with_no_subscribers_does_not_throw() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + Action act = () => events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + + act.Should().NotThrow(); + } + + [Fact] + [Trait("EdgeCase", null)] + public void Raising_generic_event_with_no_subscribers_does_not_throw() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + Action act = () => events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Payload = 42, + }); + + act.Should().NotThrow(); + } + + [Fact] + [Trait("EdgeCase", null)] + public void Event_data_contains_source_and_target_information() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var source = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + EventData? capturedData = null; + + events.Subscribe(eventTag, x => capturedData = x); + + events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Source = source, + Target = target, + EventMagnitude = 123.5f, + }); + + capturedData.Should().NotBeNull(); + capturedData!.Value.Source.Should().Be(source); + capturedData.Value.Target.Should().Be(target); + capturedData.Value.EventMagnitude.Should().Be(123.5f); + } + + [Fact] + [Trait("EdgeCase", null)] + public void Value_type_payload_is_not_boxed() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var capturedPayload = default(TestValuePayload); + + events.Subscribe(eventTag, x => capturedPayload = x.Payload); + + var payload = new TestValuePayload(1.5f, 2.5f, 3.5f); + events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Payload = payload, + }); + + capturedPayload.X.Should().Be(1.5f); + capturedPayload.Y.Should().Be(2.5f); + capturedPayload.Z.Should().Be(3.5f); + } + + [Fact] + [Trait("EdgeCase", null)] + public void Subscription_order_preserved_for_same_priority() + { + var events = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); + var invocationOrder = new List(); + + // All same priority, should be invoked in subscription order + events.Subscribe(eventTag, _ => invocationOrder.Add(1), priority: 0); + events.Subscribe(eventTag, _ => invocationOrder.Add(2), priority: 0); + events.Subscribe(eventTag, _ => invocationOrder.Add(3), priority: 0); + + events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()! }); + + // Note: The actual order depends on the Sort stability + // If unstable, order might not be preserved + invocationOrder.Should().HaveCount(3); + invocationOrder.Should().Contain([1, 2, 3]); + } + + private readonly record struct TestValuePayload(float X, float Y, float Z); +} diff --git a/Forge.Tests/Helpers/CustomTestExecutionClass.cs b/Forge.Tests/Helpers/CustomTestExecutionClass.cs index ce05db9..2bdd6f5 100644 --- a/Forge.Tests/Helpers/CustomTestExecutionClass.cs +++ b/Forge.Tests/Helpers/CustomTestExecutionClass.cs @@ -23,16 +23,16 @@ public class CustomTestExecutionClass : CustomExecution public AttributeCaptureDefinition TargetAttribute2 { get; } - public CustomTestExecutionClass() + public CustomTestExecutionClass(bool snapshot) { SourceAttribute1 = new AttributeCaptureDefinition( "TestAttributeSet.Attribute3", AttributeCaptureSource.Source, - false); + snapshot); SourceAttribute2 = new AttributeCaptureDefinition( "TestAttributeSet.Attribute5", AttributeCaptureSource.Source, - false); + snapshot); SourceAttribute3 = new AttributeCaptureDefinition( "TestAttributeSet.Attribute90", AttributeCaptureSource.Source, @@ -40,11 +40,11 @@ public CustomTestExecutionClass() TargetAttribute1 = new AttributeCaptureDefinition( "TestAttributeSet.Attribute1", AttributeCaptureSource.Target, - false); + true); TargetAttribute2 = new AttributeCaptureDefinition( "TestAttributeSet.Attribute2", AttributeCaptureSource.Target, - false); + true); AttributesToCapture.Add(SourceAttribute1); AttributesToCapture.Add(SourceAttribute2); @@ -55,12 +55,30 @@ public CustomTestExecutionClass() CustomCueParameters.Add("custom.parameter", 0); } - public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target) + public override ModifierEvaluatedData[] EvaluateExecution( + Effect effect, + IForgeEntity target, + EffectEvaluatedData? effectEvaluatedData) { var result = new List(); - var sourceAttribute1value = CaptureAttributeMagnitude(SourceAttribute1, effect, effect.Ownership.Source); - var sourceAttribute2value = CaptureAttributeMagnitude(SourceAttribute2, effect, effect.Ownership.Source); + var sourceAttribute1value = CaptureAttributeMagnitude( + SourceAttribute1, + effect, + target, + effectEvaluatedData); + + var sourceAttribute2value = CaptureAttributeMagnitude( + SourceAttribute2, + effect, + target, + effectEvaluatedData); + + var targetAttribute1value = CaptureAttributeMagnitude( + TargetAttribute1, + effect, + target, + effectEvaluatedData); if (TargetAttribute1.TryGetAttribute(target, out EntityAttribute? targetAttribute1)) { @@ -75,7 +93,7 @@ public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeE result.Add(new ModifierEvaluatedData( targetAttribute2, ModifierOperation.FlatBonus, - sourceAttribute1value + sourceAttribute2value)); + sourceAttribute1value + sourceAttribute2value + targetAttribute1value)); } if (SourceAttribute3.TryGetAttribute(effect.Ownership.Source, out EntityAttribute? sourceAttribute3)) diff --git a/Forge.Tests/Helpers/TestAttributeSet.cs b/Forge.Tests/Helpers/TestAttributeSet.cs index 76b3c7b..cc34025 100644 --- a/Forge.Tests/Helpers/TestAttributeSet.cs +++ b/Forge.Tests/Helpers/TestAttributeSet.cs @@ -16,6 +16,8 @@ public class TestAttributeSet : AttributeSet public EntityAttribute Attribute90 { get; } + public EntityAttribute Attribute1000 { get; } + public TestAttributeSet() { Attribute1 = InitializeAttribute(nameof(Attribute1), 1, 0, 99, 2); @@ -23,5 +25,6 @@ public TestAttributeSet() Attribute3 = InitializeAttribute(nameof(Attribute3), 3, 0, 99, 2); Attribute5 = InitializeAttribute(nameof(Attribute5), 5, 0, 99, 2); Attribute90 = InitializeAttribute(nameof(Attribute90), 90, 0, 99, 2); + Attribute1000 = InitializeAttribute(nameof(Attribute1000), 0, 0, 1000, 2); } } diff --git a/Forge.Tests/Helpers/TestEntity.cs b/Forge.Tests/Helpers/TestEntity.cs index 3e83dcd..e596257 100644 --- a/Forge.Tests/Helpers/TestEntity.cs +++ b/Forge.Tests/Helpers/TestEntity.cs @@ -3,6 +3,7 @@ using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Cues; using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Events; using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Tests.Helpers; @@ -17,6 +18,10 @@ public class TestEntity : IForgeEntity public EffectsManager EffectsManager { get; } + public EntityAbilities Abilities { get; } + + public EventManager Events { get; } + public TestEntity(TagsManager tagsManager, CuesManager cuesManager) { PlayerAttributeSet = new TestAttributeSet(); @@ -30,5 +35,7 @@ public TestEntity(TagsManager tagsManager, CuesManager cuesManager) EffectsManager = new(this, cuesManager); Attributes = new(PlayerAttributeSet); Tags = new(originalTags); + Abilities = new(this); + Events = new(); } } diff --git a/Forge.Tests/Samples/ExamplesTestFixture.cs b/Forge.Tests/Samples/ExamplesTestFixture.cs index 023208f..01731f6 100644 --- a/Forge.Tests/Samples/ExamplesTestFixture.cs +++ b/Forge.Tests/Samples/ExamplesTestFixture.cs @@ -14,6 +14,8 @@ public class ExamplesTestFixture public CuesManager CuesManager { get; } + public MockCueHandler MockCueHandler { get; } = new(); + public ExamplesTestFixture() { CuesManager = new CuesManager(); @@ -23,13 +25,17 @@ public ExamplesTestFixture() "class.warrior", "status.stunned", "status.burning", + "status.enraged", "status.immune.fire", - "cues.damage.fire" + "cues.damage.fire", + "events.combat.damage", + "events.combat.hit", + "cooldown.fireball" }); CuesManager.RegisterCue( Tag.RequestTag(TagsManager, "cues.damage.fire"), - new FireDamageCueHandler() + MockCueHandler ); } } diff --git a/Forge.Tests/Samples/QuickStartTests.cs b/Forge.Tests/Samples/QuickStartTests.cs index 2019026..bc302eb 100644 --- a/Forge.Tests/Samples/QuickStartTests.cs +++ b/Forge.Tests/Samples/QuickStartTests.cs @@ -6,6 +6,7 @@ using FluentAssertions; using Gamesmiths; using Gamesmiths.Forge; +using Gamesmiths.Forge.Abilities; using Gamesmiths.Forge.Attributes; using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Cues; @@ -17,6 +18,7 @@ using Gamesmiths.Forge.Effects.Modifiers; using Gamesmiths.Forge.Effects.Periodic; using Gamesmiths.Forge.Effects.Stacking; +using Gamesmiths.Forge.Events; using Gamesmiths.Forge.Tags; using Gamesmiths.Forge.Tests; using Gamesmiths.Forge.Tests.Core; @@ -41,6 +43,7 @@ public void Creating_a_basic_entity() var player = new Player(tagsManager, cuesManager); player.Attributes["PlayerAttributeSet.Health"].CurrentValue.Should().Be(100); + player.Attributes["PlayerAttributeSet.Mana"].CurrentValue.Should().Be(100); player.Attributes["PlayerAttributeSet.Strength"].CurrentValue.Should().Be(10); player.Attributes["PlayerAttributeSet.Speed"].CurrentValue.Should().Be(5); } @@ -121,7 +124,11 @@ public void Buff_effect_with_duration() // Create a strength buff effect that lasts for 10 seconds var strengthBuffEffectData = new EffectData( "Strength Potion", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), // 10 seconds duration + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f))), // 10 seconds duration new[] { new Modifier( "PlayerAttributeSet.Strength", @@ -204,7 +211,7 @@ public void Infinite_effect_example_equipment_buff() // Remove the effect manually (e.g., when the item is unequipped) if (activeEffectHandle is not null) { - player.EffectsManager.UnapplyEffect(activeEffectHandle); + player.EffectsManager.RemoveEffect(activeEffectHandle); } // Assuming base strength was 10 @@ -227,7 +234,11 @@ public void Periodic_effect_example() // Create a poison effect that ticks every 2 seconds for 10 seconds var poisonEffectData = new EffectData( "Poison", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f))), new[] { new Modifier( "PlayerAttributeSet.Health", @@ -270,7 +281,11 @@ public void Stacking_poison_effect_example() // Create a poison effect that stacks up to 3 times var stackingPoisonEffectData = new EffectData( "Stacking Poison", - new DurationData(DurationType.HasDuration, new ScalableFloat(6.0f)), // Each stack lasts 6 seconds + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(6.0f))), // Each stack lasts 6 seconds new[] { new Modifier( "PlayerAttributeSet.Health", @@ -335,7 +350,11 @@ public void Unique_effect_example() // Define the unique effect data var uniqueEffectData = new EffectData( "Unique Buff", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), // Lasts 10 seconds + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f))), // Lasts 10 seconds new[] { new Modifier( "PlayerAttributeSet.Strength", @@ -395,7 +414,11 @@ public void Adding_a_temporary_tag_with_an_effect() // Create a "Stunned" effect that adds a tag and reduces speed to 0 var stunEffectData = new EffectData( "Stunned", - new DurationData(DurationType.HasDuration, new ScalableFloat(3.0f)), // 3 seconds duration + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(3.0f))), // 3 seconds duration new[] { new Modifier( "PlayerAttributeSet.Speed", @@ -484,7 +507,11 @@ public void Advanced_custom_components() // Create a "Stunned" effect that adds a tag and reduces speed to 0 var stunEffectData = new EffectData( "Stunned", - new DurationData(DurationType.HasDuration, new ScalableFloat(3.0f)), // 3 seconds duration + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(3.0f))), // 3 seconds duration new[] { new Modifier( "PlayerAttributeSet.Speed", @@ -612,9 +639,8 @@ public void Advanced_creating_a_custom_execution() public void Triggering_a_cue_through_effects() { // Arrange - var stringWriter = new StringWriter(); - var originalOut = Console.Out; - Console.SetOut(stringWriter); + var mockCueHandler = tagsAndCueFixture.MockCueHandler; + mockCueHandler.Reset(); // Initialize managers var tagsManager = _tagsManager; @@ -626,7 +652,11 @@ public void Triggering_a_cue_through_effects() // Define a burning effect that includes the fire damage cue var burningEffectData = new EffectData( "Burning", - new DurationData(DurationType.HasDuration, new ScalableFloat(5.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(5.0f))), new[] { new Modifier( "PlayerAttributeSet.Health", @@ -655,31 +685,15 @@ public void Triggering_a_cue_through_effects() var burningEffect = new Effect(burningEffectData, new EffectOwnership(player, player)); - try - { - // Apply the burning effect - player.EffectsManager.ApplyEffect(burningEffect); - player.EffectsManager.UpdateEffects(5f); // Simulate 5 seconds of game time - - var output = "Fire damage cue applied to target.\n" + - "Fire damage executed: -5\n" + - "Fire damage executed: -5\n" + - "Fire damage executed: -5\n" + - "Fire damage executed: -5\n" + - "Fire damage executed: -5\n" + - "Fire damage executed: -5\n" + - "Fire damage cue removed."; - - // Normalize line endings to be consistent across environments - var normalizedOutput = output.Replace("\n", Environment.NewLine); - - stringWriter.ToString().Should().Contain(normalizedOutput); - } - finally - { - // Cleanup - Console.SetOut(originalOut); - } + // Act + player.EffectsManager.ApplyEffect(burningEffect); + player.EffectsManager.UpdateEffects(5f); // Simulate 5 seconds of game time + + // Assert + mockCueHandler.ApplyCount.Should().Be(1); + mockCueHandler.ExecuteCount.Should().Be(6); // 1 on application + 5 from periodic ticks + mockCueHandler.Magnitudes.Should().OnlyContain(x => x == -5); + mockCueHandler.RemoveCount.Should().Be(1); } [Fact] @@ -687,9 +701,8 @@ public void Triggering_a_cue_through_effects() public void Manually_triggering_a_cue() { // Arrange - var stringWriter = new StringWriter(); - var originalOut = Console.Out; - Console.SetOut(stringWriter); + var mockCueHandler = tagsAndCueFixture.MockCueHandler; + mockCueHandler.Reset(); // Initialize managers var tagsManager = _tagsManager; @@ -710,33 +723,354 @@ public void Manually_triggering_a_cue() } ); - try + // Act + cuesManager.ExecuteCue( + cueTag: Tag.RequestTag(tagsManager, "cues.damage.fire"), + target: player, + parameters: parameters + ); + + // Assert + mockCueHandler.ExecuteCount.Should().Be(1); + mockCueHandler.Magnitudes.Should().Contain(25); + } + + [Fact] + [Trait("Quick Start", null)] + public void Subscribing_and_raising_an_event() + { + // Initialize managers + var tagsManager = _tagsManager; + var cuesManager = _cuesManager; + + var player = new Player(tagsManager, cuesManager); + var damageTag = Tag.RequestTag(tagsManager, "events.combat.damage"); + + float receivedDamage = 0f; + bool eventFired = false; + + player.Events.Subscribe(damageTag, eventData => + { + eventFired = true; + receivedDamage = eventData.EventMagnitude; + }); + + player.Events.Raise(new EventData + { + EventTags = damageTag.GetSingleTagContainer(), + Source = null, + Target = player, + EventMagnitude = 50f + }); + + eventFired.Should().BeTrue(); + receivedDamage.Should().Be(50f); + } + + [Fact] + [Trait("Quick Start", null)] + public void Strongly_typed_events() + { + // Initialize managers + var tagsManager = _tagsManager; + var cuesManager = _cuesManager; + + var player = new Player(tagsManager, cuesManager); + var damageTag = Tag.RequestTag(tagsManager, "events.combat.damage"); + + int value = 0; + DamageType damageType = DamageType.Magical; + bool isCritical = false; + + // Subscribe with generic type + player.Events.Subscribe(damageTag, eventData => + { + value = eventData.Payload.Value; + damageType = eventData.Payload.DamageType; + isCritical = eventData.Payload.IsCritical; + }); + + // Raise with generic type + player.Events.Raise(new EventData + { + EventTags = damageTag.GetSingleTagContainer(), + Source = null, + Target = player, + Payload = new DamageInfo(120, DamageType.Physical, true) + }); + + value.Should().Be(120); + damageType.Should().Be(DamageType.Physical); + isCritical.Should().Be(true); + } + + [Fact] + [Trait("Quick Start", null)] + public void Granting_activating_and_removing_an_ability() + { + // Initialize managers + var tagsManager = _tagsManager; + var cuesManager = _cuesManager; + + var player = new Player(tagsManager, cuesManager); + + var fireballCostEffect = new EffectData( + "Fireball Mana Cost", + new DurationData(DurationType.Instant), + new[] { + new Modifier( + "PlayerAttributeSet.Mana", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(-20) // -20 mana cost + ) + ) + }); + + var fireballCooldownEffect = new EffectData( + "Fireball Cooldown", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f))), // 10 seconds cooldown + effectComponents: new[] { + new ModifierTagsEffectComponent( + tagsManager.RequestTagContainer(new[] { "cooldown.fireball" }) + ) + }); + + var fireballData = new AbilityData( + name: "Fireball", + costEffect: fireballCostEffect, + cooldownEffects: [fireballCooldownEffect], + instancingPolicy: AbilityInstancingPolicy.PerEntity, + behaviorFactory: () => new CustomAbilityBehavior("Fireball")); + + var grantConfig = new GrantAbilityConfig { - cuesManager.ExecuteCue( - cueTag: Tag.RequestTag(tagsManager, "cues.damage.fire"), - target: player, - parameters: parameters + AbilityData = fireballData, + ScalableLevel = new ScalableInt(1), + LevelOverridePolicy = LevelComparison.None, + RemovalPolicy = AbilityDeactivationPolicy.CancelImmediately, + InhibitionPolicy = AbilityDeactivationPolicy.CancelImmediately, + }; + + var grantFireballEffect = new EffectData( + "Grant Fireball Effect", + new DurationData(DurationType.Infinite), + effectComponents: [new GrantAbilityEffectComponent([grantConfig])] ); - stringWriter.ToString().Should().Contain("Fire damage executed: 25"); - } - finally + var grantEffectHandle = player.EffectsManager.ApplyEffect( + new Effect(grantFireballEffect, new EffectOwnership(player, player))); + + // Retrieve handle directly from component as shown in docs + var fireballAbilityHandle = grantEffectHandle.GetComponent().GrantedAbilities[0]; + + bool successfulActivation = fireballAbilityHandle.Activate(out AbilityActivationFailures failures); + + successfulActivation.Should().BeTrue(); + failures.Should().Be(AbilityActivationFailures.None); + fireballAbilityHandle.IsActive.Should().BeFalse(); + + player.EffectsManager.RemoveEffect(grantEffectHandle); + fireballAbilityHandle.IsValid.Should().BeFalse(); + } + + [Fact] + [Trait("Quick Start", null)] + public void Activating_an_ability_with_checks() + { + // Initialize managers + var tagsManager = _tagsManager; + var cuesManager = _cuesManager; + + var player = new Player(tagsManager, cuesManager); + + // Setup ability with cost and cooldown + var fireballCostEffect = new EffectData( + "Fireball Mana Cost", + new DurationData(DurationType.Instant), + new[] { + new Modifier( + "PlayerAttributeSet.Mana", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(-20) + ) + ) + }); + + var fireballCooldownEffect = new EffectData( + "Fireball Cooldown", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f))), + effectComponents: new[] { + new ModifierTagsEffectComponent( + tagsManager.RequestTagContainer(new[] { "cooldown.fireball" }) + ) + }); + + var fireballData = new AbilityData( + name: "Fireball", + costEffect: fireballCostEffect, + cooldownEffects: [fireballCooldownEffect], + instancingPolicy: AbilityInstancingPolicy.PerEntity, + behaviorFactory: () => new CustomAbilityBehavior("Fireball")); + + // Grant permanently + AbilityHandle handle = player.Abilities.GrantAbilityPermanently( + fireballData, + abilityLevel: 1, + levelOverridePolicy: LevelComparison.None, + sourceEntity: player); + + // Check Cooldown + var cooldowns = handle.GetCooldownData(); + cooldowns.Should().NotBeEmpty(); + cooldowns[0].RemainingTime.Should().Be(0); + + // Check Cost + var costs = handle.GetCostData(); + costs.Should().Contain(c => c.Attribute == "PlayerAttributeSet.Mana" && c.Cost == -20); + + // Activate + bool success = handle.Activate(out AbilityActivationFailures failures); + success.Should().BeTrue(); + + // Verify resources consumed and cooldown started + player.Attributes["PlayerAttributeSet.Mana"].CurrentValue.Should().Be(80); + handle.GetCooldownData()[0].RemainingTime.Should().BeGreaterThan(0); + } + + [Fact] + [Trait("Quick Start", null)] + public void Granting_an_ability_and_activating_once() + { + // Initialize managers + var tagsManager = _tagsManager; + var cuesManager = _cuesManager; + + var player = new Player(tagsManager, cuesManager); + + // Simple fireball data + var fireballData = new AbilityData( + name: "Fireball", + instancingPolicy: AbilityInstancingPolicy.PerEntity, + behaviorFactory: () => new CustomAbilityBehavior("Fireball")); + + // Simulate using a scroll + AbilityHandle? handle = player.Abilities.GrantAbilityAndActivateOnce( + abilityData: fireballData, + abilityLevel: 1, + levelOverridePolicy: LevelComparison.None, + out AbilityActivationFailures failureFlags, + targetEntity: player, // Target of the fireball + sourceEntity: player // Source (e.g., the scroll item) + ); + + // Fireball ends instantly so handle is null + handle.Should().BeNull(); + failureFlags.Should().Be(AbilityActivationFailures.None); + } + + [Fact] + [Trait("Quick Start", null)] + public void Triggering_an_ability_through_an_event() + { + // Initialize managers + var tagsManager = _tagsManager; + var cuesManager = _cuesManager; + + var player = new Player(tagsManager, cuesManager); + var hitTag = Tag.RequestTag(tagsManager, "events.combat.hit"); + + var autoShieldData = new AbilityData( + name: "Auto Shield", + // Configure the trigger + abilityTriggerData: AbilityTriggerData.ForEvent(hitTag), + instancingPolicy: AbilityInstancingPolicy.PerEntity, + behaviorFactory: () => new CustomAbilityBehavior("Auto Shield")); + + var handle = player.Abilities.GrantAbilityPermanently(autoShieldData, 1, LevelComparison.None, player); + + handle!.IsActive.Should().BeFalse(); + + player.Events.Raise(new EventData { - // Cleanup - Console.SetOut(originalOut); - } + EventTags = hitTag.GetSingleTagContainer(), + }); + // Ability ends itself instantly so it should be false here + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Quick Start", null)] + public void Triggering_an_ability_through_tags() + { + // Initialize managers + var tagsManager = _tagsManager; + var cuesManager = _cuesManager; + + var player = new Player(tagsManager, cuesManager); + var rageTag = Tag.RequestTag(tagsManager, "status.enraged"); + + // Ability configuration + var rageAbilityData = new AbilityData( + "Rage Aura", + abilityTriggerData: AbilityTriggerData.ForTagPresent(rageTag), + instancingPolicy: AbilityInstancingPolicy.PerEntity, + // Using a persistent behavior to verify active state + behaviorFactory: () => new PersistentAbilityBehavior()); + + // Grant permanently + var handle = player.Abilities.GrantAbilityPermanently(rageAbilityData, 1, LevelComparison.None, player); + + handle.IsActive.Should().BeFalse(); + + // Apply effect that adds the tag + var enrageEffect = new EffectData( + "Enrage", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10f))), + effectComponents: [ + new ModifierTagsEffectComponent(tagsManager.RequestTagContainer(["status.enraged"])) + ]); + + var effectHandle = player.EffectsManager.ApplyEffect( + new Effect(enrageEffect, new EffectOwnership(player, player))); + + // Should activate automatically + handle.IsActive.Should().BeTrue(); + + // Remove effect (removes tag) + player.EffectsManager.RemoveEffect(effectHandle); + + // Should deactivate automatically + handle.IsActive.Should().BeFalse(); } public class PlayerAttributeSet : AttributeSet { public EntityAttribute Health { get; } + public EntityAttribute Mana { get; } public EntityAttribute Strength { get; } public EntityAttribute Speed { get; } public PlayerAttributeSet() { Health = InitializeAttribute(nameof(Health), 100, 0, 150); + Mana = InitializeAttribute(nameof(Mana), 100, 0, 100); Strength = InitializeAttribute(nameof(Strength), 10, 0, 99); Speed = InitializeAttribute(nameof(Speed), 5, 0, 10); } @@ -747,6 +1081,9 @@ public class Player : IForgeEntity public EntityAttributes Attributes { get; } public EntityTags Tags { get; } public EffectsManager EffectsManager { get; } + public EntityAbilities Abilities { get; } + + public EventManager Events { get; } public Player(TagsManager tagsManager, CuesManager cuesManager) { @@ -761,6 +1098,8 @@ public Player(TagsManager tagsManager, CuesManager cuesManager) Attributes = new EntityAttributes(new PlayerAttributeSet()); Tags = new EntityTags(baseTags); EffectsManager = new EffectsManager(this, cuesManager); + Abilities = new(this); + Events = new(); } } @@ -808,10 +1147,13 @@ public StrengthDamageCalculator() AttributesToCapture.Add(SpeedAttribute); } - public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target) + public override float CalculateBaseMagnitude( + Effect effect, + IForgeEntity target, + EffectEvaluatedData? effectEvaluatedData) { - int strength = CaptureAttributeMagnitude(StrengthAttribute, effect, target); - int speed = CaptureAttributeMagnitude(SpeedAttribute, effect, target); + int strength = CaptureAttributeMagnitude(StrengthAttribute, effect, target, effectEvaluatedData); + int speed = CaptureAttributeMagnitude(SpeedAttribute, effect, target, effectEvaluatedData); // Base damage plus 50% of strength float damage = (speed * 2) + (strength * 0.5f); @@ -851,14 +1193,29 @@ public HealthDrainExecution() AttributesToCapture.Add(SourceStrength); } - public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target) + public override ModifierEvaluatedData[] EvaluateExecution( + Effect effect, + IForgeEntity target, + EffectEvaluatedData effectEvaluatedData) { var results = new List(); // Get attribute values - int targetHealth = CaptureAttributeMagnitude(TargetHealth, effect, target); - int sourceHealth = CaptureAttributeMagnitude(SourceHealth, effect, effect.Ownership.Owner); - int sourceStrength = CaptureAttributeMagnitude(SourceStrength, effect, effect.Ownership.Owner); + int targetHealth = CaptureAttributeMagnitude( + TargetHealth, + effect, + target, + effectEvaluatedData); + int sourceHealth = CaptureAttributeMagnitude( + SourceHealth, + effect, + effect.Ownership.Owner, + effectEvaluatedData); + int sourceStrength = CaptureAttributeMagnitude( + SourceStrength, + effect, + effect.Ownership.Owner, + effectEvaluatedData); // Calculate health drain amount based on source strength float drainAmount = sourceStrength * 0.5f; @@ -892,38 +1249,74 @@ public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeE } } - public class FireDamageCueHandler : ICueHandler + public class MockCueHandler : ICueHandler { + public int ApplyCount { get; private set; } + public int ExecuteCount { get; private set; } + public int RemoveCount { get; private set; } + public List Magnitudes { get; } = new(); + + public void OnApply(IForgeEntity? target, CueParameters? parameters) => ApplyCount++; + public void OnExecute(IForgeEntity? target, CueParameters? parameters) { + ExecuteCount++; if (parameters.HasValue) { - Console.WriteLine($"Fire damage executed: {parameters.Value.Magnitude}"); + Magnitudes.Add(parameters.Value.Magnitude); } } - public void OnApply(IForgeEntity? target, CueParameters? parameters) + public void OnRemove(IForgeEntity? target, bool interrupted) => RemoveCount++; + + public void OnUpdate(IForgeEntity? target, CueParameters? parameters) { } + + public void Reset() { - // Logic for when a persistent cue starts (e.g., play fire animation) - if (target != null) - { - Console.WriteLine("Fire damage cue applied to target."); - } + ApplyCount = 0; + ExecuteCount = 0; + RemoveCount = 0; + Magnitudes.Clear(); } + } - public void OnRemove(IForgeEntity? target, bool interrupted) + private class CustomAbilityBehavior(string parameter) : IAbilityBehavior + { + public void OnStarted(AbilityBehaviorContext context) { - // Logic for when a cue ends (e.g., stop fire animation) - Console.WriteLine("Fire damage cue removed."); + context.AbilityHandle.CommitAbility(); + + // Instantiate a projectile here (omitted for brevity) + Console.WriteLine($"{context.Owner} used ability ({parameter}) on target {context.Target}"); + + context.InstanceHandle.End(); } - public void OnUpdate(IForgeEntity? target, CueParameters? parameters) + public void OnEnded(AbilityBehaviorContext context) { - // Logic for updating persistent cues (e.g., adjust fire intensity) - if (parameters.HasValue) - { - Console.WriteLine($"Fire damage cue updated with Magnitude: {parameters.Value.Magnitude}"); - } + // Cleanup if necessary } } + + private class PersistentAbilityBehavior : IAbilityBehavior + { + public void OnStarted(AbilityBehaviorContext context) + { + context.AbilityHandle.CommitAbility(); + // Does NOT call End() to simulate a persistent effect/aura + } + + public void OnEnded(AbilityBehaviorContext context) + { + // Cleanup + } + } + + public enum DamageType + { + Physical, + Magical, + } + + public record struct DamageInfo(int Value, DamageType DamageType, bool IsCritical); } diff --git a/Forge.Tests/Tags/TagTests.cs b/Forge.Tests/Tags/TagTests.cs index 8e670c8..33b48e4 100644 --- a/Forge.Tests/Tags/TagTests.cs +++ b/Forge.Tests/Tags/TagTests.cs @@ -159,7 +159,7 @@ public void Higher_than_InvalidTagNetIndex_value_deserialization_throws_exceptio } [Theory] - [Trait("IsValid", "Correct")] + [Trait("IsActive", "Correct")] [InlineData("Entity.Attributes.Strengh")] [InlineData("item.consumable.potion.stamina")] [InlineData("color.black")] @@ -170,7 +170,7 @@ public void Correctly_formatted_string_is_a_valid_tag_keys(string tagKey) } [Theory] - [Trait("IsValid", "Incorrect")] + [Trait("IsActive", "Incorrect")] [InlineData(" Entity,Attr ibutes,Strength ", "Entity_Attr_ibutes_Strength")] [InlineData("item.consumable.potion.stamina ", "item.consumable.potion.stamina")] [InlineData("color.dark.green.", "color.dark.green")] diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs new file mode 100644 index 0000000..7235ca9 --- /dev/null +++ b/Forge/Abilities/Ability.cs @@ -0,0 +1,657 @@ +// Copyright © Gamesmiths Guild. + +using System.Reflection; +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Effects.Calculator; +using Gamesmiths.Forge.Effects.Components; +using Gamesmiths.Forge.Effects.Modifiers; +using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Instance of an ability that has been granted to an entity. +/// +internal sealed class Ability +{ + private record struct BehaviorBinding(IAbilityBehavior Behavior, AbilityBehaviorContext Context); + + private readonly Effect[]? _cooldownEffects; + + private readonly ActiveEffectHandle?[]? _activeCooldownHandles; + + private readonly Effect? _costEffect; + + private readonly TagContainer? _abilityTags; + + private readonly List _activeInstances = []; + + private readonly Dictionary _behaviors = []; + + private readonly Action? _tagChangedHandler; + + private readonly EventSubscriptionToken? _eventSubscriptionToken; + + private AbilityInstance? _persistentInstance; + + internal event Action? OnAbilityDeactivated; + + /// + /// Gets the owner of this ability. + /// + public IForgeEntity Owner { get; } + + internal AbilityData AbilityData { get; } + + internal int Level { get; set; } + + internal IForgeEntity? SourceEntity { get; } + + internal AbilityHandle Handle { get; } + + internal bool IsInhibited { get; set; } + + internal bool IsActive => _activeInstances.Count > 0; + + /// + /// Initializes a new instance of the class. + /// + /// The entity that owns this ability. + /// The data defining this ability. + /// The level of the ability. + /// The entity that granted us this ability. + internal Ability( + IForgeEntity owner, + AbilityData abilityData, + int level, + IForgeEntity? sourceEntity = null) + { + Owner = owner; + AbilityData = abilityData; + Level = level; + SourceEntity = sourceEntity; + IsInhibited = false; + + if (abilityData.CooldownEffects is not null) + { + _cooldownEffects = new Effect[abilityData.CooldownEffects.Length]; + _activeCooldownHandles = new ActiveEffectHandle[abilityData.CooldownEffects.Length]; + + for (var i = 0; i < abilityData.CooldownEffects.Length; i++) + { + _cooldownEffects[i] = new Effect( + abilityData.CooldownEffects[i], + new EffectOwnership(owner, sourceEntity), + level); + } + } + + if (abilityData.CostEffect is not null) + { + _costEffect = new Effect( + abilityData.CostEffect.Value, + new EffectOwnership(owner, sourceEntity), + level); + } + + if (abilityData.AbilityTags is not null) + { + _abilityTags = abilityData.AbilityTags; + } + + if (abilityData.AbilityTriggerData is not null) + { + AbilityTriggerData triggerData = abilityData.AbilityTriggerData.Value; + + switch (triggerData.TriggerSource) + { + case AbitityTriggerSource.TagAdded: + _tagChangedHandler = TagAdded_OnTagChanged; + owner.Tags.OnTagsChanged += _tagChangedHandler; + break; + case AbitityTriggerSource.TagPresent: + _tagChangedHandler = TagPresent_OnTagChanged; + owner.Tags.OnTagsChanged += _tagChangedHandler; + break; + case AbitityTriggerSource.Event: + if (triggerData.PayloadType is not null) + { + _eventSubscriptionToken = SubscribeTypedEvent(triggerData); + } + else + { + _eventSubscriptionToken = owner.Events.Subscribe( + triggerData.TriggerTag, + x => TryActivateAbility(x.Target, out _, x.EventMagnitude), + triggerData.Priority); + } + + break; + } + } + + Handle = new AbilityHandle(this); + } + + internal bool TryActivateAbility( + IForgeEntity? abilityTarget, + out AbilityActivationFailures failureFlags, + float magnitude) + { + if (CanActivate(abilityTarget, out failureFlags)) + { + Activate(abilityTarget, magnitude); + return true; + } + + return false; + } + + internal bool TryActivateAbility( + IForgeEntity? abilityTarget, + out AbilityActivationFailures failureFlags, + TData data, + float magnitude) + { + if (CanActivate(abilityTarget, out failureFlags)) + { + Activate(abilityTarget, data, magnitude); + return true; + } + + return false; + } + + internal void CommitAbility() + { + CommitCooldown(); + CommitCost(); + } + + internal void CommitCooldown() + { + 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 (var i = 0; i < _cooldownEffects.Length; i++) + { + Effect effect = _cooldownEffects[i]; + _activeCooldownHandles[i] = Owner.EffectsManager.ApplyEffect(effect); + } + } + } + + internal void CommitCost() + { + if (_costEffect is not null) + { + Owner.EffectsManager.ApplyEffect(_costEffect); + } + } + + internal void End() + { + if (_activeInstances.Count == 0) + { + return; + } + + AbilityInstance last = _activeInstances[^1]; + last.End(); + + Owner.Abilities.NotifyAbilityEnded(new AbilityEndedData(Handle, false)); + } + + internal void CancelAllInstances() + { + if (_activeInstances.Count == 0) + { + return; + } + + // Copy to avoid modification during iteration. + foreach (AbilityInstance instance in _activeInstances.ToArray()) + { + instance.Cancel(); + } + + Owner.Abilities.NotifyAbilityEnded(new AbilityEndedData(Handle, true)); + } + + internal void Cleanup() + { + if (_tagChangedHandler is not null) + { + Owner.Tags.OnTagsChanged -= _tagChangedHandler; + } + + if (_eventSubscriptionToken is not null) + { + Owner.Events.Unsubscribe(_eventSubscriptionToken.Value); + } + } + + internal void OnInstanceStarted(AbilityInstance instance, float magnitude) + { + if (AbilityData.BehaviorFactory is null) + { + return; + } + + IAbilityBehavior? behavior = AbilityData.BehaviorFactory.Invoke(); + if (behavior is null) + { + return; + } + + var context = new AbilityBehaviorContext(this, instance, magnitude); + _behaviors[instance] = new BehaviorBinding(behavior, context); + + try + { + behavior.OnStarted(context); + } + catch (Exception ex) + { + Console.WriteLine($"Ability behavior threw on start: {ex}"); + instance.Cancel(); + } + } + + internal void OnInstanceStarted(AbilityInstance instance, TPayload payload, float magnitude) + { + if (AbilityData.BehaviorFactory is null) + { + return; + } + + IAbilityBehavior? behavior = AbilityData.BehaviorFactory.Invoke(); + if (behavior is null) + { + return; + } + + var context = new AbilityBehaviorContext(this, instance, payload, magnitude); + _behaviors[instance] = new BehaviorBinding(behavior, context); + + try + { + if (behavior is IAbilityBehavior typedBehavior) + { + typedBehavior.OnStarted(context, payload); + } + else + { + behavior.OnStarted(context); + } + } + catch (Exception ex) + { + Console.WriteLine($"Ability behavior threw on start: {ex}"); + instance.Cancel(); + } + } + + internal void OnInstanceEnded(AbilityInstance instance) + { + _activeInstances.Remove(instance); + + if (_persistentInstance == instance) + { + _persistentInstance = null; + } + + if (_behaviors.Remove(instance, out BehaviorBinding binding)) + { + try + { + binding.Behavior.OnEnded(binding.Context); + } + catch (Exception ex) + { + Console.WriteLine($"Ability behavior threw on end: {ex}"); + } + } + + instance.Handle.Free(); + + if (_activeInstances.Count == 0) + { + OnAbilityDeactivated?.Invoke(this); + Owner.Abilities.NotifyAbilityEnded(new AbilityEndedData(Handle, false)); + } + } + + internal bool CanActivate(IForgeEntity? abilityTarget, out AbilityActivationFailures failureFlags) + { + var canActivate = true; + failureFlags = AbilityActivationFailures.None; + + if (IsInhibited) + { + failureFlags |= AbilityActivationFailures.Inhibited; + canActivate = false; + } + + // Check instance policy for non re-triggerable persistent instance. + if (AbilityData.InstancingPolicy == AbilityInstancingPolicy.PerEntity + && !AbilityData.RetriggerInstancedAbility + && _persistentInstance?.IsActive == true) + { + failureFlags |= AbilityActivationFailures.PersistentInstanceActive; + canActivate = false; + } + + // Check cooldown. + if (_cooldownEffects is not null) + { + foreach (Effect effect in _cooldownEffects) + { + if (effect?.CachedGrantedTags is not null && Owner.Tags.CombinedTags.HasAny(effect.CachedGrantedTags)) + { + failureFlags |= AbilityActivationFailures.Cooldown; + canActivate = false; + } + } + } + + // Check resources. + if (_costEffect is not null + && !Owner.EffectsManager.CanApplyEffect(_costEffect, Level)) + { + failureFlags |= AbilityActivationFailures.InsufficientResources; + canActivate = false; + } + + // Check tags condition. + TagContainer ownerTags = Owner.Tags.CombinedTags; + TagContainer? sourceTags = SourceEntity?.Tags.CombinedTags; + TagContainer? targetTags = abilityTarget?.Tags.CombinedTags; + + // Owner tags. + if (FailsRequiredTags(AbilityData.ActivationRequiredTags, ownerTags) + || HasBlockedTags(AbilityData.ActivationBlockedTags, ownerTags)) + { + failureFlags |= AbilityActivationFailures.OwnerTagRequirements; + canActivate = false; + } + + // Source tags. + if (FailsRequiredTags(AbilityData.SourceRequiredTags, sourceTags) + || HasBlockedTags(AbilityData.SourceBlockedTags, sourceTags)) + { + failureFlags |= AbilityActivationFailures.SourceTagRequirements; + canActivate = false; + } + + // Target tags. + if (FailsRequiredTags(AbilityData.TargetRequiredTags, targetTags) + || HasBlockedTags(AbilityData.TargetBlockedTags, targetTags)) + { + failureFlags |= AbilityActivationFailures.TargetTagRequirements; + canActivate = false; + } + + // Check ability tags against BlockAbilitiesWithTag + if (_abilityTags?.HasAny(Owner.Abilities.BlockedAbilityTags.CombinedTags) == true) + { + failureFlags |= AbilityActivationFailures.BlockedByTags; + canActivate = false; + } + + return canActivate; + } + + internal CooldownData[] GetCooldownData() + { + var cooldownData = new CooldownData[_activeCooldownHandles?.Length ?? 0]; + + if (_activeCooldownHandles is not null) + { + for (var i = 0; i < _activeCooldownHandles.Length; i++) + { + ActiveEffectHandle? effectHandle = _activeCooldownHandles[i]; + if (effectHandle?.ActiveEffect is not null) + { + ActiveEffect activeEffect = effectHandle.ActiveEffect; + + TagContainer cooldownTags = activeEffect.Effect.CachedGrantedTags!; + var totalTime = activeEffect.EffectEvaluatedData.Duration; + var remainingTime = (float)activeEffect.RemainingDuration; + + cooldownData[i] = new CooldownData(cooldownTags, totalTime, remainingTime); + } + else + { + EffectData effectData = _cooldownEffects![i].EffectData; + + ModifierTagsEffectComponent modifierTagsComponent = + effectData.EffectComponents.OfType().First(); + TagContainer cooldownTags = modifierTagsComponent.TagsToAdd; + + var totalTime = effectData.DurationData.DurationMagnitude!.Value.GetMagnitude( + _cooldownEffects[i], Owner, Level); + + cooldownData[i] = new CooldownData(cooldownTags, totalTime, 0f); + } + } + } + + return cooldownData; + } + + internal float GetRemainingCooldownTime(Tag tag) + { + if (_activeCooldownHandles is not null) + { + for (var i = 0; i < _activeCooldownHandles.Length; i++) + { + ActiveEffectHandle? effectHandle = _activeCooldownHandles[i]; + if (effectHandle?.ActiveEffect is not null) + { + ActiveEffect activeEffect = effectHandle.ActiveEffect; + TagContainer cooldownTags = activeEffect.Effect.CachedGrantedTags!; + if (cooldownTags.HasTag(tag)) + { + return (float)activeEffect.RemainingDuration; + } + } + } + } + + return 0f; + } + + internal CostData[]? GetCostData(StringKey? specificAttribute = null) + { + if (_costEffect is null) + { + return null; + } + + ModifierEvaluatedData[] allModifiersEvaluatedData = EvaluateInstantModifiers(_costEffect, specificAttribute); + + Dictionary costByAttribute = []; + + foreach (ModifierEvaluatedData modifierEvaluatedData in allModifiersEvaluatedData) + { + if (!costByAttribute.TryGetValue(modifierEvaluatedData.Attribute.Key, out var value)) + { + value = 0f; + costByAttribute[modifierEvaluatedData.Attribute.Key] = value; + } + + var baseValue = modifierEvaluatedData.Attribute.BaseValue + + value; + + switch (modifierEvaluatedData.ModifierOperation) + { + case ModifierOperation.FlatBonus: + costByAttribute[modifierEvaluatedData.Attribute.Key] += modifierEvaluatedData.Magnitude; + break; + + case ModifierOperation.PercentBonus: + costByAttribute[modifierEvaluatedData.Attribute.Key] += + (int)(baseValue * (1 + modifierEvaluatedData.Magnitude)) - baseValue; + break; + + case ModifierOperation.Override: + costByAttribute[modifierEvaluatedData.Attribute.Key] += + modifierEvaluatedData.Magnitude - baseValue; + break; + } + } + + return [.. costByAttribute.Select(x => new CostData(x.Key, (int)x.Value))]; + } + + internal int GetCostForAttribute(StringKey attributeKey) + { + CostData[]? costData = GetCostData(attributeKey); + + if (costData is null) + { + return 0; + } + + foreach (CostData cost in costData) + { + if (cost.Attribute == attributeKey) + { + return cost.Cost; + } + } + + return 0; + } + + private static bool FailsRequiredTags(TagContainer? required, TagContainer? present) + { + return required is not null && (present?.HasAll(required) != true); + } + + private static bool HasBlockedTags(TagContainer? blocked, TagContainer? present) + { + return blocked is not null && (present?.HasAny(blocked) == true); + } + + private EventSubscriptionToken SubscribeTypedEvent(AbilityTriggerData triggerData) + { +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + // Do not attempt this in production environments without adult supervision. + MethodInfo method = typeof(Ability) + .GetMethod(nameof(SubscribeTypedEventCore), BindingFlags.NonPublic | BindingFlags.Instance)! + .MakeGenericMethod(triggerData.PayloadType!); +#pragma warning restore S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + + return (EventSubscriptionToken)method.Invoke(this, [triggerData.TriggerTag, triggerData.Priority])!; + } + + private EventSubscriptionToken SubscribeTypedEventCore(Tag tag, int priority) + { + return Owner.Events.Subscribe( + tag, + x => TryActivateAbility(x.Target, out _, x.Payload, x.EventMagnitude), + priority: priority); + } + + private void Activate(IForgeEntity? abilityTarget, float magnitude) + { + AbilityInstance instance = CreateInstance(abilityTarget); + _activeInstances.Add(instance); + instance.Start(magnitude); + } + + private void Activate(IForgeEntity? abilityTarget, TData data, float magnitude) + { + AbilityInstance instance = CreateInstance(abilityTarget); + _activeInstances.Add(instance); + instance.Start(data, magnitude); + } + + private AbilityInstance CreateInstance(IForgeEntity? abilityTarget) + { + // Cancel conflicting abilities before we start this one. + if (AbilityData.CancelAbilitiesWithTag is not null) + { + Owner.Abilities.CancelAbilitiesWithTag(AbilityData.CancelAbilitiesWithTag); + } + + if (AbilityData.InstancingPolicy == AbilityInstancingPolicy.PerEntity) + { + if (_persistentInstance?.IsActive == true) + { + Validation.Assert( + AbilityData.RetriggerInstancedAbility, "Should not reach here due to CanActivate check."); + + _persistentInstance.Cancel(); + _persistentInstance = null; + } + + _persistentInstance ??= new AbilityInstance(this, abilityTarget); + return _persistentInstance; + } + + return new AbilityInstance(this, abilityTarget); + } + + private ModifierEvaluatedData[] EvaluateInstantModifiers(Effect effect, StringKey? specificAttribute = null) + { + var modifiersEvaluatedData = new List(effect.EffectData.Modifiers.Length); + + foreach (Modifier modifier in effect.EffectData.Modifiers) + { + // Ignore modifiers for attributes not present in the target. + if (!Owner.Attributes.ContainsAttribute(modifier.Attribute) || + (specificAttribute.HasValue && specificAttribute.Value != modifier.Attribute)) + { + continue; + } + + modifiersEvaluatedData.Add( + new ModifierEvaluatedData( + Owner.Attributes[modifier.Attribute], + modifier.Operation, + modifier.Magnitude.GetMagnitude(effect, Owner, Level, null), + modifier.Channel)); + } + + foreach (CustomExecution execution in effect.EffectData.CustomExecutions) + { + if (CustomExecution.ExecutionHasInvalidAttributeCaptures(execution, effect, Owner)) + { + continue; + } + + modifiersEvaluatedData.AddRange(execution.EvaluateExecution(effect, Owner, null)); + } + + return [.. modifiersEvaluatedData]; + } + + private void TagPresent_OnTagChanged(TagContainer container) + { + if (container.HasTag(AbilityData.AbilityTriggerData!.Value.TriggerTag)) + { + TryActivateAbility(null, out _, 0f); + } + else + { + CancelAllInstances(); + } + } + + private void TagAdded_OnTagChanged(TagContainer container) + { + if (container.HasTag(AbilityData.AbilityTriggerData!.Value.TriggerTag)) + { + TryActivateAbility(null, out _, 0f); + } + } +} diff --git a/Forge/Abilities/AbilityActivationFailures.cs b/Forge/Abilities/AbilityActivationFailures.cs new file mode 100644 index 0000000..98b7889 --- /dev/null +++ b/Forge/Abilities/AbilityActivationFailures.cs @@ -0,0 +1,75 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Flags indicating the result of an ability activation attempt. +/// +/// +/// This enumeration provides detailed outcomes for ability activation attempts, allowing the caller to determine the +/// specific reason for success or failure. Use this result to handle activation logic appropriately based on the +/// returned value. +/// +[Flags] +public enum AbilityActivationFailures +{ + /// + /// Successfully activated the ability. + /// + None = 0, + + /// + /// Failed to activate the ability due to an invalid handler. + /// + InvalidHandler = 1 << 0, + + /// + /// Failed to activate the ability because it is currently inhibited. + /// + Inhibited = 1 << 1, + + /// + /// Failed to activate the ability because a persistent instance is already active. + /// + PersistentInstanceActive = 1 << 2, + + /// + /// Failed to activate the ability because it is on cooldown. + /// + Cooldown = 1 << 3, + + /// + /// Failed to activate the ability due to insufficient resources. + /// + InsufficientResources = 1 << 4, + + /// + /// Failed to activate the ability due to unmet tag requirements. + /// + OwnerTagRequirements = 1 << 5, + + /// + /// Failed to activate the ability due to unmet source tag requirements. + /// + SourceTagRequirements = 1 << 6, + + /// + /// Failed to activate the ability due to unmet target tag requirements. + /// + TargetTagRequirements = 1 << 7, + + /// + /// Failed to activate the ability due to being blocked by tags. + /// + BlockedByTags = 1 << 8, + + /// + /// Failed to activate the ability because the target tag is not present. + /// + TargetTagNotPresent = 1 << 9, + + /// + /// Failed to activate the ability due to invalid tag configuration. + /// + InvalidTagConfiguration = 1 << 11, +} diff --git a/Forge/Abilities/AbilityBehaviorContext.Data.cs b/Forge/Abilities/AbilityBehaviorContext.Data.cs new file mode 100644 index 0000000..7dc3110 --- /dev/null +++ b/Forge/Abilities/AbilityBehaviorContext.Data.cs @@ -0,0 +1,26 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Context that carries strongly-typed additional data. +/// Created automatically when using . +/// +/// The activation data type. +public sealed class AbilityBehaviorContext : AbilityBehaviorContext +{ + /// + /// Gets the additional data passed during ability activation. + /// + public TData Data { get; } + + internal AbilityBehaviorContext( + Ability ability, + AbilityInstance instance, + TData data, + float magnitude) + : base(ability, instance, magnitude) + { + Data = data; + } +} diff --git a/Forge/Abilities/AbilityBehaviorContext.cs b/Forge/Abilities/AbilityBehaviorContext.cs new file mode 100644 index 0000000..eb5ec62 --- /dev/null +++ b/Forge/Abilities/AbilityBehaviorContext.cs @@ -0,0 +1,55 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Runtime context for a single ability activation. +/// +public class AbilityBehaviorContext +{ + /// + /// Gets the owner of this ability. + /// + public IForgeEntity Owner { get; } + + /// + /// Gets the source entity that granted this ability. + /// + public IForgeEntity? Source { get; } + + /// + /// Gets the target entity of this ability instance. + /// + public IForgeEntity? Target => InstanceHandle.Target; + + /// + /// Gets the level of the ability at the time of execution. + /// + public int Level => AbilityHandle.Level; + + /// + /// Gets the handle to the ability being executed (ability-level operations). + /// + public AbilityHandle AbilityHandle { get; } + + /// + /// Gets the per-instance handle for this execution (end/cancel this instance). + /// + public AbilityInstanceHandle InstanceHandle { get; } + + /// + /// Gets the magnitude associated with the activation of this ability, if applicable. + /// + public float Magnitude { get; } + + internal AbilityBehaviorContext(Ability ability, AbilityInstance instance, float magnitude) + { + AbilityHandle = ability.Handle; + InstanceHandle = instance.Handle; + Owner = ability.Owner; + Source = ability.SourceEntity; + Magnitude = magnitude; + } +} diff --git a/Forge/Abilities/AbilityData.cs b/Forge/Abilities/AbilityData.cs new file mode 100644 index 0000000..ae4ded7 --- /dev/null +++ b/Forge/Abilities/AbilityData.cs @@ -0,0 +1,215 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Effects.Components; +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Represents the data required to define an ability, including its name, effects, tags, and activation policies. +/// +/// +/// This structure encapsulates all the metadata and configuration necessary to describe an ability in a system. It +/// includes optional effects for cost and cooldown, various tag-based requirements and restrictions, and policies for +/// instancing and retriggering abilities. Use this type to define the behavior and constraints of abilities in a +/// consistent and extensible manner. +/// +public readonly record struct AbilityData +{ + /// + /// Gets the name of the ability. + /// + public string Name { get; } + + /// + /// Gets the effect that represents the cost of using the ability called when the ability is committed. + /// + public EffectData? CostEffect { get; } + + /// + /// Gets a list of effects that represents the cooldowns of the ability. + /// + public EffectData[]? CooldownEffects { get; } + + /// + /// Gets tags associated with the ability for categorization and filtering. + /// + public TagContainer? AbilityTags { get; } + + /// + /// Gets the instancing policy for the ability, determining how instances are created and managed. + /// + public AbilityInstancingPolicy InstancingPolicy { get; } + + /// + /// Gets a value indicating whether an instanced ability can be re-triggered while it is still active. If on, it + /// will stop and re-trigger the ability. + /// + public bool RetriggerInstancedAbility { get; } + + /// + /// Gets the trigger data associated with the ability, defining how and when the ability can be executed. + /// + public AbilityTriggerData? AbilityTriggerData { get; } + + /// + /// Gets the tags that, if present on other abilities, will cause those abilities to be canceled when this ability + /// is activated. + /// + public TagContainer? CancelAbilitiesWithTag { get; } + + /// + /// Gets the tags that, if present on other abilities, will block those abilities from being activated while this + /// ability is active. + /// + public TagContainer? BlockAbilitiesWithTag { get; } + + /// + /// Gets tags that will be applied to the owner when the ability is activated. + /// + public TagContainer? ActivationOwnedTags { get; } + + /// + /// Gets tags required on the owner to activate the ability. + /// + public TagContainer? ActivationRequiredTags { get; } + + /// + /// Gets tags that, if present on the owner, will block the ability from being activated. + /// + public TagContainer? ActivationBlockedTags { get; } + + /// + /// Gets tags required on the source to activate the ability. + /// + public TagContainer? SourceRequiredTags { get; } + + /// + /// Gets tags that, if present on the source, will block the ability from being activated. + /// + public TagContainer? SourceBlockedTags { get; } + + /// + /// Gets tags required on the target to activate the ability. + /// + public TagContainer? TargetRequiredTags { get; } + + /// + /// Gets tags that, if present on the target, will block the ability from being activated. + /// + public TagContainer? TargetBlockedTags { get; } + + /// + /// Gets the factory function to create custom ability behavior instances. + /// + public Func? BehaviorFactory { get; } + + /// + /// Initializes a new instance of the structure. + /// + /// The name of the ability. + /// The effect that represents the cost of using the ability called when the ability is + /// committed. + /// A list of effects that represents the cooldowns of the ability. + /// Tags associated with the ability for categorization and filtering. + /// The instancing policy for the ability, determining how instances are created and + /// managed. + /// Flag indicating whether an instanced ability can be re-triggered while it is + /// Still active. If on, it will stop and re-trigger the ability. + /// The trigger data associated with the ability, defining how and when the ability can + /// be executed. + /// Abilities with any of these tags will be canceled when this ability is + /// executed. + /// Abilities with any of these tags will be blocked from being executed while this + /// ability is active. + /// Tags that will be applied to the owner when the ability is activated. + /// Tags required on the owner to activate the ability. + /// Tags that, if present on the owner, will block the ability from being activated. + /// + /// Tags required on the source to activate the ability. + /// Tags that, if present on the source, will block the ability from being activated. + /// + /// Tags required on the target to activate the ability. + /// Tags that, if present on the target, will block the ability from being activated. + /// + /// The factory function to create custom ability behavior instances. + public AbilityData( + string name, + EffectData? costEffect = null, + EffectData[]? cooldownEffects = null, + TagContainer? abilityTags = null, + AbilityInstancingPolicy instancingPolicy = AbilityInstancingPolicy.PerEntity, + bool retriggerInstancedAbility = false, + AbilityTriggerData? abilityTriggerData = null, + TagContainer? cancelAbilitiesWithTag = null, + TagContainer? blockAbilitiesWithTag = null, + TagContainer? activationOwnedTags = null, + TagContainer? activationRequiredTags = null, + TagContainer? activationBlockedTags = null, + TagContainer? sourceRequiredTags = null, + TagContainer? sourceBlockedTags = null, + TagContainer? targetRequiredTags = null, + TagContainer? targetBlockedTags = null, + Func? behaviorFactory = null) + { + Name = name; + CostEffect = costEffect; + CooldownEffects = cooldownEffects; + AbilityTags = abilityTags; + InstancingPolicy = instancingPolicy; + RetriggerInstancedAbility = retriggerInstancedAbility; + AbilityTriggerData = abilityTriggerData; + CancelAbilitiesWithTag = cancelAbilitiesWithTag; + BlockAbilitiesWithTag = blockAbilitiesWithTag; + ActivationOwnedTags = activationOwnedTags; + ActivationRequiredTags = activationRequiredTags; + ActivationBlockedTags = activationBlockedTags; + SourceRequiredTags = sourceRequiredTags; + SourceBlockedTags = sourceBlockedTags; + TargetRequiredTags = targetRequiredTags; + TargetBlockedTags = targetBlockedTags; + BehaviorFactory = behaviorFactory; + + if (Validation.Enabled) + { + ValidateData(); + } + } + + private void ValidateData() + { + if (CooldownEffects is not null) + { + for (var i = 0; i < CooldownEffects.Length; i++) + { + Validation.Assert( + Array.TrueForAll( + CooldownEffects, + x => x.DurationData.DurationType == Effects.Duration.DurationType.HasDuration), + "Cooldown effects should have a duration."); + + Validation.Assert( + Array.Exists( + CooldownEffects[i].EffectComponents, + x => x is ModifierTagsEffectComponent y && !y.TagsToAdd.IsEmpty), + "Cooldown effects should have modifier tags."); + } + } + + if (CostEffect is not null) + { + Validation.Assert( + CostEffect.Value.DurationData.DurationType == Effects.Duration.DurationType.Instant, + "Cost effects should be instant."); + } + + if (RetriggerInstancedAbility) + { + Validation.Assert( + InstancingPolicy == AbilityInstancingPolicy.PerEntity, + "RetriggerInstancedAbility can only be true when InstancingPolicy is PerEntity."); + } + } +} diff --git a/Forge/Abilities/AbilityDeactivationPolicy.cs b/Forge/Abilities/AbilityDeactivationPolicy.cs new file mode 100644 index 0000000..59e7a0f --- /dev/null +++ b/Forge/Abilities/AbilityDeactivationPolicy.cs @@ -0,0 +1,30 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Specifies the policy for removing or inhibiting a granted ability based on its activation state. +/// +/// +/// This enumeration defines the behavior for handling the removal and inhibition of abilities. +/// The policy determines whether the ability is removed or inhibited immediately, allowed to complete its current +/// execution, or retained indefinitely. +/// +public enum AbilityDeactivationPolicy : byte +{ + /// + /// Ability is removed or inhibited immediately. + /// + CancelImmediately = 0, + + /// + /// Ability is removed or inhibited, but it is allowed to finish its current execution + /// first. + /// + RemoveOnEnd = 1, + + /// + /// Granted ability is not removed or inhibited. + /// + Ignore = 2, +} diff --git a/Forge/Abilities/AbilityEndedData.cs b/Forge/Abilities/AbilityEndedData.cs new file mode 100644 index 0000000..d628a27 --- /dev/null +++ b/Forge/Abilities/AbilityEndedData.cs @@ -0,0 +1,12 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Represents data associated with the ending of an ability within the Forge framework. +/// +/// The handle of the ability that has ended. +/// Whether the ability was cancelled or ended gracefully or got cancelled. +public readonly record struct AbilityEndedData( + AbilityHandle Ability, + bool WasCanceled); diff --git a/Forge/Abilities/AbilityHandle.cs b/Forge/Abilities/AbilityHandle.cs new file mode 100644 index 0000000..23093af --- /dev/null +++ b/Forge/Abilities/AbilityHandle.cs @@ -0,0 +1,171 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Represents a handle to a granted ability. +/// +public class AbilityHandle +{ + /// + /// Gets a value indicating whether the ability associated with this handle is valid and active. + /// + public bool IsActive => Ability?.IsActive == true; + + /// + /// Gets a value indicating whether the ability associated with this handle is currently inhibited. + /// + public bool IsInhibited => Ability?.IsInhibited == true; + + /// + /// Gets a value indicating whether the handle is valid. + /// + public bool IsValid => Ability is not null; + + /// + /// Gets a value indicating the level of the ability associated with this handle. + /// + public int Level => Ability?.Level ?? 0; + + internal Ability? Ability { get; private set; } + + internal AbilityHandle(Ability ability) + { + Ability = ability; + } + + /// + /// Activates 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( + out AbilityActivationFailures failureFlags, + IForgeEntity? target = null, + float magnitude = 0) + { + failureFlags = AbilityActivationFailures.InvalidHandler; + return Ability?.TryActivateAbility(target, out failureFlags, magnitude) ?? false; + } + + /// + /// Activates 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. + /// 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( + TData data, + out AbilityActivationFailures failureFlags, + IForgeEntity? target = null, + float magnitude = 0f) + { + if (Ability is null) + { + failureFlags = AbilityActivationFailures.InvalidHandler; + return false; + } + + return Ability.TryActivateAbility(target, out failureFlags, data, magnitude); + } + + /// + /// Cancels all instances of the ability associated with this handle. + /// + public void Cancel() + { + Ability?.CancelAllInstances(); + } + + /// + /// Commits the ability cooldown and cost. + /// + public void CommitAbility() + { + Ability?.CommitAbility(); + } + + /// + /// Commits the ability cooldown. + /// + public void CommitCooldown() + { + Ability?.CommitCooldown(); + } + + /// + /// Commits the ability cost. + /// + public void CommitCost() + { + Ability?.CommitCost(); + } + + /// + /// Checks if the ability can be activated for the given target. + /// + /// Flags indicating the failure reasons for the ability activation. + /// Optional target entity for the ability activation check. + /// Returns if the ability can be activated; otherwise, . + /// + public bool CanActivate(out AbilityActivationFailures failureFlags, IForgeEntity? abilityTarget = null) + { + failureFlags = AbilityActivationFailures.InvalidHandler; + return Ability?.CanActivate(abilityTarget, out failureFlags) ?? false; + } + + /// + /// Gets the cooldown data for the ability associated with this handle. + /// + /// A list of cooldown data. Returns if the ability is invalid. + public CooldownData[]? GetCooldownData() + { + return Ability?.GetCooldownData(); + } + + /// + /// Gets the remaining cooldown time for a specific tag. + /// + /// The tag to check for remaining cooldown time. + /// The remaining cooldown time in seconds. Returns 0 if there is no cooldown or the ability is invalid. + /// + public float GetRemainingCooldownTime(Tag tag) + { + return Ability?.GetRemainingCooldownTime(tag) ?? 0f; + } + + /// + /// Gets the cost data for the ability associated with this handle. + /// + /// The cost data. Returns if the ability is invalid or has no cost. + public CostData[]? GetCostData() + { + return Ability?.GetCostData(); + } + + /// + /// Gets the cost for a specific attribute. + /// + /// The attribute key to get the cost for. + /// The cost for the specified attribute. Returns 0 if the ability is invalid or has no cost for the + /// attribute. + public int GetCostForAttribute(StringKey attribute) + { + return Ability?.GetCostForAttribute(attribute) ?? 0; + } + + internal void Free() + { + Ability = null; + } +} diff --git a/Forge/Abilities/AbilityInstance.cs b/Forge/Abilities/AbilityInstance.cs new file mode 100644 index 0000000..05aa6e2 --- /dev/null +++ b/Forge/Abilities/AbilityInstance.cs @@ -0,0 +1,97 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Represents a single activation/execution of an Ability. +/// Responsible for per-activation state (activation-owned tags, blocking tags). +/// +internal sealed class AbilityInstance +{ + private readonly Ability _ability; + + internal bool IsActive { get; private set; } + + internal IForgeEntity? Target { get; } + + internal AbilityInstanceHandle Handle { get; } + + internal AbilityInstance(Ability ability, IForgeEntity? target) + { + _ability = ability; + Target = target; + Handle = new AbilityInstanceHandle(this); + } + + internal void Start(float magnitude = 0f) + { + if (IsActive) + { + return; + } + + ApplyActivationState(); + IsActive = true; + _ability.OnInstanceStarted(this, magnitude); + } + + internal void Start(TData data, float magnitude = 0f) + { + if (IsActive) + { + return; + } + + ApplyActivationState(); + IsActive = true; + _ability.OnInstanceStarted(this, data, magnitude); + } + + internal void End() + { + if (!IsActive) + { + return; + } + + // Remove activation-owned tags. + if (_ability.AbilityData.ActivationOwnedTags is not null) + { + _ability.Owner.Tags.RemoveModifierTags(_ability.AbilityData.ActivationOwnedTags); + } + + // Unblock abilities with tags for this instance. + TagContainer? blockTags = _ability.AbilityData.BlockAbilitiesWithTag; + if (blockTags is not null) + { + _ability.Owner.Abilities.BlockedAbilityTags.RemoveModifierTags(blockTags); + } + + IsActive = false; + _ability.OnInstanceEnded(this); + } + + internal void Cancel() + { + End(); + } + + private void ApplyActivationState() + { + // Apply activation-owned tags. + if (_ability.AbilityData.ActivationOwnedTags is not null) + { + _ability.Owner.Tags.AddModifierTags(_ability.AbilityData.ActivationOwnedTags); + } + + // Block abilities with tags while this instance is active. + TagContainer? blockTags = _ability.AbilityData.BlockAbilitiesWithTag; + if (blockTags is not null) + { + _ability.Owner.Abilities.BlockedAbilityTags.AddModifierTags(blockTags); + } + } +} diff --git a/Forge/Abilities/AbilityInstanceHandle.cs b/Forge/Abilities/AbilityInstanceHandle.cs new file mode 100644 index 0000000..3c2d7f6 --- /dev/null +++ b/Forge/Abilities/AbilityInstanceHandle.cs @@ -0,0 +1,47 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Slim handle for controlling a single active ability instance (end / cancel). +/// Additional context (owner, source, level, commits) lives in . +/// +public sealed class AbilityInstanceHandle +{ + /// + /// Gets the target entity of this ability instance. + /// + public IForgeEntity? Target => AbilityInstance?.Target; + + /// + /// Gets a value indicating whether this ability instance is currently active. + /// + public bool IsActive => AbilityInstance?.IsActive ?? false; + + /// + /// Gets a value indicating whether the handle is valid. + /// + public bool IsValid => AbilityInstance is not null; + + internal AbilityInstance? AbilityInstance { get; private set; } + + internal AbilityInstanceHandle(AbilityInstance instance) + { + AbilityInstance = instance; + } + + /// + /// Ends the ability instance. + /// + public void End() + { + AbilityInstance?.End(); + } + + internal void Free() + { + AbilityInstance = null; + } +} diff --git a/Forge/Abilities/AbilityInstancingPolicy.cs b/Forge/Abilities/AbilityInstancingPolicy.cs new file mode 100644 index 0000000..82560a1 --- /dev/null +++ b/Forge/Abilities/AbilityInstancingPolicy.cs @@ -0,0 +1,25 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Specifies the instancing policy for abilities, determining how ability instances are created and managed. +/// +/// This enumeration defines the instancing behavior for abilities, which can affect their lifecycle and +/// usage: Each entity gets its own +/// instance of the ability, ensuring that the ability's state is isolated per entity. +/// A new instance of the ability is created for each execution, +/// allowing for stateless or transient behavior. Choose the appropriate policy based on +/// whether the ability requires persistent state per entity or should be stateless and transient. +public enum AbilityInstancingPolicy : byte +{ + /// + /// Abilities are instantiated per entity, meaning each entity has its own instance of the ability. + /// + PerEntity = 0, + + /// + /// Abilities are instantiated per execution, meaning a new instance is created each time the ability is used. + /// + PerExecution = 1, +} diff --git a/Forge/Abilities/AbilityTriggerData.cs b/Forge/Abilities/AbilityTriggerData.cs new file mode 100644 index 0000000..d17f5df --- /dev/null +++ b/Forge/Abilities/AbilityTriggerData.cs @@ -0,0 +1,78 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Data for triggering abilities based on tags or events. Provides factory methods to create trigger configurations. +/// +public readonly record struct AbilityTriggerData +{ + internal AbitityTriggerSource TriggerSource { get; } + + internal Tag TriggerTag { get; } + + internal int Priority { get; } + + internal Type? PayloadType { get; } + + private AbilityTriggerData( + AbitityTriggerSource triggerSource, + Tag triggerTag, + int priority, + Type? payloadType = null) + { + TriggerSource = triggerSource; + TriggerTag = triggerTag; + Priority = priority; + PayloadType = payloadType; + } + + /// + /// Creates trigger data for event-based activation with strongly-typed payloads. + /// + /// + /// Use this when your ability behavior implements + /// to receive the event payload directly in . + /// + /// The payload type from the triggering event. + /// The tag that triggers the ability. + /// The priority of the event subscription. + /// Configured trigger data for typed event handling. + public static AbilityTriggerData ForEvent(Tag triggerTag, int priority = 0) + { + return new AbilityTriggerData(AbitityTriggerSource.Event, triggerTag, priority, typeof(TPayload)); + } + + /// + /// Creates trigger data for event-based activation without typed payloads. + /// + /// The tag that triggers the ability. + /// The priority of the event subscription. + /// Configured trigger data for non-typed event handling. + public static AbilityTriggerData ForEvent(Tag triggerTag, int priority = 0) + { + return new AbilityTriggerData(AbitityTriggerSource.Event, triggerTag, priority); + } + + /// + /// Creates trigger data that activates when the specified tag is added to the entity. + /// + /// The tag that triggers the ability when added. + /// Configured trigger data for tag-added triggers. + public static AbilityTriggerData ForTagAdded(Tag triggerTag) + { + return new AbilityTriggerData(AbitityTriggerSource.TagAdded, triggerTag, 0); + } + + /// + /// Creates trigger data that activates while the specified tag is present on the entity. + /// + /// The tag that triggers the ability while present. + /// Configured trigger data for tag-present triggers. + public static AbilityTriggerData ForTagPresent(Tag triggerTag) + { + return new AbilityTriggerData(AbitityTriggerSource.TagPresent, triggerTag, 0); + } +} diff --git a/Forge/Abilities/AbitityTriggerSource.cs b/Forge/Abilities/AbitityTriggerSource.cs new file mode 100644 index 0000000..08b362e --- /dev/null +++ b/Forge/Abilities/AbitityTriggerSource.cs @@ -0,0 +1,28 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Specifies the source or condition that triggers an ability. +/// +/// +/// This enumeration defines the possible ways an ability can be triggered, such as through explicit events or changes +/// in the entity's state (e.g., the addition or presence of specific tags). +/// +public enum AbitityTriggerSource : byte +{ + /// + /// Ability is triggered by an explicit event call. + /// + Event = 0, + + /// + /// Ability is triggered when a specific tag is added to the entity. + /// + TagAdded = 1, + + /// + /// Ability is triggered when a specific tag is added on the entity and removed when the tag is gone. + /// + TagPresent = 2, +} diff --git a/Forge/Abilities/CooldownData.cs b/Forge/Abilities/CooldownData.cs new file mode 100644 index 0000000..96c31b0 --- /dev/null +++ b/Forge/Abilities/CooldownData.cs @@ -0,0 +1,13 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Contains information about an ability's cooldown state. +/// +/// Tags associated with the cooldown. +/// The total duration of the cooldown. +/// The remaining time left on the cooldown. +public record struct CooldownData(TagContainer CooldownTags, float TotalTime, float RemainingTime); diff --git a/Forge/Abilities/CostData.cs b/Forge/Abilities/CostData.cs new file mode 100644 index 0000000..dac6e6c --- /dev/null +++ b/Forge/Abilities/CostData.cs @@ -0,0 +1,12 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Contains information about the cost associated with an ability activation. +/// +/// The attribute used to pay the cost. +/// The amount of the cost. +public record struct CostData(StringKey Attribute, int Cost); diff --git a/Forge/Abilities/EffectGrantSource.cs b/Forge/Abilities/EffectGrantSource.cs new file mode 100644 index 0000000..5b9d789 --- /dev/null +++ b/Forge/Abilities/EffectGrantSource.cs @@ -0,0 +1,18 @@ +// Copyright © Gamesmiths Guild. +using Gamesmiths.Forge.Effects; + +namespace Gamesmiths.Forge.Abilities; + +internal sealed class EffectGrantSource( + ActiveEffectHandle effectHandle, + AbilityDeactivationPolicy removalPolicy, + AbilityDeactivationPolicy inhibitionPolicy) : IAbilityGrantSource +{ + public ActiveEffectHandle EffectHandle { get; init; } = effectHandle; + + public bool IsInhibited => EffectHandle.IsInhibited; + + public AbilityDeactivationPolicy RemovalPolicy { get; init; } = removalPolicy; + + public AbilityDeactivationPolicy InhibitionPolicy { get; init; } = inhibitionPolicy; +} diff --git a/Forge/Abilities/IAbilityBehavior.cs b/Forge/Abilities/IAbilityBehavior.cs new file mode 100644 index 0000000..2be6c01 --- /dev/null +++ b/Forge/Abilities/IAbilityBehavior.cs @@ -0,0 +1,42 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Abilities; + +/// +/// Interface for defining custom behavior when an ability instance starts and ends. +/// +public interface IAbilityBehavior +{ + /// + /// Called when an ability instance has started. + /// + /// The context for the started ability instance. + void OnStarted(AbilityBehaviorContext context); + + /// + /// Called when an ability instance has ended. + /// + /// The context for the ended ability instance. + void OnEnded(AbilityBehaviorContext context); +} + +/// +/// Interface for defining custom behavior with strongly-typed additional data. +/// +/// The type of the additional data expected. +public interface IAbilityBehavior : IAbilityBehavior +{ + /// + /// Called when an ability instance has started with a typed data. + /// + /// The context for the started ability instance. + /// The strongly-typed additional data from the triggering event. + void OnStarted(AbilityBehaviorContext context, TData data); + + /// + void IAbilityBehavior.OnStarted(AbilityBehaviorContext context) + { + // Default implementation for non-payload activations + OnStarted(context, default!); + } +} diff --git a/Forge/Abilities/IAbilityGrantSource.cs b/Forge/Abilities/IAbilityGrantSource.cs new file mode 100644 index 0000000..2698b5a --- /dev/null +++ b/Forge/Abilities/IAbilityGrantSource.cs @@ -0,0 +1,23 @@ +// Copyright © Gamesmiths Guild. +namespace Gamesmiths.Forge.Abilities; + +/// +/// Interface for sources that can grant abilities. +/// +internal interface IAbilityGrantSource +{ + /// + /// Gets a value indicating whether the ability grant source is currently inhibited. + /// + bool IsInhibited { get; } + + /// + /// Gets the policy for removing the ability when the grant source is removed. + /// + AbilityDeactivationPolicy RemovalPolicy { get; } + + /// + /// Gets the policy for inhibiting the ability when the grant source is inhibited. + /// + AbilityDeactivationPolicy InhibitionPolicy { get; } +} diff --git a/Forge/Abilities/PermanentGrantSource.cs b/Forge/Abilities/PermanentGrantSource.cs new file mode 100644 index 0000000..c989149 --- /dev/null +++ b/Forge/Abilities/PermanentGrantSource.cs @@ -0,0 +1,11 @@ +// Copyright © Gamesmiths Guild. +namespace Gamesmiths.Forge.Abilities; + +internal sealed class PermanentGrantSource : IAbilityGrantSource +{ + public bool IsInhibited => false; + + public AbilityDeactivationPolicy RemovalPolicy => AbilityDeactivationPolicy.Ignore; + + public AbilityDeactivationPolicy InhibitionPolicy => AbilityDeactivationPolicy.Ignore; +} diff --git a/Forge/Abilities/TransientGrantSource.cs b/Forge/Abilities/TransientGrantSource.cs new file mode 100644 index 0000000..62e8f53 --- /dev/null +++ b/Forge/Abilities/TransientGrantSource.cs @@ -0,0 +1,11 @@ +// Copyright © Gamesmiths Guild. +namespace Gamesmiths.Forge.Abilities; + +internal sealed class TransientGrantSource : IAbilityGrantSource +{ + public bool IsInhibited => false; + + public AbilityDeactivationPolicy RemovalPolicy => AbilityDeactivationPolicy.RemoveOnEnd; + + public AbilityDeactivationPolicy InhibitionPolicy => AbilityDeactivationPolicy.Ignore; +} diff --git a/Forge/Attributes/AttributeSet.cs b/Forge/Attributes/AttributeSet.cs index 790e9eb..e7987ed 100644 --- a/Forge/Attributes/AttributeSet.cs +++ b/Forge/Attributes/AttributeSet.cs @@ -92,8 +92,9 @@ protected EntityAttribute InitializeAttribute( { Validation.Assert(!string.IsNullOrEmpty(attributeName), "attributeName should never be null or empty."); - var attribute = new EntityAttribute(defaultValue, minValue, maxValue, channels); - AttributesMap.Add($"{GetType().Name}.{attributeName}", attribute); + StringKey attributeKey = $"{GetType().Name}.{attributeName}"; + var attribute = new EntityAttribute(attributeKey, defaultValue, minValue, maxValue, channels); + AttributesMap.Add(attributeKey, attribute); attribute.OnValueChanged += AttributeOnValueChanged; return attribute; } diff --git a/Forge/Attributes/ChannelData.cs b/Forge/Attributes/ChannelData.cs index 4bcb514..51b49f4 100644 --- a/Forge/Attributes/ChannelData.cs +++ b/Forge/Attributes/ChannelData.cs @@ -9,7 +9,7 @@ namespace Gamesmiths.Forge.Attributes; /// Attributes have channels for calculating its modifiers. Multiple channels can be used to calculate in sequence, /// granting it's possible to have various kinds of formulas combining flat and percentage modifiers. /// -public struct ChannelData +internal struct ChannelData { /// /// Gets or sets an override value at this channel. @@ -27,5 +27,5 @@ public struct ChannelData /// /// Gets or sets a percent modifier for this channel. /// - public float PercentModifier { get; set; } + public double PercentModifier { get; set; } } diff --git a/Forge/Attributes/EntityAttribute.cs b/Forge/Attributes/EntityAttribute.cs index 0b96250..d18366c 100644 --- a/Forge/Attributes/EntityAttribute.cs +++ b/Forge/Attributes/EntityAttribute.cs @@ -26,6 +26,11 @@ public sealed class EntityAttribute /// public event Action? OnValueChanged; + /// + /// Gets the unique key identifying this attribute. + /// + public StringKey Key { get; internal set; } + /// /// Gets the base value for this attribute. /// @@ -73,11 +78,14 @@ public sealed class EntityAttribute internal int PendingValueChange { get; private set; } internal EntityAttribute( + StringKey key, int defaultValue, int minValue, int maxValue, int channels) { + Key = key; + PendingValueChange = 0; Min = minValue; @@ -169,7 +177,7 @@ internal void ExecutePercentModifier(float value) { var oldValue = CurrentValue; - BaseValue = Math.Clamp((int)(BaseValue * (1 + value)), Min, Max); + BaseValue = Math.Clamp((int)(BaseValue * Math.Round(1 + value, 6)), Min, Max); UpdateCachedValues(); @@ -241,7 +249,7 @@ internal void AddPercentModifier(float value, int channel) var oldValue = CurrentValue; ref ChannelData channelData = ref _channels[channel]; - channelData.PercentModifier += value; + channelData.PercentModifier += Math.Round(value, 6); UpdateCachedValues(); @@ -265,7 +273,50 @@ internal float CalculateMagnitudeUpToChannel(int finalChanel) continue; } - evaluatedValue = (evaluatedValue + _channels[i].FlatModifier) * _channels[i].PercentModifier; + evaluatedValue = (float)((evaluatedValue + _channels[i].FlatModifier) * _channels[i].PercentModifier); + } + + return Math.Clamp((int)evaluatedValue, Min, Max); + } + + internal float CalculateValueWithPendingModifiers( + Dictionary? pendingFlatBonusByChannel, + Dictionary? pendingPercentBonusByChannel, + Dictionary? pendingOverrideByChannel) + { + var evaluatedValue = (float)BaseValue; + + for (var i = 0; i < _channels.Length; i++) + { + if (pendingOverrideByChannel is not null && + pendingOverrideByChannel.TryGetValue(i, out var pendingOverride)) + { + evaluatedValue = pendingOverride; + continue; + } + + var channelOverride = _channels[i].Override; + if (channelOverride.HasValue) + { + evaluatedValue = channelOverride.Value; + continue; + } + + var flatBonus = _channels[i].FlatModifier; + if (pendingFlatBonusByChannel is not null && + pendingFlatBonusByChannel.TryGetValue(i, out var pendingFlat)) + { + flatBonus += (int)pendingFlat; + } + + var percentMultiplier = _channels[i].PercentModifier; + if (pendingPercentBonusByChannel is not null && + pendingPercentBonusByChannel.TryGetValue(i, out var pendingPercent)) + { + percentMultiplier += pendingPercent; + } + + evaluatedValue = (float)((evaluatedValue + flatBonus) * percentMultiplier); } return Math.Clamp((int)evaluatedValue, Min, Max); @@ -293,7 +344,7 @@ private void UpdateCachedValues() continue; } - evaluatedValue = (evaluatedValue + channel.FlatModifier) * channel.PercentModifier; + evaluatedValue = (float)((evaluatedValue + channel.FlatModifier) * channel.PercentModifier); } CurrentValue = Math.Clamp((int)evaluatedValue, Min, Max); diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs new file mode 100644 index 0000000..c9727da --- /dev/null +++ b/Forge/Core/EntityAbilities.cs @@ -0,0 +1,398 @@ +// Copyright © Gamesmiths Guild. + +using System.Diagnostics.CodeAnalysis; +using Gamesmiths.Forge.Abilities; +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Core; + +/// +/// Manager for handling an entity's abilities. +/// +/// +/// Initializes a new instance of the class. +/// +/// The owner of this manager. +public class EntityAbilities(IForgeEntity owner) +{ + private readonly Dictionary> _grantSources = []; + private Action? _removeAbility; + private Action? _inhibitAbility; + + /// + /// Event invoked when an ability ends. + /// + public event Action? OnAbilityEnded; + + /// + /// Gets the owner of this effects manager. + /// + public IForgeEntity Owner { get; } = owner; + + /// + /// Gets the set of abilities currently granted to the entity. + /// + public HashSet GrantedAbilities { get; } = []; + + /// + /// Gets the tags that block abilities from being used. + /// + public EntityTags BlockedAbilityTags { get; } = new EntityTags(new TagContainer(owner.Tags.BaseTags.TagsManager)); + + /// + /// Tries to get a granted ability from its data. + /// + /// The data of the ability to find. + /// The handle of the found ability. + /// The source entity of the ability, if any. + /// true if the ability was found; otherwise, false. + public bool TryGetAbility( + AbilityData abilityData, + [NotNullWhen(true)] out AbilityHandle? abilityHandle, + IForgeEntity? source = null) + { + Ability? ability = GrantedAbilities.FirstOrDefault( + x => x?.Ability?.AbilityData == abilityData && x.Ability?.SourceEntity == source)?.Ability; + if (ability is not null) + { + abilityHandle = ability.Handle; + return true; + } + + abilityHandle = null; + return false; + } + + /// + /// Cancels all active abilities whose AbilityTags overlap the provided tags. + /// For PerEntity abilities, cancels the single active instance. + /// For per-execution abilities, cancels all active instances. + /// + /// Tags that identify abilities to cancel. + public void CancelAbilitiesWithTag(TagContainer tagsToCancel) + { + if (tagsToCancel is null) + { + return; + } + + foreach (AbilityHandle? handle in GrantedAbilities.ToArray()) + { + Ability? ability = handle?.Ability; + if (ability is null) + { + continue; + } + + TagContainer? abilityTags = ability.AbilityData.AbilityTags; + if (abilityTags?.HasAny(tagsToCancel) == true) + { + ability.CancelAllInstances(); + } + } + } + + /// + /// Tries to activate all abilities whose AbilityTags overlap the provided tags. + /// + /// Tags that identify abilities to activate. + /// Optional target for the abilities. + /// Flags indicating the failure reasons for the abilities activation. + /// Returns if any abilities were activated; otherwise, . + /// + public bool TryActivateAbilitiesByTag( + TagContainer tagsToActivate, + IForgeEntity? target, + out AbilityActivationFailures[] failureFlags) + { + if (tagsToActivate is null) + { + failureFlags = + [.. Enumerable.Repeat(AbilityActivationFailures.InvalidTagConfiguration, GrantedAbilities.Count)]; + return false; + } + + var anyActivated = false; + failureFlags = + [.. Enumerable.Repeat(AbilityActivationFailures.TargetTagNotPresent, GrantedAbilities.Count)]; + + AbilityHandle[] array = [.. GrantedAbilities]; + for (var i = 0; i < array.Length; i++) + { + AbilityHandle? handle = array[i]; + Ability? ability = handle?.Ability; + if (ability is null) + { + continue; + } + + TagContainer? abilityTags = ability.AbilityData.AbilityTags; + if (abilityTags?.HasAny(tagsToActivate) == true) + { + anyActivated |= ability.TryActivateAbility(target, out failureFlags[i], 0f); + } + } + + return anyActivated; + } + + /// + /// Grants an ability and activates it once. The ability grant will be removed if activation fails or after it ends. + /// + /// The configuration data of the ability to grant and activate. + /// The level at which to grant the ability. + /// The policy for overriding the level of an existing granted ability. + /// Flags indicating the failure reasons for the ability activation. + /// The target entity for the ability activation, if any. + /// The source entity of the granted ability, if any. + /// The handle of the granted and activated ability, or if activation failed. + /// + public AbilityHandle? GrantAbilityAndActivateOnce( + AbilityData abilityData, + int abilityLevel, + LevelComparison levelOverridePolicy, + out AbilityActivationFailures failureFlags, + IForgeEntity? targetEntity = null, + IForgeEntity? sourceEntity = null) + { + var grantSource = new TransientGrantSource(); + + AbilityHandle abilityHandle = GrantAbility( + abilityData, + abilityLevel, + levelOverridePolicy, + grantSource, + sourceEntity); + + abilityHandle.Activate(out failureFlags, targetEntity); + + RemoveGrantedAbility(abilityHandle, grantSource); + + return abilityHandle.IsValid ? abilityHandle : null; + } + + /// + /// Grants an ability permanently. + /// + /// + /// Abilities granted permanently cannot be removed nor inhibited. + /// + /// The configuration data of the ability to grant. + /// The level at which to grant the ability. + /// The policy for overriding the level of an existing granted ability. + /// The source entity of the granted ability, if any. + /// The handle of the granted ability. + public AbilityHandle GrantAbilityPermanently( + AbilityData abilityData, + int abilityLevel, + LevelComparison levelOverridePolicy, + IForgeEntity? sourceEntity) + { + Ability? existingAbility = + GrantedAbilities.FirstOrDefault(x => x?.Ability?.AbilityData == abilityData)?.Ability; + + if (existingAbility is not null && existingAbility.SourceEntity == sourceEntity) + { + _grantSources[existingAbility].Add(new PermanentGrantSource()); + + // If the ability was fully inhibited, this permanent grant should re-enable it. + existingAbility.IsInhibited = false; + + var shouldOverride = + (levelOverridePolicy.HasFlag(LevelComparison.Higher) && abilityLevel > existingAbility.Level) || + (levelOverridePolicy.HasFlag(LevelComparison.Lower) && abilityLevel < existingAbility.Level) || + (levelOverridePolicy.HasFlag(LevelComparison.Equal) && abilityLevel == existingAbility.Level); + + if (shouldOverride) + { + existingAbility.Level = abilityLevel; + } + + return existingAbility.Handle; + } + + var newAbility = new Ability(Owner, abilityData, abilityLevel, sourceEntity); + GrantedAbilities.Add(newAbility.Handle); + _grantSources[newAbility] = [new PermanentGrantSource()]; + + return newAbility.Handle; + } + + internal AbilityHandle GrantAbility( + AbilityData abilityData, + int abilityLevel, + LevelComparison levelOverridePolicy, + IAbilityGrantSource grantSource, + IForgeEntity? sourceEntity) + { + Ability? existingAbility = + GrantedAbilities.FirstOrDefault(x => x?.Ability?.AbilityData == abilityData)?.Ability; + + if (existingAbility is not null && existingAbility.SourceEntity == sourceEntity) + { + // Ability already granted, just add the new source to the mapping. + _grantSources[existingAbility].Add(grantSource); + + // If the ability was fully inhibited, this new grant may need to re-enable it. + existingAbility.IsInhibited = CheckIsInhibited(); + + var shouldOverride = + (levelOverridePolicy.HasFlag(LevelComparison.Higher) && abilityLevel > existingAbility.Level) || + (levelOverridePolicy.HasFlag(LevelComparison.Lower) && abilityLevel < existingAbility.Level) || + (levelOverridePolicy.HasFlag(LevelComparison.Equal) && abilityLevel == existingAbility.Level); + + if (shouldOverride) + { + existingAbility.Level = abilityLevel; + } + + return existingAbility.Handle; + } + + var newAbility = new Ability(Owner, abilityData, abilityLevel, sourceEntity); + GrantedAbilities.Add(newAbility.Handle); + _grantSources[newAbility] = [grantSource]; + + newAbility.IsInhibited = grantSource.IsInhibited; + + return newAbility.Handle; + } + + internal void RemoveGrantedAbility(AbilityHandle abilityHandle, IAbilityGrantSource grantSource) + { + RemoveGrantedAbility(GrantedAbilities.FirstOrDefault(x => x == abilityHandle)?.Ability, grantSource); + } + + internal void RemoveGrantedAbility(Ability? abilityToRemove, IAbilityGrantSource grantSource) + { + if (abilityToRemove is null || grantSource.RemovalPolicy == AbilityDeactivationPolicy.Ignore) + { + return; + } + + List grantSources = _grantSources[abilityToRemove]; + + grantSources.Remove(grantSource); + + if (grantSources.Count > 0) + { + if (CheckIsInhibited()) + { + InhibitAbilityBasedOnPolicy(abilityToRemove, grantSource.InhibitionPolicy); + } + + return; + } + + switch (grantSource.RemovalPolicy) + { + case AbilityDeactivationPolicy.Ignore: + return; + + case AbilityDeactivationPolicy.CancelImmediately: + if (abilityToRemove.IsActive) + { + abilityToRemove.End(); + } + + RemoveAbility(abilityToRemove); + return; + + case AbilityDeactivationPolicy.RemoveOnEnd: + if (abilityToRemove.IsActive) + { + _removeAbility = RemoveAbility; + abilityToRemove.OnAbilityDeactivated += _removeAbility; + return; + } + + RemoveAbility(abilityToRemove); + return; + } + } + + internal void InhibitGrantedAbility(AbilityHandle abilityHandle, IAbilityGrantSource grantSource) + { + InhibitGrantedAbility(GrantedAbilities.FirstOrDefault(x => x == abilityHandle)?.Ability, grantSource); + } + + internal void InhibitGrantedAbility(Ability? abilityToInhibit, IAbilityGrantSource grantSource) + { + if (abilityToInhibit is null || grantSource.InhibitionPolicy == AbilityDeactivationPolicy.Ignore) + { + return; + } + + InhibitAbilityBasedOnPolicy(abilityToInhibit, grantSource.InhibitionPolicy); + } + + internal void NotifyAbilityEnded(AbilityEndedData abilityEndedData) + { + OnAbilityEnded?.Invoke(abilityEndedData); + } + + private void InhibitAbilityBasedOnPolicy(Ability abilityToInhibit, AbilityDeactivationPolicy inhibitionPolicy) + { + switch (inhibitionPolicy) + { + case AbilityDeactivationPolicy.Ignore: + return; + + case AbilityDeactivationPolicy.CancelImmediately: + if (abilityToInhibit.IsActive) + { + abilityToInhibit.End(); + } + + InhibitAbility(abilityToInhibit); + return; + + case AbilityDeactivationPolicy.RemoveOnEnd: + if (abilityToInhibit.IsActive) + { + _inhibitAbility = InhibitAbility; + abilityToInhibit.OnAbilityDeactivated += _inhibitAbility; + } + + return; + } + } + + private void RemoveAbility(Ability abilityToRemove) + { + if (_removeAbility is not null) + { + abilityToRemove.OnAbilityDeactivated -= _removeAbility; + _removeAbility = null; + } + + if (_grantSources.TryGetValue(abilityToRemove, out List? grantSources) + && grantSources?.Count > 0) + { + return; + } + + abilityToRemove.Cleanup(); + abilityToRemove.Handle.Free(); + GrantedAbilities.Remove(abilityToRemove.Handle); + } + + private void InhibitAbility(Ability abilityToInhibit) + { + if (_inhibitAbility is not null) + { + abilityToInhibit.OnAbilityDeactivated -= _inhibitAbility; + _inhibitAbility = null; + } + + abilityToInhibit.IsInhibited = CheckIsInhibited(); + } + + private bool CheckIsInhibited() + { + return _grantSources.Values.All(x => + { + return x.TrueForAll(source => source.IsInhibited); + }); + } +} diff --git a/Forge/Core/EntityAttributes.cs b/Forge/Core/EntityAttributes.cs index 545b9e6..c482faf 100644 --- a/Forge/Core/EntityAttributes.cs +++ b/Forge/Core/EntityAttributes.cs @@ -6,7 +6,8 @@ namespace Gamesmiths.Forge.Core; /// -/// Container class which handles and manages all s and s of an entity. +/// Container class which handles and manages all s and s of an +/// entity. /// Attributes can be accessed with the indexer. /// public class EntityAttributes : IEnumerable diff --git a/Forge/Core/IForgeEntity.cs b/Forge/Core/IForgeEntity.cs index b0dc8d6..39e306f 100644 --- a/Forge/Core/IForgeEntity.cs +++ b/Forge/Core/IForgeEntity.cs @@ -1,6 +1,7 @@ // Copyright © Gamesmiths Guild. using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Events; namespace Gamesmiths.Forge.Core; @@ -23,4 +24,14 @@ public interface IForgeEntity /// Gets the effects manager for this entity. /// EffectsManager EffectsManager { get; } + + /// + /// Gets the abilities manager for this entity. + /// + EntityAbilities Abilities { get; } + + /// + /// Gets the event bus for this entity. + /// + EventManager Events { get; } } diff --git a/Forge/Effects/Stacking/LevelComparison.cs b/Forge/Core/LevelComparison.cs similarity index 73% rename from Forge/Effects/Stacking/LevelComparison.cs rename to Forge/Core/LevelComparison.cs index 082f4a1..27c188c 100644 --- a/Forge/Effects/Stacking/LevelComparison.cs +++ b/Forge/Core/LevelComparison.cs @@ -1,9 +1,9 @@ // Copyright © Gamesmiths Guild. -namespace Gamesmiths.Forge.Effects.Stacking; +namespace Gamesmiths.Forge.Core; /// -/// Type of level comparison for when is set. +/// Flags for comparing levels. /// [Flags] public enum LevelComparison : byte diff --git a/Forge/Cues/CuesManager.cs b/Forge/Cues/CuesManager.cs index 6f3661c..21c5674 100644 --- a/Forge/Cues/CuesManager.cs +++ b/Forge/Cues/CuesManager.cs @@ -130,7 +130,7 @@ internal void ApplyCues(in EffectEvaluatedData effectEvaluatedData) EffectData effectData = effectEvaluatedData.Effect.EffectData; EntityAttributes targetAttributes = effectEvaluatedData.Target.Attributes; - if (!ShouldTriggerCue(in effectData, in targetAttributes)) + if (!ShouldTriggerCue(in effectData, in targetAttributes, CueTriggerRequirement.OnApply)) { return; } @@ -181,7 +181,7 @@ internal void ExecuteCues(in EffectEvaluatedData effectEvaluatedData) EffectData effectData = effectEvaluatedData.Effect.EffectData; EntityAttributes targetAttributes = effectEvaluatedData.Target.Attributes; - if (!ShouldTriggerCue(in effectData, in targetAttributes)) + if (!ShouldTriggerCue(in effectData, in targetAttributes, CueTriggerRequirement.OnExecute)) { return; } @@ -214,7 +214,7 @@ internal void UpdateCues(in EffectEvaluatedData effectEvaluatedData) EffectData effectData = effectEvaluatedData.Effect.EffectData; EntityAttributes targetAttributes = effectEvaluatedData.Target.Attributes; - if (!ShouldTriggerCue(in effectData, in targetAttributes)) + if (!ShouldTriggerCue(in effectData, in targetAttributes, CueTriggerRequirement.OnUpdate)) { return; } @@ -244,9 +244,15 @@ internal void UpdateCues(in EffectEvaluatedData effectEvaluatedData) private static bool ShouldTriggerCue( in EffectData effectData, - in EntityAttributes attributes) + in EntityAttributes attributes, + CueTriggerRequirement triggerRequirements) { - return !effectData.RequireModifierSuccessToTriggerCue || attributes.Any(x => x.PendingValueChange != 0); + if (!effectData.RequireModifierSuccessToTriggerCue.HasFlag(triggerRequirements)) + { + return true; + } + + return attributes.Any(x => x.PendingValueChange != 0); } private static int CalculateMagnitude( diff --git a/Forge/Effects/ActiveEffect.cs b/Forge/Effects/ActiveEffect.cs index daa6783..4c86571 100644 --- a/Forge/Effects/ActiveEffect.cs +++ b/Forge/Effects/ActiveEffect.cs @@ -2,10 +2,13 @@ using Gamesmiths.Forge.Attributes; using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Effects.Components; using Gamesmiths.Forge.Effects.Duration; +using Gamesmiths.Forge.Effects.Magnitudes; using Gamesmiths.Forge.Effects.Modifiers; using Gamesmiths.Forge.Effects.Periodic; using Gamesmiths.Forge.Effects.Stacking; +using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Effects; @@ -16,13 +19,15 @@ internal sealed class ActiveEffect { private const double Epsilon = 0.00001; - private double _internalTime; + private readonly HashSet _nonSnapshotSetByCallerTags; - private bool _isInhibited; + private double _internalTime; internal ActiveEffectHandle Handle { get; } - internal EffectEvaluatedData EffectEvaluatedData { get; private set; } + internal EffectEvaluatedData EffectEvaluatedData { get; } + + internal bool IsInhibited { get; private set; } internal double RemainingDuration { get; set; } @@ -40,20 +45,58 @@ internal sealed class ActiveEffect internal Effect Effect => EffectEvaluatedData.Effect; - internal ActiveEffect(Effect effect, IForgeEntity target) + /// + /// Gets the component instances for this active effect. + /// + /// + /// These are created via when the effect is applied, + /// allowing stateful components to maintain per-effect-instance state. + /// + internal IEffectComponent[] ComponentInstances { get; } + + internal ActiveEffect(Effect effect, IForgeEntity target, EffectApplicationContext? applicationContext) { Handle = new ActiveEffectHandle(this); + // Create component instances for this specific effect application + IEffectComponent[] definitions = effect.EffectData.EffectComponents; + ComponentInstances = new IEffectComponent[definitions.Length]; + for (var i = 0; i < definitions.Length; i++) + { + ComponentInstances[i] = definitions[i].CreateInstance(); + } + if (effect.EffectData.StackingData.HasValue) { - StackCount = effect.EffectData.StackingData.Value.InitialStack.GetValue(EffectEvaluatedData.Level); + StackCount = effect.EffectData.StackingData.Value.InitialStack.GetValue(effect.Level); } else { StackCount = 1; } - EffectEvaluatedData = new EffectEvaluatedData(effect, target, StackCount); + EffectEvaluatedData = new EffectEvaluatedData(effect, target, StackCount, applicationContext: applicationContext); + + _nonSnapshotSetByCallerTags = []; + + ModifierMagnitude? durationMagnitude = effect.EffectData.DurationData.DurationMagnitude; + if (durationMagnitude.HasValue + && durationMagnitude.Value.MagnitudeCalculationType == MagnitudeCalculationType.SetByCaller + && !durationMagnitude.Value.SetByCallerFloat!.Value.Snapshot) + { + _nonSnapshotSetByCallerTags.Add( + effect.EffectData.DurationData.DurationMagnitude!.Value.SetByCallerFloat!.Value.Tag); + } + + for (var i = 0; i < EffectEvaluatedData.Effect.EffectData.Modifiers.Length; i++) + { + SetByCallerFloat? setByCallerFloat = + EffectEvaluatedData.Effect.EffectData.Modifiers[i].Magnitude.SetByCallerFloat; + if (setByCallerFloat.HasValue && !setByCallerFloat.Value.Snapshot) + { + _nonSnapshotSetByCallerTags.Add(setByCallerFloat.Value.Tag); + } + } } internal void Apply(bool reApplication = false, bool inhibited = false) @@ -62,10 +105,10 @@ internal void Apply(bool reApplication = false, bool inhibited = false) { ExecutionCount = 0; _internalTime = 0; - _isInhibited = inhibited; + IsInhibited = inhibited; RemainingDuration = EffectEvaluatedData.Duration; - if (!EffectData.SnapshopLevel) + if (!EffectData.SnapshotLevel) { Effect.OnLevelChanged += Effect_OnLevelChanged; } @@ -74,12 +117,14 @@ internal void Apply(bool reApplication = false, bool inhibited = false) { attribute.OnValueChanged += Attribute_OnValueChanged; } + + Effect.OnSetByCallerFloatChanged += Effect_OnSetByCallerFloatChanged; } if (EffectData.PeriodicData.HasValue) { if (EffectData.PeriodicData.Value.ExecuteOnApplication && - !reApplication && !_isInhibited) + !reApplication && !IsInhibited) { Execute(); } @@ -89,7 +134,7 @@ internal void Apply(bool reApplication = false, bool inhibited = false) NextPeriodicTick = EffectEvaluatedData.Period; } } - else if (!_isInhibited) + else if (!IsInhibited) { ApplyModifiers(); } @@ -97,7 +142,7 @@ internal void Apply(bool reApplication = false, bool inhibited = false) internal void Unapply(bool reApplication = false) { - if (!EffectData.PeriodicData.HasValue && !_isInhibited) + if (!EffectData.PeriodicData.HasValue && !IsInhibited) { ApplyModifiers(true); } @@ -111,10 +156,12 @@ internal void Unapply(bool reApplication = false) attribute.OnValueChanged -= Attribute_OnValueChanged; } - if (!EffectData.SnapshopLevel) + if (!EffectData.SnapshotLevel) { Effect.OnLevelChanged -= Effect_OnLevelChanged; } + + Effect.OnSetByCallerFloatChanged -= Effect_OnSetByCallerFloatChanged; } } @@ -184,18 +231,18 @@ internal bool AddStack(Effect effect, int stacks = 1) return false; } - Effect evaluatedEffect = EffectEvaluatedData.Effect; + Effect evaluatedEffect = Effect; if (stackingData.OwnerDenialPolicy.HasValue) { if (stackingData.OwnerDenialPolicy.Value == StackOwnerDenialPolicy.DenyIfDifferent && - EffectEvaluatedData.Effect.Ownership.Owner != effect.Ownership.Owner) + Effect.Ownership.Owner != effect.Ownership.Owner) { return false; } if (stackingData.OwnerOverridePolicy == StackOwnerOverridePolicy.Override && - EffectEvaluatedData.Effect.Ownership.Owner != effect.Ownership.Owner) + Effect.Ownership.Owner != effect.Ownership.Owner) { evaluatedEffect = effect; hasChanges = true; @@ -216,7 +263,7 @@ internal bool AddStack(Effect effect, int stacks = 1) } } - // It can be a successfull application and still not increase stack count. + // It can be a successful application and still not increase stack count. // In some cases we can even skip re-application. if (resetStacks) { @@ -255,7 +302,7 @@ internal bool AddStack(Effect effect, int stacks = 1) NextPeriodicTick = EffectEvaluatedData.Period; } - if (stackingData.ExecuteOnSuccessfulApplication == true && !_isInhibited) + if (stackingData.ExecuteOnSuccessfulApplication == true && !IsInhibited) { Execute(); } @@ -276,7 +323,7 @@ internal void RemoveStack() EffectEvaluatedData.Target.EffectsManager.OnActiveEffectUnapplied_InternalCall(this); StackCount--; - ReapplyEffect(EffectEvaluatedData.Effect, isStackingCall: true); + ReapplyEffect(Effect, isStackingCall: true); } internal void Update(double deltaTime) @@ -327,16 +374,16 @@ internal void Update(double deltaTime) internal void SetInhibit(bool value) { - if (_isInhibited == value) + if (IsInhibited == value) { return; } - _isInhibited = value; + IsInhibited = value; if (EffectData.PeriodicData.HasValue) { - if (_isInhibited) + if (IsInhibited) { return; } @@ -356,7 +403,9 @@ internal void SetInhibit(bool value) return; } - ApplyModifiers(_isInhibited); + ApplyModifiers(IsInhibited); + + EffectEvaluatedData.Target.EffectsManager.OnActiveEffectChanged_InternalCall(this); } private void ExecutePeriodicEffects(double deltaTime) @@ -367,7 +416,7 @@ private void ExecutePeriodicEffects(double deltaTime) { while (_internalTime >= NextPeriodicTick - Epsilon) { - if (!_isInhibited) + if (!IsInhibited) { Execute(); } @@ -381,12 +430,7 @@ private void ReapplyEffect(Effect effect, int? level = null, bool isStackingCall { Unapply(true); - EffectEvaluatedData = - new EffectEvaluatedData( - effect, - EffectEvaluatedData.Target, - StackCount, - level); + EffectEvaluatedData.ReEvaluate(effect, StackCount, level); Apply(reApplication: true); @@ -394,7 +438,7 @@ private void ReapplyEffect(Effect effect, int? level = null, bool isStackingCall EffectEvaluatedData effectEvaluatedData = EffectEvaluatedData; - if (!EffectEvaluatedData.Effect.EffectData.SuppressStackingCues || !isStackingCall) + if (!Effect.EffectData.SuppressStackingCues || !isStackingCall) { EffectEvaluatedData.Target.EffectsManager.TriggerCuesUpdate_InternalCall(in effectEvaluatedData); } @@ -404,18 +448,18 @@ private void ReapplyEffect(Effect effect, int? level = null, bool isStackingCall private void ApplyModifiers(bool unapply = false) { - var multiplier = unapply ? -1 : 1; - foreach (ModifierEvaluatedData modifier in EffectEvaluatedData.ModifiersEvaluatedData) { switch (modifier.ModifierOperation) { case ModifierOperation.FlatBonus: - modifier.Attribute.AddFlatModifier(multiplier * (int)modifier.Magnitude, modifier.Channel); + var flatMagnitude = unapply ? -(int)modifier.Magnitude : (int)modifier.Magnitude; + modifier.Attribute.AddFlatModifier(flatMagnitude, modifier.Channel); break; case ModifierOperation.PercentBonus: - modifier.Attribute.AddPercentModifier(multiplier * modifier.Magnitude, modifier.Channel); + var percentMagnitude = unapply ? -modifier.Magnitude : modifier.Magnitude; + modifier.Attribute.AddPercentModifier(percentMagnitude, modifier.Channel); break; case ModifierOperation.Override: @@ -423,13 +467,13 @@ private void ApplyModifiers(bool unapply = false) modifier.AttributeOverride is not null, "AttributeOverrideData should never be null at this point."); - if (multiplier == 1) + if (unapply) { - modifier.Attribute.AddOverride(modifier.AttributeOverride.Value); + modifier.Attribute.ClearOverride(modifier.AttributeOverride.Value); break; } - modifier.Attribute.ClearOverride(modifier.AttributeOverride.Value); + modifier.Attribute.AddOverride(modifier.AttributeOverride.Value); break; } } @@ -438,19 +482,65 @@ private void ApplyModifiers(bool unapply = false) private void Execute() { EffectEvaluatedData effectEvaluatedData = EffectEvaluatedData; - Effect.Execute(in effectEvaluatedData); + Effect.Execute(in effectEvaluatedData, ComponentInstances); ExecutionCount++; } + private void UpdateEffectEvaluation() + { + if (!EffectData.DurationData.DurationMagnitude.HasValue) + { + ReapplyEffect(Effect); + return; + } + + var updatedDuration = EffectEvaluatedData.EvaluateDuration(EffectData.DurationData); + + if (EffectEvaluatedData.Duration > updatedDuration + Epsilon + || EffectEvaluatedData.Duration < updatedDuration - Epsilon) + { + RemainingDuration += updatedDuration - EffectEvaluatedData.Duration; + } + + if (RemainingDuration <= 0 + && EffectData.DurationData.DurationType == DurationType.HasDuration + && StackCount == 1) + { + Unapply(); + EffectEvaluatedData.Target.EffectsManager.RemoveActiveEffect_InternalCall(this); + return; + } + + ReapplyEffect(Effect); + } + private void Attribute_OnValueChanged(EntityAttribute attribute, int change) { - // This could be optimized by re-evaluating only the modifiers with the attribute that changed. - ReapplyEffect(EffectEvaluatedData.Effect); + if (!EffectEvaluatedData.AttributesToCapture.Contains(attribute)) + { + return; + } + + UpdateEffectEvaluation(); + } + + private void Effect_OnLevelChanged(int newLevel) + { + if (EffectData.SnapshotLevel) + { + return; + } + + UpdateEffectEvaluation(); } - private void Effect_OnLevelChanged(int obj) + private void Effect_OnSetByCallerFloatChanged(Tag identifierTag, float magnitude) { - // This one has to re-calculate everything that uses ScalableFloats. - ReapplyEffect(EffectEvaluatedData.Effect); + if (!_nonSnapshotSetByCallerTags.Contains(identifierTag)) + { + return; + } + + UpdateEffectEvaluation(); } } diff --git a/Forge/Effects/ActiveEffectHandle.cs b/Forge/Effects/ActiveEffectHandle.cs index 13a9f25..fd26aa6 100644 --- a/Forge/Effects/ActiveEffectHandle.cs +++ b/Forge/Effects/ActiveEffectHandle.cs @@ -1,5 +1,7 @@ // Copyright © Gamesmiths Guild. +using Gamesmiths.Forge.Effects.Components; + namespace Gamesmiths.Forge.Effects; /// @@ -7,6 +9,25 @@ namespace Gamesmiths.Forge.Effects; /// public class ActiveEffectHandle { + /// + /// Gets a value indicating whether the effect is currently inhibited. + /// + public bool IsInhibited => ActiveEffect?.IsInhibited ?? false; + + /// + /// Gets a value indicating whether the handle is valid. + /// + public bool IsValid => ActiveEffect is not null; + + /// + /// Gets the component instances for this active effect. + /// + /// + /// These are the actual component instances used for this specific effect application, + /// which may hold per-instance state (such as granted abilities for ). + /// + public IReadOnlyList ComponentInstances => ActiveEffect?.ComponentInstances ?? []; + internal ActiveEffect? ActiveEffect { get; private set; } internal ActiveEffectHandle(ActiveEffect activeEffect) @@ -23,6 +44,30 @@ public void SetInhibit(bool value) ActiveEffect?.SetInhibit(value); } + /// + /// Gets the first component instance of the specified type. + /// + /// The type of component to find. + /// The component instance, or null if not found. + public T? GetComponent() + where T : class, IEffectComponent + { + if (ActiveEffect is null) + { + return null; + } + + foreach (IEffectComponent component in ActiveEffect.ComponentInstances) + { + if (component is T typed) + { + return typed; + } + } + + return null; + } + internal void Free() { ActiveEffect = null; diff --git a/Forge/Effects/AttributeSnapshotKey.cs b/Forge/Effects/AttributeSnapshotKey.cs new file mode 100644 index 0000000..9fb8f96 --- /dev/null +++ b/Forge/Effects/AttributeSnapshotKey.cs @@ -0,0 +1,12 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Effects.Magnitudes; + +namespace Gamesmiths.Forge.Effects; + +internal readonly record struct AttributeSnapshotKey( + StringKey Attribute, + AttributeCaptureSource Source, + AttributeCalculationType CalculationType, + int FinalChannel); diff --git a/Forge/Effects/Calculator/CustomCalculator.cs b/Forge/Effects/Calculator/CustomCalculator.cs index 31014ac..e93c97c 100644 --- a/Forge/Effects/Calculator/CustomCalculator.cs +++ b/Forge/Effects/Calculator/CustomCalculator.cs @@ -3,6 +3,7 @@ using Gamesmiths.Forge.Attributes; using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Effects.Magnitudes; +using Gamesmiths.Forge.Effects.Modifiers; namespace Gamesmiths.Forge.Effects.Calculator; @@ -27,6 +28,7 @@ public abstract class CustomCalculator /// Definition for the attribute to be captured. /// The effect which makes use of this custom calculator. /// The target of the effect. + /// The evaluated data for the effect. /// Which type of calculation to use to capture the magnitude. /// In case == /// a final channel for the calculation @@ -36,37 +38,138 @@ protected static int CaptureAttributeMagnitude( AttributeCaptureDefinition capturedAttribute, Effect effect, IForgeEntity? target, + EffectEvaluatedData? effectEvaluatedData, AttributeCalculationType calculationType = AttributeCalculationType.CurrentValue, int finalChannel = 0) { - switch (capturedAttribute.Source) + IForgeEntity? captureTarget = capturedAttribute.Source switch { - case AttributeCaptureSource.Source: + AttributeCaptureSource.Source => effect.Ownership.Owner, + AttributeCaptureSource.Target => target, + _ => null, + }; + + if (captureTarget is null) + { + return 0; + } + + var capturedValue = (int)CaptureAttributeSnapshotAware( + capturedAttribute, + calculationType, + finalChannel, + captureTarget, + effectEvaluatedData); + + capturedValue += GetPendingModifierContribution( + capturedAttribute.Attribute, + capturedValue, + captureTarget, + effectEvaluatedData); + + return capturedValue; + } + + private static int GetPendingModifierContribution( + StringKey attribute, + int currentValue, + IForgeEntity captureTarget, + EffectEvaluatedData? effectEvaluatedData) + { + if (!captureTarget.Attributes.ContainsAttribute(attribute) || effectEvaluatedData?.ModifiersEvaluatedData is null) + { + return 0; + } - if (effect.Ownership.Owner?.Attributes.ContainsAttribute(capturedAttribute.Attribute) != true) - { - return 0; - } + Dictionary? pendingFlatBonusByChannel = null; + Dictionary? pendingPercentBonusByChannel = null; + Dictionary? pendingOverrideByChannel = null; + + foreach (ModifierEvaluatedData modifier in effectEvaluatedData.ModifiersEvaluatedData) + { + if (modifier.Attribute.Key != attribute) + { + continue; + } - return CaptureMagnitudeValue( - effect.Ownership.Owner.Attributes[capturedAttribute.Attribute], - calculationType, - finalChannel); + switch (modifier.ModifierOperation) + { + case ModifierOperation.FlatBonus: + pendingFlatBonusByChannel ??= []; + if (!pendingFlatBonusByChannel.TryGetValue(modifier.Channel, out var flatValue)) + { + flatValue = 0f; + } - case AttributeCaptureSource.Target: + pendingFlatBonusByChannel[modifier.Channel] = flatValue + modifier.Magnitude; + break; - if (target?.Attributes.ContainsAttribute(capturedAttribute.Attribute) != true) - { - return 0; - } + case ModifierOperation.PercentBonus: + pendingPercentBonusByChannel ??= []; + if (!pendingPercentBonusByChannel.TryGetValue(modifier.Channel, out var percentValue)) + { + percentValue = 0f; + } - return CaptureMagnitudeValue( - target.Attributes[capturedAttribute.Attribute], - calculationType, - finalChannel); + pendingPercentBonusByChannel[modifier.Channel] = percentValue + modifier.Magnitude; + break; + + case ModifierOperation.Override: + pendingOverrideByChannel ??= []; + pendingOverrideByChannel[modifier.Channel] = modifier.Magnitude; + break; + } + } + + if (pendingFlatBonusByChannel is null && + pendingPercentBonusByChannel is null && + pendingOverrideByChannel is null) + { + return 0; + } + + EntityAttribute entityAttribute = captureTarget.Attributes[attribute]; + var newValue = (int)entityAttribute.CalculateValueWithPendingModifiers( + pendingFlatBonusByChannel, + pendingPercentBonusByChannel, + pendingOverrideByChannel); + + return newValue - currentValue; + } + + private static float CaptureAttributeSnapshotAware( + AttributeCaptureDefinition capturedAttribute, + AttributeCalculationType calculationType, + int finalChannel, + IForgeEntity? sourceEntity, + EffectEvaluatedData? effectEvaluatedData) + { + if (sourceEntity?.Attributes.ContainsAttribute(capturedAttribute.Attribute) != true) + { + return 0f; + } + + EntityAttribute attribute = sourceEntity.Attributes[capturedAttribute.Attribute]; + + if (!capturedAttribute.Snapshot || effectEvaluatedData is null) + { + return CaptureMagnitudeValue(attribute, calculationType, finalChannel); + } + + var key = new AttributeSnapshotKey( + capturedAttribute.Attribute, + capturedAttribute.Source, + calculationType, + finalChannel); + + if (effectEvaluatedData.SnapshotAttributes.TryGetValue(key, out var cachedValue)) + { + return cachedValue; } - return 0; + var currentValue = CaptureMagnitudeValue(attribute, calculationType, finalChannel); + effectEvaluatedData.SnapshotAttributes[key] = currentValue; + return currentValue; } private static int CaptureMagnitudeValue( @@ -85,7 +188,7 @@ private static int CaptureMagnitudeValue( AttributeCalculationType.Max => attribute.Max, AttributeCalculationType.MagnitudeEvaluatedUpToChannel => (int)attribute.CalculateMagnitudeUpToChannel(finalChannel), - _ => throw new ArgumentOutOfRangeException(nameof(calculationType), calculationType, null), + _ => 0, }; } } diff --git a/Forge/Effects/Calculator/CustomExecution.cs b/Forge/Effects/Calculator/CustomExecution.cs index 5432800..f77d42c 100644 --- a/Forge/Effects/Calculator/CustomExecution.cs +++ b/Forge/Effects/Calculator/CustomExecution.cs @@ -1,6 +1,7 @@ // Copyright © Gamesmiths Guild. using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Effects.Magnitudes; namespace Gamesmiths.Forge.Effects.Calculator; @@ -14,6 +15,42 @@ public abstract class CustomExecution : CustomCalculator /// /// The effect to be used as context for the calculation. /// The target entity to be used as context for the calculation. - /// An array of evaluated datas for each modified attribute. - public abstract ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target); + /// The evaluated data for the effect. + /// An array of evaluated data for each modified attribute. + public abstract ModifierEvaluatedData[] EvaluateExecution( + Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData); + + internal static bool ExecutionHasInvalidAttributeCaptures( + CustomExecution execution, + Effect effect, + IForgeEntity target) + { + foreach (AttributeCaptureDefinition capturedAttribute in execution.AttributesToCapture) + { + switch (capturedAttribute.Source) + { + case AttributeCaptureSource.Target: + + if (!target.Attributes.ContainsAttribute(capturedAttribute.Attribute)) + { + return true; + } + + break; + + case AttributeCaptureSource.Source: + + IForgeEntity? sourceEntity = effect.Ownership.Source; + + if (sourceEntity?.Attributes.ContainsAttribute(capturedAttribute.Attribute) != true) + { + return true; + } + + break; + } + } + + return false; + } } diff --git a/Forge/Effects/Calculator/CustomModifierMagnitudeCalculator.cs b/Forge/Effects/Calculator/CustomModifierMagnitudeCalculator.cs index 90280a1..8ac981c 100644 --- a/Forge/Effects/Calculator/CustomModifierMagnitudeCalculator.cs +++ b/Forge/Effects/Calculator/CustomModifierMagnitudeCalculator.cs @@ -14,6 +14,10 @@ public abstract class CustomModifierMagnitudeCalculator : CustomCalculator /// /// The effect to be used as context for the calculation. /// The target entity to be used as context for the calculation. + /// The evaluated data for the effect. /// The custom calculated base magnitude. - public abstract float CalculateBaseMagnitude(Effect effect, IForgeEntity target); + public abstract float CalculateBaseMagnitude( + Effect effect, + IForgeEntity target, + EffectEvaluatedData? effectEvaluatedData); } diff --git a/Forge/Effects/Components/GrantAbilityConfig.cs b/Forge/Effects/Components/GrantAbilityConfig.cs new file mode 100644 index 0000000..724b9cd --- /dev/null +++ b/Forge/Effects/Components/GrantAbilityConfig.cs @@ -0,0 +1,29 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Abilities; +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Effects.Magnitudes; + +namespace Gamesmiths.Forge.Effects.Components; + +/// +/// Configuration for granting an ability to an entity. +/// +/// The data defining the ability to be granted. +/// The level of the granted ability, which can scale based on the effect level. +/// Which policy to use when determining when to remove the granted ability. +/// Which policy to use when determining how the granted ability behaves when it becomes +/// inhibited. +/// Whether to attempt to activate the ability immediately upon granting it. +/// Whether to attempt to activate the ability when the effect is enabled back from +/// inhibition. +/// How to override the level of the granted ability if it already exists on the +/// target. +public readonly record struct GrantAbilityConfig( + AbilityData AbilityData, + ScalableInt ScalableLevel, + AbilityDeactivationPolicy RemovalPolicy, + AbilityDeactivationPolicy InhibitionPolicy, + bool TryActivateOnGrant = false, + bool TryActivateOnEnable = false, + LevelComparison LevelOverridePolicy = LevelComparison.None); diff --git a/Forge/Effects/Components/GrantAbilityEffectComponent.cs b/Forge/Effects/Components/GrantAbilityEffectComponent.cs new file mode 100644 index 0000000..6175cc6 --- /dev/null +++ b/Forge/Effects/Components/GrantAbilityEffectComponent.cs @@ -0,0 +1,177 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Abilities; +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Effects.Components; + +/// +/// Grant an ability to the target when the effect is applied. +/// +/// +/// This component maintains per-effect-instance state (granted abilities, inhibition state). +/// When used in , each effect application will create its own instance +/// via to isolate state between different effect applications. +/// +/// Configurations for the abilities to be granted. +public class GrantAbilityEffectComponent(GrantAbilityConfig[] grantAbilityConfigs) : IEffectComponent +{ + private readonly GrantAbilityConfig[] _grantAbilityConfigs = grantAbilityConfigs; + + private readonly AbilityHandle[] _grantedAbilities = new AbilityHandle[grantAbilityConfigs.Length]; + private readonly IAbilityGrantSource[] _grantSources = new IAbilityGrantSource[grantAbilityConfigs.Length]; + + private bool _hasGrantedAbilities; + private bool _isInhibited; + + /// + /// Gets a read-only list of the granted abilities. + /// + public IReadOnlyList GrantedAbilities => _grantedAbilities; + + /// + public IEffectComponent CreateInstance() + { + // Create a new instance for each effect application to isolate state + return new GrantAbilityEffectComponent(_grantAbilityConfigs); + } + + /// + public void OnEffectExecuted(IForgeEntity target, in EffectEvaluatedData effectEvaluatedData) + { + if (_hasGrantedAbilities) + { + return; + } + + GrantAbilitiesPermanently(target, effectEvaluatedData); + } + + /// + public bool OnActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) + { + GrantAbilities(target, activeEffectEvaluatedData); + + return true; + } + + /// + public void OnPostActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) + { + if (activeEffectEvaluatedData.ActiveEffectHandle.IsInhibited) + { + _isInhibited = activeEffectEvaluatedData.ActiveEffectHandle.IsInhibited; + InhibitGrantedAbilities(target); + return; + } + + for (var i = 0; i < _grantAbilityConfigs.Length; i++) + { + if (_grantAbilityConfigs[i].TryActivateOnGrant) + { + _grantedAbilities[i].Activate(out _); + } + } + } + + /// + public void OnActiveEffectUnapplied( + IForgeEntity target, + in ActiveEffectEvaluatedData activeEffectEvaluatedData, + bool removed) + { + if (removed) + { + RemoveGrantedAbilities(target); + } + } + + /// + public void OnActiveEffectChanged(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) + { + if (_isInhibited != activeEffectEvaluatedData.ActiveEffectHandle.IsInhibited) + { + _isInhibited = activeEffectEvaluatedData.ActiveEffectHandle.IsInhibited; + InhibitGrantedAbilities(target); + + if (!_isInhibited) + { + for (var i = 0; i < _grantAbilityConfigs.Length; i++) + { + if (_grantAbilityConfigs[i].TryActivateOnEnable) + { + _grantedAbilities[i].Activate(out _); + } + } + } + } + } + + private void GrantAbilitiesPermanently(IForgeEntity target, in EffectEvaluatedData effectEvaluatedData) + { + for (var i = 0; i < _grantAbilityConfigs.Length; i++) + { + GrantAbilityConfig config = _grantAbilityConfigs[i]; + + _grantedAbilities[i] = target.Abilities.GrantAbilityPermanently( + config.AbilityData, + config.ScalableLevel.GetValue(effectEvaluatedData.Level), + config.LevelOverridePolicy, + effectEvaluatedData.Effect.Ownership.Source); + } + + _hasGrantedAbilities = true; + + // Try to activate on grant for permanent abilities + for (var i = 0; i < _grantAbilityConfigs.Length; i++) + { + if (_grantAbilityConfigs[i].TryActivateOnGrant) + { + _grantedAbilities[i].Activate(out _); + } + } + } + + private void GrantAbilities(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) + { + for (var i = 0; i < _grantAbilityConfigs.Length; i++) + { + GrantAbilityConfig config = _grantAbilityConfigs[i]; + + var grantSource = new EffectGrantSource( + activeEffectEvaluatedData.ActiveEffectHandle, + config.RemovalPolicy, + config.InhibitionPolicy); + _grantSources[i] = grantSource; + + _grantedAbilities[i] = target.Abilities.GrantAbility( + config.AbilityData, + config.ScalableLevel.GetValue(activeEffectEvaluatedData.EffectEvaluatedData.Level), + config.LevelOverridePolicy, + grantSource, + activeEffectEvaluatedData.EffectEvaluatedData.Effect.Ownership.Source); + } + + _hasGrantedAbilities = true; + } + + private void RemoveGrantedAbilities(IForgeEntity target) + { + for (var i = 0; i < _grantedAbilities.Length; i++) + { + AbilityHandle ability = _grantedAbilities[i]; + target.Abilities.RemoveGrantedAbility(ability, _grantSources[i]); + } + + _hasGrantedAbilities = false; + } + + private void InhibitGrantedAbilities(IForgeEntity target) + { + for (var i = 0; i < _grantedAbilities.Length; i++) + { + AbilityHandle ability = _grantedAbilities[i]; + target.Abilities.InhibitGrantedAbility(ability, _grantSources[i]); + } + } +} diff --git a/Forge/Effects/Components/IEffectComponent.cs b/Forge/Effects/Components/IEffectComponent.cs index 6ebab92..9894ec4 100644 --- a/Forge/Effects/Components/IEffectComponent.cs +++ b/Forge/Effects/Components/IEffectComponent.cs @@ -8,12 +8,43 @@ namespace Gamesmiths.Forge.Effects.Components; /// Interface for implementing custom effect components. Components can be used to extend effects /// functionality and implement custom conditions for application. /// +/// +/// +/// Components are stored in and should be designed as stateless definitions. +/// If a component needs to maintain per-effect-instance state (such as tracking granted abilities, +/// event subscriptions, or other runtime data), it should return a new instance from +/// that implements the stateful behavior. +/// +/// +/// Stateless components can use the default implementation which returns +/// this, allowing the same component instance to be shared across all effect applications. +/// +/// public interface IEffectComponent { + /// + /// Creates an instance of this component for a specific effect application. + /// + /// + /// + /// Override this method to return a new instance when your component needs to maintain + /// per-effect-instance state. The returned instance will be used for all lifecycle callbacks + /// for that specific effect application. + /// + /// + /// Stateless components can use the default implementation which returns this. + /// + /// + /// An instance to use for this effect application. + IEffectComponent CreateInstance() + { + return this; + } + /// /// A custom validation method for validating whether a effect can be applied or not. /// - /// The target of the gampleplay effect. + /// The target of the gameplay effect. /// The effect instance. /// if the effect can be applied; otherwise. /// @@ -30,20 +61,31 @@ bool CanApplyEffect(in IForgeEntity target, in Effect effect) /// /// The target receiving the active effect. /// The evaluated data for the active effect being added. - /// if the applied effect remains active; if it has been + /// if the applied effect remains active; if it has been /// inhibited by the component during application. bool OnActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) { return true; } + /// + /// Executes and implements extra functionality for when an is added to a target after + /// all other components have processed and finished evaluating. + /// + /// The target receiving the active effect. + /// The evaluated data for the active effect being added. + void OnPostActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) + { + // This method is intentionally left blank. + } + /// /// Executes and implements extra functionality for when an is unapplied from a /// target. It's also called when a single stack is removed. The data /// contains the number of stacks just before it's removed, so it's never going to be zero. /// /// - /// Note that only effects with duration can be unappled. + /// Note that only effects with duration can be unapplied. /// /// The target whose the active effect is being removed. /// The evaluated data for the active effect being removed.> @@ -71,11 +113,11 @@ void OnActiveEffectChanged(IForgeEntity target, in ActiveEffectEvaluatedData act /// Executes and implements extra functionality for when a effect is applied to a target. /// /// - /// Note that a effect is considered to be applied both when it's intially added and when a new stack is + /// Note that a effect is considered to be applied both when it's initially added and when a new stack is /// successfully applied. All effects, including instant effects, are considered to be applied and will trigger this /// method. /// - /// The target of the gampleplay effect. + /// The target of the gameplay effect. /// The evaluated data for the effect being applied. void OnEffectApplied(IForgeEntity target, in EffectEvaluatedData effectEvaluatedData) { @@ -88,7 +130,7 @@ void OnEffectApplied(IForgeEntity target, in EffectEvaluatedData effectEvaluated /// /// Note that only instant and periodic effects can be executed on a target. /// - /// The target of the gampleplay effect. + /// The target of the gameplay effect. /// The evaluated data for the effect being applied. void OnEffectExecuted(IForgeEntity target, in EffectEvaluatedData effectEvaluatedData) { diff --git a/Forge/Effects/Components/ModifierTagsEffectComponent.cs b/Forge/Effects/Components/ModifierTagsEffectComponent.cs index a483ea8..561b974 100644 --- a/Forge/Effects/Components/ModifierTagsEffectComponent.cs +++ b/Forge/Effects/Components/ModifierTagsEffectComponent.cs @@ -15,14 +15,14 @@ namespace Gamesmiths.Forge.Effects.Components; /// Which tags to be added as modifier tags. public class ModifierTagsEffectComponent(TagContainer tagsToAdd) : IEffectComponent { - private readonly TagContainer _tagsToAdd = tagsToAdd; + internal TagContainer TagsToAdd { get; } = tagsToAdd; /// public bool OnActiveEffectAdded( IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) { - target.Tags.AddModifierTags(_tagsToAdd); + target.Tags.AddModifierTags(TagsToAdd); return true; } @@ -37,6 +37,6 @@ public void OnActiveEffectUnapplied( return; } - target.Tags.RemoveModifierTags(_tagsToAdd); + target.Tags.RemoveModifierTags(TagsToAdd); } } diff --git a/Forge/Effects/Components/TargetTagRequirementsEffectComponent.cs b/Forge/Effects/Components/TargetTagRequirementsEffectComponent.cs index 0c326a5..f681dad 100644 --- a/Forge/Effects/Components/TargetTagRequirementsEffectComponent.cs +++ b/Forge/Effects/Components/TargetTagRequirementsEffectComponent.cs @@ -11,6 +11,11 @@ namespace Gamesmiths.Forge.Effects.Components; /// define the conditions under which the effect should be removed, while the /// specify conditions for toggling effect inhibition. /// +/// +/// This component maintains per-effect-instance state (event subscriptions). +/// When used in , each effect application will create its own instance +/// via to isolate state between different effect applications. +/// /// Tags required for the effect to be applied. /// Tags that, if met, trigger effect removal. /// Tags that, if met, toggle the inhibition state of the effect. @@ -19,7 +24,7 @@ public class TargetTagRequirementsEffectComponent( TagRequirements? removalTagRequirements = null, TagRequirements? ongoingTagRequirements = null) : IEffectComponent { - private readonly Dictionary> _subscriptionMap = []; + private Action? _handler; private TagRequirements? ApplicationTagRequirements { get; } = applicationTagRequirements; @@ -27,6 +32,16 @@ public class TargetTagRequirementsEffectComponent( private TagRequirements? OngoingTagRequirements { get; } = ongoingTagRequirements; + /// + public IEffectComponent CreateInstance() + { + // Create a new instance for each effect application to isolate event subscription state + return new TargetTagRequirementsEffectComponent( + ApplicationTagRequirements, + RemovalTagRequirements, + OngoingTagRequirements); + } + /// public bool CanApplyEffect(in IForgeEntity target, in Effect effect) { @@ -56,14 +71,14 @@ public bool OnActiveEffectAdded( { ActiveEffectHandle handle = activeEffectEvaluatedData.ActiveEffectHandle; - // Create a distinct handler that captures this 'target' and 'handle' + // Create a handler that captures this 'target' and 'handle' void Handler(TagContainer tags) { if (RemovalTagRequirements.HasValue && !RemovalTagRequirements.Value.IsEmpty && RemovalTagRequirements.Value.RequirementsMet(tags)) { - target.EffectsManager.UnapplyEffect(handle, true); + target.EffectsManager.RemoveEffect(handle, true); return; } @@ -74,10 +89,11 @@ void Handler(TagContainer tags) } // Store it so we can unsubscribe later - _subscriptionMap[handle] = Handler; - target.Tags.OnTagsChanged += Handler; + _handler = Handler; + target.Tags.OnTagsChanged += _handler; - return OngoingTagRequirements?.IsEmpty != false || OngoingTagRequirements.Value.RequirementsMet(target.Tags.CombinedTags); + return OngoingTagRequirements?.IsEmpty != false + || OngoingTagRequirements.Value.RequirementsMet(target.Tags.CombinedTags); } /// @@ -86,12 +102,10 @@ public void OnActiveEffectUnapplied( in ActiveEffectEvaluatedData activeEffectEvaluatedData, bool removed) { - ActiveEffectHandle handle = activeEffectEvaluatedData.ActiveEffectHandle; - - if (removed && _subscriptionMap.TryGetValue(handle, out Action? handler)) + if (removed && _handler is not null) { - target.Tags.OnTagsChanged -= handler; - _subscriptionMap.Remove(handle); + target.Tags.OnTagsChanged -= _handler; + _handler = null; } } } diff --git a/Forge/Effects/CueTriggerRequirement.cs b/Forge/Effects/CueTriggerRequirement.cs new file mode 100644 index 0000000..d4c9200 --- /dev/null +++ b/Forge/Effects/CueTriggerRequirement.cs @@ -0,0 +1,30 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Effects; + +/// +/// Defines the requirements for triggering different types of cues. +/// +[Flags] +public enum CueTriggerRequirement +{ + /// + /// No specific trigger requirements. + /// + None = 0, + + /// + /// Requirement for triggering cues on a persistent effect application. + /// + OnApply = 1 << 0, + + /// + /// Requirement for triggering cues update when effect updates. + /// + OnUpdate = 1 << 1, + + /// + /// Requirement for triggering cues on effect execution. + /// + OnExecute = 1 << 2, +} diff --git a/Forge/Effects/Duration/DurationData.cs b/Forge/Effects/Duration/DurationData.cs index 4fc586e..7213772 100644 --- a/Forge/Effects/Duration/DurationData.cs +++ b/Forge/Effects/Duration/DurationData.cs @@ -8,6 +8,7 @@ namespace Gamesmiths.Forge.Effects.Duration; /// Duration data for a effect. /// /// The type of duration for the effect. -/// The duration for this effect in case it's of type . +/// The duration for this effect in case it's of type +/// . /// -public readonly record struct DurationData(DurationType DurationType, ScalableFloat? Duration = null); +public readonly record struct DurationData(DurationType DurationType, ModifierMagnitude? DurationMagnitude = null); diff --git a/Forge/Effects/Effect.cs b/Forge/Effects/Effect.cs index 1784c8c..d7fa3d4 100644 --- a/Forge/Effects/Effect.cs +++ b/Forge/Effects/Effect.cs @@ -12,36 +12,67 @@ namespace Gamesmiths.Forge.Effects; /// A runtime version of a used to apply the effects with level and ownership /// information. /// -/// The configuration data for this effect. -/// The ownership info for this effect. -/// The initial level for this effect. -public class Effect(EffectData effectData, EffectOwnership ownership, int level = 1) +public class Effect { /// /// Event triggered when the level of this effect changes. /// public event Action? OnLevelChanged; + /// + /// Event triggered when a magnitude changes. + /// + public event Action? OnSetByCallerFloatChanged; + /// /// Gets the configuration data for this effect. /// - public EffectData EffectData { get; } = effectData; + public EffectData EffectData { get; } /// - /// Gets information about the owership and source of this effect. + /// Gets information about the ownership and source of this effect. /// - public EffectOwnership Ownership { get; } = ownership; + public EffectOwnership Ownership { get; } /// /// Gets the current level o this effect. /// - public int Level { get; private set; } = level; + public int Level { get; private set; } /// /// Gets the mapping for custom magnitudes. /// public Dictionary DataTag { get; } = []; + internal TagContainer? CachedGrantedTags { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The configuration data for this effect. + /// The ownership info for this effect. + /// The initial level for this effect. + public Effect(EffectData effectData, EffectOwnership ownership, int level = 1) + { + EffectData = effectData; + Ownership = ownership; + Level = level; + + foreach (IEffectComponent component in EffectData.EffectComponents) + { + if (component is ModifierTagsEffectComponent modifierTagsComponent) + { + if (CachedGrantedTags is null) + { + CachedGrantedTags = new TagContainer(modifierTagsComponent.TagsToAdd); + continue; + } + + CachedGrantedTags.AppendTags(modifierTagsComponent.TagsToAdd); + } + } + } + /// /// Level up this effect by exactly one level. /// @@ -68,10 +99,20 @@ public void SetLevel(int level) /// The magnitude to be set for the given tag. public void SetSetByCallerMagnitude(Tag identifierTag, float magnitude) { + if (DataTag.ContainsKey(identifierTag)) + { + DataTag[identifierTag] = magnitude; + OnSetByCallerFloatChanged?.Invoke(identifierTag, magnitude); + return; + } + DataTag.Add(identifierTag, magnitude); + OnSetByCallerFloatChanged?.Invoke(identifierTag, magnitude); } - internal static void Execute(in EffectEvaluatedData effectEvaluatedData) + internal static void Execute( + in EffectEvaluatedData effectEvaluatedData, + IEffectComponent[]? componentInstances) { foreach (ModifierEvaluatedData modifier in effectEvaluatedData.ModifiersEvaluatedData) { @@ -91,7 +132,9 @@ internal static void Execute(in EffectEvaluatedData effectEvaluatedData) } } - effectEvaluatedData.Target.EffectsManager.OnEffectExecuted_InternalCall(effectEvaluatedData); + effectEvaluatedData.Target.EffectsManager.OnEffectExecuted_InternalCall( + effectEvaluatedData, + componentInstances); effectEvaluatedData.Target.Attributes.ApplyPendingValueChanges(); } diff --git a/Forge/Effects/EffectApplicationContext.Data.cs b/Forge/Effects/EffectApplicationContext.Data.cs new file mode 100644 index 0000000..06da308 --- /dev/null +++ b/Forge/Effects/EffectApplicationContext.Data.cs @@ -0,0 +1,24 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Effects; + +/// +/// Strongly-typed effect application context for passing custom data through the effect pipeline. +/// +/// The type of the context data. +/// +/// Created automatically when using . +/// Access the data in CustomExecution or CustomModifierMagnitudeCalculator via +/// . +/// +/// +/// Initializes a new instance of the class. +/// +/// The custom data for this effect application. +public sealed class EffectApplicationContext(TData data) : EffectApplicationContext +{ + /// + /// Gets the custom context data. + /// + public TData Data { get; } = data; +} diff --git a/Forge/Effects/EffectApplicationContext.cs b/Forge/Effects/EffectApplicationContext.cs new file mode 100644 index 0000000..ddc0205 --- /dev/null +++ b/Forge/Effects/EffectApplicationContext.cs @@ -0,0 +1,39 @@ +// Copyright © Gamesmiths Guild. + +using System.Diagnostics.CodeAnalysis; +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Effects; + +/// +/// Base class for custom effect application context data. +/// Subclass this to pass arbitrary data through the effect pipeline to CustomCalculators and CustomExecutions. +/// +/// +/// This allows custom data to flow through the effect system during application and execution. +/// +public abstract class EffectApplicationContext +{ + /// + /// Attempts to get the context data as a specific type. + /// + /// The expected data type. + /// The extracted data, if successful. + /// if the context contains data of the expected type. + public bool TryGetData([NotNullWhen(true)] out TData? data) + { +#pragma warning disable S3060 // "is" should not be used with "this" + if (this is EffectApplicationContext typedContext) + { + data = typedContext.Data; + Validation.Assert( + data is not null, + "EffectApplicationContext data should never be null for a successfully typed context."); + return true; + } +#pragma warning restore S3060 // "is" should not be used with "this" + + data = default; + return false; + } +} diff --git a/Forge/Effects/EffectData.cs b/Forge/Effects/EffectData.cs index a7dfe92..5540984 100644 --- a/Forge/Effects/EffectData.cs +++ b/Forge/Effects/EffectData.cs @@ -54,19 +54,20 @@ public readonly record struct EffectData public PeriodicData? PeriodicData { get; } /// - /// Gets a value indicating whether this effect snapshots the level at the momment of creation. + /// Gets a value indicating whether this effect snapshots the level at the moment of creation. /// - public bool SnapshopLevel { get; } + public bool SnapshotLevel { get; } /// - /// Gets the list of effect components that further customize this effect behaviour. + /// Gets the list of effect components that further customize this effect behavior. /// public IEffectComponent[] EffectComponents { get; } /// - /// Gets a value indicating whether this effect requires the modifier to be successful to trigger cues. + /// Gets a value indicating whether this effect requires the modifier to be successful to trigger cues for each + /// types of cues. /// - public bool RequireModifierSuccessToTriggerCue { get; } + public CueTriggerRequirement RequireModifierSuccessToTriggerCue { get; } /// /// Gets a value indicating whether this effect suppresses stacking cues. @@ -86,11 +87,11 @@ public readonly record struct EffectData /// The list of modifiers for this effect. /// The stacking data for this effect, if it's stackable. /// The periodic data for this effect, if it's periodic. - /// Whether or not this effect snapshots the level at the momment of creation. + /// Whether or not this effect snapshots the level at the moment of creation. /// /// The list of effects components for this effect. - /// Wheter or not trigger cues only when modifiers are successfully - /// applied. + /// Flags indicating whether or not, and which types of cues are + /// are triggered when modifiers are successfully applied. /// Whether or not to trigger cues when applying stacks. /// The list of custom executions for this effect. /// The cues associated with this effect. @@ -100,9 +101,9 @@ public EffectData( Modifier[]? modifiers = null, StackingData? stackingData = null, PeriodicData? periodicData = null, - bool snapshopLevel = true, + bool snapshotLevel = true, IEffectComponent[]? effectComponents = null, - bool requireModifierSuccessToTriggerCue = false, + CueTriggerRequirement requireModifierSuccessToTriggerCue = CueTriggerRequirement.None, bool suppressStackingCues = false, CustomExecution[]? customExecutions = null, CueData[]? cues = null) @@ -112,7 +113,7 @@ public EffectData( Modifiers = modifiers ?? []; StackingData = stackingData; PeriodicData = periodicData; - SnapshopLevel = snapshopLevel; + SnapshotLevel = snapshotLevel; EffectComponents = effectComponents ?? []; RequireModifierSuccessToTriggerCue = requireModifierSuccessToTriggerCue; SuppressStackingCues = suppressStackingCues; @@ -132,7 +133,7 @@ private void ValidateData() "Periodic effects can't be set as instant."); Validation.Assert( - !(DurationData.DurationType != DurationType.HasDuration && DurationData.Duration.HasValue), + !(DurationData.DurationType != DurationType.HasDuration && DurationData.DurationMagnitude.HasValue), $"Can't set duration if {nameof(DurationType)} is set to {DurationData.DurationType}."); Validation.Assert( @@ -219,7 +220,7 @@ private void ValidateData() $"Effects set as {DurationType.Instant} and {MagnitudeCalculationType.AttributeBased} cannot be set as non Snapshot."); Validation.Assert( - !(DurationData.DurationType == DurationType.Instant && !SnapshopLevel), + !(DurationData.DurationType == DurationType.Instant && !SnapshotLevel), $"Effects set as {DurationType.Instant} cannot be set as non Snapshot for Level."); Validation.Assert( diff --git a/Forge/Effects/EffectEvaluatedData.cs b/Forge/Effects/EffectEvaluatedData.cs index 6b9a1fc..f04ea28 100644 --- a/Forge/Effects/EffectEvaluatedData.cs +++ b/Forge/Effects/EffectEvaluatedData.cs @@ -9,24 +9,27 @@ using Gamesmiths.Forge.Effects.Modifiers; using Gamesmiths.Forge.Effects.Periodic; using Gamesmiths.Forge.Effects.Stacking; +using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Effects; /// -/// Represents the precomputed static data for a effect that has been applied. +/// Represents the precomputed data for a effect that has been applied. /// /// /// Optimizes performance by avoiding repeated complex calculations and serves as data for event arguments. /// -public readonly record struct EffectEvaluatedData +public sealed class EffectEvaluatedData { private const string InvalidPeriodicDataException = "Evaluated period must be greater than zero. A non-positive" + " value would cause the effect to loop indefinitely."; + private readonly int _snapshotLevel; + /// /// Gets the effect for this evaluated data. /// - public Effect Effect { get; } + public Effect Effect { get; private set; } /// /// Gets the target used for the evaluation of this effect. @@ -36,27 +39,27 @@ public readonly record struct EffectEvaluatedData /// /// Gets the stack count of the effect at the moment of the evaluation. /// - public int Stack { get; } + public int Stack { get; private set; } /// /// Gets the level of the effect at the moment of the evaluation. /// - public int Level { get; } + public int Level { get; private set; } /// /// Gets the duration of the effect at the moment of the evaluation. /// - public float Duration { get; } + public float Duration { get; private set; } /// /// Gets the period of the effect at the moment of the evaluation. /// - public float Period { get; } + public float Period { get; private set; } /// /// Gets the evaluated data for the modifiers of the effect. /// - public ModifierEvaluatedData[] ModifiersEvaluatedData { get; } + public ModifierEvaluatedData[] ModifiersEvaluatedData { get; private set; } /// /// Gets an array of the attributes to be captured by an active effect. @@ -64,32 +67,44 @@ public readonly record struct EffectEvaluatedData public EntityAttribute[] AttributesToCapture { get; } /// - /// Getsan array of custom cue parameters. + /// Gets an array of custom cue parameters. /// - public Dictionary? CustomCueParameters { get; } + public Dictionary? CustomCueParameters { get; private set; } + + internal EffectApplicationContext? ApplicationContext { get; } + + internal Dictionary SnapshotAttributes { get; } = []; + + internal Dictionary SnapshotSetByCallers { get; } = []; /// - /// Initializes a new instance of the struct. + /// Initializes a new instance of the class. /// - /// The taget effect of this evaluated data. + /// The target effect of this evaluated data. /// The target of this evaluated data. /// The stack for this evaluated data. /// The level for this evaluated data. + /// Optional custom context data for this effect application. public EffectEvaluatedData( Effect effect, IForgeEntity target, int stack = 1, - int? level = null) + int? level = null, + EffectApplicationContext? applicationContext = null) { Effect = effect; Target = target; Stack = stack; Level = level ?? effect.Level; + ApplicationContext = applicationContext; + + if (effect.EffectData.SnapshotLevel) + { + _snapshotLevel = Level; + } Duration = EvaluateDuration(effect.EffectData.DurationData); Period = EvaluatePeriod(effect.EffectData.PeriodicData); - - // Modifiers should be evaluated after duration and period because it requires those already evaluated. ModifiersEvaluatedData = EvaluateModifiers(); CustomCueParameters = EvaluateCustomCueParameters(); @@ -103,14 +118,54 @@ public EffectEvaluatedData( AttributesToCapture = EvaluateAttributesToCapture(); } - private float EvaluateDuration(DurationData durationData) + /// + /// Gets the application context data as a specific type, if available. + /// + /// The expected data type. + /// The extracted data, if successful. + /// if the context contains data of the expected type. + public bool TryGetContextData([NotNullWhen(true)] out TData? data) { - if (!durationData.Duration.HasValue) + if (ApplicationContext is EffectApplicationContext typedContext) + { + data = typedContext.Data; + Validation.Assert( + data is not null, + "EffectApplicationContext data should never be null for a successfully typed context."); + return true; + } + + data = default; + return false; + } + + internal void ReEvaluate(Effect effect, int stack = 1, int? level = null) + { + Effect = effect; + Stack = stack; + Level = level ?? effect.Level; + ModifiersEvaluatedData = []; + + if (level is null && effect.EffectData.SnapshotLevel) + { + Level = _snapshotLevel; + } + + Duration = EvaluateDuration(effect.EffectData.DurationData); + Period = EvaluatePeriod(effect.EffectData.PeriodicData); + ModifiersEvaluatedData = EvaluateModifiers(); + + CustomCueParameters = EvaluateCustomCueParameters(); + } + + internal float EvaluateDuration(DurationData durationData) + { + if (!durationData.DurationMagnitude.HasValue) { return 0; } - return durationData.Duration.Value.GetValue(Level); + return durationData.DurationMagnitude.Value.GetMagnitude(Effect, Target, Level, this); } private float EvaluatePeriod(PeriodicData? periodicData) @@ -127,7 +182,7 @@ private float EvaluatePeriod(PeriodicData? periodicData) throw new ArgumentOutOfRangeException(nameof(periodicData), InvalidPeriodicDataException); } - return periodicData.Value.Period.GetValue(Level); + return evaluatedDuration; } private ModifierEvaluatedData[] EvaluateModifiers() @@ -142,22 +197,32 @@ private ModifierEvaluatedData[] EvaluateModifiers() continue; } + var baseMagnitude = modifier.Magnitude.GetMagnitude(Effect, Target, Level, this); + var finalMagnitude = ApplyStackPolicy(baseMagnitude); + modifiersEvaluatedData.Add( new ModifierEvaluatedData( Target.Attributes[modifier.Attribute], modifier.Operation, - EvaluateModifierMagnitude(modifier.Magnitude), + finalMagnitude, modifier.Channel)); } + if (Effect.EffectData.CustomExecutions.Length == 0) + { + return [.. modifiersEvaluatedData]; + } + + ModifiersEvaluatedData = [.. modifiersEvaluatedData]; + foreach (CustomExecution execution in Effect.EffectData.CustomExecutions) { - if (ExecutionHasInvalidAttributeCaptures(execution)) + if (CustomExecution.ExecutionHasInvalidAttributeCaptures(execution, Effect, Target)) { continue; } - modifiersEvaluatedData.AddRange(execution.EvaluateExecution(Effect, Target)); + modifiersEvaluatedData.AddRange(execution.EvaluateExecution(Effect, Target, this)); } return [.. modifiersEvaluatedData]; @@ -169,12 +234,19 @@ private EntityAttribute[] EvaluateAttributesToCapture() foreach (ModifierMagnitude modifierMagnitude in Effect.EffectData.Modifiers.Select(x => x.Magnitude)) { - if (!IsModifierSnapshop(modifierMagnitude)) + if (!IsModifierSnapshot(modifierMagnitude)) { attributesToCapture.AddRange(CaptureModifierBackingAttribute(modifierMagnitude)); } } + if (Effect.EffectData.DurationData.DurationType == DurationType.HasDuration + && Effect.EffectData.DurationData.DurationMagnitude.HasValue) + { + attributesToCapture.AddRange( + CaptureModifierBackingAttribute(Effect.EffectData.DurationData.DurationMagnitude.Value)); + } + foreach (CustomExecution execution in Effect.EffectData.CustomExecutions) { foreach (AttributeCaptureDefinition attributeCaptureDefinition in execution.AttributesToCapture) @@ -184,7 +256,9 @@ private EntityAttribute[] EvaluateAttributesToCapture() IForgeEntity? attributeSource = attributeCaptureDefinition.Source == AttributeCaptureSource.Source ? Effect.Ownership.Source : Target; - if (!attributeCaptureDefinition.TryGetAttribute(attributeSource, out EntityAttribute? attributeToCapture)) + if (!attributeCaptureDefinition.TryGetAttribute( + attributeSource, + out EntityAttribute? attributeToCapture)) { continue; } @@ -197,19 +271,19 @@ private EntityAttribute[] EvaluateAttributesToCapture() return [.. attributesToCapture]; } - private float EvaluateModifierMagnitude(ModifierMagnitude modifierMagnitude) + private float ApplyStackPolicy(float baseMagnitude) { - float stackMultiplier = Stack; + var stackMultiplier = Stack; if (Effect.EffectData.StackingData.HasValue && Effect.EffectData.StackingData.Value.MagnitudePolicy == StackMagnitudePolicy.DontStack) { stackMultiplier = 1; } - return modifierMagnitude.GetMagnitude(Effect, Target, Level) * stackMultiplier; + return baseMagnitude * stackMultiplier; } - private bool IsModifierSnapshop(ModifierMagnitude modifierMagnitude) + private bool IsModifierSnapshot(ModifierMagnitude modifierMagnitude) { if (Effect.EffectData.DurationData.DurationType == DurationType.Instant) { @@ -306,37 +380,6 @@ private bool TryGetBackingAttribute( return attributeSource.TryGetAttribute(attributeSourceOwner, out backingAttribute); } - private bool ExecutionHasInvalidAttributeCaptures(CustomExecution execution) - { - foreach (AttributeCaptureDefinition capturedAttribute in execution.AttributesToCapture) - { - switch (capturedAttribute.Source) - { - case AttributeCaptureSource.Target: - - if (!Target.Attributes.ContainsAttribute(capturedAttribute.Attribute)) - { - return true; - } - - break; - - case AttributeCaptureSource.Source: - - IForgeEntity? sourceEntity = Effect.Ownership.Source; - - if (sourceEntity?.Attributes.ContainsAttribute(capturedAttribute.Attribute) != true) - { - return true; - } - - break; - } - } - - return false; - } - private Dictionary? EvaluateCustomCueParameters() { var customParameters = new Dictionary(); @@ -365,7 +408,7 @@ private bool ExecutionHasInvalidAttributeCaptures(CustomExecution execution) foreach (CustomExecution execution in Effect.EffectData.CustomExecutions) { - if (ExecutionHasInvalidAttributeCaptures(execution)) + if (CustomExecution.ExecutionHasInvalidAttributeCaptures(execution, Effect, Target)) { continue; } diff --git a/Forge/Effects/EffectsManager.cs b/Forge/Effects/EffectsManager.cs index 8c8983e..2330fe8 100644 --- a/Forge/Effects/EffectsManager.cs +++ b/Forge/Effects/EffectsManager.cs @@ -4,6 +4,7 @@ using Gamesmiths.Forge.Cues; using Gamesmiths.Forge.Effects.Components; using Gamesmiths.Forge.Effects.Duration; +using Gamesmiths.Forge.Effects.Modifiers; using Gamesmiths.Forge.Effects.Stacking; namespace Gamesmiths.Forge.Effects; @@ -32,47 +33,26 @@ public class EffectsManager(IForgeEntity owner, CuesManager cuesManager) /// public ActiveEffectHandle? ApplyEffect(Effect effect) { - if (!effect.CanApply(Owner)) - { - return null; - } - - if (effect.EffectData.DurationData.DurationType == DurationType.Instant) - { - var evaluatedData = new EffectEvaluatedData(effect, Owner); - - foreach (IEffectComponent component in effect.EffectData.EffectComponents) - { - component.OnEffectApplied(Owner, in evaluatedData); - } - - Effect.Execute(in evaluatedData); - return null; - } - - if (!effect.EffectData.StackingData.HasValue) - { - return ApplyNewEffect(effect).Handle; - } - - ActiveEffect? stackableEffect = FindStackableEffect(effect); - - if (stackableEffect is not null) - { - var successfulApplication = stackableEffect.AddStack(effect); - - if (successfulApplication) - { - foreach (IEffectComponent component in stackableEffect.EffectData.EffectComponents) - { - component.OnEffectApplied(Owner, stackableEffect.EffectEvaluatedData); - } - } - - return stackableEffect.Handle; - } + return ApplyEffectInternal(effect, applicationContext: null); + } - return ApplyNewEffect(effect).Handle; + /// + /// Applies an effect to the owner of this manager with custom context data. + /// + /// The type of custom data to pass through the effect pipeline. + /// The instance of the effect to be applied. + /// Custom data to pass through the effect pipeline to CustomCalculators and + /// CustomExecutions. + /// A handle to the applied effect if it was successfully applied as an . + /// + /// + /// The context data can be accessed in or + /// via + /// . + /// + public ActiveEffectHandle? ApplyEffect(Effect effect, TData contextData) + { + return ApplyEffectInternal(effect, new EffectApplicationContext(contextData)); } /// @@ -80,11 +60,11 @@ public class EffectsManager(IForgeEntity owner, CuesManager cuesManager) /// . /// /// The instance of the active effect to be removed. - /// Forces unapplication even if is set to + /// Forces removal even if is set to /// . - public void UnapplyEffect(ActiveEffectHandle activeEffect, bool forceUnapply = false) + public void RemoveEffect(ActiveEffectHandle activeEffect, bool forceRemoval = false) { - RemoveStackOrUnapply(activeEffect.ActiveEffect, forceUnapply); + RemoveStackOrUnapply(activeEffect.ActiveEffect, forceRemoval); } /// @@ -92,26 +72,26 @@ public void UnapplyEffect(ActiveEffectHandle activeEffect, bool forceUnapply = f /// . /// /// The instance of the effect to be removed. - /// Forces unapplication even if is set to + /// Forces removal even if is set to /// . - public void UnapplyEffect(Effect effect, bool forceUnapply = false) + public void RemoveEffect(Effect effect, bool forceRemoval = false) { - RemoveStackOrUnapply(FilterEffectsByEffect(effect).FirstOrDefault(), forceUnapply); + RemoveStackOrUnapply(FilterEffectsByEffect(effect).FirstOrDefault(), forceRemoval); } /// - /// Unapply an effect based on an or an stack if it's a stackable effect with + /// Removes an effect based on an or an stack if it's a stackable effect with /// . /// /// /// This method searches for the first instance of the given effect data it can find and removes it. /// /// Which effect data to look for to removal. - /// /// Forces unapplication even if is set to + /// /// Forces removal even if is set to /// . - public void UnapplyEffectData(EffectData effectData, bool forceUnapply = false) + public void RemoveEffectData(EffectData effectData, bool forceRemoval = false) { - RemoveStackOrUnapply(FilterEffectsByData(effectData).FirstOrDefault(), forceUnapply); + RemoveStackOrUnapply(FilterEffectsByData(effectData).FirstOrDefault(), forceRemoval); } /// @@ -123,12 +103,13 @@ public void UnapplyEffectData(EffectData effectData, bool forceUnapply = false) /// Time passed since the last update call. public void UpdateEffects(double deltaTime) { - foreach (ActiveEffect effect in _activeEffects) + ActiveEffect[] effectsToUpdate = [.. _activeEffects]; + foreach (ActiveEffect effect in effectsToUpdate) { effect.Update(deltaTime); } - foreach (ActiveEffect expiredEffect in _activeEffects.Where(x => x.IsExpired).ToArray()) + foreach (ActiveEffect expiredEffect in effectsToUpdate.Where(x => x.IsExpired).ToArray()) { RemoveActiveEffect(expiredEffect, false); } @@ -145,11 +126,12 @@ public IEnumerable GetEffectInfo(EffectData effectData) return ConvertToStackInstanceData(filteredEffects); } - internal void OnEffectExecuted_InternalCall(EffectEvaluatedData executedEffectEvaluatedData) + internal void OnEffectExecuted_InternalCall( + EffectEvaluatedData executedEffectEvaluatedData, + IEffectComponent[]? componentInstances) { - EffectData effectData = executedEffectEvaluatedData.Effect.EffectData; - - foreach (IEffectComponent component in effectData.EffectComponents) + foreach (IEffectComponent component in componentInstances + ?? executedEffectEvaluatedData.Effect.EffectData.EffectComponents) { component.OnEffectExecuted(Owner, in executedEffectEvaluatedData); } @@ -159,7 +141,7 @@ internal void OnEffectExecuted_InternalCall(EffectEvaluatedData executedEffectEv internal void OnActiveEffectUnapplied_InternalCall(ActiveEffect removedEffect) { - foreach (IEffectComponent component in removedEffect.Effect.EffectData.EffectComponents) + foreach (IEffectComponent component in removedEffect.ComponentInstances) { component.OnActiveEffectUnapplied( Owner, @@ -175,7 +157,7 @@ internal void OnActiveEffectUnapplied_InternalCall(ActiveEffect removedEffect) internal void OnActiveEffectChanged_InternalCall(ActiveEffect removedEffect) { - foreach (IEffectComponent component in removedEffect.EffectData.EffectComponents) + foreach (IEffectComponent component in removedEffect.ComponentInstances) { component.OnActiveEffectChanged( Owner, @@ -193,6 +175,24 @@ internal void TriggerCuesUpdate_InternalCall(in EffectEvaluatedData effectEvalua _cuesManager.UpdateCues(in effectEvaluatedData); } + internal void RemoveActiveEffect_InternalCall(ActiveEffect effect) + { + RemoveActiveEffect(effect, false); + } + + internal bool CanApplyEffect(Effect costEffect, int level) + { + foreach (Modifier modifier in costEffect.EffectData.Modifiers) + { + if (!modifier.CanApply(costEffect, Owner, level)) + { + return false; + } + } + + return true; + } + private static bool MatchesStackPolicy(ActiveEffect existingEffect, Effect newEffect) { Validation.Assert( @@ -245,14 +245,67 @@ private IEnumerable FilterEffectsByEffect(Effect effect) MatchesStackLevelPolicy(x, effect)); } - private ActiveEffect ApplyNewEffect(Effect effect) + private ActiveEffectHandle? ApplyEffectInternal(Effect effect, EffectApplicationContext? applicationContext) + { + if (!effect.CanApply(Owner)) + { + return null; + } + + if (effect.EffectData.DurationData.DurationType == DurationType.Instant) + { + var evaluatedData = new EffectEvaluatedData(effect, Owner, applicationContext: applicationContext); + + // Create component instances for instant effects to ensure stateful components + IEffectComponent[] definitions = effect.EffectData.EffectComponents; + var componentInstances = new IEffectComponent[definitions.Length]; + for (var i = 0; i < definitions.Length; i++) + { + componentInstances[i] = definitions[i].CreateInstance(); + } + + foreach (IEffectComponent component in effect.EffectData.EffectComponents) + { + component.OnEffectApplied(Owner, in evaluatedData); + } + + Effect.Execute(in evaluatedData, componentInstances); + return null; + } + + if (!effect.EffectData.StackingData.HasValue) + { + return ApplyNewEffect(effect, applicationContext).Handle; + } + + ActiveEffect? stackableEffect = FindStackableEffect(effect); + + if (stackableEffect is not null) + { + var successfulApplication = stackableEffect.AddStack(effect); + + if (successfulApplication) + { + foreach (IEffectComponent component in stackableEffect.ComponentInstances) + { + component.OnEffectApplied(Owner, stackableEffect.EffectEvaluatedData); + } + } + + return stackableEffect.Handle; + } + + return ApplyNewEffect(effect, applicationContext).Handle; + } + + private ActiveEffect ApplyNewEffect(Effect effect, EffectApplicationContext? applicationContext) { - var activeEffect = new ActiveEffect(effect, Owner); + var activeEffect = new ActiveEffect(effect, Owner, applicationContext); _activeEffects.Add(activeEffect); var remainActive = true; - foreach (IEffectComponent component in effect.EffectData.EffectComponents) + foreach (IEffectComponent component in activeEffect.ComponentInstances) { remainActive &= component.OnActiveEffectAdded( Owner, @@ -285,17 +338,29 @@ private ActiveEffect ApplyNewEffect(Effect effect) effectEvaluatedData.Target.Attributes.ApplyPendingValueChanges(); + foreach (IEffectComponent component in activeEffect.ComponentInstances) + { + component.OnPostActiveEffectAdded( + Owner, + new ActiveEffectEvaluatedData( + activeEffect.Handle, + activeEffect.EffectEvaluatedData, + activeEffect.RemainingDuration, + activeEffect.NextPeriodicTick, + activeEffect.ExecutionCount)); + } + return activeEffect; } - private void RemoveStackOrUnapply(ActiveEffect? effectToRemove, bool forceUnapply) + private void RemoveStackOrUnapply(ActiveEffect? effectToRemove, bool forceRemoval) { if (effectToRemove is null) { return; } - if (!forceUnapply + if (!forceRemoval && effectToRemove.EffectData.StackingData.HasValue && effectToRemove.EffectData.StackingData.Value.ExpirationPolicy == StackExpirationPolicy.RemoveSingleStackAndRefreshDuration) @@ -313,11 +378,11 @@ private void RemoveStackOrUnapply(ActiveEffect? effectToRemove, bool forceUnappl if (effectToRemove.EffectData.DurationData.DurationType == DurationType.HasDuration) { - forceUnapply = true; + forceRemoval = true; } effectToRemove.Unapply(); - RemoveActiveEffect(effectToRemove, forceUnapply); + RemoveActiveEffect(effectToRemove, forceRemoval); } private void RemoveActiveEffect(ActiveEffect effectToRemove, bool interrupted) @@ -331,7 +396,7 @@ private void RemoveActiveEffect(ActiveEffect effectToRemove, bool interrupted) EffectEvaluatedData effectEvaluatedData = effectToRemove.EffectEvaluatedData; - foreach (IEffectComponent component in effectToRemove.EffectData.EffectComponents) + foreach (IEffectComponent component in effectToRemove.ComponentInstances) { component.OnActiveEffectUnapplied( Owner, diff --git a/Forge/Effects/Magnitudes/AttributeBasedFloat.cs b/Forge/Effects/Magnitudes/AttributeBasedFloat.cs index d023a67..c273a10 100644 --- a/Forge/Effects/Magnitudes/AttributeBasedFloat.cs +++ b/Forge/Effects/Magnitudes/AttributeBasedFloat.cs @@ -17,9 +17,11 @@ namespace Gamesmiths.Forge.Effects.Magnitudes; /// /// Information about which and attribute should be captured and how. /// How the magnitude is going to be extracted from the given attribute. -/// Value to be multiplied with the calculcuated magnitude. -/// Value to be added to the magnitude before multiplying the coeficient. -/// Value to be added to the magnitude after multiplying the coeficient. +/// Value to be multiplied with the calculated magnitude. +/// Value to be added to the magnitude before multiplying the coefficient. +/// +/// Value to be added to the magnitude after multiplying the coefficient. +/// /// In case == /// a final channel for the calculation /// must be provided. @@ -34,90 +36,82 @@ public readonly record struct AttributeBasedFloat( int FinalChannel = 0, ICurve? LookupCurve = null) { - /// - /// Calculates the final magnitude based on the AttributeBasedFloat configurations. - /// - /// The source effect that will be used to capture source attributes from. - /// The target enity that will be used to capture source attributes from. - /// Level to use in the magnitude calculation. - /// The calculated magnitude for this . - public readonly float CalculateMagnitude(Effect effect, IForgeEntity target, int level) + internal readonly float CalculateMagnitude( + Effect effect, + IForgeEntity target, + int level, + Dictionary? snapshotAttributes) { - EntityAttribute? attribute = null; + float magnitude = 0; switch (BackingAttribute.Source) { case AttributeCaptureSource.Source: - - if (effect.Ownership.Owner?.Attributes.ContainsAttribute(BackingAttribute.Attribute) != true) - { - break; - } - - attribute = effect.Ownership.Owner.Attributes[BackingAttribute.Attribute]; + magnitude = CaptureAttributeSnapshotAware(effect.Ownership.Owner, snapshotAttributes); break; case AttributeCaptureSource.Target: - - if (!target.Attributes.ContainsAttribute(BackingAttribute.Attribute)) - { - break; - } - - attribute = target.Attributes[BackingAttribute.Attribute]; + magnitude = CaptureAttributeSnapshotAware(target, snapshotAttributes); break; } - if (attribute is null) + var finalMagnitude = (Coefficient.GetValue(level) * (PreMultiplyAdditiveValue.GetValue(level) + magnitude)) + + PostMultiplyAdditiveValue.GetValue(level); + + if (LookupCurve is not null) { - return 0f; + finalMagnitude = LookupCurve.Evaluate(finalMagnitude); } - float magnitude = 0; + return finalMagnitude; + } - switch (AttributeCalculationType) + private float CaptureAttributeSnapshotAware( + IForgeEntity? sourceEntity, + Dictionary? snapshotAttributes) + { + if (sourceEntity?.Attributes.ContainsAttribute(BackingAttribute.Attribute) != true) { - case AttributeCalculationType.CurrentValue: - magnitude = attribute.CurrentValue; - break; - - case AttributeCalculationType.BaseValue: - magnitude = attribute.BaseValue; - break; - - case AttributeCalculationType.Modifier: - magnitude = attribute.Modifier; - break; - - case AttributeCalculationType.Overflow: - magnitude = attribute.Overflow; - break; - - case AttributeCalculationType.ValidModifier: - magnitude = attribute.ValidModifier; - break; - - case AttributeCalculationType.Min: - magnitude = attribute.Min; - break; + return 0f; + } - case AttributeCalculationType.Max: - magnitude = attribute.Max; - break; + EntityAttribute attribute = sourceEntity.Attributes[BackingAttribute.Attribute]; - case AttributeCalculationType.MagnitudeEvaluatedUpToChannel: - magnitude = attribute.CalculateMagnitudeUpToChannel(FinalChannel); - break; + if (!BackingAttribute.Snapshot || snapshotAttributes is null) + { + return CaptureNow(attribute); } - var finalMagnitude = (Coefficient.GetValue(level) * (PreMultiplyAdditiveValue.GetValue(level) + magnitude)) - + PostMultiplyAdditiveValue.GetValue(level); + var key = new AttributeSnapshotKey( + BackingAttribute.Attribute, + BackingAttribute.Source, + AttributeCalculationType, + FinalChannel); - if (LookupCurve is not null) + if (snapshotAttributes.TryGetValue(key, out var cachedValue)) { - finalMagnitude = LookupCurve.Evaluate(finalMagnitude); + return cachedValue; } - return finalMagnitude; + var currentValue = CaptureNow(attribute); + snapshotAttributes[key] = currentValue; + return currentValue; + } + + private int CaptureNow(EntityAttribute attribute) + { + return AttributeCalculationType switch + { + AttributeCalculationType.CurrentValue => attribute.CurrentValue, + AttributeCalculationType.BaseValue => attribute.BaseValue, + AttributeCalculationType.Modifier => attribute.Modifier, + AttributeCalculationType.Overflow => attribute.Overflow, + AttributeCalculationType.ValidModifier => attribute.ValidModifier, + AttributeCalculationType.Min => attribute.Min, + AttributeCalculationType.Max => attribute.Max, + AttributeCalculationType.MagnitudeEvaluatedUpToChannel => + (int)attribute.CalculateMagnitudeUpToChannel(FinalChannel), + _ => 0, + }; } } diff --git a/Forge/Effects/Magnitudes/AttributeCaptureDefinition.cs b/Forge/Effects/Magnitudes/AttributeCaptureDefinition.cs index 33f4680..28e249b 100644 --- a/Forge/Effects/Magnitudes/AttributeCaptureDefinition.cs +++ b/Forge/Effects/Magnitudes/AttributeCaptureDefinition.cs @@ -7,7 +7,7 @@ namespace Gamesmiths.Forge.Effects.Magnitudes; /// -/// Set of data that definies how an attribute is going to be captured. +/// Set of data that defines how an attribute is going to be captured. /// /// Which attribute to capture. /// From what target to capture the attribute from. diff --git a/Forge/Effects/Magnitudes/CustomCalculationBasedFloat.cs b/Forge/Effects/Magnitudes/CustomCalculationBasedFloat.cs index b3f840d..0e7a38e 100644 --- a/Forge/Effects/Magnitudes/CustomCalculationBasedFloat.cs +++ b/Forge/Effects/Magnitudes/CustomCalculationBasedFloat.cs @@ -28,16 +28,13 @@ public readonly record struct CustomCalculationBasedFloat( ScalableFloat PostMultiplyAdditiveValue, ICurve? LookupCurve = null) { - /// - /// Calculates the final magnitude based on the CustomCalculationBasedFloat configurations. - /// - /// The source effect that will be used for calculating this magnitude. - /// The target of the effect to be used for calcuilating this magnitude. - /// Level to use in the final magnitude calculation. - /// The calculated magnitude for this . - public float CalculateMagnitude(in Effect effect, IForgeEntity target, int level) + internal float CalculateMagnitude( + in Effect effect, + IForgeEntity target, + int level, + EffectEvaluatedData? effectEvaluatedData) { - var baseMagnitude = MagnitudeCalculatorClass.CalculateBaseMagnitude(effect, target); + var baseMagnitude = MagnitudeCalculatorClass.CalculateBaseMagnitude(effect, target, effectEvaluatedData); var finalMagnitude = (Coefficient.GetValue(level) * (PreMultiplyAdditiveValue.GetValue(level) + baseMagnitude)) + PostMultiplyAdditiveValue.GetValue(level); diff --git a/Forge/Effects/Magnitudes/ModifierMagnitude.cs b/Forge/Effects/Magnitudes/ModifierMagnitude.cs index 410d42b..b6ed7bf 100644 --- a/Forge/Effects/Magnitudes/ModifierMagnitude.cs +++ b/Forge/Effects/Magnitudes/ModifierMagnitude.cs @@ -90,16 +90,11 @@ public ModifierMagnitude( SetByCallerFloat = setByCallerFloat; } - /// - /// Gets the calculated magnitude for a given and this - /// configurations. - /// - /// The effect to calculate the magnitude for. - /// The target which might be used for the magnitude calculation. - /// An optional custom level used for magnitude calculation. Will use the effect's level if not - /// provided. - /// The evaluated magnitude. - public readonly float GetMagnitude(Effect effect, IForgeEntity target, int? level = null) + internal readonly float GetMagnitude( + Effect effect, + IForgeEntity target, + int level, + EffectEvaluatedData? effectEvaluatedData = null) { switch (MagnitudeCalculationType) { @@ -107,24 +102,38 @@ public readonly float GetMagnitude(Effect effect, IForgeEntity target, int? leve Validation.Assert( ScalableFloatMagnitude.HasValue, $"{nameof(ScalableFloatMagnitude)} should always have a value at this point."); - return ScalableFloatMagnitude.Value.GetValue(level ?? effect.Level); + return ScalableFloatMagnitude.Value.GetValue(level); case MagnitudeCalculationType.AttributeBased: Validation.Assert( AttributeBasedFloat.HasValue, $"{nameof(AttributeBasedFloat)} should always have a value at this point."); - return AttributeBasedFloat.Value.CalculateMagnitude(effect, target, level ?? effect.Level); + return AttributeBasedFloat.Value.CalculateMagnitude( + effect, target, level, effectEvaluatedData?.SnapshotAttributes); case MagnitudeCalculationType.CustomCalculatorClass: Validation.Assert( CustomCalculationBasedFloat.HasValue, $"{nameof(CustomCalculationBasedFloat)} should always have a value at this point."); - return CustomCalculationBasedFloat.Value.CalculateMagnitude(effect, target, level ?? effect.Level); + return CustomCalculationBasedFloat.Value.CalculateMagnitude(effect, target, level, effectEvaluatedData); case MagnitudeCalculationType.SetByCaller: Validation.Assert( SetByCallerFloat.HasValue, $"{nameof(SetByCallerFloat)} should always have a value at this point."); + + if (SetByCallerFloat.Value.Snapshot && effectEvaluatedData is not null) + { + if (effectEvaluatedData.SnapshotSetByCallers.TryGetValue( + SetByCallerFloat.Value.Tag, out var snapshotValue)) + { + return snapshotValue; + } + + effectEvaluatedData.SnapshotSetByCallers.Add( + SetByCallerFloat.Value.Tag, effect.DataTag[SetByCallerFloat.Value.Tag]); + } + return effect.DataTag[SetByCallerFloat.Value.Tag]; default: diff --git a/Forge/Effects/Magnitudes/SetByCallerFloat.cs b/Forge/Effects/Magnitudes/SetByCallerFloat.cs index 22f2b83..e543ef0 100644 --- a/Forge/Effects/Magnitudes/SetByCallerFloat.cs +++ b/Forge/Effects/Magnitudes/SetByCallerFloat.cs @@ -8,7 +8,8 @@ namespace Gamesmiths.Forge.Effects.Magnitudes; /// A set by caller float is a magnitude used for allowing the caller to set a specific value before applying an effect. /// /// The tag used to identify this custom magnitude. +/// Whether this magnitude should be snapshotted when the effect is applied. /// /// A is used for mapping different possible custom values. /// -public readonly record struct SetByCallerFloat(Tag Tag); +public readonly record struct SetByCallerFloat(Tag Tag, bool Snapshot = true); diff --git a/Forge/Effects/Modifiers/Modifier.cs b/Forge/Effects/Modifiers/Modifier.cs index b063baa..3bc41b3 100644 --- a/Forge/Effects/Modifiers/Modifier.cs +++ b/Forge/Effects/Modifiers/Modifier.cs @@ -1,5 +1,6 @@ // Copyright © Gamesmiths Guild. +using Gamesmiths.Forge.Attributes; using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Effects.Magnitudes; @@ -16,4 +17,29 @@ public readonly record struct Modifier( StringKey Attribute, ModifierOperation Operation, ModifierMagnitude Magnitude, - int Channel = 0); + int Channel = 0) +{ + internal bool CanApply(Effect effect, IForgeEntity target, int level) + { + if (!target.Attributes.ContainsAttribute(Attribute)) + { + return false; + } + + var magnitude = Magnitude.GetMagnitude(effect, target, level); + + EntityAttribute attribute = target.Attributes[Attribute]; + + if (magnitude < 0) + { + return magnitude >= attribute.Min - attribute.CurrentValue; + } + + if (magnitude > 0) + { + return magnitude <= attribute.Max - attribute.CurrentValue; + } + + return true; + } +} diff --git a/Forge/Effects/Stacking/StackingData.cs b/Forge/Effects/Stacking/StackingData.cs index 3bafd8a..ada2c99 100644 --- a/Forge/Effects/Stacking/StackingData.cs +++ b/Forge/Effects/Stacking/StackingData.cs @@ -1,5 +1,6 @@ // Copyright © Gamesmiths Guild. +using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Effects.Magnitudes; namespace Gamesmiths.Forge.Effects.Stacking; @@ -17,12 +18,12 @@ namespace Gamesmiths.Forge.Effects.Stacking; /// How to handle applications from different owner. /// How to handle the effect's instance owner when accepting application /// from different owner. -/// How to handle the stack count when the owner is overriden. +/// How to handle the stack count when the owner is overridden. /// /// How to handle stack applications of different levels. /// How to handle the effect's instance level when accepting applications of different /// levels. -/// How to handle the stack count when the level is overriden. +/// How to handle the stack count when the level is overridden. /// What happens with the stack duration when a new stack is applied. /// What happens with periodic durations when a new stack is applied. /// Whether the effect executes when a new stack is applied. diff --git a/Forge/Events/EventData.cs b/Forge/Events/EventData.cs new file mode 100644 index 0000000..a66d7dc --- /dev/null +++ b/Forge/Events/EventData.cs @@ -0,0 +1,69 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Events; + +/// +/// Represents data associated with an event within the Forge framework. +/// +public readonly record struct EventData +{ + /// + /// Gets the tags associated with the event. + /// + public TagContainer EventTags { get; init; } + + /// + /// Gets the source entity that triggered the event. + /// + public IForgeEntity? Source { get; init; } + + /// + /// Gets the target entity of the event. + /// + public IForgeEntity? Target { get; init; } + + /// + /// Gets the magnitude or intensity of the event. + /// + public float EventMagnitude { get; init; } + + /// + /// Gets any additional payload data associated with the event. + /// + public object? Payload { get; init; } +} + +/// +/// Represents data associated with an event within the Forge framework. +/// +/// The type of the payload data associated with the event. +public readonly record struct EventData +{ + /// + /// Gets the tags associated with the event. + /// + public TagContainer EventTags { get; init; } + + /// + /// Gets the source entity that triggered the event. + /// + public IForgeEntity? Source { get; init; } + + /// + /// Gets the target entity of the event. + /// + public IForgeEntity? Target { get; init; } + + /// + /// Gets the magnitude or intensity of the event. + /// + public float EventMagnitude { get; init; } + + /// + /// Gets the additional payload data associated with the event. + /// + public TPayload Payload { get; init; } +} diff --git a/Forge/Events/EventManager.cs b/Forge/Events/EventManager.cs new file mode 100644 index 0000000..82e928c --- /dev/null +++ b/Forge/Events/EventManager.cs @@ -0,0 +1,151 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Events; + +/// +/// Per-entity event bus that supports both non-generic and generic (typed) event subscriptions. +/// Subscriptions are ordered by priority (higher priority invoked first). +/// Generic handlers are invoked without boxing. Generic raises do NOT forward to non-generic handlers. +/// +public sealed class EventManager +{ + private readonly List _nonGeneric = []; + private readonly Dictionary> _genericByType = []; + + /// + /// Raise a non-generic event. + /// + /// The event data to raise. + public void Raise(in EventData data) + { + for (var i = 0; i < _nonGeneric.Count; i++) + { + NonGenericSubscription sub = _nonGeneric[i]; + if (!data.EventTags.HasTag(sub.EventTag)) + { + continue; + } + + sub.Handler.Invoke(data); + } + } + + /// + /// Raise a generic event. + /// + /// The type of the payload associated with the event. + /// The event data to raise. + public void Raise(in EventData data) + { + Type key = typeof(TPayload); + if (_genericByType.TryGetValue(key, out List? typedList)) + { + for (var i = 0; i < typedList.Count; i++) + { + GenericSubscription sub = typedList[i]; + if (!data.EventTags.HasTag(sub.EventTag)) + { + continue; + } + + ((Action>)sub.Handler).Invoke(data); + } + } + } + + /// + /// Subscribe using a tag; returns a token for later un-subscription. + /// + /// The event tag to subscribe to. + /// The handler to invoke when the event is raised. + /// The priority of the subscription; higher values indicate higher priority. + /// The subscription token for later un-subscription. + public EventSubscriptionToken Subscribe(Tag eventTag, Action handler, int priority = 0) + { + var token = new EventSubscriptionToken(Guid.NewGuid()); + _nonGeneric.Add(new NonGenericSubscription(token, eventTag, priority, handler)); + + _nonGeneric.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + return token; + } + + /// + /// Subscribe using a tag; returns a token for later un-subscription. + /// + /// The type of the payload associated with the event. + /// The event tag to subscribe to. + /// The handler to invoke when the event is raised. + /// The priority of the subscription; higher values indicate higher priority. + /// The subscription token for later un-subscription. + public EventSubscriptionToken Subscribe( + Tag eventTag, + Action> handler, + int priority = 0) + { + var token = new EventSubscriptionToken(Guid.NewGuid()); + Type key = typeof(TPayload); + + if (!_genericByType.TryGetValue(key, out List? list)) + { + list = []; + _genericByType[key] = list; + } + + list.Add(new GenericSubscription(token, eventTag, priority, handler)); + + list.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + return token; + } + + /// + /// Unsubscribe using the provided token; returns if successful. + /// + /// The subscription token to unsubscribe. + /// if un-subscription was successful; otherwise, . + public bool Unsubscribe(EventSubscriptionToken token) + { + var removed = _nonGeneric.RemoveAll(x => x.Token == token) > 0; + + List? keysToRemove = null; + foreach (KeyValuePair> keyValuePair in _genericByType) + { + List list = keyValuePair.Value; + if (list.RemoveAll(x => x.Token == token) > 0) + { + removed = true; + } + + if (list.Count == 0) + { + keysToRemove ??= []; + keysToRemove.Add(keyValuePair.Key); + } + } + + if (keysToRemove is null) + { + return removed; + } + + for (var i = 0; i < keysToRemove.Count; i++) + { + _genericByType.Remove(keysToRemove[i]); + } + + return removed; + } + + private readonly record struct NonGenericSubscription( + EventSubscriptionToken Token, + Tag EventTag, + int Priority, + Action Handler); + + private readonly record struct GenericSubscription( + EventSubscriptionToken Token, + Tag EventTag, + int Priority, + Delegate Handler); +} diff --git a/Forge/Events/EventSubscriptionToken.cs b/Forge/Events/EventSubscriptionToken.cs new file mode 100644 index 0000000..89fca52 --- /dev/null +++ b/Forge/Events/EventSubscriptionToken.cs @@ -0,0 +1,9 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Events; + +/// +/// Represents a unique identifier for an event subscription. +/// +/// The unique identifier for the subscription. +public readonly record struct EventSubscriptionToken(Guid Id); diff --git a/Forge/Tags/Tag.cs b/Forge/Tags/Tag.cs index 4fe8ea7..191d4c4 100644 --- a/Forge/Tags/Tag.cs +++ b/Forge/Tags/Tag.cs @@ -56,7 +56,7 @@ public static Tag RequestTag(TagsManager tagsManager, StringKey tagKey, bool err /// Serializes the given into a net index. /// /// - /// TODO: Use a propper BitStream or similar solution in the future. + /// TODO: Use a proper BitStream or similar solution in the future. /// /// The manager responsible for tag lookup and net index handling. /// The to be serialized. @@ -89,7 +89,7 @@ public static bool NetSerialize(TagsManager tagsManager, Tag tag, out ushort net /// Deserializes a from a given net index value. /// /// - /// TODO: Use a propper BitStream or similar solution in the future. + /// TODO: Use a proper BitStream or similar solution in the future. /// /// The manager responsible for tag lookup and net index handling. /// The data stream to be deserialized. diff --git a/Forge/Tags/TagContainer.cs b/Forge/Tags/TagContainer.cs index 2f00ded..46193c2 100644 --- a/Forge/Tags/TagContainer.cs +++ b/Forge/Tags/TagContainer.cs @@ -12,7 +12,7 @@ namespace Gamesmiths.Forge.Tags; /// -/// A represets a collection of s, containing tags added +/// A represents a collection of s, containing tags added /// explicitly and implicitly through their parent-child tag hierarchy. /// public sealed class TagContainer : IEnumerable, IEquatable @@ -117,7 +117,7 @@ public TagContainer(TagsManager tagsManager, HashSet sourceTags) /// /// The manager responsible for tag lookup and net index handling. /// The to be serialized. - /// The serialized stream for this caontainer. + /// The serialized stream for this container. /// if successfully serialized; otherwise. /// Throws if there are more tags than the configured max size. public static bool NetSerialize( @@ -147,7 +147,7 @@ public static bool NetSerialize( // Containers at this point should always have a designated manager. Validation.Assert( container.TagsManager is not null, - $"Container isn't properly registred in a {typeof(TagsManager)}."); + $"Container isn't properly registered in a {typeof(TagsManager)}."); if (tagsManager != container.TagsManager) { @@ -170,7 +170,7 @@ public static bool NetSerialize( { Tag.NetSerialize(tagsManager, tag, out var index); - // Read net index from buffer. This is just a practical example, use a BitStream reader here isntead. + // Read net index from buffer. This is just a practical example, use a BitStream reader here instead. var netIndex = new ushort[] { index }; var netIndexStream = new byte[2]; Buffer.BlockCopy(netIndex, 0, netIndexStream, 0, 2); diff --git a/README.md b/README.md index 37b7eb3..a66b34d 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,16 @@ # Forge Gameplay System +[![CI](https://github.com/gamesmiths-guild/forge/actions/workflows/validate-project.yml/badge.svg)](https://github.com/gamesmiths-guild/forge/actions/workflows/validate-project.yml) [![NuGet](https://img.shields.io/nuget/v/Gamesmiths.Forge.svg)](https://www.nuget.org/packages/Gamesmiths.Forge) +[![License](https://img.shields.io/github/license/gamesmiths-guild/forge)](LICENSE) A gameplay framework for developing games using C#. -Forge is an engine-agnostic gameplay framework designed for building robust game systems in C#. Inspired by Unreal Engine's Gameplay Ability System (GAS), Forge provides a centralized and controlled approach to managing attributes, effects, tags, and cues in your games. +Forge is an engine-agnostic gameplay framework designed for building robust game systems in C#. Inspired by Unreal Engine's Gameplay Ability System (GAS), Forge provides a centralized and controlled approach to managing attributes, effects, tags, abilities, events, and cues in your games. The framework eliminates the need to rebuild status systems for every game project by offering a flexible, data-driven architecture that works seamlessly with Unity, Godot, and other C#-compatible engines. With Forge, all attribute changes are handled through effects, ensuring organized and maintainable code even in complex gameplay scenarios. -**Keywords:** gameplay system, C# game development, Unity, Godot, attribute system, status effects, gameplay abilities, data-driven +**Keywords:** gameplay framework, C#, engine-agnostic, data-driven, attributes, gameplay effects, abilities, gameplay tags ## Quick Start @@ -16,13 +18,15 @@ New to Forge? Check out the [Quick Start Guide](docs/quick-start.md) to build yo ## Architecture Overview -Forge is built around four core systems that work together to provide comprehensive gameplay functionality: +Forge is built around core systems that work together to provide comprehensive gameplay functionality: ### Core Systems - **[Attributes](docs/attributes.md)**: Centralized attribute management with min/max values, channels, and controlled modifications. - **[Effects](docs/effects/README.md)**: Data-driven system for applying temporary or permanent changes to entities. - **[Tags](docs/tags.md)**: Hierarchical tagging system for entity classification and effect targeting. +- **[Abilities](docs/abilities.md)**: Creation, granting, activation, cooldowns, costs, and instancing rules for gameplay abilities. +- **[Events](docs/events.md)**: Gameplay event handling and propagation used for ability triggers and game logic reactions. - **[Cues](docs/cues.md)**: Visual and audio feedback system that bridges gameplay with presentation. ### Entity Integration @@ -32,6 +36,8 @@ Every game object that uses Forge implements the `IForgeEntity` interface, provi - `EntityAttributes` - Manages all attributes and attribute sets. - `EntityTags` - Handles base and modifier tags with automatic inheritance. - `EffectsManager` - Controls effect application, stacking, and lifecycle. +- `EntityAbilities` - Grants and activates abilities, handles cooldowns/costs, and manages instancing. +- `EventManager` - Dispatches and listens to gameplay events for triggers and reactions. ### Advanced Features @@ -55,12 +61,13 @@ Forge supports a variety of gameplay mechanics through specialized subsystems: - **Effects System**: Comprehensive effect application with stacking support. - **Cues System**: Visual feedback system for effect application/removal. - **Custom Calculators**: Flexible logic execution for effects. +- **Abilities System**: Ability granting, activation, instancing, costs, cooldowns, and tag-based requirements. +- **Events System**: Gameplay event handling, tagging, and trigger support. ### Planned Features 🚧 -- **Abilities System**: Complete ability system similar to GAS abilities. - **Multiplayer Support**: Network replication for all systems. -- **Events System**: Gameplay event handling and propagation. +- **Statescript**: Backend support for state-based scripting of Ability behaviors. ## Installation @@ -78,7 +85,7 @@ Install via NuGet, reference the Forge project directly, or download the precomp Install the package via .NET CLI: ```shell -dotnet add package Gamesmiths.Forge --version 0.1.2 +dotnet add package Gamesmiths.Forge ``` Or search for `Gamesmiths.Forge` in the NuGet Package Manager UI in Visual Studio. diff --git a/docs/README.md b/docs/README.md index 326fc6d..9b3d7d2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,8 @@ At the center of Forge is the entity system, represented by `IForgeEntity`. Each - **Attributes**: Numeric values that define an entity's capabilities and state. - **Tags**: Labels that identify entity characteristics and enable queries. - **Effects**: Gameplay effects that can modify attributes and behaviors. +- **Abilities**: Granted and activated gameplay abilities with instancing, costs, cooldowns, and tag requirements. +- **Events**: Gameplay events dispatched and listened to for triggers and reactions. Entities serve as containers for these components, allowing for modular construction of game objects. @@ -48,6 +50,25 @@ Tags are central to many Forge systems, enabling contextual application of effec Effects can be instant or persistent, with various duration types including infinite, timed, and conditional. +### Ability System + +[Abilities](abilities.md) manage creation and use of gameplay abilities: + +- **Granting**: Add abilities to entities, including level scaling and ownership. +- **Activation**: Start ability instances with checks for inhibition and requirements. +- **Costs and Cooldowns**: Apply cost effects and cooldown effects via the effects system. +- **Instancing Policies**: Control per-entity or per-execution instances, retrigger behavior, and cancellation. +- **Tag Rules**: Use tags to require, block, cancel, or inhibit abilities. + +### Event System + +[Events](events.md) deliver gameplay event data for triggers and reactions: + +- **Event Data**: Structured payloads carrying source, target, and parameters. +- **Tagging**: Event tags for filtering and matching listeners. +- **Dispatching**: Raise events through entity event managers. +- **Listeners and Triggers**: Drive ability activation and other game logic from event hooks. + ### Cue System [Cues](cues.md) bridge gameplay systems with visual and audio feedback: @@ -95,6 +116,8 @@ For more detailed information about specific systems, refer to these documentati - [Attributes](attributes.md): Details on attribute definition, evaluation, and channels. - [Effects](effects/README.md): Creating and applying effects to entities. - [Tags](tags.md): Using the tag system for entity identification. +- [Abilities](abilities.md): Ability granting, activation, instancing, costs, cooldowns, and tag requirements. +- [Events](events.md): Event data, tagging, and trigger hooks. - [Cues](cues.md): Connecting gameplay events to visual and audio feedback. ### Effect Features diff --git a/docs/abilities.md b/docs/abilities.md new file mode 100644 index 0000000..be4eeb6 --- /dev/null +++ b/docs/abilities.md @@ -0,0 +1,857 @@ +# Abilities System + +The Abilities system in Forge provides a framework for defining, granting, activating, and managing gameplay abilities. Abilities encapsulate discrete actions or powers that entities can perform, with built-in support for cooldowns, costs, tag requirements, instancing policies, and triggered activation. + +## Core Concepts + +- **Granting**: Abilities are granted through [Effects](effects/README.md) or directly via the `EntityAbilities` manager. +- **Identity**: An ability is uniquely identified by the combination of the **Owner**, the **AbilityData**, and the **Source Entity**. +- **Activation**: Each ability has configurable activation requirements, costs, and cooldowns. +- **Instancing**: Policies control how multiple concurrent activations are handled. +- **Triggers**: Activation can be triggered manually, by events, or by tag changes. +- **Interruption**: Abilities can be canceled or interrupted, with configurable behavior. +- **Behaviors**: Custom logic is implemented through the `IAbilityBehavior` interface. + +## Ability Data + +`AbilityData` defines the configuration for an ability: + +```csharp +var abilityData = new AbilityData( + name: "Fireball", + costEffect: costEffectData, + cooldownEffects: [cooldownEffectData, globalCooldownData], + abilityTags: fireballTags, + instancingPolicy: AbilityInstancingPolicy.PerEntity, + retriggerInstancedAbility: false, + abilityTriggerData: null, + cancelAbilitiesWithTag: null, + blockAbilitiesWithTag: null, + activationOwnedTags: castingTags, + activationRequiredTags: null, + activationBlockedTags: stunnedTags, + sourceRequiredTags: null, + sourceBlockedTags: null, + targetRequiredTags: enemyTags, + targetBlockedTags: immuneTags, + behaviorFactory: () => new FireballBehavior()); +``` + +### Configuration Options + +- **Name**: Identifier for the ability. +- **CostEffect**: An instant effect defining resource costs. +- **CooldownEffects**: Duration effects with tags preventing reactivation. +- **AbilityTags**: Tags identifying this ability for blocking/cancellation. +- **InstancingPolicy**: Controls concurrent activation handling. +- **RetriggerInstancedAbility**: Restarts persistent instances on re-activation. +- **AbilityTriggerData**: Configuration for automatic activation triggers. +- **CancelAbilitiesWithTag**: Cancels matching abilities on activation. +- **BlockAbilitiesWithTag**: Blocks matching abilities while active. +- **ActivationOwnedTags**: Tags applied to owner while active. +- **ActivationRequiredTags**: Owner tags required to activate. +- **ActivationBlockedTags**: Owner tags preventing activation. +- **SourceRequiredTags**: Source tags required to activate. +- **SourceBlockedTags**: Source tags preventing activation. +- **TargetRequiredTags**: Target tags required to activate. +- **TargetBlockedTags**: Target tags preventing activation. +- **BehaviorFactory**: Factory creating the behavior instance. + +## Granting Abilities + +Abilities can be granted to entities in several ways: through effects, permanently, or transiently for one-time activation. + +### Granting Through Effects + +Use `GrantAbilityEffectComponent` to grant abilities that are tied to an effect's lifecycle. The ability's level is determined by the `abilityLevel` ScalableInt evaluated against the **granting effect's level**. + +```csharp +var grantAbilityConfig = new GrantAbilityConfig( + abilityData, + ScalableLevel: new ScalableInt(1, ScalingCurve: myLevelCurve), + RemovalPolicy: AbilityDeactivationPolicy.CancelImmediately, + InhibitionPolicy: AbilityDeactivationPolicy.CancelImmediately, + TryActivateOnGrant = false, + TryActivateOnEnable = false, + LevelOverridePolicy: LevelComparison.Higher); + +var grantComponent = new GrantAbilityEffectComponent([grantAbilityConfig]); + +var grantEffect = new EffectData( + "Grant Fireball", + new DurationData(DurationType.Infinite), + effectComponents: [grantComponent]); + +// If the effect is applied at level 5, the ScalableInt calculates the ability level accordingly +entity.EffectsManager.ApplyEffect(new Effect(grantEffect, ownership, level: 5)); +``` + +**Tip:** By holding a reference to the `GrantAbilityEffectComponent` used in your `EffectData`, you can access the `grantComponent.GrantedAbilities` list. This provides a direct reference to the `AbilityHandle`s created by the effect, which can be more reliable than searching via `TryGetAbility` if you need to manipulate that specific instance immediately. + +Abilities granted by **instant effects** become permanent, while abilities granted by **duration or infinite effects** are temporary and tied to the effect's lifecycle. + +`TryActivateOnGrant` attempts to activate the ability immediately when it is granted, while `TryActivateOnEnable` attempts activation when the granting effect is re-enabled after inhibition. + +### Granting Permanently + +There are three ways to grant an ability that persists permanently: + +1. **Direct API**: Use `entity.Abilities.GrantAbilityPermanently(...)`. These abilities cannot be removed or inhibited by the effects system. +2. **Instant Effects**: Apply an effect with `DurationType.Instant` that contains a `GrantAbilityEffectComponent`. These behave exactly like manually granted permanent abilities. +3. **Ignore Policy**: Apply a Duration/Infinite effect with a `GrantAbilityEffectComponent` configured with `RemovalPolicy = AbilityDeactivationPolicy.Ignore`. + * Unlike the other two methods, abilities granted this way *can* still be inhibited if the source effect is inhibited (depending on `InhibitionPolicy`). + * They will simply not be removed when the source effect is removed. + +```csharp +AbilityHandle handle = entity.Abilities.GrantAbilityPermanently( + abilityData: fireballAbility, + abilityLevel: 1, + levelOverridePolicy: LevelComparison.Higher, + sourceEntity: null); +``` + +### Granting and Activating Once + +Use `GrantAbilityAndActivateOnce` to grant an ability temporarily and immediately attempt to activate it: + +```csharp +AbilityHandle? handle = entity.Abilities.GrantAbilityAndActivateOnce( + abilityData: consumableAbility, + abilityLevel: 1, + levelOverridePolicy: LevelComparison.None, + out AbilityActivationFailures failureFlags, + targetEntity: enemy, + sourceEntity: item); + +if (handle is not null) +{ + // Ability activated successfully (failureFlags == AbilityActivationFailures.None) + // The grant will be removed automatically when the ability ends +} +else +{ + // Activation failed, the grant was already removed + // Check failureFlags for the specific reasons (e.g. failureFlags.HasFlag(AbilityActivationFailures.InsufficientResources)) +} +``` + +The ability grant is automatically removed when the ability ends. If activation fails, the grant is removed immediately and the method returns `null`. + +## Grant Sources and Policies + +Each time an ability is granted, a **grant source** is created that tracks how that specific grant should behave. An ability can have multiple grant sources if it's granted multiple times (e.g., by different effects or methods). + +### Multiple Grant Sources + +If an ability is granted by multiple sources, it remains granted until all sources are removed: + +```csharp +// Apply two effects that grant the same ability +ActiveEffectHandle? effectHandle0 = entity.EffectsManager.ApplyEffect(grantEffect1); +ActiveEffectHandle? effectHandle1 = entity.EffectsManager.ApplyEffect(grantEffect2); + +// Only one ability instance exists +entity.Abilities.GrantedAbilities.Count; // 0 + +// Remove first grant - ability still exists +entity.EffectsManager.RemoveEffect(effectHandle0); +entity.Abilities.GrantedAbilities.Count; // 0 + +// Remove second grant - now the ability is removed +entity.EffectsManager.RemoveEffect(effectHandle1); +entity.Abilities.GrantedAbilities.Count; // -1 +``` + +### Level Override Policy + +When an ability is granted multiple times, the `LevelOverridePolicy` determines whether the level should be updated: + +```csharp +// First grant at level 2 +var config1 = new GrantAbilityConfig(abilityData, new ScalableInt(2), ...); +entity.EffectsManager.ApplyEffect(grantEffect1); +// handle.Level == 2 + +// Second grant at level 3 with Higher policy: level updates +var config2 = new GrantAbilityConfig( + abilityData, + new ScalableInt(3), + levelOverridePolicy: LevelComparison.Higher, ...); +entity.EffectsManager.ApplyEffect(grantEffect2); +// handle.Level == 3 + +// Third grant at level 1 with Higher policy: level stays at 3 +var config3 = new GrantAbilityConfig( + abilityData, + new ScalableInt(1), + levelOverridePolicy: LevelComparison.Higher, ...); +entity.EffectsManager.ApplyEffect(grantEffect3); +// handle.Level == 3 +``` + +### Deactivation Policies + +`AbilityDeactivationPolicy` controls behavior when a grant source is removed or inhibited: + +- **CancelImmediately**: Cancel all active instances and remove/inhibit immediately. +- **RemoveOnEnd**: Wait for all active instances to end before removing/inhibiting. +- **Ignore**: The grant source ignores removal/inhibition requests entirely. + +### Policy Interactions Between Grant Sources + +When an ability has multiple grant sources, each source has its own policies. The behavior depends on how these policies interact: + +```csharp +// Create two effects that grant the same ability with different policies +var grantConfig1 = new GrantAbilityConfig( + abilityData, + new ScalableInt(1), + removalPolicy: AbilityDeactivationPolicy.RemoveOnEnd, + inhibitionPolicy: AbilityDeactivationPolicy.Ignore); + +var grantConfig2 = new GrantAbilityConfig( + abilityData, + new ScalableInt(1), + removalPolicy: AbilityDeactivationPolicy.CancelImmediately, + inhibitionPolicy: AbilityDeactivationPolicy.Ignore); + +// Assume grantEffect1 and grantEffect2 are created using the configs above... + +// Apply both effects - they grant the same ability +ActiveEffectHandle? effectHandle1 = entity.EffectsManager.ApplyEffect(grantEffect1); +ActiveEffectHandle? effectHandle2 = entity.EffectsManager.ApplyEffect(grantEffect2); + +// Get the ability handle (both grants reference the same ability) +entity.Abilities.TryGetAbility(abilityData, out AbilityHandle? handle); +handle.Activate(out _); + +// Removing effect 1 (RemoveOnEnd): ability stays active, waits for end +entity.EffectsManager.RemoveEffect(effectHandle1); +// Ability is still active and granted + +// Removing effect 2 (CancelImmediately): cancels immediately and removes +entity.EffectsManager.RemoveEffect(effectHandle2); +// Ability is now canceled and removed (no more grant sources) +``` + +**Key behaviors:** + +1. **Multiple sources, one removed**: The ability remains granted as long as at least one grant source exists. +2. **CancelImmediately takes precedence**: If any remaining grant source has `CancelImmediately` policy when removed, it will cancel the ability immediately regardless of other sources' policies. +3. **Inhibition is cumulative**: The ability is only inhibited when ALL non-ignored grant sources are inhibited. + +## Entity Abilities Manager + +`EntityAbilities` is the manager that handles all ability operations for an entity: + +```csharp +// Access through the entity +EntityAbilities abilities = entity.Abilities; + +// Get all granted abilities +HashSet granted = abilities.GrantedAbilities; + +// Get blocked ability tags (used internally for ability blocking) +EntityTags blockedTags = abilities.BlockedAbilityTags; +``` + +### Finding Abilities + +Use `TryGetAbility` to find a granted ability by its data. + +**Note on Identity:** An ability is uniquely identified by its `AbilityData` **and** its `SourceEntity`. You can have the same ability granted multiple times if the sources differ (e.g., one from an Item, one from a Class). + +```csharp +if (entity.Abilities.TryGetAbility(fireballData, out AbilityHandle? handle)) +{ + // Ability is granted, use the handle + handle.Activate(out AbilityActivationFailures failures); +} + +// With a specific source entity +if (entity.Abilities.TryGetAbility(buffData, out AbilityHandle? handle, sourceEntity: caster)) +{ + // Found the ability granted by this specific source +} +``` + +### Activating Abilities by Tag + +Use `TryActivateAbilitiesByTag` to activate all abilities that match specific tags: + +```csharp +var attackTags = new TagContainer(tagsManager, [attackTag]); + +bool anyActivated = entity.Abilities.TryActivateAbilitiesByTag( + attackTags, + target: enemy, + out AbilityActivationFailures[] failures); + +if (anyActivated) +{ + // At least one ability with matching tags was activated +} +``` + +This is useful for input handling where a single button might activate different abilities based on context. + +### Canceling Abilities by Tag + +Use `CancelAbilitiesWithTag` to cancel all active abilities that match specific tags: + +```csharp +var interruptibleTags = new TagContainer(tagsManager, [interruptibleTag]); + +// Cancel all interruptible abilities (e.g., when stunned) +entity.Abilities.CancelAbilitiesWithTag(interruptibleTags); +``` + +### Ability Events + +Subscribe to `OnAbilityEnded` to react when abilities end: + +```csharp +entity.Abilities.OnAbilityEnded += data => +{ + AbilityHandle ability = data.Ability; + bool wasCanceled = data.WasCanceled; + + if (wasCanceled) + { + // Ability was interrupted + ShowInterruptedFeedback(); + } + else + { + // Ability completed normally + ShowCompletedFeedback(); + } +}; +``` + +## Ability Handle + +`AbilityHandle` is the public interface for interacting with a granted ability: + +```csharp +if (entity.Abilities.TryGetAbility(abilityData, out AbilityHandle? handle)) +{ + if (handle.Activate(out AbilityActivationFailures failureFlags)) + { + // Ability activated successfully + } + else + { + // Check specific failure flags + if (failureFlags.HasFlag(AbilityActivationFailures.Cooldown)) + { + // Show cooldown UI + } + + if (failureFlags.HasFlag(AbilityActivationFailures.InsufficientResources)) + { + // Show "not enough mana" message + } + } +} +``` + +### Handle Properties and Methods + +- **IsActive**: Whether any instance of the ability is currently active. +- **IsInhibited**: Whether the ability is inhibited by its granting effect. +- **IsValid**: Whether the handle still references a valid granted ability. +- **Level**: The current level of the ability. +- **Activate(out failureFlags, target?, magnitude?)**: Attempt to activate the ability with optional target and magnitude. +- **Activate\(data, out failureFlags, target?, magnitude?)**: Attempt to activate the ability passing additional typed activation data. +- **Cancel()**: Cancel all active instances. +- **CommitAbility()**: Helper that calls both `CommitCooldown()` and `CommitCost()`. +- **CommitCooldown()**: Apply the cooldown effects. +- **CommitCost()**: Apply the cost effect. +- **GetCooldownData()**: Get information about all cooldowns. +- **GetRemainingCooldownTime(tag)**: Get remaining time for a specific cooldown. +- **GetCostData()**: Get information about all costs. +- **GetCostForAttribute(attribute)**: Get cost for a specific attribute. + +### Activation Failures + +`AbilityActivationFailures` is a **Flags Enum** that indicates all reasons why an activation failed. Unlike a simple result code, this allows the system to report multiple failures simultaneously (e.g., Insufficient Resources AND Cooldown). + +- **None**: Successfully activated. +- **InvalidHandler**: The ability handle is invalid. +- **Inhibited**: Ability is inhibited by its granting effect. +- **PersistentInstanceActive**: A non-retriggerable persistent instance is already active. +- **Cooldown**: Ability is on cooldown. +- **InsufficientResources**: Cannot afford the cost. +- **OwnerTagRequirements**: Owner doesn't meet tag requirements. +- **SourceTagRequirements**: Source doesn't meet tag requirements. +- **TargetTagRequirements**: Target doesn't meet tag requirements. +- **BlockedByTags**: Another active ability is blocking this one. +- **TargetTagNotPresent**: No abilities matched the requested tags (when using `TryActivateAbilitiesByTag`). +- **InvalidTagConfiguration**: Invalid tag configuration provided. + +## Instancing Policies + +`AbilityInstancingPolicy` determines how multiple activations are handled. + +**Note on Identity**: Forge creates one instance of the Ability per entity + source entity. This means if you have a source entity configured (e.g., two different equipped swords granting "Slash"), you will have two distinct abilities that can execute independently with their own levels and cooldowns. + +### PerEntity + +Only one instance can be active at a time per entity (per unique ability identity): + +```csharp +var abilityData = new AbilityData( + "Shield Block", + instancingPolicy: AbilityInstancingPolicy.PerEntity, + retriggerInstancedAbility: false); +``` + +With `retriggerInstancedAbility: false`, attempting to activate while active fails with `AbilityActivationFailures.PersistentInstanceActive`. + +```csharp +var abilityData = new AbilityData( + "Channeled Beam", + instancingPolicy: AbilityInstancingPolicy.PerEntity, + retriggerInstancedAbility: true); +``` + +With `retriggerInstancedAbility: true`, the active instance is canceled and a new one starts: + +### PerExecution + +Multiple instances can be active simultaneously: + +```csharp +var abilityData = new AbilityData( + "Trap", + instancingPolicy: AbilityInstancingPolicy.PerExecution); + +// Each activation creates a new instance +handle.Activate(out _); // Instance 1 +handle.Activate(out _); // Instance 2 +handle.Activate(out _); // Instance 3 + +// Cancel ends all instances +handle.Cancel(); +``` + +## Cooldowns + +Cooldowns prevent ability reactivation for a duration. They are implemented as duration effects that grant tags. + +**Requirements**: +- Cooldown effects **must** have a Duration (not Instant, not Infinite). +- Cooldown effects **must** have a `ModifierTagsEffectComponent`. + +The system receives an array of cooldown effects, allowing you to trigger multiple independent cooldowns at once (e.g., a long "Skill Cooldown" and a shorter "Global Cooldown"). + +```csharp +var cooldownEffect = new EffectData( + "Fireball Cooldown", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(5f))), + effectComponents: [new ModifierTagsEffectComponent(cooldownTags)]); + +var abilityData = new AbilityData( + "Fireball", + cooldownEffects: [cooldownEffect]); +``` + +Multiple cooldown effects can be used for abilities with multiple cooldown conditions: + +```csharp +// Ability has both a long cooldown and a global cooldown +var abilityData = new AbilityData( + "Dash", + cooldownEffects: [dashCooldownEffect, globalCooldownEffect]); +``` + +### Querying Cooldown State + +```csharp +// Get all cooldown information +CooldownData[] cooldowns = handle.GetCooldownData(); +foreach (CooldownData cd in cooldowns) +{ + float remaining = cd.RemainingTime; + float total = cd.TotalTime; + float progress = 1f - (remaining / total); +} + +// Get specific cooldown by tag +float remainingTime = handle.GetRemainingCooldownTime(cooldownTag); +``` + +Cooldowns are checked during activation but only applied when `CommitCooldown()` or `CommitAbility()` is called. + +## Costs + +Costs are instant effects that modify attributes when committed. + +**Requirements**: +- Cost effects **must** be Instant. +- Attribute modifiers must be **negative** to consume resources (e.g., -30 Mana). + +**Validation Logic**: + +Cost modifiers are validated against the attribute's configured min/max bounds: +- If the modifier is **negative** (consumption), it tests against the attribute's **Minimum Value**. (e.g., Do I have enough Mana to pay -30 without going below 0?) +- If the modifier is **positive** (restoration), it tests against the attribute's **Maximum Value**. (e.g., Is my Health low enough to receive +50 healing without exceeding Max Health?) + +You can add multiple modifiers to the single `CostEffect`, allowing an ability to consume multiple different attributes (e.g., Mana and Health). + +```csharp +var costEffect = new EffectData( + "Fireball Cost", + new DurationData(DurationType.Instant), + [new Modifier( + "ManaAttributeSet.CurrentMana", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-30f)))]); + +var abilityData = new AbilityData( + "Fireball", + costEffect: costEffect); +``` + +Cost is checked during activation but only applied when `CommitCost()` or `CommitAbility()` is called. + +## Ability Behavior + +`IAbilityBehavior` defines custom logic that runs during an ability's lifecycle. It gives the developer total control, but comes with important responsibilities. + +### Developer Responsibilities + +1. **Ending Instances**: It is up to the developer to call `context.InstanceHandle.End()` when the ability logic is complete. If you fail to do this, the system will consider the ability "Active" indefinitely. +2. **Committing**: Resources and Cooldowns are not applied automatically. You must call `context.AbilityHandle.CommitAbility()` (or `CommitCost` / `CommitCooldown` separately). + - `CommitAbility()` calls both `CommitCost()` and `CommitCooldown()`. + - Do **not** call all three; it is redundant. + - Deferring commits allows for mechanics like "free cast if cancelled early." + +**Note**: It is entirely possible to **not end** an ability. This is useful for passive abilities or toggles that should run continuously until cancelled externally or by tag triggers. + +```csharp +public class FireballBehavior : IAbilityBehavior +{ + public void OnStarted(AbilityBehaviorContext context) + { + // Called when the ability instance starts + IForgeEntity owner = context.Owner; + IForgeEntity? source = context.Source; + IForgeEntity? target = context.Target; + int level = context.Level; + AbilityHandle abilityHandle = context.AbilityHandle; + AbilityInstanceHandle instanceHandle = context.InstanceHandle; + + // Commit cooldown and cost + // This calls both CommitCooldown() and CommitCost() + abilityHandle.CommitAbility(); + + // Spawn projectile, start animation, etc. + SpawnFireball(owner, target, level); + } + + public void OnEnded(AbilityBehaviorContext context) + { + // Called when the ability instance ends + // Clean up effects, stop animations, etc. + } +} +``` + +### Behavior Context + +`AbilityBehaviorContext` provides access to ability state: + +- **Owner**: The entity that owns this ability. +- **Source**: The entity that granted this ability (may be null). +- **Target**: The target passed during activation (may be null). +- **Level**: The ability's current level. +- **AbilityHandle**: Handle to the ability for committing cost/cooldown. +- **InstanceHandle**: Handle to this specific instance for ending it. +- **Magnitude**: A numeric value associated with the activation attempt. + +### Behavior Context \ + +In addition to the core fields, the generic behavior context also carries: + +- **Data**: Optional strongly-typed activation data when using generic activation or event triggers. + +This context is primarily consumed by behaviors implementing `IAbilityBehavior`, allowing abilities to react to activation-specific data. + +### Ending Instances + +Behaviors can end their instance at any time: + +```csharp +public class InstantAbilityBehavior : IAbilityBehavior +{ + public void OnStarted(AbilityBehaviorContext context) + { + context.AbilityHandle.CommitAbility(); + + // Do the instant effect + ApplyDamage(context.Target); + + // End immediately + context.InstanceHandle.End(); + } + + public void OnEnded(AbilityBehaviorContext context) + { + // Cleanup if needed + } +} +``` + +### Behavior Factory + +The behavior factory creates a new behavior instance for **each activation**: + +```csharp +// Simple factory +var abilityData = new AbilityData( + "Fireball", + behaviorFactory: () => new FireballBehavior()); + +// Factory with dependencies +var abilityData = new AbilityData( + "Fireball", + behaviorFactory: () => new FireballBehavior(projectilePool, audioManager)); + +// Per-execution instancing creates separate behavior instances +var abilityData = new AbilityData( + "Trap", + instancingPolicy: AbilityInstancingPolicy.PerExecution, + behaviorFactory: () => new TrapBehavior()); // Each trap gets its own behavior +``` + +## Ability Triggers + +Abilities can be automatically activated in response to events or tag changes: + +### Event Trigger + +Activate when a specific event is raised: + +```csharp +var abilityData = new AbilityData( + "Counter Attack", + abilityTriggerData: new AbilityTriggerData( + TriggerTag: Tag.RequestTag(tagsManager, "events.combat.blocked"), + TriggerSource: AbilityTriggerSource.Event)); + +// Later, when the entity blocks an attack: +entity.Events.Raise(new EventData +{ + EventTags = blockedEventTags, + Source = attacker, + Target = entity +}); +// Counter Attack activates automatically +``` + +### Tag Added Trigger + +Activate when a tag is added to the entity: + +```csharp +var abilityData = new AbilityData( + "Rage", + abilityTriggerData: new AbilityTriggerData( + TriggerTag: Tag.RequestTag(tagsManager, "status.enraged"), + TriggerSource: AbilityTriggerSource.TagAdded)); + +// When the entity gains the "status.enraged" tag, Rage activates +``` + +### Tag Present Trigger + +Stay active while a tag is present. This acts as a toggle: + +```csharp +var abilityData = new AbilityData( + "Burning Aura", + abilityTriggerData: new AbilityTriggerData( + TriggerTag: Tag.RequestTag(tagsManager, "status.on_fire"), + TriggerSource: AbilityTriggerSource.TagPresent)); + +// 1. Tag "status.on_fire" added -> Ability Activates +// 2. Tag "status.on_fire" removed -> Ability is Canceled +``` + +## Tag Interactions + +### Blocking and Canceling + +Abilities can block or cancel other abilities based on tags: + +```csharp +// This ability cancels any active ability with "ability.interruptible" tag +var interruptAbility = new AbilityData( + "Interrupt", + cancelAbilitiesWithTag: interruptibleTags); + +// This ability prevents abilities with "ability.movement" from activating +var rootAbility = new AbilityData( + "Root", + blockAbilitiesWithTag: movementTags); +``` + +Blocking tags are tracked per-instance. If multiple instances of a blocking ability are active, the blocked abilities remain blocked until all instances end. + +### Activation Owned Tags + +Tags that are applied to the owner while the ability is active: + +```csharp +var channelAbility = new AbilityData( + "Channel", + activationOwnedTags: channelingTags); + +// While Channel is active, owner has "status.channeling" tag +// Other abilities can check for this tag in their requirements +``` + +## Inhibition + +When a granting effect is inhibited (e.g., due to tag requirements), the granted ability becomes inhibited: + +```csharp +// Grant ability with ongoing tag requirements +var grantEffect = new EffectData( + "Grant Fireball", + new DurationData(DurationType.Infinite), + effectComponents: + [ + new GrantAbilityEffectComponent([grantConfig]), + new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements(IgnoreTags: silencedTags)) + ]); + +// When entity gains "status.silenced", the ability becomes inhibited +// Activation fails with AbilityActivationFailures.Inhibited +``` + +With `GrantedAbilityInhibitionPolicy.RemoveOnEnd`, an active ability continues running but becomes inhibited after it ends. + +Abilities granted permanently via `GrantAbilityPermanently` cannot be inhibited. + +## Ability Activation Context + +Ability activation supports passing additional contextual information at runtime. This context represents **dynamic execution data**, not static ability configuration. + +Forge exposes this data through the ability behavior context during activation. + +### Magnitude + +`Magnitude` is a numeric value associated with an activation attempt. + +- It can be passed explicitly when calling `AbilityHandle.Activate(...)`. +- It is automatically populated when abilities are triggered by **Event Triggers**. +- It is accessible via `context.Magnitude` inside the behavior. + +Typical use cases include damage scaling, impulse strength, or contextual intensity values. + +### Strongly-Typed Activation Data + +For cases where a numeric magnitude is not sufficient, abilities can receive strongly-typed activation data. + +This is done using the generic activation method: + +```csharp +handle.Activate( + new HitLocationData(HitZone.Head), + out AbilityActivationFailures failures, + target: enemy); +``` + +When using this overload, Forge automatically creates an `AbilityBehaviorContext` instance. + +### AbilityBehaviorContext + +When activated with typed data, the behavior receives an `AbilityBehaviorContext`, which provides: + +- All standard ability context fields. +- Strongly-typed activation data via `context.Data`. + +```csharp +public sealed class HitReactionBehavior : IAbilityBehavior +{ + public void OnStarted(AbilityBehaviorContext context, HitLocationData data) + { + context.AbilityHandle.CommitAbility(); + + switch (data.Zone) + { + case HitZone.Head: + ApplyCriticalDamage(context.Target); + break; + + case HitZone.Arm: + ApplyDisarm(context.Target); + break; + + case HitZone.Leg: + ApplySlow(context.Target); + break; + + default: + ApplyBaseDamage(context.Target); + break; + } + + context.InstanceHandle.End(); + } + + public void OnEnded(AbilityBehaviorContext context) + { + // Cleanup if needed + } +} + +``` + +### Event Triggers and Context Propagation + +Abilities triggered by Event Triggers are the only automatic source of activation context. + +- `EventMagnitude` is mapped to `context.Magnitude`. +- `EventData.Payload` is mapped to `context.Data`. + +This allows external systems to inject runtime context into abilities without direct activation calls. + +```csharp +entity.Events.Raise(new EventData +{ + EventTags = hitEventTags, + Target = enemy, + EventMagnitude = 1.0f, + Payload = new HitLocationData(HitZone.Arm) +}); +``` + +### Context Design Guidelines + +- Context data should represent execution-specific state. +- Do not use activation data for static ability configuration. +- Prefer typed data over loosely structured objects. +- Event Triggers are ideal for world-driven context injection. + +## Best Practices + +1. **Separate Data from Behavior**: Define ability configuration in `AbilityData` and implement logic in `IAbilityBehavior`. +2. **Use Appropriate Instancing**: Choose `PerEntity` for abilities that should have one active instance, `PerExecution` for stackable abilities. +3. **Commit Explicitly**: Call `CommitAbility()` (or individual commits) inside your behavior. +4. **End Instances**: Always call `context.InstanceHandle.End()` when logic completes to prevent "stuck" abilities. +5. **Handle Failure Flags**: Use the `AbilityActivationFailures` flags to provide specific feedback to the player (e.g. check for `Cooldown` and `InsufficientResources`). +6. **Clean Up in OnEnded**: Always clean up spawned objects, effects, and state in `OnEnded`. +7. **Use Tag Requirements**: Leverage tag-based requirements for complex activation conditions. +8. **Consider Policy Interactions**: When granting abilities from multiple sources, be aware that `CancelImmediately` policies take precedence. +9. **Query Before Activation**: Use `GetCooldownData()` and `GetCostData()` to show UI state before attempting activation. +10. **Use Permanent Grants for Innate Abilities**: Use `GrantAbilityPermanently` for abilities that should always be available. +11. **Use Tag-Based Activation**: Use `TryActivateAbilitiesByTag` for flexible input handling where multiple abilities share activation contexts. +12. **Check Validation Rules**: Ensure cooldowns have durations/tags and costs are instant. +13. **Use Activation Context for Runtime Data**: Pass external execution data via activation context, preferring typed data. diff --git a/docs/attributes.md b/docs/attributes.md index 81651db..c1b7759 100644 --- a/docs/attributes.md +++ b/docs/attributes.md @@ -10,6 +10,7 @@ For a practical guide on using attributes, see the [Quick Start Guide](quick-sta An `EntityAttribute` represents a single numeric property with constraints and modification tracking: +- **Key**: Identifier in the format `.` (e.g. `CombatAttributeSet.MaxHealth`). - **BaseValue**: The fundamental value before modifications. - **CurrentValue**: The actual value after all modifications, constrained by Min/Max. - **Min/Max**: The lower and upper bounds for the attribute. diff --git a/docs/cues.md b/docs/cues.md index 5ab1e62..f2193c4 100644 --- a/docs/cues.md +++ b/docs/cues.md @@ -307,6 +307,29 @@ cuesManager.UpdateCue(burningTag, targetEntity, updatedParameters); cuesManager.RemoveCue(burningTag, targetEntity, interrupted: false); ``` +## Cues vs Events + +Cues are designed for the presentation layer: visual effects, audio, and player feedback. For gameplay logic that affects simulation state, use the [Events system](events.md) instead. + +- **Cues** handle presentation: particle effects, sounds, UI animations. In a networked context, they can use unreliable replication. +- **Events** handle simulation: damage calculations, ability triggers, state changes. In a networked context, they require reliable replication. + +A common pattern is to raise an Event for gameplay logic, then trigger the corresponding Cue for feedback: + +```csharp +// Event for gameplay (reliable, affects game state) +entity.Events. Raise(new EventData +{ + EventTags = damageEventTag.GetSingleTagContainer(), + Source = attacker, + Target = victim, + EventMagnitude = damage +}); + +// Cue for presentation (can be unreliable) +cuesManager.ExecuteCue(damageCueTag, victim, cueParameters); +``` + ## Best Practices 1. **Separate Concerns**: Keep gameplay logic in effects and executions, and visual feedback in cues. diff --git a/docs/effects/README.md b/docs/effects/README.md index 6a88cb3..f6645ee 100644 --- a/docs/effects/README.md +++ b/docs/effects/README.md @@ -54,7 +54,7 @@ ActiveEffectHandle? handle = entity.EffectsManager.ApplyEffect(effect); // Remove an effect by its handle if (handle is not null) { - bool removed = entity.EffectsManager.UnapplyEffect(handle); + bool removed = entity.EffectsManager.RemoveEffect(handle); } // Update all active effects on the entity @@ -84,30 +84,54 @@ The `UpdateEffects` method must be called regularly to process all active effect ### ActiveEffectHandle -When a non-instant effect is applied, the `EffectsManager` returns an `ActiveEffectHandle` that provides control over the effect's lifecycle. +When a non-instant effect is applied, the `EffectsManager` returns an `ActiveEffectHandle` that provides control and runtime access to the applied effect instance. ```csharp // Apply a buff and get its handle ActiveEffectHandle? buffHandle = target.EffectsManager.ApplyEffect(buffEffect); // Check if application was successful -if (buffHandle != null) +if (buffHandle is not null) { // Store the handle for later control _activeBuffs.Add(buffHandle); // Remove the effect using the handle - target.EffectsManager.UnapplyEffect(buffHandle); + target.EffectsManager.RemoveEffect(buffHandle); } ``` +#### Public Properties + +- **IsInhibited**: Indicates whether the effect is currently inhibited (e.g. due to tag changes or other logic). +- **IsValid**: Indicates whether the handle is valid (the effect is still active). +- **ComponentInstances**: The actual per-application component instances for this effect. These may hold state unique to this particular effect instance (such as granted abilities or subscriptions). + +#### Public Methods + +- **SetInhibit(bool value)**: Sets the inhibition status of the effect (e.g., to temporarily pause its action without removing it). +- **GetComponent\()**: Returns the first component instance of type `T` attached to this effect, or `null` if not found. + + Useful for retrieving a specific effect component's runtime state. + + ```csharp + // Retrieve a component instance by type + var grantComponent = handle.GetComponent(); + if (grantComponent is not null) + { + var abilities = grantComponent.GrantedAbilities; + // Use abilities granted by this effect instance + } + ``` + Other removal methods exist but are less precise: + ```csharp // Removes first effect instance matching the Effect -entity.EffectsManager.UnapplyEffect(effect); +entity.EffectsManager.RemoveEffect(effect); // Removes first effect instance matching the EffectData -entity.EffectsManager.UnapplyEffectData(effectData); +entity.EffectsManager.RemoveEffectData(effectData); ``` ## Effect Lifecycle @@ -260,12 +284,12 @@ var effectData = new EffectData( name: "Fireball", // Human-readable name for debugging and UI modifiers: [/*...*/], durationData: new DurationData(DurationType.Instant), - snapshopLevel: true // Whether to lock the effect's level when applied + snapshotLevel: true // Whether to lock the effect's level when applied ); ``` - **Name**: Identifies the effect for debugging, UI display, and designer reference. -- **SnapshopLevel**: Controls how the effect responds to level changes after application. +- **SnapshotLevel**: Controls how the effect responds to level changes after application. - `true`: Effect uses the level it had when initially applied (default). - `false`: Effect dynamically updates when the effect's level changes—all modifiers, durations, periods, and other scalable values will automatically adjust if they use curves. @@ -282,7 +306,7 @@ var effectData = new EffectData( // Instant effect (execute once and end) var healData = new EffectData( "Instant Heal", - durationData: new DurationData(DurationType.Instant) + durationData: new DurationData(DurationType.Instant), modifiers: [/*...*/], ); @@ -291,7 +315,10 @@ var buffData = new EffectData( "Temporary Buff", durationData: new DurationData( DurationType.HasDuration, - new ScalableFloat(10.0f) // 10 second duration + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f) // 10 second duration + ) ), modifiers: [/*...*/] ); @@ -317,7 +344,13 @@ var curseData = new EffectData( ```csharp var effectData = new EffectData( "Strength Potion", - durationData: new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), + durationData: new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f) + ) + ), modifiers: [ // First modifier - flat strength bonus new Modifier( @@ -369,7 +402,10 @@ var stackingEffectData = new EffectData( "Bleed", durationData: new DurationData( DurationType.HasDuration, - new ScalableFloat(5.0f) + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(5.0f) + ) ), modifiers: [/*...*/], stackingData: new StackingData( @@ -407,7 +443,10 @@ var dotEffectData = new EffectData( "Poison", durationData: new DurationData( DurationType.HasDuration, - new ScalableFloat(8.0f), // 8 second total duration + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(8.0f) // 8 second total duration + ) ), modifiers: [/*...*/], periodicData: new PeriodicData( @@ -508,7 +547,7 @@ var cueEnabledEffectData = new EffectData( "Fire Strike", durationData: new DurationData(DurationType.Instant), modifiers: [/*...*/], - requireModifierSuccessToTriggerCue: true, // Only trigger if damage was dealt + requireModifierSuccessToTriggerCue: CueTriggerRequirement.OnExecute, // Only trigger if damage was dealt suppressStackingCues: false, // Always trigger cues on stack changes cues: [ new CueData( @@ -523,9 +562,15 @@ var cueEnabledEffectData = new EffectData( **Cue-related properties:** -- **RequireModifierSuccessToTriggerCue**: When true, cues only trigger if at least one attribute was successfully modified. +- **RequireModifierSuccessToTriggerCue**: Specifies for which effect lifecycle events a cue should only trigger if at least one attribute was successfully modified. + - `None`: No modifier success required; cues may trigger regardless of success. + - `OnApply`: Only trigger cues on application if at least one attribute is modified. + - `OnUpdate`: Only trigger cues on update if at least one attribute is modified. + - `OnExecute`: Only trigger cues on execution if at least one attribute is modified. - **SuppressStackingCues**: When true, cues don't trigger when stacks are applied (only on initial application). +`RequireModifierSuccessToTriggerCue` uses the flags enum `CueTriggerRequirement`, allowing you to define for which lifecycle events modifier success should be required before cues trigger. This provides granular control over visual, audio, and UI feedback. + **Example use cases:** - Visual effects for damage, healing, buffs. @@ -533,6 +578,32 @@ var cueEnabledEffectData = new EffectData( - Camera effects for important gameplay moments. - UI indicators for effect application/removal. +## Effect Activation Context + +Effects can be applied with custom activation context data using the generic `ApplyEffect` method on `EffectsManager`. This allows you to pass arbitrary, strongly-typed context through the effect pipeline to custom calculators and custom executions. + +### Applying Effects With Context Data + +Use `ApplyEffect(Effect effect, TData contextData)` to apply an effect and provide custom runtime data for advanced logic: + +```csharp +var contextData = new DamageContext(damage: 50, isCritical: true); +entity.EffectsManager.ApplyEffect(effect, contextData); +``` + +### Accessing Activation Context in Custom Calculators + +Custom context data provided at activation is accessible from `EffectEvaluatedData` by using the `TryGetContextData(out TData? data)` method. This method is designed for use within custom calculators and custom executions. + +```csharp +if (effectEvaluatedData.TryGetContextData(out DamageContext? data)) +{ + // Use data.Damage, data.IsCritical, etc. +} +``` + +Custom context data is most commonly consumed by [custom calculators and custom executions](calculators.md) for advanced behaviors. For thorough documentation and best practices on using effect activation context data, see the [Custom Calculators documentation](calculators.md#effect-activation-context). + ## Best Practices 1. **Reuse Effect Definitions**: Create a library of effect templates for consistency. diff --git a/docs/effects/calculators.md b/docs/effects/calculators.md index 7044134..6e2ae1c 100644 --- a/docs/effects/calculators.md +++ b/docs/effects/calculators.md @@ -1,6 +1,6 @@ # Custom Calculators -Custom Calculators in Forge enables developers to implement complex, dynamic calculations for effect modifiers. These calculators provide a powerful way to create game-specific logic that goes beyond the built-in [modifier types](modifiers.md#magnitude-calculation-types). +Custom Calculators in Forge enable developers to implement complex, dynamic calculations for effect modifiers. These calculators provide a powerful way to create game-specific logic that goes beyond the built-in [modifier types](modifiers.md#magnitude-calculation-types). ## Core Concepts @@ -16,6 +16,7 @@ public abstract class CustomCalculator AttributeCaptureDefinition capturedAttribute, Effect effect, IForgeEntity? target, + EffectEvaluatedData? effectEvaluatedData, AttributeCalculationType calculationType = AttributeCalculationType.CurrentValue, int finalChannel = 0); @@ -36,6 +37,18 @@ Forge offers two primary calculator types that inherit from `CustomCalculator`: ## Key Components +### EffectEvaluatedData + +Most custom calculator methods, such as `CalculateBaseMagnitude` and `EvaluateExecution`, take an `EffectEvaluatedData? effectEvaluatedData` parameter. This object encapsulates all the computed and contextual data for an effect application at that moment. + +**Why is EffectEvaluatedData important?** + +- It caches and provides all the results and inputs computed for the current effect application, including attribute values, modifiers, levels, stacks, durations, and more. +- It is the central place for storing and re-accessing the current state of the applied effect, which is crucial for accurate and consistent logic when recalculating due to non-snapshot attribute dependencies or stack/level changes. +- When any non-snapshot property (like a non-snapshotted AttributeCaptureDefinition) changes in the game, Forge will re-evaluate the effect and update its `EffectEvaluatedData` accordingly. + +Pass `effectEvaluatedData` to all helper and custom methods that need contextual evaluation data, so they always use the freshest relevant information and respect the proper state and dependencies of the effect system. This ensures correct operation and optimal performance in both realtime and turn-based games. + ### Attribute Capture The `AttributeCaptureDefinition` struct is central to retrieving attribute values from the [Attributes system](../attributes.md): @@ -92,11 +105,11 @@ public class MyCalculator : CustomModifierMagnitudeCalculator AttributesToCapture.Add(TargetArmor); } - public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target) + public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData) { // 4. Use CaptureAttributeMagnitude to safely get values - int health = CaptureAttributeMagnitude(SourceHealth, effect, target); - int armor = CaptureAttributeMagnitude(TargetArmor, effect, target); + int health = CaptureAttributeMagnitude(SourceHealth, effect, target, effectEvaluatedData); + int armor = CaptureAttributeMagnitude(TargetArmor, effect, target, effectEvaluatedData); // Your calculation logic... return health * (1.0f - (armor / 200.0f)); @@ -120,11 +133,14 @@ The `CaptureAttributeMagnitude` method: - `AttributeCalculationType.MagnitudeEvaluatedUpToChannel`: Gets the value calculated up to a specific channel (requires finalChannel parameter). Example with a specific calculation type: + +```csharp // Get the valid modifier value (total modifier without overflow) int validModifier = CaptureAttributeMagnitude( StrengthAttribute, effect, target, + effectEvaluatedData, AttributeCalculationType.ValidModifier); // Get magnitude calculated up to a specific channel @@ -132,9 +148,12 @@ int channelMagnitude = CaptureAttributeMagnitude( StrengthAttribute, effect, target, + effectEvaluatedData, AttributeCalculationType.MagnitudeEvaluatedUpToChannel, finalChannel: 2); -Even when not using non-snapshot functionality, it's recommended to follow this pattern to ensure consistent and safe attribute access. +``` + +Even when capturing with snapshot, it's recommended to follow this pattern to ensure consistent and safe attribute access. ### ModifierEvaluatedData @@ -222,9 +241,9 @@ public class DamageCalculator : CustomModifierMagnitudeCalculator CustomCueParameters.Add("cues.damage.amount", 0); } - public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target) + public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData) { - int strength = CaptureAttributeMagnitude(AttackerStrength, effect, target); + int strength = CaptureAttributeMagnitude(AttackerStrength, effect, target, effectEvaluatedData); // Check for critical hit bool isCritical = new Random().NextDouble() < 0.2; @@ -283,11 +302,11 @@ public class MyDamageCalculator : CustomModifierMagnitudeCalculator CustomCueParameters.Add("cues.damage.type", "physical"); } - public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target) + public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData) { // Get attribute values - int strength = CaptureAttributeMagnitude(StrengthAttribute, effect, target); - int agility = CaptureAttributeMagnitude(AgilityAttribute, effect, target); + int strength = CaptureAttributeMagnitude(StrengthAttribute, effect, target, effectEvaluatedData); + int agility = CaptureAttributeMagnitude(AgilityAttribute, effect, target, effectEvaluatedData); // Custom calculation logic float baseDamage = strength * 0.7f + agility * 0.3f; @@ -401,14 +420,14 @@ public class ManaDrainExecution : CustomExecution CustomCueParameters.Add("cues.spell.transfer_amount", 0f); } - public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target) + public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData) { var results = new List(); // Get attribute values - int targetMana = CaptureAttributeMagnitude(TargetCurrentMana, effect, target); - int magicResist = CaptureAttributeMagnitude(TargetMagicResist, effect, target); - int intelligence = CaptureAttributeMagnitude(SourceIntelligence, effect, target); + int targetMana = CaptureAttributeMagnitude(TargetCurrentMana, effect, target, effectEvaluatedData); + int magicResist = CaptureAttributeMagnitude(TargetMagicResist, effect, target, effectEvaluatedData); + int intelligence = CaptureAttributeMagnitude(SourceIntelligence, effect, target, effectEvaluatedData); // Calculate mana drain amount (reduced by magic resistance) float resistFactor = 1.0f - (magicResist / 200.0f); // 200 resist = 100% reduction @@ -499,9 +518,9 @@ public class QuestDamageCalculator : CustomModifierMagnitudeCalculator CustomCueParameters.Add("cues.quest.target_bonus", false); } - public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target) + public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData) { - float baseDamage = CaptureAttributeMagnitude(BaseDamage, effect, target); + float baseDamage = CaptureAttributeMagnitude(BaseDamage, effect, target, effectEvaluatedData); // Check if target is related to an active quest if (target is IQuestTarget questTarget && _questManager.IsTargetForActiveQuest(questTarget.QuestTargetId)) @@ -548,11 +567,11 @@ public class ComboAttackExecution : CustomExecution CustomCueParameters.Add("cues.combat.combo_damage", 0f); } - public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target) + public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData) { var results = new List(); - int strength = CaptureAttributeMagnitude(AttackerStrength, effect, target); + int strength = CaptureAttributeMagnitude(AttackerStrength, effect, target, effectEvaluatedData); int comboCount = _comboSystem.GetCurrentComboCount(effect.Ownership.Owner); // Calculate combo damage @@ -582,6 +601,79 @@ public class ComboAttackExecution : CustomExecution } ``` +## Effect Activation Context + +Custom activation context enables you to pass dynamic, strongly-typed data to your custom calculator or execution logic when applying an effect. This is especially useful for effects that depend on user input, situational gameplay, or real-time information beyond static effect definition. + +To provide context data, use the generic `ApplyEffect` method from the `EffectsManager`: + +```csharp +var hitLocationData = new HitLocationData(HitZone.Head); +entity.EffectsManager.ApplyEffect(effect, hitLocationData); +``` + +Inside your `CustomModifierMagnitudeCalculator` or `CustomExecution`, retrieve the context data using `effectEvaluatedData.TryGetContextData(out T? data)`: + +### Practical Example: Bonus Damage on Headshot + +Suppose you want a damage effect that doubles its damage when applied as a headshot. You could define the following context struct: + +```csharp +public readonly record struct HitLocationData(HitZone Zone); + +public enum HitZone { Head, Torso, Arm, Leg } +``` + +And a custom calculator like this: + +```csharp +public class HeadshotDamageCalculator : CustomModifierMagnitudeCalculator +{ + public AttributeCaptureDefinition SourceWeaponDamage { get; } + + public HeadshotDamageCalculator() + { + SourceWeaponDamage = new AttributeCaptureDefinition( + "WeaponAttributeSet.Damage", AttributeCaptureSource.Source, snapshot: true); + AttributesToCapture.Add(SourceWeaponDamage); + } + + public override float CalculateBaseMagnitude( + Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData) + { + int damage = CaptureAttributeMagnitude(SourceWeaponDamage, effect, target, effectEvaluatedData); + + // Default is single damage + float finalDamage = damage; + + // Check activation context for hit location + if (effectEvaluatedData?.TryGetContextData(out HitLocationData? hitLocation) + && hitLocation.Zone == HitZone.Head) + { + finalDamage *= 2.0f; // Double damage for headshots + CustomCueParameters["cues.damage.headshot"] = true; + } + else + { + CustomCueParameters["cues.damage.headshot"] = false; + } + + return -finalDamage; // Negative for damage + } +} +``` + +When applying the effect, specify the hit location: + +```csharp +// Apply the effect as a headshot +entity.EffectsManager.ApplyEffect(effect, new HitLocationData(HitZone.Head)); +// Apply the effect as a torso shot +entity.EffectsManager.ApplyEffect(effect, new HitLocationData(HitZone.Torso)); +``` + +This pattern ensures that your custom logic can access dynamic activation information whenever the effect is applied, enabling rich scenario-driven gameplay without muddying your effect definitions. + ## Debugging Custom Calculators When debugging issues with custom calculators: diff --git a/docs/effects/components.md b/docs/effects/components.md index c51e33a..def46c2 100644 --- a/docs/effects/components.md +++ b/docs/effects/components.md @@ -27,8 +27,10 @@ To create a custom component, implement the `IEffectComponent` interface: ```csharp public interface IEffectComponent { + IEffectComponent CreateInstance(); bool CanApplyEffect(in IForgeEntity target, in Effect effect); bool OnActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData); + void OnPostActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData); void OnActiveEffectUnapplied(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData, bool removed); void OnActiveEffectChanged(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData); void OnEffectApplied(IForgeEntity target, in EffectEvaluatedData effectEvaluatedData); @@ -40,6 +42,31 @@ The interface provides default implementations for all methods, so you only need ### Component Lifecycle Methods +#### CreateInstance + +`CreateInstance` is called when the effect is applied and allows the component to provide either a shared (stateless) instance or return a new instance for per-application (stateful) data. + +Override this method when your component holds data that must be isolated per-effect application, such as event subscriptions or runtime counters. + +```csharp +public class ExampleComponent : IEffectComponent +{ + private int _someState; + + public IEffectComponent CreateInstance() + { + // Return a new instance so each effect application has its own state + return new ExampleComponent(); + } +} +``` + +Use cases: + +- Tracking data or resources that must not be shared across multiple effect instances. +- Managing event subscriptions or references tied to a specific application of an effect. +- Ensuring thread safety or isolation when effects are applied to different targets simultaneously. + #### CanApplyEffect Called during the validation phase to determine if an effect can be applied. Return `false` to block the application. @@ -76,6 +103,30 @@ Use cases: - Setting up event subscriptions. - Initializing effect-specific game state. +#### OnPostActiveEffectAdded + +`OnPostActiveEffectAdded` is called after all components’ `OnActiveEffectAdded` callbacks have completed, and the effect has finished its initial application logic. At this point, the effect is fully initialized. + +Override this method to perform actions that rely on other components being initialized, or when you need to trigger behaviors that should occur after the effect is completely active. + +```csharp +public void OnPostActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) +{ + // Logic here runs after all initialization and validation is complete + // For example, attempt activation if not inhibited + if (!activeEffectEvaluatedData.ActiveEffectHandle.IsInhibited) + { + // Custom post-activation logic + } +} +``` + +Use cases: + +- Conditionally activating abilities granted by earlier components. +- Synchronizing with other components after full effect application. +- Triggering animations, particles, or gameplay effects that should be delayed until the effect is stable. + #### OnActiveEffectUnapplied Called when an effect is unapplied or a stack is removed. @@ -160,9 +211,10 @@ Example custom component: // Component that tracks damage thresholds and applies additional effects public class DamageThresholdComponent : IEffectComponent { - private readonly Dictionary _accumulatedDamage = new(); private readonly float _threshold; private readonly Effect _thresholdEffect; + private float _accumulatedDamage; + private EventSubscriptionToken? _damageEventToken; public DamageThresholdComponent(float threshold, Effect thresholdEffect) { @@ -170,12 +222,26 @@ public class DamageThresholdComponent : IEffectComponent _thresholdEffect = thresholdEffect; } + // Guarantees each effect application has its own unique instance and state + public IEffectComponent CreateInstance() + { + return new DamageThresholdComponent(_threshold, _thresholdEffect); + } + public bool OnActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedData activeEffectEvaluatedData) { - _accumulatedDamage[activeEffectEvaluatedData.ActiveEffectHandle] = 0; - // Note: This is a simplified example. A real implementation would need a more robust way to subscribe/unsubscribe. - target.Attributes.GetAttribute("CombatAttributeSet.CurrentHealth").OnValueChanged += - (attribute, change) => TrackDamage(target, activeEffectEvaluatedData.ActiveEffectHandle, change); + _accumulatedDamage = 0f; + // Subscribe to an "events.combat.damage_taken" event using Forge's Events system + var damageTakenTag = Tag.RequestTag(target.TagsManager, "events.combat.damage_taken"); + _damageEventToken = target.Events.Subscribe(damageTakenTag, data => + { + _accumulatedDamage += data.EventMagnitude; + if (_accumulatedDamage >= _threshold) + { + _accumulatedDamage = 0; + target.EffectsManager.ApplyEffect(_thresholdEffect); + } + }); return true; } @@ -184,32 +250,58 @@ public class DamageThresholdComponent : IEffectComponent in ActiveEffectEvaluatedData activeEffectEvaluatedData, bool removed) { - if (removed) + if (removed && _damageEventToken is not null) { - _accumulatedDamage.Remove(activeEffectEvaluatedData.ActiveEffectHandle); - // Note: This is a simplified example. A real implementation would need a more robust way to subscribe/unsubscribe. - target.Attributes.GetAttribute("CombatAttributeSet.CurrentHealth").OnValueChanged -= - (attribute, change) => TrackDamage(target, activeEffectEvaluatedData.ActiveEffectHandle, change); + target.Events.Unsubscribe(_damageEventToken.Value); + _damageEventToken = null; } } +} +``` + +### Accessing Component Instances at Runtime + +When you apply a duration (non-instant) effect, you receive an `ActiveEffectHandle` from the `EffectsManager`. This handle provides access to the specific component instances that were created for this effect application. + +This is useful if you need to check runtime state, interact with a component that manages resources, or access data (such as granted abilities or custom counters) unique to this particular effect instance. + +#### Retrieving a Component Instance + +You can retrieve a component instance of a given type using the handle's generic `GetComponent()` method: - private void TrackDamage(IForgeEntity target, ActiveEffectHandle handle, int change) +```csharp +// Apply an effect and get the handle. +ActiveEffectHandle? handle = entity.EffectsManager.ApplyEffect(new Effect(effectData, ownership)); + +if (handle is not null) +{ + // Retrieve a specific component instance used by this effect. + var grantAbilityComponent = handle.GetComponent(); + if (grantAbilityComponent is not null) { - if (change < 0 && _accumulatedDamage.ContainsKey(handle)) - { - _accumulatedDamage[handle] += Math.Abs(change); + // Access runtime data exposed by the component + IReadOnlyList grantedAbilities = grantAbilityComponent.GrantedAbilities; + // ... use grantedAbilities as needed + } - if (_accumulatedDamage[handle] >= _threshold) - { - // Reset accumulation and apply threshold effect - _accumulatedDamage[handle] = 0; - target.EffectsManager.ApplyEffect(_thresholdEffect); - } - } + // You can also enumerate all component instances for additional logic + foreach (var component in handle.ComponentInstances) + { + // Inspect or interact with component instances } } ``` +- `GetComponent()` returns the first component instance of type `T` (or `null` if none exists). +- `ComponentInstances` exposes all component instances for this effect (may hold per-instance state). + +**Typical use cases:** +- Accessing granted ability handles from a `GrantAbilityEffectComponent`. +- Inspecting or updating internal state on a custom component. +- Coordinating follow-up logic or queries in gameplay systems. + +For more details on the structure of `ActiveEffectHandle`, see the [ActiveEffectHandle documentation](README.md#activeeffecthandle). + ### Advanced Component Integration Components can be used to implement complex systems that integrate with your game's mechanics: @@ -224,6 +316,55 @@ Components can be used to implement complex systems that integrate with your gam Forge includes several built-in components that demonstrate the component system's capabilities and provide ready-to-use functionality. +### GrantAbilityEffectComponent + +Grants one or more abilities to the target entity. This is the primary bridge between the Effects system and the Abilities system. + +```csharp +public class GrantAbilityEffectComponent(GrantAbilityConfig[] grantAbilityConfigs) : IEffectComponent +{ + public IReadOnlyList GrantedAbilities { get; } + // Implementation... +} +``` + +#### Usage Example + +```csharp +var grantConfig = new GrantAbilityConfig( + AbilityData: fireballData, + ScalableLevel: new ScalableInt(1), // Scales with effect level if a curve is defined + RemovalPolicy: AbilityDeactivationPolicy.CancelImmediately, // Cancels running instances immediately when effect ends + InhibitionPolicy: AbilityDeactivationPolicy.CancelImmediately, // Cancels running instances immediately if effect is inhibited + TryActivateOnGrant: false, // Do not try to activate automatically when granted + TryActivateOnEnable: false, // Do not try to activate automatically when enabled back from inhibition + LevelOverridePolicy: LevelComparison.Higher // Update level if higher than existing grant +); + +// Keep a reference to the component if you need to access the granted ability handles later +var grantComponent = new GrantAbilityEffectComponent([grantConfig]); + +var grantEffect = new EffectData( + "Grant Fireball", + new DurationData(DurationType.Infinite), + effectComponents: [grantComponent] +); + +// Apply the effect +entity.EffectsManager.ApplyEffect(new Effect(grantEffect, ownership)); + +// Access the handle directly from the component instance +AbilityHandle fireballHandle = grantComponent.GrantedAbilities[0]; +``` + +Key points: + +- **Direct Handle Access**: You can keep a reference to the component instance to access its `GrantedAbilities`, which contains the `AbilityHandle`s created by this specific effect application. Alternatively, use the effect handle's `GetComponent()` method to retrieve the runtime component instance when needed. +- **Lifecycle Management**: Automatically handles granting, removing, and inhibiting abilities based on the effect's lifecycle and the configured policies. +- **Permanent vs. Temporary**: + - If used in an **Instant** effect, the ability is granted permanently. + - If used in a **Duration** effect, the ability exists only while the effect is active (unless removal policy is set to `Ignore`). + ### ChanceToApplyEffectComponent Adds a random chance for effects to be applied, with support for level-based scaling. @@ -263,7 +404,13 @@ The component specifically uses the `NextSingle()` method, which returns a rando // Create a "Stun" effect with a 25% chance to apply var stunEffectData = new EffectData( "Stun", - new DurationData(DurationType.HasDuration, new ScalableFloat(3.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(3.0f) + ) + ), effectComponents: new[] { new ChanceToApplyEffectComponent( randomProvider, // Your game's random number generator @@ -320,7 +467,13 @@ Usage example: // Create a "Burning" effect that adds the "Status.Burning" tag to the target var burningEffectData = new EffectData( "Burning", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f) + ) + ), new[] { new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-5))) }, @@ -426,7 +579,13 @@ query.Build(new TagQueryExpression(tagsManager) // is removed if target gains the "Fire" tag, and is inhibited if target has the "Cold.Immune" tag var frostEffectData = new EffectData( "Frost", - new DurationData(DurationType.HasDuration, new ScalableFloat(8.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(8.0f) + ) + ), [/*...*/], effectComponents: new[] { new TargetTagRequirementsEffectComponent( diff --git a/docs/effects/duration.md b/docs/effects/duration.md index f9117c3..141b51c 100644 --- a/docs/effects/duration.md +++ b/docs/effects/duration.md @@ -21,13 +21,13 @@ public enum DurationType : byte ### DurationData -`DurationData` encapsulates the duration configuration for an effect: +`DurationData` encapsulates the duration configuration for an effect. Durations use `ModifierMagnitude`, enabling scalable, attribute-driven, custom-calculated, or set-by-caller values. ```csharp -public readonly struct DurationData(DurationType durationType, ScalableFloat? duration = null) +public readonly record struct DurationData(DurationType durationType, ModifierMagnitude? durationMagnitude = null) { - public DurationType Type { get; } = durationType; - public ScalableFloat? Duration { get; } = duration; + public DurationType DurationType { get; } + public ModifierMagnitude? DurationMagnitude { get; } } ``` @@ -46,10 +46,13 @@ Instant effects apply their changes immediately and then end. They: // Create an instant damage effect var damageEffectData = new EffectData( "Direct Damage", + new DurationData(DurationType.Instant), new[] { - new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-25))) - }, - new DurationData(DurationType.Instant) + new Modifier( + "CombatAttributeSet.CurrentHealth", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-25))) + } ); ``` @@ -59,7 +62,7 @@ Infinite effects have no built-in expiration time and remain active until manual - Apply their modifiers continuously. - Remain active indefinitely. -- Must be explicitly removed using `EffectsManager.UnapplyEffect`. +- Must be explicitly removed using `EffectsManager.RemoveEffect`. - Are useful for permanent buffs, persistent status effects, and equipment bonuses. Equipment-based buffs are a perfect use case for Infinite effects: @@ -67,11 +70,11 @@ Equipment-based buffs are a perfect use case for Infinite effects: // Create an equipment buff that lasts until the item is unequipped var swordBuffEffectData = new EffectData( "Magic Sword Bonus", + new DurationData(DurationType.Infinite), new[] { - new Modifier("CombatAttributeSet.AttackPower", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))), + new Modifier("CombatAttributeSet.AttackPower", ModifierOperation.FlatBonus, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))), new Modifier("CombatAttributeSet.CriticalChance", ModifierOperation.PercentBonus, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(0.05f))) - }, - new DurationData(DurationType.Infinite) + } ); var swordBuffEffect = new Effect(swordBuffEffectData, new EffectOwnership(character, character)); @@ -82,7 +85,7 @@ ActiveEffectHandle? equipmentBuffHandle = character.EffectsManager.ApplyEffect(s // When unequipping the item if (equipmentBuffHandle is not null) { - character.EffectsManager.UnapplyEffect(equipmentBuffHandle); + character.EffectsManager.RemoveEffect(equipmentBuffHandle); } ``` @@ -92,17 +95,40 @@ Duration-based effects automatically expire after a specific amount of time. The - Apply their modifiers for a limited period. - Automatically remove themselves when their duration ends. -- Can use `ScalableFloat` for level-based duration scaling. - Are often used for temporary buffs and debuffs. +- Can use any `ModifierMagnitude`, so durations can scale with level, attributes, custom calculators, or set-by-caller values. +- Re-evaluate while active if the duration depends on non-snapshot inputs (live attribute captures or non-snapshot set-by-caller values). ```csharp // Create a temporary buff that lasts 30 seconds var temporaryBuffEffectData = new EffectData( "Temporary Speed Boost", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + scalableFloatMagnitude: new ScalableFloat(30.0f))), new[] { new Modifier("MovementAttributeSet.Speed", ModifierOperation.PercentBonus, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(0.3f))) - }, - new DurationData(DurationType.HasDuration, new ScalableFloat(30.0f)) + } +); + +// Attribute-driven duration (live capture) +var attributeDurationEffectData = new EffectData( + "Attribute-Based Shield", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.AttributeBased, + attributeBasedFloat: new AttributeBasedFloat( + new AttributeCaptureDefinition("StatAttributeSet.Strength", AttributeCaptureSource.Source, snapshot: false), + AttributeCalculationType.CurrentValue, + coefficient: new ScalableFloat(0.2f), + preMultiplyAdditiveValue: new ScalableFloat(0), + postMultiplyAdditiveValue: new ScalableFloat(5)))), + new[] { + new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.FlatBonus, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(25))) + } ); ``` @@ -141,7 +167,7 @@ When working with durations, several constraints apply to ensure effects behave "Invalid Effect", new DurationData(DurationType.Instant), [/*...*/], - snapshopLevel: false // Error + snapshotLevel: false // Error ); ``` @@ -158,7 +184,7 @@ When working with durations, several constraints apply to ensure effects behave ### HasDuration Effects Constraints -1. **Duration Required**: `HasDuration` effects must provide a valid `Duration` property. +1. **Duration Required**: `HasDuration` effects must provide a valid `DurationMagnitude`. ```csharp // INVALID - HasDuration requires a duration value new EffectData( @@ -167,41 +193,53 @@ When working with durations, several constraints apply to ensure effects behave [/*...*/] ); - // VALID - Providing required duration + // VALID - Providing required duration magnitude new EffectData( "Valid Effect", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), // Correct - [/*...*/] + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + scalableFloatMagnitude: new ScalableFloat(10.0f))), + [/*...*/] ); ``` -2. **Stacking Requirements**: `HasDuration` effects with stacking must define `ApplicationRefreshPolicy`. +2. **Stacking Requirement**: If stacking is configured, `ApplicationRefreshPolicy` must be defined. ```csharp // VALID - HasDuration with stacking needs ApplicationRefreshPolicy new EffectData( "Valid Effect", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + scalableFloatMagnitude: new ScalableFloat(10.0f))), [/*...*/], stackingData: new StackingData( stackLimit: new ScalableInt(3), initialStack: new ScalableInt(1), // ... other stacking data - applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication + applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication ) ); ``` +3. **Periodic + Stacking**: When both periodic and stacking are present, `ExecuteOnSuccessfulApplication` and `ApplicationResetPeriodPolicy` must be defined in stacking data. + ### General Constraints -1. **Duration Value Requirement**: The `duration` value is only valid for `HasDuration` effects. +1. **DurationMagnitude Scope**: `DurationMagnitude` is only valid when `DurationType` is `HasDuration`. ```csharp - // INVALID - Can't provide duration for non-HasDuration effects + // INVALID - Can't provide duration magniteude for non-HasDuration effects new DurationData( DurationType.Infinite, - new ScalableFloat(10.0f) // Error + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10.0f)) // Invalid ); ``` +2. **Dynamic Re-evaluation**: Active effects re-evaluate duration when non-snapshot inputs used by `DurationMagnitude` change (live attribute captures or non-snapshot set-by-caller values). Remaining duration adjusts accordingly. + ## Best Practices 1. **Choose Appropriate Types**: @@ -209,21 +247,22 @@ When working with durations, several constraints apply to ensure effects behave - Use `HasDuration` for temporary buffs and debuffs. - Use `Infinite` for permanent effects or those requiring manual removal. -2. **Scale Durations with Level**: +2. **Choose Appropriate ModifierMagnitudes**: - Use `ScalableFloat` with curves for level-based duration scaling. - ```csharp - new DurationData( - DurationType.HasDuration, - new ScalableFloat(10.0f, durationCurve) // Scale with effect level - ) - ``` + - Use `AttributeBased` for attribute-driven duration. + - Use `CustomCalculatorClass` for custom logic duration. + - Use `SetByCaller` for duration based on external inputs. + +3. **Snapshot Attributes**: + - Capture only the attributes needed. + - Decide between snapshot and live captures based on whether the duration should update while active. -3. **Handle Effect Removal**: +4. **Handle Effect Removal**: - Always store `ActiveEffectHandle` for `Infinite` effects. - Consider early removal conditions for `HasDuration` effects. - - Use `EffectsManager.UnapplyEffect` appropriately. + - Use `EffectsManager.RemoveEffect` appropriately. -4. **Consider Performance**: +5. **Consider Performance**: - Minimize the number of long-duration effects active simultaneously. - Use `Instant` effects when appropriate to avoid tracking overhead. - Be cautious with infinitely stacking `HasDuration` effects. diff --git a/docs/effects/modifiers.md b/docs/effects/modifiers.md index 38233a8..070cc31 100644 --- a/docs/effects/modifiers.md +++ b/docs/effects/modifiers.md @@ -1,6 +1,6 @@ # Effect Modifiers -Effect Modifiers in Forge provides a flexible way to modify entity [attributes](../attributes.md) through [effects](README.md). Modifiers define how an effect changes attribute values, with support for different operation types and magnitude calculations. +Effect Modifiers in Forge provide a flexible way to modify entity [attributes](../attributes.md) through [effects](README.md). Modifiers define how an effect changes attribute values, with support for different operation types and magnitude calculations. For a practical guide on using modifiers, see the [Quick Start Guide](../quick-start.md). @@ -10,10 +10,10 @@ At its core, a modifier represents a mathematical operation that changes the val ```csharp public readonly struct Modifier( - StringKey attribute, - ModifierOperation operation, - ModifierMagnitude magnitude, - int channel = 0) + StringKey Attribute, + ModifierOperation Operation, + ModifierMagnitude Magnitude, + int Channel = 0) { // Implementation... } @@ -136,17 +136,17 @@ When evaluated, the formula is: `BaseValue * ScalingCurve.Evaluate(level)`, or j ### AttributeBasedFloat -Calculates magnitude based on another attribute's value using a powerful formula: +`AttributeBasedFloat` computes its magnitude from another attribute (including snapshot logic for effect context). ```csharp public readonly struct AttributeBasedFloat( - AttributeCaptureDefinition backingAttribute, - AttributeCalculationType attributeCalculationType, - ScalableFloat coefficient, - ScalableFloat preMultiplyAdditiveValue, - ScalableFloat postMultiplyAdditiveValue, - int? finalChannel = null, - ICurve? lookupCurve = null) + AttributeCaptureDefinition BackingAttribute, + AttributeCalculationType AttributeCalculationType, + ScalableFloat Coefficient, + ScalableFloat PreMultiplyAdditiveValue, + ScalableFloat PostMultiplyAdditiveValue, + int? FinalChannel = null, + ICurve? LookupCurve = null) { // Implementation... } @@ -245,11 +245,11 @@ For complex calculations requiring custom logic, see the [Custom Calculators doc ```csharp public readonly struct CustomCalculationBasedFloat( - CustomModifierMagnitudeCalculator magnitudeCalculatorClass, - ScalableFloat coefficient, - ScalableFloat preMultiplyAdditiveValue, - ScalableFloat postMultiplyAdditiveValue, - ICurve? lookupCurve = null) + CustomModifierMagnitudeCalculator MagnitudeCalculatorClass, + ScalableFloat Coefficient, + ScalableFloat PreMultiplyAdditiveValue, + ScalableFloat PostMultiplyAdditiveValue, + ICurve? LookupCurve = null) { // Implementation... } @@ -258,7 +258,7 @@ public readonly struct CustomCalculationBasedFloat( The magnitude is calculated using the same formula as `AttributeBasedFloat`, but with a custom calculator providing the base magnitude: ``` -baseMagnitude = magnitudeCalculatorClass.CalculateBaseMagnitude() +baseMagnitude = magnitudeCalculatorClass.CalculateBaseMagnitude(effect, target, effectEvaluatedData) finalValue = (coefficient * (baseMagnitude + preMultiply)) + postMultiply ``` @@ -311,16 +311,25 @@ var missingHealthDamage = new Modifier( ### SetByCallerFloat -Allows the effect's magnitude to be set externally before it's applied: +`SetByCallerFloat` is a magnitude type that allows the caller to provide a custom value when applying an effect. ```csharp -public readonly struct SetByCallerFloat(Tag tag) +public readonly struct SetByCallerFloat(Tag tag, bool Snapshot = true) { // Implementation... } ``` -The `Tag` property is used as a key to look up the magnitude value that must be set before applying the effect: +#### Tag + +The `Tag` property is used as a key to look up the magnitude value that must be set before applying the effect. + +#### Snapshot + +The `Snapshot` parameter controls whether the provided value is captured at application time or evaluated dynamically for non-instant effects. + +- When `Snapshot` is set to `true`, the value associated with the tag is captured when the effect is applied and remains fixed for the lifetime of the effect. +- When `Snapshot` is set to `false`, the effect always uses the latest value associated with the tag, allowing the magnitude to change if the caller updates the value after the effect has already been applied. ```csharp // Magnitude will be set before the effect is applied @@ -330,7 +339,8 @@ var variableDamageModifier = new Modifier( new ModifierMagnitude( MagnitudeCalculationType.SetByCaller, setByCallerFloat: new SetByCallerFloat( - Tag.RequestTag(tagsManager, "damage.amount") + Tag.RequestTag(tagsManager, "damage.amount"), + Snapshot: true ) ) ); @@ -338,7 +348,7 @@ var variableDamageModifier = new Modifier( var effectData = new EffectData("Variable Damage", new DurationData(DurationType.Instant), [variableDamageModifier]); var effect = new Effect(effectData, new EffectOwnership(caster, caster)); -// Before applying the effect: +// Set the caller-provided magnitude before applying the effect: effect.SetSetByCallerMagnitude(Tag.RequestTag(tagsManager, "damage.amount"), 25.5f); target.EffectsManager.ApplyEffect(effect); ``` diff --git a/docs/effects/periodic.md b/docs/effects/periodic.md index eaf4932..05de1ce 100644 --- a/docs/effects/periodic.md +++ b/docs/effects/periodic.md @@ -65,7 +65,13 @@ To create a periodic effect, specify a period duration, whether it executes on a // Create a poison effect that deals damage every 2 seconds var poisonEffectData = new EffectData( "Poison", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), // 10 second duration + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f) // 10 second duration + ) + ), new[] { new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-5))) }, @@ -85,7 +91,13 @@ The `Period` property uses `ScalableFloat`, allowing periods to change based on // Create a healing effect with frequency that scales with level var healingEffectData = new EffectData( "Healing Aura", - new DurationData(DurationType.HasDuration, new ScalableFloat(30.0f)), // 30 second duration + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(30.0f) // 30 second duration + ) + ), new[] { new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) }, @@ -112,21 +124,21 @@ var neverResetPolicy = new PeriodicData( period: new ScalableFloat(5.0f), executeOnApplication: false, periodInhibitionRemovedPolicy: PeriodInhibitionRemovedPolicy.NeverReset - // When uninhibited, continues with original timing - might execute immediately if period elapsed + // When re-enabled, continues with original timing - might execute immediately if period elapsed ); var resetPolicy = new PeriodicData( period: new ScalableFloat(5.0f), executeOnApplication: false, periodInhibitionRemovedPolicy: PeriodInhibitionRemovedPolicy.ResetPeriod - // When uninhibited, restarts the period counter + // When re-enabled, restarts the period counter ); var executeAndResetPolicy = new PeriodicData( period: new ScalableFloat(5.0f), executeOnApplication: false, periodInhibitionRemovedPolicy: PeriodInhibitionRemovedPolicy.ExecuteAndResetPeriod - // When uninhibited, executes immediately and restarts the period counter + // When re-enabled, executes immediately and restarts the period counter ); ``` @@ -152,7 +164,12 @@ Periodic effects have specific constraints and interactions with other systems: // VALID - HasDuration periodic effect new EffectData( "Valid Effect", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f)) + ), [/*...*/], periodicData: new PeriodicData(new ScalableFloat(1.0f)) ); @@ -179,7 +196,12 @@ When combining periodic effects with [stacking](stacking.md): // VALID - Stacking periodic effect var stackingPeriodicEffect = new EffectData( "Bleeding", - new DurationData(DurationType.HasDuration, new ScalableFloat(8.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(8.0f)) + ), [/*...*/], stackingData: new StackingData( stackLimit: new ScalableInt(3), @@ -206,7 +228,12 @@ var stackingPeriodicEffect = new EffectData( // Burning effect that deals damage every second for 5 seconds var burningEffectData = new EffectData( "Burning", - new DurationData(DurationType.HasDuration, new ScalableFloat(5.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(5.0f)) + ), new[] { new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-8))) }, @@ -224,7 +251,12 @@ var burningEffectData = new EffectData( // Regeneration effect that heals every 2 seconds for 10 seconds var regenerationEffectData = new EffectData( "Regeneration", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f)) + ), new[] { new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(15))) }, @@ -260,7 +292,12 @@ var manaRegenEffectData = new EffectData( // Bleeding effect that stacks up to 3 times, each stack does damage every second var bleedingEffectData = new EffectData( "Bleeding", - new DurationData(DurationType.HasDuration, new ScalableFloat(6.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(6.0f)) + ), new[] { new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-3))) }, diff --git a/docs/effects/stacking.md b/docs/effects/stacking.md index b06889a..272fdee 100644 --- a/docs/effects/stacking.md +++ b/docs/effects/stacking.md @@ -223,7 +223,11 @@ public enum StackLevelOverrideStackCountPolicy : byte // Simple poison effect that stacks up to 5 times, each stack adds to the damage var poisonEffectData = new EffectData( "Poison", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + scalableFloatMagnitude: new ScalableFloat(10.0f))), new[] { new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-5))) }, @@ -246,7 +250,11 @@ var poisonEffectData = new EffectData( // Buff that allows higher level applications to override lower ones var hierarchicalBuffEffect = new EffectData( "Strength Buff", - new DurationData(DurationType.HasDuration, new ScalableFloat(30.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + scalableFloatMagnitude: new ScalableFloat(30.0f))), new[] { new Modifier("CombatAttributeSet.AttackPower", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) }, @@ -277,7 +285,11 @@ var hierarchicalBuffEffect = new EffectData( // Bleeding effect that ticks every 2 seconds and stacks up to 3 times var bleedingEffectData = new EffectData( "Bleeding", - new DurationData(DurationType.HasDuration, new ScalableFloat(8.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + scalableFloatMagnitude: new ScalableFloat(8.0f))), new[] { new Modifier("CombatAttributeSet.CurrentHealth", ModifierOperation.Add, new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-3))) }, @@ -387,3 +399,8 @@ Stacking effects have several constraints and required relationships: 8. **Document Your Stacking Rules**: - Clearly explain to players how stacks work for key abilities. - Use UI to communicate current stack counts. + +9. **Duration Magnitude**: + - `DurationData` uses `ModifierMagnitude` (ScalableFloat, AttributeBased, CustomCalculatorClass, SetByCaller). + - For non-snapshot attribute captures or `SetByCaller` values, durations are re-evaluated at runtime. + - Stack refresh/reset behaviors (e.g., `ApplicationRefreshPolicy` or `RemoveSingleStackAndRefreshDuration`) use the current evaluated duration when they apply. diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 0000000..e9cb6fc --- /dev/null +++ b/docs/events.md @@ -0,0 +1,169 @@ +# Events System + +The Events system in Forge provides a flexible event bus for triggering gameplay reactions, driving ability activation, and propagating tagged event data. + +## Core Concepts + +- Events carry tags for filtering `EventTags` plus optional source, target, magnitude, and payload data. +- Handlers subscribe by tag and run in priority order (higher priority first). +- Generic events avoid boxing by using typed payloads. +- Generic raises do **not** forward to non-generic handlers. + +## Event Data + +### EventData + +```csharp +public readonly record struct EventData +{ + public TagContainer EventTags { get; init; } + public IForgeEntity? Source { get; init; } + public IForgeEntity? Target { get; init; } + public float EventMagnitude { get; init; } + public object? Payload { get; init; } +} +``` + +### EventData\ + +```csharp +public readonly record struct EventData +{ + public TagContainer EventTags { get; init; } + public IForgeEntity? Source { get; init; } + public IForgeEntity? Target { get; init; } + public float EventMagnitude { get; init; } + public TPayload Payload { get; init; } +} +``` + +- **EventTags**: Tag-based filtering key (uses `TagContainer.HasTag`). +- **Source/Target**: Originator and intended recipient of the event. +- **EventMagnitude**: Optional numeric intensity. +- **Payload**: Optional opaque object, or a typed payload for generic events. + +## Event Manager + +`EventManager` manages subscriptions and dispatch. While every `IForgeEntity` has an `EventManager` instance, you can create and manage additional instances for any purpose: global event buses, subsystem-specific channels, or custom scopes. + +```csharp +// Per-entity event manager (built-in) +entity.Events.Raise(in eventData); + +// Custom event manager for a subsystem +var combatEvents = new EventManager(); +combatEvents.Subscribe(damageTag, OnDamageDealt); + +// Global event bus +public static class GlobalEvents +{ + public static EventManager Instance { get; } = new EventManager(); +} +``` + +### API Reference + +```csharp +public sealed class EventManager +{ + public void Raise(in EventData data); + public void Raise(in EventData data); + + public EventSubscriptionToken Subscribe(Tag eventTag, Action handler, int priority = 0); + public EventSubscriptionToken Subscribe(Tag eventTag, Action> handler, int priority = 0); + + public bool Unsubscribe(EventSubscriptionToken token); +} +``` + +- Subscriptions are sorted by `priority` (higher first). +- A handler is invoked when `data.EventTags.HasTag(eventTag)` is true. +- Generic subscriptions are stored per `TPayload` type and are only invoked for matching generic raises. + +## Usage Examples + +### Subscribe and Raise (non-generic) + +```csharp +var eventTag = Tag.RequestTag(tagsManager, "events.combat.hit"); + +EventSubscriptionToken token = entity.Events.Subscribe(eventTag, data => +{ + // React to combat hit + var source = data.Source; + var target = data.Target; + float magnitude = data.EventMagnitude; +}); + +// Raise the event +entity.Events.Raise(new EventData +{ + EventTags = eventTag.GetSingleTagContainer(), + Source = attacker, + Target = victim, + EventMagnitude = 25f +}); +``` + +### Generic Payload + +```csharp +public record struct HitPayload(int Damage, bool Critical); + +var hitTag = Tag.RequestTag(tagsManager, "events.combat.hit"); + +EventSubscriptionToken token = entity.Events.Subscribe(hitTag, data => +{ + HitPayload payload = data.Payload; + int damage = payload.Damage; + bool crit = payload.Critical; +}); + +entity.Events.Raise(new EventData +{ + EventTags = hitTag.GetSingleTagContainer(), + Source = attacker, + Target = victim, + EventMagnitude = 25f, + Payload = new HitPayload(25, critical: true) +}); +``` + +### Unsubscribe + +To unsubscribe from an event, call `Unsubscribe` using the corresponding `EventSubscriptionToken`. + +```csharp +entity.Events.Unsubscribe(token); +``` + +## Tagging and Filtering + +- Use dedicated event tags (e.g., `events.combat.hit`, `events.status.applied`) registered in `TagsManager`. +- Matching uses hierarchy: `EventTags.HasTag(subscriptionTag)` supports parent/child tag relationships. + +## Integration Notes + +- Abilities can use event tags as triggers (see ability trigger configurations in the Abilities system). +- Event tags can align with cues or effects to coordinate cross-system reactions. +- When an event should trigger visual feedback, raise the event for gameplay logic, then trigger the corresponding cue separately for presentation. + +## Events vs Cues + +Events and [Cues](cues.md) serve distinct purposes: + +- **Events** are part of the core simulation. They drive gameplay logic, trigger abilities, and propagate state changes. In a networked context, events would require reliable replication. +- **Cues** are for the presentation layer. They handle visual effects, audio, and player feedback. In a networked context, cues can use unreliable replication since they don't affect game state. + +Use Events when the outcome affects game state or triggers other gameplay systems. Use Cues when you need to communicate changes to the player through feedback. + +## Best Practices + +1. **Define Tag Conventions**: Use consistent prefixes (e.g., `events.*`) for clarity. +2. **Prefer Typed Payloads**: Use `EventData` to avoid boxing and improve safety. +3. **Use Priorities Sparingly**: Reserve high priorities for critical handlers; keep most at default. +4. **Unsubscribe When Done**: Store tokens and call `Unsubscribe` to avoid stale handlers. +5. **Keep Handlers Lightweight**: Avoid heavy work inside handlers; defer long tasks if needed. +6. **Validate Tags**: Ensure tags are registered in `TagsManager` before use. +7. **Separate Events from Cues**: Use events for gameplay-affecting logic; trigger cues separately for presentation. +8. **Consider Scope**: Use entity-level `EventManager` for entity-specific events; create custom instances for broader or specialized scopes. diff --git a/docs/quick-start.md b/docs/quick-start.md index fde7537..3f177ef 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -9,7 +9,7 @@ This guide will help you quickly get started with the Forge framework, showing y Install Forge via NuGet (recommended): ```shell -dotnet add package Gamesmiths.Forge --version 0.1.2 +dotnet add package Gamesmiths.Forge ``` For other installation methods, see the [main README](../README.md). @@ -18,7 +18,7 @@ For other installation methods, see the [main README](../README.md). ## Creating a Basic Entity -Let's create a simple player entity with three attributes: health, strength and speed. +Let's create a simple player entity with three attributes: health, mana, strength and speed. For that we need to first define an `AttributeSet` that will hold those attributes. @@ -27,13 +27,15 @@ For that we need to first define an `AttributeSet` that will hold those attribut public class PlayerAttributeSet : AttributeSet { public EntityAttribute Health { get; } + public EntityAttribute Mana { get; } public EntityAttribute Strength { get; } public EntityAttribute Speed { get; } public PlayerAttributeSet() { // Initialize the attributes with the current, min and max values. - Health = InitializeAttribute(nameof(Health), 100, 0, 100); + Health = InitializeAttribute(nameof(Health), 100, 0, 150); + Mana = InitializeAttribute(nameof(Mana), 100, 0, 100); Strength = InitializeAttribute(nameof(Strength), 10, 0, 99); Speed = InitializeAttribute(nameof(Speed), 5, 0, 10); } @@ -45,6 +47,8 @@ public class Player : IForgeEntity public EntityAttributes Attributes { get; } public EntityTags Tags { get; } public EffectsManager EffectsManager { get; } + public EntityAbilities Abilities { get; } + public EventManager Events { get; } public Player(TagsManager tagsManager, CuesManager cuesManager) { @@ -60,6 +64,8 @@ public class Player : IForgeEntity Attributes = new EntityAttributes(new PlayerAttributeSet()); Tags = new EntityTags(baseTags); EffectsManager = new EffectsManager(this, cuesManager); + Abilities = new(this); + Events = new(); } } @@ -71,8 +77,12 @@ var tagsManager = new TagsManager(new string[] "class.warrior", "status.stunned", "status.burning", + "status.enraged", "status.immune.fire", - "cues.damage.fire" + "cues.damage.fire", + "events.combat.damage", + "events.combat.hit", + "cooldown.fireball" }); // Create player instance @@ -80,6 +90,7 @@ var player = new Player(tagsManager, cuesManager); // Access the player's attribute values var health = player.Attributes["PlayerAttributeSet.Health"].CurrentValue; // 100 +var mana = player.Attributes["PlayerAttributeSet.Mana"].CurrentValue; // 100 var strength = player.Attributes["PlayerAttributeSet.Strength"].CurrentValue; // 10 var speed = player.Attributes["PlayerAttributeSet.Speed"].CurrentValue; // 5 ``` @@ -154,7 +165,13 @@ The following effect increases the player's strength by +5 for 10 seconds: // Create a strength buff effect that lasts for 10 seconds var strengthBuffEffectData = new EffectData( "Strength Potion", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), // 10 seconds duration + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f) // 10 seconds duration + ) + ), new[] { new Modifier( "PlayerAttributeSet.Strength", @@ -234,7 +251,7 @@ var activeEffectHandle = player.EffectsManager.ApplyEffect(equipmentBuffEffect); // Remove the effect manually (e.g., when the item is unequipped) if (activeEffectHandle != null) { - player.EffectsManager.UnapplyEffect(activeEffectHandle); + player.EffectsManager.RemoveEffect(activeEffectHandle); } ``` @@ -250,7 +267,13 @@ This poison effect ticks every 2 seconds for 10 seconds, causing -5 damage per t // Create a poison effect that ticks every 2 seconds for 10 seconds var poisonEffectData = new EffectData( "Poison", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f) + ) + ), new[] { new Modifier( "PlayerAttributeSet.Health", @@ -271,6 +294,13 @@ var poisonEffectData = new EffectData( // Apply the poison effect var poisonEffect = new Effect(poisonEffectData, new EffectOwnership(player, player)); player.EffectsManager.ApplyEffect(poisonEffect); + +// Simulate 10 seconds of game time +player.EffectsManager.UpdateEffects(10f); + +// After 6 ticks total (including the initial execute), health should be 70 if it started at 100 +int currentHealthAfterPoison = player.Attributes["PlayerAttributeSet.Health"].CurrentValue; // 70 +int baseHealthAfterPoison = player.Attributes["PlayerAttributeSet.Health"].BaseValue; // 70 ``` --- @@ -283,7 +313,13 @@ This example shows a poison effect that ticks every 2 seconds for -3 damage per // Create a poison effect that stacks up to 3 times var stackingPoisonEffectData = new EffectData( "Stacking Poison", - new DurationData(DurationType.HasDuration, new ScalableFloat(6.0f)), // Each stack lasts 6 seconds + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(6.0f) // Each stack lasts 6 seconds + ) + ), new[] { new Modifier( "PlayerAttributeSet.Health", @@ -305,7 +341,7 @@ var stackingPoisonEffectData = new EffectData( overflowPolicy: StackOverflowPolicy.DenyApplication, // Deny if max stacks reached magnitudePolicy: StackMagnitudePolicy.Sum, // Total damage increases with stacks expirationPolicy: StackExpirationPolicy.ClearEntireStack, // All stacks expire together - applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication, // Refresh duration on new stack + applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication, stackPolicy: StackPolicy.AggregateBySource, // Aggregate stacks from the same source stackLevelPolicy: StackLevelPolicy.SegregateLevels, // Each stack can have its own level @@ -322,6 +358,12 @@ var stackingPoisonEffect = new Effect(stackingPoisonEffectData, new EffectOwners player.EffectsManager.ApplyEffect(stackingPoisonEffect); player.EffectsManager.ApplyEffect(stackingPoisonEffect); player.EffectsManager.ApplyEffect(stackingPoisonEffect); + +// Simulate 6 seconds of game time +player.EffectsManager.UpdateEffects(6f); + +// After three ticks at -9 per tick, total damage is -27 +var healthAfterStacks = player.Attributes["PlayerAttributeSet.Health"].CurrentValue; // 73 if starting at 100 ``` --- @@ -334,7 +376,13 @@ This example shows an effect that is unique to a target and can be overridden on // Define the unique effect data var uniqueEffectData = new EffectData( "Unique Buff", - new DurationData(DurationType.HasDuration, new ScalableFloat(10.0f)), // Lasts 10 seconds + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f) // Lasts 10 seconds + ) + ), new[] { new Modifier( "PlayerAttributeSet.Strength", @@ -355,7 +403,7 @@ var uniqueEffectData = new EffectData( overflowPolicy: StackOverflowPolicy.AllowApplication, // Allow application even if max stacks reached magnitudePolicy: StackMagnitudePolicy.Sum, // Total damage increases with stacks expirationPolicy: StackExpirationPolicy.ClearEntireStack, // All stacks expire together - applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication, // Refresh duration on new stack + applicationRefreshPolicy: StackApplicationRefreshPolicy.RefreshOnSuccessfulApplication, stackPolicy: StackPolicy.AggregateByTarget, // Only one effect per target ownerDenialPolicy: StackOwnerDenialPolicy.AlwaysAllow, // Always allow application regardless of owner ownerOverridePolicy: StackOwnerOverridePolicy.Override, // Override existing effect if applied again @@ -388,7 +436,13 @@ This example shows how to add a temporary "status.stunned" tag to the target whi // Create a "Stunned" effect that adds a tag and reduces speed to 0 var stunEffectData = new EffectData( "Stunned", - new DurationData(DurationType.HasDuration, new ScalableFloat(3.0f)), // 3 seconds duration + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(3.0f) // 3 seconds duration + ) + ), new[] { new Modifier( "PlayerAttributeSet.Speed", @@ -459,7 +513,7 @@ player.EffectsManager.ApplyEffect(fireAttack); You can extend effects with custom logic using components. -This component applies an additional effect when the target's Health attribute falls below a specified threshold: +This component applies an additional effect when the target's Health attribute falls below a specified threshold at the time of application: ```csharp // Custom component that applies extra effect when Health is below threshold @@ -504,9 +558,10 @@ var thresholdAttackData = new EffectData( ] ); -// Apply the effect +// Apply the effect twice (second application should trigger the stun) var thresholdAttack = new Effect(thresholdAttackData, new EffectOwnership(player, player)); player.EffectsManager.ApplyEffect(thresholdAttack); +player.EffectsManager.ApplyEffect(thresholdAttack); // Check if the stun was applied (will be true if health was 90 or less after damage) bool isStunned = player.Tags.CombinedTags.HasTag(Tag.RequestTag(tagsManager, "status.stunned")); @@ -543,10 +598,10 @@ public class StrengthDamageCalculator : CustomModifierMagnitudeCalculator AttributesToCapture.Add(SpeedAttribute); } - public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target) + public override float CalculateBaseMagnitude(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData) { - int strength = CaptureAttributeMagnitude(StrengthAttribute, effect, target); - int speed = CaptureAttributeMagnitude(SpeedAttribute, effect, target); + int strength = CaptureAttributeMagnitude(StrengthAttribute, effect, target, effectEvaluatedData); + int speed = CaptureAttributeMagnitude(SpeedAttribute, effect, target, effectEvaluatedData); // Calculate damage based on strength and speed float damage = (speed * 2) + (strength * 0.5f); @@ -621,14 +676,14 @@ public class HealthDrainExecution : CustomExecution AttributesToCapture.Add(SourceStrength); } - public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target) + public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData) { var results = new List(); // Get attribute values - int targetHealth = CaptureAttributeMagnitude(TargetHealth, effect, target); - int sourceHealth = CaptureAttributeMagnitude(SourceHealth, effect, effect.Ownership.Owner); - int sourceStrength = CaptureAttributeMagnitude(SourceStrength, effect, effect.Ownership.Owner); + int targetHealth = CaptureAttributeMagnitude(TargetHealth, effect, target, effectEvaluatedData); + int sourceHealth = CaptureAttributeMagnitude(SourceHealth, effect, effect.Ownership.Owner, effectEvaluatedData); + int sourceStrength = CaptureAttributeMagnitude(SourceStrength, effect, effect.Ownership.Owner, effectEvaluatedData); // Calculate health drain amount based on source strength float drainAmount = sourceStrength * 0.5f; @@ -678,7 +733,7 @@ player2.EffectsManager.ApplyEffect(drainEffect); --- -### Cue Examples +## Cue Examples Cues bridge gameplay mechanics with visual and audio feedback. Here's how to define, trigger, and implement them. @@ -704,7 +759,13 @@ This example shows how to trigger cues as part of an effect: // Define a burning effect that includes the fire damage cue var burningEffectData = new EffectData( "Burning", - new DurationData(DurationType.HasDuration, new ScalableFloat(5.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(5.0f) + ) + ), new[] { new Modifier( "PlayerAttributeSet.Health", @@ -734,6 +795,7 @@ var burningEffectData = new EffectData( // Apply the burning effect var burningEffect = new Effect(burningEffectData, new EffectOwnership(player, player)); player.EffectsManager.ApplyEffect(burningEffect); +player.EffectsManager.UpdateEffects(5f); ``` --- @@ -807,6 +869,319 @@ public class FireDamageCueHandler : ICueHandler --- +## Events + +The Events system allows entities to communicate and trigger reactions through tagged events. + +### Subscribing and Raising an Event + +```csharp +// Subscribe to the damage event +var damageTag = Tag.RequestTag(tagsManager, "events.combat.damage"); +player.Events.Subscribe(damageTag, eventData => +{ + Console.WriteLine($"Player took {eventData.EventMagnitude} damage!"); +}); + +// Raise the event +player.Events.Raise(new EventData +{ + EventTags = damageTag.GetSingleTagContainer(), + Source = null, + Target = player, + EventMagnitude = 50f +}); +``` + +You can also instantiate your own `EventManager` and use it in any part of your code, providing a way to handle global or system-specific events independently of entities. + +### Strongly Typed Events + +You can optimize events to avoid boxing by using generic `EventData`. + +```csharp +// Define a strongly typed payload +public record struct DamageInfo(int Value, DamageType DamageType, bool IsCritical); + +var damageTag = Tag.RequestTag(tagsManager, "events.combat.damage"); + +// Subscribe using the specific payload type +player.Events.Subscribe(damageTag, eventData => +{ + Console.WriteLine( + $"[Combat Log] Damage: {eventData.Payload.Value}, " + + $"Type: {eventData.Payload.DamageType}, " + + $"Critical: {eventData.Payload.IsCritical}" + ); +}); + +// Raise the event with the typed payload +player.Events.Raise(new EventData +{ + EventTags = damageTag.GetSingleTagContainer(), + Source = null, + Target = player, + Payload = new DamageInfo(120, DamageType.Physical, true) +}); +``` + +--- + +## Abilities + +Abilities are discrete actions that can have costs, cooldowns, and custom behaviors. They can be triggered manually, by events, or in reaction to tag application. + +### Defining an Ability + +When defining an ability, you typically configure effects for costs and cooldowns, implement a behavior, and then tie it all together in the `AbilityData`. + +```csharp +// Define cost: 20 Mana +var fireballCostEffect = new EffectData( + "Fireball Mana Cost", + new DurationData(DurationType.Instant), + new[] { + new Modifier( + "PlayerAttributeSet.Mana", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(-20) + ) + ) + }); + +// Define cooldown: 10 seconds +var fireballCooldownEffect = new EffectData( + "Fireball Cooldown", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10.0f))), + effectComponents: new[] { + new ModifierTagsEffectComponent( + tagsManager.RequestTagContainer(new[] { "cooldown.fireball" }) + ) + }); + +// Define behavior +public class FireballBehavior : IAbilityBehavior +{ + public void OnStarted(AbilityBehaviorContext context) + { + // Apply costs and cooldowns + context.AbilityHandle.CommitAbility(); + Console.WriteLine("Fireball cast!"); + context.InstanceHandle.End(); + } + + public void OnEnded(AbilityBehaviorContext context) + { + // Do any necessary cleanups + } +} + +// Define Ability Data +var fireballData = new AbilityData( + name: "Fireball", + costEffect: fireballCostEffect, + cooldownEffects: [fireballCooldownEffect], + instancingPolicy: AbilityInstancingPolicy.PerEntity, + behaviorFactory: () => new FireballBehavior()); +``` + +--- + +### Granting and Removing an Ability + +Abilities can be granted through effects and will be tied to the effect's lifetime. If the effect has a duration, the ability will be granted only while the effect is active. + +```csharp +// Grant an ability via a GrantAbilityEffectComponent +var grantConfig = new GrantAbilityConfig +{ + AbilityData = fireballData, + ScalableLevel = new ScalableInt(1), + LevelOverridePolicy = LevelComparison.None, + RemovalPolicy = AbilityDeactivationPolicy.CancelImmediately, + InhibitionPolicy = AbilityDeactivationPolicy.CancelImmediately, +}; + +var grantAbilityComponent = new GrantAbilityEffectComponent([grantConfig]); + +// Wrap the component in an infinite effect +var grantFireballEffect = new EffectData( + "Grant Fireball Effect", + new DurationData(DurationType.Infinite), + effectComponents: [grantAbilityComponent] +); + +// Apply the effect to grant the ability (e.g., when Wand of Fireball is equipped) +var grantEffectHandle = player.EffectsManager.ApplyEffect( + new Effect(grantFireballEffect, new EffectOwnership(player, player))); + +// You can access the granted ability handle directly from the component +// This list contains handles for all abilities granted by this specific effect component instance +AbilityHandle grantedHandle = grantAbilityComponent.GrantedAbilities[0]; + +// The ability is now granted. To remove it, simply remove the effect. (e.g., when the wand is unequipped) +player.EffectsManager.RemoveEffect(grantEffectHandle); +``` + +--- + +### Activating an Ability + +Abilities can be activated directly through their handle. You can also use the handle to find out what's the required cost and cooldown of the ability, useful for updating the UI. The activation returns flags indicating failure reasons, which is useful for player feedback. + +```csharp +// Retrieve the handle from the granted ability component or via TryGetAbility +if (player.Abilities.TryGetAbility(fireballData, out AbilityHandle? handle)) +{ + // Check cooldown state before activation (useful for UI) + var cooldowns = handle.GetCooldownData(); + foreach (var cd in cooldowns) + { + Console.WriteLine($"Cooldown remaining: {cd.RemainingTime}"); + } + + // Check cost state before activation (useful for UI) + var costs = handle.GetCostData(); + foreach (var cost in costs) + { + // Assuming you want to check Mana costs + if (cost.AttributeName == "PlayerAttributeSet.Mana") + { + Console.WriteLine($"Mana Cost: {cost.Value}"); + } + } + + // Try to activate + if (handle.Activate(out AbilityActivationFailures failures)) + { + Console.WriteLine("Activation successful"); + } + else + { + Console.WriteLine($"Activation failed: {failures}"); + + if (failures.HasFlag(AbilityActivationFailures.InsufficientResources)) + { + Console.WriteLine("Not enough mana!"); + } + } +} +``` + +--- + +### Granting an Ability Permanently + +One other way to grant an ability permanently is directly through the entity's `EntityAbilities` component. + +```csharp +// Grant permanently +AbilityHandle handle = player.Abilities.GrantAbilityPermanently( + fireballData, + abilityLevel: 1, + levelOverridePolicy: LevelComparison.None, + sourceEntity: player); +``` + +--- + +### Granting an Ability and Activating Once + +In some cases you just want a quick way to activate an ability on a target without creating a persistent effect or granting it permanently. + +The example below shows the use of a "Scroll of Fireball" that grants the fireball ability transiently, attempts to activate it immediately, and then removes the grant once the ability concludes or fails. + +```csharp +AbilityHandle? handle = player.Abilities.GrantAbilityAndActivateOnce( + abilityData: fireballData, + abilityLevel: 1, + levelOverridePolicy: LevelComparison.None, + out AbilityActivationFailures failureFlags, + targetEntity: enemy, // The target of the fireball + sourceEntity: scrollItem // The source (e.g., the scroll item) +); + +if (handle is not null) +{ + Console.WriteLine("Scroll used successfully! Fireball cast."); +} +else +{ + Console.WriteLine($"Failed to use scroll: {failureFlags}"); +} +``` + +--- + +### Triggering an Ability via Events + +You can configure abilities to trigger automatically when specific events occur. + +```csharp +var hitTag = Tag.RequestTag(tagsManager, "events.combat.hit"); + +var autoShieldData = new AbilityData( + name: "Auto Shield", + // Configure the trigger + abilityTriggerData: new AbilityTriggerData( + TriggerTag: hitTag, + TriggerSource: AbilityTriggerSource.Event + ), + instancingPolicy: AbilityInstancingPolicy.PerEntity, + behaviorFactory: () => new ShieldBehavior()); // Assumes ShieldBehavior exists + +// Grant the ability +player.Abilities.GrantAbilityPermanently(autoShieldData, 1, LevelComparison.None, player); + +// Raising the event will automatically trigger the ability +player.Events.Raise(new EventData +{ + EventTags = hitTag.GetSingleTagContainer(), + Target = player +}); +``` + +--- + +### Triggering an Ability through Tags + +In this example, a granted ability (like a passive aura) is activated automatically while the character has a specific tag (e.g., "status.enraged"). + +```csharp +// Define an ability that triggers when the "status.enraged" tag is present +var rageAbilityData = new AbilityData( + "Rage Aura", + abilityTriggerData: new AbilityTriggerData( + TriggerTag: Tag.RequestTag(tagsManager, "status.enraged"), + TriggerSource: AbilityTriggerSource.TagPresent), + instancingPolicy: AbilityInstancingPolicy.PerEntity, + behaviorFactory: () => new RageBehavior()); + +// Grant the ability permanently so it monitors tags +player.Abilities.GrantAbilityPermanently(rageAbilityData, 1, LevelComparison.None, player); + +// Apply an effect that grants the "status.enraged" tag +// The Rage Aura ability will automatically activate when this tag is added +var enrageEffect = new EffectData( + "Enrage", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(10f))), + effectComponents: [ + new ModifierTagsEffectComponent(tagsManager.RequestTagContainer(["status.enraged"])) + ]); + +player.EffectsManager.ApplyEffect(new Effect(enrageEffect, new EffectOwnership(player, player))); +``` + ## Next Steps Now that you've seen the basics of Forge, you can: @@ -818,6 +1193,8 @@ Now that you've seen the basics of Forge, you can: 5. Use [Periodic Effects](effects/periodic.md) for recurring gameplay mechanics. 6. Extend effects with [Components](effects/components.md) for custom behaviors. 7. Integrate [Cues](cues.md) for visual and audio feedback. -8. For catching configuration errors during development, see [Validation and Debugging](README.md#validation-and-debugging). +8. Orchestrate gameplay reactions with [Events](events.md). +9. Define discrete actions and skills using [Abilities](abilities.md). +10. For catching configuration errors during development, see [Validation and Debugging](README.md#validation-and-debugging). For more detailed documentation, refer to the [Forge Documentation Index](README.md). diff --git a/docs/tags.md b/docs/tags.md index ef6350d..4974d40 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -444,7 +444,13 @@ Tag modification typically happens in specific ways: // Create effect that applies a tag var stunEffectData = new EffectData( "Stun Effect", - new DurationData(DurationType.HasDuration, new ScalableFloat(3.0f)), + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + new ScalableFloat(3.0f) + ) + ), effectComponents: [ new ModifierTagsEffectComponent( tagsManager.RequestTagContainer(["status.stunned"])