From b450fa241889c2a0009fc3c9d24d77d5561d9f79 Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 3 Aug 2026 23:55:59 -0300 Subject: [PATCH 1/6] Rename ConcatenateResolver --- ...s.cs => QuaternionConcatenateResolverTests.cs} | 8 ++++---- ...solver.cs => QuaternionConcatenateResolver.cs} | 4 ++-- docs/statescript/resolvers/README.md | 2 +- docs/statescript/resolvers/conjugate-resolver.md | 4 ++-- ...lver.md => quaternion-concatenate-resolver.md} | 15 +++++++++------ 5 files changed, 18 insertions(+), 15 deletions(-) rename Forge.Tests/Statescript/Resolvers/{ConcatenateResolverTests.cs => QuaternionConcatenateResolverTests.cs} (90%) rename Forge/Statescript/Properties/{ConcatenateResolver.cs => QuaternionConcatenateResolver.cs} (83%) rename docs/statescript/resolvers/{concatenate-resolver.md => quaternion-concatenate-resolver.md} (69%) diff --git a/Forge.Tests/Statescript/Resolvers/ConcatenateResolverTests.cs b/Forge.Tests/Statescript/Resolvers/QuaternionConcatenateResolverTests.cs similarity index 90% rename from Forge.Tests/Statescript/Resolvers/ConcatenateResolverTests.cs rename to Forge.Tests/Statescript/Resolvers/QuaternionConcatenateResolverTests.cs index 1dbdb426..48c23d09 100644 --- a/Forge.Tests/Statescript/Resolvers/ConcatenateResolverTests.cs +++ b/Forge.Tests/Statescript/Resolvers/QuaternionConcatenateResolverTests.cs @@ -13,7 +13,7 @@ public class ConcatenateResolverTests [Trait("Resolver", "Concatenate")] public void Concatenate_resolver_value_type_is_quaternion() { - var resolver = new ConcatenateResolver( + var resolver = new QuaternionConcatenateResolver( new VariantResolver(new Variant128(Quaternion.Identity), typeof(Quaternion)), new VariantResolver(new Variant128(Quaternion.Identity), typeof(Quaternion))); @@ -26,7 +26,7 @@ public void Concatenate_resolver_computes_concatenation() { var left = Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.5f); var right = Quaternion.CreateFromAxisAngle(Vector3.UnitX, -0.25f); - var resolver = new ConcatenateResolver( + var resolver = new QuaternionConcatenateResolver( new VariantResolver(new Variant128(left), typeof(Quaternion)), new VariantResolver(new Variant128(right), typeof(Quaternion))); @@ -44,7 +44,7 @@ public void Concatenate_resolver_computes_concatenation() public void Concatenate_resolver_throws_for_non_quaternion_left_operand() { #pragma warning disable CA1806 // Do not ignore method results - Action act = () => new ConcatenateResolver( + Action act = () => new QuaternionConcatenateResolver( new VariantResolver(new Variant128(Vector3.UnitX), typeof(Vector3)), new VariantResolver(new Variant128(Quaternion.Identity), typeof(Quaternion))); #pragma warning restore CA1806 // Do not ignore method results @@ -57,7 +57,7 @@ public void Concatenate_resolver_throws_for_non_quaternion_left_operand() public void Concatenate_resolver_throws_for_non_quaternion_right_operand() { #pragma warning disable CA1806 // Do not ignore method results - Action act = () => new ConcatenateResolver( + Action act = () => new QuaternionConcatenateResolver( new VariantResolver(new Variant128(Quaternion.Identity), typeof(Quaternion)), new VariantResolver(new Variant128(Vector3.UnitX), typeof(Vector3))); #pragma warning restore CA1806 // Do not ignore method results diff --git a/Forge/Statescript/Properties/ConcatenateResolver.cs b/Forge/Statescript/Properties/QuaternionConcatenateResolver.cs similarity index 83% rename from Forge/Statescript/Properties/ConcatenateResolver.cs rename to Forge/Statescript/Properties/QuaternionConcatenateResolver.cs index 289e5dbd..892559d9 100644 --- a/Forge/Statescript/Properties/ConcatenateResolver.cs +++ b/Forge/Statescript/Properties/QuaternionConcatenateResolver.cs @@ -10,7 +10,7 @@ namespace Gamesmiths.Forge.Statescript.Properties; /// /// The resolver for the left quaternion operand. /// The resolver for the right quaternion operand. -public class ConcatenateResolver(IPropertyResolver left, IPropertyResolver right) : IPropertyResolver +public class QuaternionConcatenateResolver(IPropertyResolver left, IPropertyResolver right) : IPropertyResolver { private readonly IPropertyResolver _left = left; @@ -32,7 +32,7 @@ private static Type ValidateTypes(Type leftType, Type rightType) if (leftType != typeof(Quaternion) || rightType != typeof(Quaternion)) { throw new ArgumentException( - $"ConcatenateResolver only supports Quaternion operands. Got '{leftType}' and '{rightType}'."); + $"QuaternionConcatenateResolver only supports Quaternion operands. Got '{leftType}' and '{rightType}'."); } return typeof(Quaternion); diff --git a/docs/statescript/resolvers/README.md b/docs/statescript/resolvers/README.md index 5df0ef94..1a0ba0b7 100644 --- a/docs/statescript/resolvers/README.md +++ b/docs/statescript/resolvers/README.md @@ -276,7 +276,7 @@ Operations that take a nested predicate, key selector, or projection evaluate it | Resolver | Output Type | Description | |----------|-------------|-------------| -| [ConcatenateResolver](concatenate-resolver.md) | `Quaternion` | Concatenates two quaternion rotations. | +| [QuaternionConcatenateResolver](quaternion-concatenate-resolver.md) | `Quaternion` | Concatenates two quaternion rotations. | | [ConjugateResolver](conjugate-resolver.md) | `Quaternion` | Computes the conjugate of a quaternion. | | [InverseResolver](inverse-resolver.md) | `Quaternion` | Computes the inverse of a quaternion. | | [LookAtResolver](lookat-resolver.md) | `Quaternion` | Creates a look rotation from one position to another using an up vector. | diff --git a/docs/statescript/resolvers/conjugate-resolver.md b/docs/statescript/resolvers/conjugate-resolver.md index 49a07916..fc1134c6 100644 --- a/docs/statescript/resolvers/conjugate-resolver.md +++ b/docs/statescript/resolvers/conjugate-resolver.md @@ -40,7 +40,7 @@ graph.VariableDefinitions.DefineProperty("conjugateRotation", ```csharp // Compare a quaternion with its conjugate graph.VariableDefinitions.DefineProperty("rotationDifference", - new ConcatenateResolver( + new QuaternionConcatenateResolver( new VariableResolver("rotation", typeof(Quaternion)), new ConjugateResolver( new VariableResolver("rotation", typeof(Quaternion))))); @@ -50,4 +50,4 @@ graph.VariableDefinitions.DefineProperty("rotationDifference", - [Resolvers Overview](README.md) - [InverseResolver](inverse-resolver.md) -- [ConcatenateResolver](concatenate-resolver.md) +- [QuaternionConcatenateResolver](quaternion-concatenate-resolver.md) diff --git a/docs/statescript/resolvers/concatenate-resolver.md b/docs/statescript/resolvers/quaternion-concatenate-resolver.md similarity index 69% rename from docs/statescript/resolvers/concatenate-resolver.md rename to docs/statescript/resolvers/quaternion-concatenate-resolver.md index 61ec3ed0..04120acc 100644 --- a/docs/statescript/resolvers/concatenate-resolver.md +++ b/docs/statescript/resolvers/quaternion-concatenate-resolver.md @@ -1,14 +1,16 @@ -# ConcatenateResolver +# QuaternionConcatenateResolver -> **Type:** `Gamesmiths.Forge.Statescript.Properties.ConcatenateResolver` +> **Type:** `Gamesmiths.Forge.Statescript.Properties.QuaternionConcatenateResolver` > **Output Type:** `Quaternion` -Concatenates two quaternions using `Quaternion.Concatenate`. +Concatenates two quaternions using `Quaternion.Concatenate`: the result is the left rotation followed by the right one. + +> Not to be confused with [`ConcatResolver`](concat-resolver.md), which joins two **arrays**. The `Quaternion` prefix is what tells the two apart. ## Constructor ```csharp -new ConcatenateResolver(left, right) +new QuaternionConcatenateResolver(left, right) ``` | Parameter | Type | Description | @@ -32,7 +34,7 @@ new ConcatenateResolver(left, right) ```csharp graph.VariableDefinitions.DefineProperty("combinedRotation", - new ConcatenateResolver( + new QuaternionConcatenateResolver( new VariableResolver("baseRotation", typeof(Quaternion)), new VariableResolver("offsetRotation", typeof(Quaternion)))); ``` @@ -44,7 +46,7 @@ graph.VariableDefinitions.DefineProperty("combinedRotation", graph.VariableDefinitions.DefineProperty("rotatedDirection", new TransformResolver( new VariableResolver("direction", typeof(Vector3)), - new ConcatenateResolver( + new QuaternionConcatenateResolver( new VariableResolver("baseRotation", typeof(Quaternion)), new VariableResolver("offsetRotation", typeof(Quaternion))))); ``` @@ -54,3 +56,4 @@ graph.VariableDefinitions.DefineProperty("rotatedDirection", - [Resolvers Overview](README.md) - [InverseResolver](inverse-resolver.md) - [TransformResolver](transform-resolver.md) +- [ConcatResolver](concat-resolver.md) — the array operation with the similar name From 43d911cb8c00122d7282cdddeef81567202894c2 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 4 Aug 2026 00:04:26 -0300 Subject: [PATCH 2/6] Added EntityTags event tests --- Forge.Tests/Tags/EntityTagsEventTests.cs | 108 +++++++++++++++++++++++ Forge/Core/EntityTags.cs | 16 +++- 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 Forge.Tests/Tags/EntityTagsEventTests.cs diff --git a/Forge.Tests/Tags/EntityTagsEventTests.cs b/Forge.Tests/Tags/EntityTagsEventTests.cs new file mode 100644 index 00000000..55807812 --- /dev/null +++ b/Forge.Tests/Tags/EntityTagsEventTests.cs @@ -0,0 +1,108 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Cues; +using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Effects.Components; +using Gamesmiths.Forge.Effects.Duration; +using Gamesmiths.Forge.Tags; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Tags; + +public class EntityTagsEventTests(TagsAndCuesFixture tagsAndCuesFixture) : IClassFixture +{ + private readonly TagsManager _tagsManager = tagsAndCuesFixture.TagsManager; + private readonly CuesManager _cuesManager = tagsAndCuesFixture.CuesManager; + + [Fact] + [Trait("Event", null)] + public void An_effect_granting_a_modifier_tag_raises_the_event_with_the_updated_tags() + { + TestEntity target = CreateEntity(); + + int raised = 0; + TagContainer? reported = null; + + target.Tags.OnTagsChanged += tags => + { + raised++; + reported = tags; + }; + + ApplyModifierTagsEffect(target, "color.red"); + + raised.Should().Be(1); + reported.Should().BeSameAs(target.Tags.AllTags); + reported!.HasTagExact(Tag.RequestTag(_tagsManager, "color.red")).Should().BeTrue(); + } + + [Fact] + [Trait("Event", null)] + public void Removing_the_effect_raises_the_event_with_the_tag_already_gone() + { + TestEntity target = CreateEntity(); + ActiveEffectHandle? handle = ApplyModifierTagsEffect(target, "color.red"); + + bool hadTagWhenRaised = true; + target.Tags.OnTagsChanged += tags => + hadTagWhenRaised = tags.HasTagExact(Tag.RequestTag(_tagsManager, "color.red")); + + target.EffectsManager.RemoveEffect(handle!); + + hadTagWhenRaised.Should().BeFalse(); + } + + [Fact] + [Trait("Event", null)] + public void A_second_effect_granting_the_same_tag_raises_nothing() + { + TestEntity target = CreateEntity(); + ApplyModifierTagsEffect(target, "color.red"); + + int raised = 0; + target.Tags.OnTagsChanged += _ => raised++; + + // The reference count goes up but AllTags does not change, so there is nothing to report. + ActiveEffectHandle? second = ApplyModifierTagsEffect(target, "color.red"); + raised.Should().Be(0); + + // Nor when the first of the two goes away, since the tag is still held by the other. + target.EffectsManager.RemoveEffect(second!); + raised.Should().Be(0); + } + + [Fact] + [Trait("Event", null)] + public void The_event_only_reports_changes_on_its_own_entity() + { + TestEntity watched = CreateEntity(); + TestEntity other = CreateEntity(); + + int raised = 0; + watched.Tags.OnTagsChanged += _ => raised++; + + ApplyModifierTagsEffect(other, "color.red"); + + raised.Should().Be(0); + } + + private TestEntity CreateEntity() + { + return new TestEntity(_tagsManager, _cuesManager); + } + + private ActiveEffectHandle? ApplyModifierTagsEffect(TestEntity target, params string[] tagKeys) + { + var effectData = new EffectData( + "Tag Granting Effect", + new DurationData(DurationType.Infinite), + effectComponents: + [ + new ModifierTagsEffectComponent( + new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, tagKeys))) + ]); + + return target.EffectsManager.ApplyEffect(new Effect(effectData, new EffectOwnership(target, target))); + } +} diff --git a/Forge/Core/EntityTags.cs b/Forge/Core/EntityTags.cs index a4e716c0..51631a23 100644 --- a/Forge/Core/EntityTags.cs +++ b/Forge/Core/EntityTags.cs @@ -12,7 +12,21 @@ public class EntityTags { private readonly Dictionary _modifierTagCounts = []; - internal event Action? OnTagsChanged; + /// + /// Event raised whenever changes, carrying that same container. + /// + /// + /// + /// Raised after the change has landed, so the container already reflects it. A change that adds a tag the entity + /// already had — a second effect granting the same modifier tag — raises nothing, since did + /// not change. + /// + /// + /// The argument is the live container, not a copy: it keeps changing after the handler + /// returns, so read what you need inside the handler rather than storing the container. + /// + /// + public event Action? OnTagsChanged; /// /// Gets a container with the base tags for this entity. From 838073d62345eac90f0bdc6217809b6a6939472d Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 4 Aug 2026 00:14:25 -0300 Subject: [PATCH 3/6] Added Abilities observability events --- .../Abilities/EntityAbilitiesEventsTests.cs | 413 ++++++++++++++++++ Forge/Abilities/Ability.cs | 14 + Forge/Core/EntityAbilities.cs | 111 ++++- 3 files changed, 534 insertions(+), 4 deletions(-) create mode 100644 Forge.Tests/Abilities/EntityAbilitiesEventsTests.cs diff --git a/Forge.Tests/Abilities/EntityAbilitiesEventsTests.cs b/Forge.Tests/Abilities/EntityAbilitiesEventsTests.cs new file mode 100644 index 00000000..4e332133 --- /dev/null +++ b/Forge.Tests/Abilities/EntityAbilitiesEventsTests.cs @@ -0,0 +1,413 @@ +// 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.Tags; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Abilities; + +public class EntityAbilitiesEventsTests(TagsAndCuesFixture tagsAndCuesFixture) : IClassFixture +{ + private readonly TagsManager _tagsManager = tagsAndCuesFixture.TagsManager; + private readonly CuesManager _cuesManager = tagsAndCuesFixture.CuesManager; + + [Fact] + [Trait("Lifecycle", null)] + public void Granting_an_ability_reports_granted_once() + { + TestEntity entity = CreateEntity(); + var log = new EventLog(entity.Abilities); + + AbilityHandle handle = entity.Abilities.GrantAbilityPermanently( + CreateAbilityData("Fireball"), 1, LevelComparison.Higher, null); + + log.Entries.Should().Equal("Granted"); + log.Granted.Should().ContainSingle().Which.Should().BeSameAs(handle); + } + + [Fact] + [Trait("Lifecycle", null)] + public void A_repeat_grant_that_overrides_the_level_reports_changed_not_granted() + { + TestEntity entity = CreateEntity(); + AbilityData abilityData = CreateAbilityData("Fireball"); + + AbilityHandle handle = entity.Abilities.GrantAbilityPermanently(abilityData, 1, LevelComparison.Higher, null); + + var log = new EventLog(entity.Abilities); + entity.Abilities.GrantAbilityPermanently(abilityData, 3, LevelComparison.Higher, null); + + log.Entries.Should().Equal("Changed"); + log.Changed.Should().ContainSingle().Which.Should().BeSameAs(handle); + handle.Level.Should().Be(3); + } + + [Fact] + [Trait("Lifecycle", null)] + public void A_repeat_grant_that_changes_nothing_reports_nothing() + { + TestEntity entity = CreateEntity(); + AbilityData abilityData = CreateAbilityData("Fireball"); + + entity.Abilities.GrantAbilityPermanently(abilityData, 3, LevelComparison.Higher, null); + + var log = new EventLog(entity.Abilities); + + // The same level under a Higher-only override policy leaves both observable values where they were. + entity.Abilities.GrantAbilityPermanently(abilityData, 3, LevelComparison.Higher, null); + + log.Entries.Should().BeEmpty(); + } + + [Fact] + [Trait("Lifecycle", null)] + public void An_ability_losing_its_last_grant_source_reports_removed() + { + TestEntity entity = CreateEntity(); + AbilityData abilityData = CreateAbilityData("Fireball"); + + ActiveEffectHandle? effectHandle = GrantThroughEffect(entity, abilityData); + entity.Abilities.TryGetAbility(abilityData, out AbilityHandle? handle).Should().BeTrue(); + + var log = new EventLog(entity.Abilities); + entity.EffectsManager.RemoveEffect(effectHandle!); + + log.Removed.Should().ContainSingle().Which.Should().BeSameAs(handle); + entity.Abilities.GrantedAbilities.Should().BeEmpty(); + } + + [Fact] + [Trait("Lifecycle", null)] + public void The_removed_handle_is_readable_inside_the_handler_and_invalid_afterwards() + { + TestEntity entity = CreateEntity(); + AbilityData abilityData = CreateAbilityData("Fireball"); + + ActiveEffectHandle? effectHandle = GrantThroughEffect(entity, abilityData); + entity.Abilities.TryGetAbility(abilityData, out AbilityHandle? handle).Should().BeTrue(); + + bool validInsideHandler = false; + int levelInsideHandler = 0; + + entity.Abilities.OnAbilityRemoved += removed => + { + validInsideHandler = removed.IsValid; + levelInsideHandler = removed.Level; + }; + + entity.EffectsManager.RemoveEffect(effectHandle!); + + validInsideHandler.Should().BeTrue(); + levelInsideHandler.Should().Be(1); + handle!.IsValid.Should().BeFalse(); + } + + [Fact] + [Trait("Lifecycle", null)] + public void Losing_one_of_several_grant_sources_reports_no_removal() + { + TestEntity entity = CreateEntity(); + AbilityData abilityData = CreateAbilityData("Fireball"); + + ActiveEffectHandle? first = GrantThroughEffect(entity, abilityData); + GrantThroughEffect(entity, abilityData); + + var log = new EventLog(entity.Abilities); + entity.EffectsManager.RemoveEffect(first!); + + log.Removed.Should().BeEmpty(); + entity.Abilities.GrantedAbilities.Should().ContainSingle(); + } + + [Fact] + [Trait("Lifecycle", null)] + public void Inhibiting_and_uninhibiting_an_ability_reports_changed() + { + TestEntity entity = CreateEntity(); + AbilityData abilityData = CreateAbilityData("Fireball"); + + // The granting effect is inhibited while the target carries color.red, taking the ability with it. + ActiveEffectHandle? grantHandle = GrantThroughEffect( + entity, + abilityData, + new TargetTagRequirementsEffectComponent( + ongoingTagRequirements: new TagRequirements(IgnoreTags: MakeContainer("color.red")))); + + entity.Abilities.TryGetAbility(abilityData, out AbilityHandle? handle).Should().BeTrue(); + handle!.IsInhibited.Should().BeFalse(); + + var log = new EventLog(entity.Abilities); + + ActiveEffectHandle? tagHandle = ApplyTag(entity, "color.red"); + handle.IsInhibited.Should().BeTrue(); + + entity.EffectsManager.RemoveEffect(tagHandle!); + handle.IsInhibited.Should().BeFalse(); + + log.Changed.Should().HaveCount(2); + log.Removed.Should().BeEmpty(); + grantHandle.Should().NotBeNull(); + } + + [Fact] + [Trait("Activation", null)] + public void Activating_an_ability_reports_activated_then_ended() + { + TestEntity entity = CreateEntity(); + AbilityHandle handle = entity.Abilities.GrantAbilityPermanently( + CreateAbilityData("Fireball"), 1, LevelComparison.Higher, null); + + var log = new EventLog(entity.Abilities); + + handle.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + log.Entries.Should().Equal("Activated"); + + handle.Cancel(); + + log.Entries.Should().Equal("Activated", "Ended"); + log.Activated.Should().ContainSingle().Which.Should().BeSameAs(handle); + } + + // Regression: a behavior that finishes inside OnStarted ends the ability before the activation call returns. The + // activation notification is raised before the behavior starts precisely so it cannot arrive second. + [Fact] + [Trait("Activation", null)] + public void A_behavior_that_finishes_synchronously_still_reports_activated_before_ended() + { + TestEntity entity = CreateEntity(); + AbilityHandle handle = entity.Abilities.GrantAbilityPermanently( + CreateAbilityData("Fireball", behaviorFactory: () => new InstantBehavior()), + 1, + LevelComparison.Higher, + null); + + var log = new EventLog(entity.Abilities); + + handle.Activate(out _).Should().BeTrue(); + + log.Entries.Should().Equal("Activated", "Ended"); + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Activation", null)] + public void A_second_concurrent_instance_reports_nothing_until_the_last_one_ends() + { + TestEntity entity = CreateEntity(); + AbilityHandle handle = entity.Abilities.GrantAbilityPermanently( + CreateAbilityData("Fireball", instancingPolicy: AbilityInstancingPolicy.PerExecution), + 1, + LevelComparison.Higher, + null); + + var log = new EventLog(entity.Abilities); + + handle.Activate(out _).Should().BeTrue(); + handle.Activate(out _).Should().BeTrue(); + + // Both events track the ability, not its instances, so the second concurrent instance is silent. + log.Entries.Should().Equal("Activated"); + + handle.Cancel(); + + log.Entries.Should().Equal("Activated", "Ended"); + } + + [Fact] + [Trait("Activation", null)] + public void A_refused_activation_reports_the_failure_flags() + { + TestEntity entity = CreateEntity(); + AbilityData abilityData = CreateAbilityData( + "Fireball", + activationRequiredTags: MakeContainer("color.red")); + + AbilityHandle handle = entity.Abilities.GrantAbilityPermanently(abilityData, 1, LevelComparison.Higher, null); + + var log = new EventLog(entity.Abilities); + + handle.Activate(out AbilityActivationFailures failureFlags).Should().BeFalse(); + + log.Entries.Should().Equal("ActivationFailed"); + log.Failures.Should().ContainSingle(); + log.Failures[0].Handle.Should().BeSameAs(handle); + log.Failures[0].Flags.Should().Be(failureFlags); + failureFlags.Should().HaveFlag(AbilityActivationFailures.OwnerTagRequirements); + } + + [Fact] + [Trait("Activation", null)] + public void A_successful_activation_reports_no_failure() + { + TestEntity entity = CreateEntity(); + AbilityHandle handle = entity.Abilities.GrantAbilityPermanently( + CreateAbilityData("Fireball"), 1, LevelComparison.Higher, null); + + var log = new EventLog(entity.Abilities); + handle.Activate(out _).Should().BeTrue(); + + log.Failures.Should().BeEmpty(); + } + + [Fact] + [Trait("Scope", null)] + public void The_events_only_report_what_happens_to_their_own_owner() + { + TestEntity watched = CreateEntity(); + TestEntity other = CreateEntity(); + + var log = new EventLog(watched.Abilities); + + other.Abilities.GrantAbilityPermanently(CreateAbilityData("Fireball"), 1, LevelComparison.Higher, null); + + log.Entries.Should().BeEmpty(); + } + + private static AbilityData CreateAbilityData( + string name, + AbilityInstancingPolicy instancingPolicy = AbilityInstancingPolicy.PerEntity, + TagContainer? activationRequiredTags = null, + Func? behaviorFactory = null) + { + return new AbilityData( + name, + instancingPolicy: instancingPolicy, + activationRequiredTags: activationRequiredTags, + behaviorFactory: behaviorFactory); + } + + private static ActiveEffectHandle? GrantThroughEffect( + TestEntity entity, + AbilityData abilityData, + IEffectComponent? extraComponent = null) + { + List components = + [ + new GrantAbilityEffectComponent( + [ + new GrantAbilityConfig( + abilityData, + new ScalableInt(1), + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.CancelImmediately) + ]) + ]; + + if (extraComponent is not null) + { + components.Add(extraComponent); + } + + var effectData = new EffectData( + "Grant Ability Effect", + new DurationData(DurationType.Infinite), + effectComponents: [.. components]); + + return entity.EffectsManager.ApplyEffect(new Effect(effectData, new EffectOwnership(entity, entity))); + } + + private TagContainer MakeContainer(params string[] tagKeys) + { + return new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, tagKeys)); + } + + private ActiveEffectHandle? ApplyTag(TestEntity entity, string tagKey) + { + var effectData = new EffectData( + "Tag Effect", + new DurationData(DurationType.Infinite), + effectComponents: [new ModifierTagsEffectComponent(MakeContainer(tagKey))]); + + return entity.EffectsManager.ApplyEffect(new Effect(effectData, new EffectOwnership(entity, entity))); + } + + private TestEntity CreateEntity() + { + return new TestEntity(_tagsManager, _cuesManager); + } + + /// + /// Subscribes to every ability event and records them in the order they arrive, so tests can assert on both what + /// was raised and the sequence. + /// + private sealed class EventLog + { + public List Entries { get; } = []; + + public List Granted { get; } = []; + + public List Changed { get; } = []; + + public List Removed { get; } = []; + + public List Activated { get; } = []; + + public List Ended { get; } = []; + + public List<(AbilityHandle Handle, AbilityActivationFailures Flags)> Failures { get; } = []; + + public EventLog(EntityAbilities abilities) + { + abilities.OnAbilityGranted += handle => + { + Entries.Add("Granted"); + Granted.Add(handle); + }; + + abilities.OnAbilityChanged += handle => + { + Entries.Add("Changed"); + Changed.Add(handle); + }; + + abilities.OnAbilityRemoved += handle => + { + Entries.Add("Removed"); + Removed.Add(handle); + }; + + abilities.OnAbilityActivated += handle => + { + Entries.Add("Activated"); + Activated.Add(handle); + }; + + abilities.OnAbilityEnded += endedData => + { + Entries.Add("Ended"); + Ended.Add(endedData); + }; + + abilities.OnAbilityActivationFailed += (handle, flags) => + { + Entries.Add("ActivationFailed"); + Failures.Add((handle, flags)); + }; + } + } + + /// + /// Ends its own instance from inside , so the ability is over before the activation call + /// returns. + /// + private sealed class InstantBehavior : IAbilityBehavior + { + public void OnStarted(AbilityBehaviorContext context) + { + context.InstanceHandle.End(); + } + + public void OnEnded(AbilityBehaviorContext context) + { + } + } +} diff --git a/Forge/Abilities/Ability.cs b/Forge/Abilities/Ability.cs index 78ccd4df..96cc5376 100644 --- a/Forge/Abilities/Ability.cs +++ b/Forge/Abilities/Ability.cs @@ -146,6 +146,7 @@ internal bool TryActivateAbility( return true; } + Owner.Abilities.NotifyAbilityActivationFailed(Handle, failureFlags); return false; } @@ -161,6 +162,7 @@ internal bool TryActivateAbility( return true; } + Owner.Abilities.NotifyAbilityActivationFailed(Handle, failureFlags); return false; } @@ -571,6 +573,7 @@ private void Activate(IForgeEntity? abilityTarget, float magnitude) { AbilityInstance instance = CreateInstance(abilityTarget); _activeInstances.Add(instance); + NotifyActivated(); instance.Start(magnitude); } @@ -578,9 +581,20 @@ private void Activate(IForgeEntity? abilityTarget, TData data, float magn { AbilityInstance instance = CreateInstance(abilityTarget); _activeInstances.Add(instance); + NotifyActivated(); instance.Start(data, magnitude); } + private void NotifyActivated() + { + if (_activeInstances.Count != 1) + { + return; + } + + Owner.Abilities.NotifyAbilityActivated(Handle); + } + private AbilityInstance CreateInstance(IForgeEntity? abilityTarget) { // Cancel conflicting abilities before we start this one. An empty container means this ability conflicts with diff --git a/Forge/Core/EntityAbilities.cs b/Forge/Core/EntityAbilities.cs index ed55a1bb..b7604e39 100644 --- a/Forge/Core/EntityAbilities.cs +++ b/Forge/Core/EntityAbilities.cs @@ -16,14 +16,65 @@ namespace Gamesmiths.Forge.Core; public class EntityAbilities(IForgeEntity owner) { private readonly Dictionary> _grantSources = []; + private readonly HashSet _grantedAbilities = []; private Action? _removeAbility; private Action? _inhibitAbility; + /// + /// Event invoked when an ability is granted to the entity, carrying its handle. + /// + /// + /// Raised once per , after the grant has settled, so the handle already reports its level and + /// inhibition state. Granting an ability the entity already has adds a grant source instead of a second ability, + /// and raises if that changed anything. + /// + public event Action? OnAbilityGranted; + + /// + /// Event invoked when a granted ability's level or inhibition changes, carrying its handle. + /// + /// + /// A change that resolves to the same values — a repeat grant that neither overrides the level nor flips + /// inhibition — raises nothing. + /// + public event Action? OnAbilityChanged; + + /// + /// Event invoked when an ability is removed from the entity, carrying its handle. + /// + /// + /// Raised once the last grant source is gone, before the handle is invalidated, so the handle can still be read + /// inside the handler and reports as afterwards. + /// Losing one of several grant sources keeps the ability and raises instead. + /// + public event Action? OnAbilityRemoved; + + /// + /// Event invoked when an ability becomes active, carrying its handle. + /// + /// + /// The exact counterpart of : both track the ability, not its instances, so a second + /// concurrent instance of a per-execution ability raises neither. Raised as the ability becomes active and before + /// its behavior starts, so it always precedes the matching — including for a behavior + /// that finishes synchronously. + /// + public event Action? OnAbilityActivated; + /// /// Event invoked when an ability ends. /// public event Action? OnAbilityEnded; + /// + /// Event invoked when an activation attempt is refused, carrying the ability's handle and every reason it failed. + /// + /// + /// The direct callers of the activation API already receive these flags as an out parameter. This event covers the + /// activations nobody is holding the result of — those driven by tags and events, + /// and by the Statescript activation nodes — which are otherwise silent. + /// + public event Action? OnAbilityActivationFailed; + /// /// Gets the owner of this effects manager. /// @@ -32,7 +83,13 @@ public class EntityAbilities(IForgeEntity owner) /// /// Gets the set of abilities currently granted to the entity. /// - public HashSet GrantedAbilities { get; } = []; + /// + /// Read-only: the manager keeps this set in step with the grant sources behind each ability, so grant and removal + /// go through , GrantAbilityAndActivateOnce and the effect components + /// rather than through this set. The collection is live, so a handle removed while it is being enumerated + /// invalidates the enumeration; copy it first when the loop body can remove abilities. + /// + public IReadOnlyCollection GrantedAbilities => _grantedAbilities; /// /// Gets the tags that block abilities from being used. @@ -298,6 +355,9 @@ public AbilityHandle GrantAbilityPermanently( if (existingAbility is not null && existingAbility.SourceEntity == sourceEntity) { + bool wasInhibited = existingAbility.IsInhibited; + int previousLevel = existingAbility.Level; + _grantSources[existingAbility].Add(new PermanentGrantSource()); // If the ability was fully inhibited, this permanent grant should re-enable it. @@ -313,13 +373,17 @@ public AbilityHandle GrantAbilityPermanently( existingAbility.Level = abilityLevel; } + NotifyAbilityChanged(existingAbility, wasInhibited, previousLevel); + return existingAbility.Handle; } var newAbility = new Ability(Owner, abilityData, abilityLevel, sourceEntity); - GrantedAbilities.Add(newAbility.Handle); + _grantedAbilities.Add(newAbility.Handle); _grantSources[newAbility] = [new PermanentGrantSource()]; + OnAbilityGranted?.Invoke(newAbility.Handle); + return newAbility.Handle; } @@ -348,6 +412,9 @@ internal AbilityHandle GrantAbility( if (existingAbility is not null && existingAbility.SourceEntity == sourceEntity) { + bool wasInhibited = existingAbility.IsInhibited; + int previousLevel = existingAbility.Level; + // Ability already granted, just add the new source to the mapping. _grantSources[existingAbility].Add(grantSource); @@ -364,15 +431,21 @@ internal AbilityHandle GrantAbility( existingAbility.Level = abilityLevel; } + NotifyAbilityChanged(existingAbility, wasInhibited, previousLevel); + return existingAbility.Handle; } var newAbility = new Ability(Owner, abilityData, abilityLevel, sourceEntity); - GrantedAbilities.Add(newAbility.Handle); + _grantedAbilities.Add(newAbility.Handle); _grantSources[newAbility] = [grantSource]; + // Set before announcing the grant, so the handle already reports its settled inhibition state and an inhibited + // grant never reads as a change to an ability nobody has been told about yet. newAbility.IsInhibited = grantSource.IsInhibited; + OnAbilityGranted?.Invoke(newAbility.Handle); + return newAbility.Handle; } @@ -449,6 +522,16 @@ internal void NotifyAbilityEnded(AbilityEndedData abilityEndedData) OnAbilityEnded?.Invoke(abilityEndedData); } + internal void NotifyAbilityActivated(AbilityHandle abilityHandle) + { + OnAbilityActivated?.Invoke(abilityHandle); + } + + internal void NotifyAbilityActivationFailed(AbilityHandle abilityHandle, AbilityActivationFailures failureFlags) + { + OnAbilityActivationFailed?.Invoke(abilityHandle, failureFlags); + } + private static bool MatchesTags(Ability ability, TagContainer tagsToActivate) { return ability.AbilityData.AbilityTags?.HasAny(tagsToActivate) == true; @@ -533,8 +616,12 @@ private void RemoveAbility(Ability abilityToRemove) } abilityToRemove.Cleanup(); + + // Raised before the handle is freed, so handlers can still read what went away. + OnAbilityRemoved?.Invoke(abilityToRemove.Handle); + abilityToRemove.Handle.Free(); - GrantedAbilities.Remove(abilityToRemove.Handle); + _grantedAbilities.Remove(abilityToRemove.Handle); } private void InhibitAbility(Ability abilityToInhibit) @@ -545,7 +632,23 @@ private void InhibitAbility(Ability abilityToInhibit) _inhibitAbility = null; } + bool wasInhibited = abilityToInhibit.IsInhibited; + abilityToInhibit.IsInhibited = CheckIsInhibited(abilityToInhibit); + + NotifyAbilityChanged(abilityToInhibit, wasInhibited, abilityToInhibit.Level); + } + + private void NotifyAbilityChanged(Ability ability, bool wasInhibited, int previousLevel) + { + // Announces a change only when one of the two observable pieces of ability state actually moved: the grant + // paths run unconditionally, and a repeat grant that overrides nothing must stay silent. + if (ability.IsInhibited == wasInhibited && ability.Level == previousLevel) + { + return; + } + + OnAbilityChanged?.Invoke(ability.Handle); } private bool CheckIsInhibited(Ability ability) From 1aa22220fada626abf3d82570268a19235d48875 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 4 Aug 2026 00:24:22 -0300 Subject: [PATCH 4/6] Added EffectsManager observability events --- .../Effects/EffectsManagerEventsTests.cs | 430 ++++++++++++++++++ Forge/Effects/EffectsManager.cs | 111 +++++ 2 files changed, 541 insertions(+) create mode 100644 Forge.Tests/Effects/EffectsManagerEventsTests.cs diff --git a/Forge.Tests/Effects/EffectsManagerEventsTests.cs b/Forge.Tests/Effects/EffectsManagerEventsTests.cs new file mode 100644 index 00000000..01ff631d --- /dev/null +++ b/Forge.Tests/Effects/EffectsManagerEventsTests.cs @@ -0,0 +1,430 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +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.Effects.Stacking; +using Gamesmiths.Forge.Tags; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Effects; + +public class EffectsManagerEventsTests(TagsAndCuesFixture tagsAndCuesFixture) : IClassFixture +{ + private const string TargetAttribute = "TestAttributeSet.Attribute1"; + + private readonly TagsManager _tagsManager = tagsAndCuesFixture.TagsManager; + private readonly CuesManager _cuesManager = tagsAndCuesFixture.CuesManager; + + [Fact] + [Trait("Lifecycle", null)] + public void An_active_effect_reports_applied_and_added_once_each() + { + TestEntity target = CreateEntity(); + var log = new EventLog(target.EffectsManager); + + ActiveEffectHandle? handle = ApplyEffect(target, CreateEffectData(DurationType.Infinite)); + + log.Entries.Should().Equal("Applied", "Added"); + log.AddedHandles.Should().ContainSingle().Which.Should().BeSameAs(handle); + } + + [Fact] + [Trait("Lifecycle", null)] + public void An_added_effect_reports_a_settled_handle() + { + TestEntity target = CreateEntity(); + + int stacksWhenAdded = 0; + int valueWhenAdded = 0; + + target.EffectsManager.OnActiveEffectAdded += handle => + { + stacksWhenAdded = handle.StackCount; + valueWhenAdded = target.PlayerAttributeSet.Attribute1.CurrentValue; + }; + + ApplyEffect(target, CreateStackableEffectData(initialStack: 2)); + + // The event lands after the modifiers have been applied and the stack count settled, which is what a buff bar + // reads straight out of the handle. + stacksWhenAdded.Should().Be(2); + valueWhenAdded.Should().Be(11); + } + + [Fact] + [Trait("Lifecycle", null)] + public void An_instant_effect_reports_applied_and_executed_but_never_added() + { + TestEntity target = CreateEntity(); + var log = new EventLog(target.EffectsManager); + + ApplyEffect(target, CreateEffectData(DurationType.Instant)).Should().BeNull(); + + log.Entries.Should().Equal("Applied", "Executed"); + } + + [Fact] + [Trait("Lifecycle", null)] + public void A_periodic_effect_reports_executed_on_every_tick() + { + TestEntity target = CreateEntity(); + var log = new EventLog(target.EffectsManager); + + ApplyEffect(target, CreatePeriodicEffectData()); + + log.ExecutedCount.Should().Be(1, "the effect executes on application"); + + target.EffectsManager.UpdateEffects(3); + + log.ExecutedCount.Should().Be(4); + log.AddedHandles.Should().ContainSingle("a periodic effect is added once, not once per tick"); + } + + [Theory] + [Trait("Lifecycle", null)] + [InlineData(true, EffectRemovalReason.Expired)] + [InlineData(false, EffectRemovalReason.Removed)] + public void An_ending_effect_reports_removed_with_the_reason(bool letItExpire, EffectRemovalReason expected) + { + TestEntity target = CreateEntity(); + var log = new EventLog(target.EffectsManager); + + ActiveEffectHandle? handle = ApplyEffect(target, CreateEffectData(DurationType.HasDuration)); + + if (letItExpire) + { + target.EffectsManager.UpdateEffects(11); + } + else + { + target.EffectsManager.RemoveEffect(handle!); + } + + log.Removals.Should().ContainSingle(); + log.Removals[0].Handle.Should().BeSameAs(handle); + log.Removals[0].Reason.Should().Be(expected); + } + + [Fact] + [Trait("Lifecycle", null)] + public void The_removed_handle_is_readable_inside_the_handler_and_invalid_afterwards() + { + TestEntity target = CreateEntity(); + + bool validInsideHandler = false; + string? nameInsideHandler = null; + + target.EffectsManager.OnActiveEffectRemoved += (handle, _) => + { + validInsideHandler = handle.IsValid; + nameInsideHandler = handle.Effect?.EffectData.Name; + }; + + ActiveEffectHandle? applied = ApplyEffect(target, CreateEffectData(DurationType.Infinite)); + target.EffectsManager.RemoveEffect(applied!); + + validInsideHandler.Should().BeTrue(); + nameInsideHandler.Should().Be("Observable Effect"); + applied!.IsValid.Should().BeFalse(); + } + + [Fact] + [Trait("Lifecycle", null)] + public void A_new_stack_reports_applied_and_changed_but_not_added() + { + TestEntity target = CreateEntity(); + EffectData effectData = CreateStackableEffectData(); + + ApplyEffect(target, effectData); + + var log = new EventLog(target.EffectsManager); + ActiveEffectHandle? handle = ApplyEffect(target, effectData); + + log.Entries.Should().Equal("Changed", "Applied"); + log.ChangedHandles.Should().ContainSingle().Which.Should().BeSameAs(handle); + handle!.StackCount.Should().Be(2); + } + + [Fact] + [Trait("Lifecycle", null)] + public void Losing_one_stack_of_a_surviving_effect_reports_changed_not_removed() + { + TestEntity target = CreateEntity(); + EffectData effectData = CreateStackableEffectData(); + + ApplyEffect(target, effectData); + ActiveEffectHandle? handle = ApplyEffect(target, effectData); + + var log = new EventLog(target.EffectsManager); + target.EffectsManager.RemoveEffect(handle!, stacksToRemove: 1); + + log.Removals.Should().BeEmpty(); + log.ChangedHandles.Should().ContainSingle(); + handle!.StackCount.Should().Be(1); + } + + [Fact] + [Trait("Lifecycle", null)] + public void Inhibition_reports_changed() + { + TestEntity target = CreateEntity(); + ActiveEffectHandle? handle = ApplyEffect(target, CreateEffectData(DurationType.Infinite)); + + var log = new EventLog(target.EffectsManager); + + handle!.SetInhibit(true); + handle.SetInhibit(false); + + log.ChangedHandles.Should().HaveCount(2); + log.AddedHandles.Should().BeEmpty(); + log.Removals.Should().BeEmpty(); + } + + [Fact] + [Trait("Denial", null)] + public void A_stack_denied_at_the_limit_reports_stack_denied_and_nothing_else() + { + TestEntity target = CreateEntity(); + EffectData effectData = CreateStackableEffectData(stackLimit: 1); + + ActiveEffectHandle? handle = ApplyEffect(target, effectData); + + var log = new EventLog(target.EffectsManager); + var denied = new Effect(effectData, new EffectOwnership(target, target)); + + target.EffectsManager.ApplyEffect(denied).Should().BeSameAs(handle); + + log.Entries.Should().Equal("StackDenied"); + log.StackDenials.Should().ContainSingle(); + log.StackDenials[0].Effect.Should().BeSameAs(denied); + log.StackDenials[0].Handle.Should().BeSameAs(handle); + } + + [Fact] + [Trait("Denial", null)] + public void A_stack_denied_by_its_owner_policy_reports_stack_denied() + { + TestEntity target = CreateEntity(); + TestEntity otherOwner = CreateEntity(); + EffectData effectData = CreateStackableEffectData(ownerDenialPolicy: StackOwnerDenialPolicy.DenyIfDifferent); + + ApplyEffect(target, effectData); + + var log = new EventLog(target.EffectsManager); + target.EffectsManager.ApplyEffect(new Effect(effectData, new EffectOwnership(otherOwner, otherOwner))); + + log.Entries.Should().Equal("StackDenied"); + } + + [Fact] + [Trait("Denial", null)] + public void An_effect_denied_before_it_reaches_an_active_one_reports_nothing() + { + TestEntity target = CreateEntity(); + var log = new EventLog(target.EffectsManager); + + // The target does not carry color.red, so the effect denies itself before any stacking rule is consulted. + var effectData = new EffectData( + "Self Denied Effect", + new DurationData(DurationType.Infinite), + CreateModifiers(), + effectComponents: + [ + new TargetTagRequirementsEffectComponent( + applicationTagRequirements: new TagRequirements(RequiredTags: MakeContainer("color.red"))) + ]); + + target.EffectsManager.ApplyEffect(new Effect(effectData, new EffectOwnership(target, target))) + .Should().BeNull(); + + log.Entries.Should().BeEmpty(); + } + + [Fact] + [Trait("Scope", null)] + public void The_events_only_report_what_lands_on_their_own_owner() + { + TestEntity watched = CreateEntity(); + TestEntity other = CreateEntity(); + + var log = new EventLog(watched.EffectsManager); + + ApplyEffect(other, CreateEffectData(DurationType.Infinite)); + + log.Entries.Should().BeEmpty(); + } + + // Regression: a component reacting to the application can take the effect straight back off, and announcing an + // addition that already ended leaves every listener holding an entry nothing ever removes. + [Fact] + [Trait("Lifecycle", null)] + public void An_effect_removed_during_its_own_application_never_reports_as_added() + { + TestEntity target = CreateEntity(); + var log = new EventLog(target.EffectsManager); + + var effectData = new EffectData( + "Self Removing Effect", + new DurationData(DurationType.Infinite), + CreateModifiers(), + effectComponents: [new SelfRemovingComponent()]); + + target.EffectsManager.ApplyEffect(new Effect(effectData, new EffectOwnership(target, target))); + + log.AddedHandles.Should().BeEmpty(); + log.Removals.Should().ContainSingle(); + target.EffectsManager.GetActiveEffects().Should().BeEmpty(); + } + + private static Modifier[] CreateModifiers() + { + return + [ + new Modifier( + TargetAttribute, + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(5))) + ]; + } + + private static EffectData CreateEffectData(DurationType durationType) + { + ModifierMagnitude? duration = durationType == DurationType.HasDuration + ? new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10f)) + : null; + + return new EffectData( + "Observable Effect", + new DurationData(durationType, duration), + CreateModifiers()); + } + + private static EffectData CreatePeriodicEffectData() + { + return new EffectData( + "Observable Periodic Effect", + new DurationData(DurationType.Infinite), + CreateModifiers(), + periodicData: new PeriodicData( + new ScalableFloat(1f), + true, + PeriodInhibitionRemovedPolicy.NeverReset)); + } + + private static EffectData CreateStackableEffectData( + int stackLimit = 3, + int initialStack = 1, + StackOwnerDenialPolicy ownerDenialPolicy = StackOwnerDenialPolicy.AlwaysAllow) + { + return new EffectData( + "Observable Stackable Effect", + new DurationData(DurationType.Infinite), + CreateModifiers(), + new StackingData( + new ScalableInt(stackLimit), + new ScalableInt(initialStack), + StackPolicy.AggregateByTarget, + StackLevelPolicy.SegregateLevels, + StackMagnitudePolicy.Sum, + StackOverflowPolicy.DenyApplication, + StackExpirationPolicy.ClearEntireStack, + ownerDenialPolicy, + StackOwnerOverridePolicy.KeepCurrent, + StackOwnerOverrideStackCountPolicy.IncreaseStacks)); + } + + private static ActiveEffectHandle? ApplyEffect(TestEntity target, EffectData effectData) + { + return target.EffectsManager.ApplyEffect(new Effect(effectData, new EffectOwnership(target, target))); + } + + private TagContainer MakeContainer(params string[] tagKeys) + { + return new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, tagKeys)); + } + + private TestEntity CreateEntity() + { + return new TestEntity(_tagsManager, _cuesManager); + } + + /// + /// Subscribes to every manager event and records them in the order they arrive, so tests can assert on both what + /// was raised and the sequence. + /// + private sealed class EventLog + { + public List Entries { get; } = []; + + public List AddedHandles { get; } = []; + + public List ChangedHandles { get; } = []; + + public List<(ActiveEffectHandle Handle, EffectRemovalReason Reason)> Removals { get; } = []; + + public List<(Effect Effect, ActiveEffectHandle Handle)> StackDenials { get; } = []; + + public int AppliedCount { get; private set; } + + public int ExecutedCount { get; private set; } + + public EventLog(EffectsManager effectsManager) + { + effectsManager.OnEffectApplied += _ => + { + Entries.Add("Applied"); + AppliedCount++; + }; + + effectsManager.OnEffectExecuted += _ => + { + Entries.Add("Executed"); + ExecutedCount++; + }; + + effectsManager.OnActiveEffectAdded += handle => + { + Entries.Add("Added"); + AddedHandles.Add(handle); + }; + + effectsManager.OnActiveEffectChanged += handle => + { + Entries.Add("Changed"); + ChangedHandles.Add(handle); + }; + + effectsManager.OnActiveEffectRemoved += (handle, reason) => + { + Entries.Add("Removed"); + Removals.Add((handle, reason)); + }; + + effectsManager.OnEffectStackDenied += (effect, handle) => + { + Entries.Add("StackDenied"); + StackDenials.Add((effect, handle)); + }; + } + } + + /// + /// Takes its own effect back off from inside the very application that added it. + /// + private sealed class SelfRemovingComponent : IEffectComponent + { + public void OnPostActiveEffectAdded( + IForgeEntity target, + in ActiveEffectEvaluatedData activeEffectEvaluatedData) + { + target.EffectsManager.RemoveEffect(activeEffectEvaluatedData.ActiveEffectHandle); + } + } +} diff --git a/Forge/Effects/EffectsManager.cs b/Forge/Effects/EffectsManager.cs index 1dd9c983..269a173d 100644 --- a/Forge/Effects/EffectsManager.cs +++ b/Forge/Effects/EffectsManager.cs @@ -34,6 +34,77 @@ public class EffectsManager(IForgeEntity owner, CuesManager cuesManager) private int _applicationDepth; + /// + /// Event triggered whenever an effect lands on the owner, carrying its evaluated data. + /// + /// + /// + /// The manager-wide counterpart of , with the same meaning of + /// "applied": every effect raises it, including instant ones that never become active, and a stackable effect + /// raises it again on each successful application. + /// + /// + /// Application is the registration phase, so the effect's attribute changes have not necessarily landed yet: an + /// instant effect has yet to execute, and a duration effect has yet to apply its modifiers. Read values from + /// , or + /// instead. + /// + /// + /// For the buff-bar lifecycle — one event per active effect appearing and disappearing — use + /// and instead. + /// + /// + public event Action? OnEffectApplied; + + /// + /// Event triggered whenever an effect executes on the owner, carrying its evaluated data. + /// + /// + /// The manager-wide counterpart of : only instant and periodic + /// effects execute, and a periodic effect raises it on every tick. The execution has already changed the base + /// values by this point, so handlers read post-execution attributes. + /// + public event Action? OnEffectExecuted; + + /// + /// Event triggered when an effect becomes active on the owner, carrying its handle. + /// + /// + /// Raised once per , after every component has finished processing the application, so + /// the handle already reports its settled stack count, level and inhibition state. Instant effects never become + /// active and never raise it; a new stack on an already-active effect raises + /// rather than this. + /// + public event Action? OnActiveEffectAdded; + + /// + /// Event triggered when an active effect on the owner changes, carrying its handle. + /// + /// + /// The manager-wide counterpart of : stack count, level, + /// re-evaluated magnitudes and inhibition all report through it. An application that changes nothing — a stack + /// arriving at an effect already at its limit under + /// — does not raise it. + /// + public event Action? OnActiveEffectChanged; + + /// + /// Event triggered when an active effect is removed from the owner, carrying its handle and why it ended. + /// + /// + /// + /// Raised once per , after every component has processed the removal but before the + /// handle is invalidated, so the handle can still be read inside the handler and reports + /// as afterwards. Losing a single stack of an + /// effect that survives raises instead. + /// + /// + /// The owner's attributes still carry the effect's modifiers at this point; they are released immediately after, + /// and is the seam for observing that. + /// + /// + public event Action? OnActiveEffectRemoved; + /// /// Event triggered when a registered denies an effect application. Carries /// the effect that was blocked and the blocker that denied it. @@ -44,6 +115,20 @@ public class EffectsManager(IForgeEntity owner, CuesManager cuesManager) /// public event Action? OnEffectApplicationBlocked; + /// + /// Event triggered when an effect reaches an active effect it would stack onto and its + /// refuses the application. Carries the effect that was refused and the handle of the active effect that refused + /// it. + /// + /// + /// The stacking counterpart of , covering every stacking-driven denial: + /// at the stack limit, + /// , and . These + /// applications are otherwise invisible, since still returns the handle of the + /// effect already in place. + /// + public event Action? OnEffectStackDenied; + /// /// Gets the owner of this effects manager. /// @@ -294,6 +379,8 @@ internal void OnEffectExecuted_InternalCall( component.OnEffectExecuted(Owner, in executedEffectEvaluatedData); } + OnEffectExecuted?.Invoke(executedEffectEvaluatedData); + _cuesManager.ExecuteCues(in executedEffectEvaluatedData); } @@ -327,6 +414,8 @@ internal void OnActiveEffectChanged_InternalCall(ActiveEffect removedEffect) removedEffect.NextPeriodicTick, removedEffect.ExecutionCount)); } + + OnActiveEffectChanged?.Invoke(removedEffect.Handle); } internal void TriggerCuesUpdate_InternalCall(in EffectEvaluatedData effectEvaluatedData) @@ -443,6 +532,8 @@ private static EffectStackInstanceData CreateStackInstanceData(ActiveEffect effe component.OnEffectApplied(Owner, in evaluatedData); } + OnEffectApplied?.Invoke(evaluatedData); + Effect.Execute(in evaluatedData, componentInstances); return null; } @@ -464,6 +555,12 @@ private static EffectStackInstanceData CreateStackInstanceData(ActiveEffect effe { component.OnEffectApplied(Owner, stackableEffect.EffectEvaluatedData); } + + OnEffectApplied?.Invoke(stackableEffect.EffectEvaluatedData); + } + else + { + OnEffectStackDenied?.Invoke(effect, stackableEffect.Handle); } return stackableEffect.Handle; @@ -543,6 +640,8 @@ private ActiveEffect ApplyNewEffect(Effect effect, EffectApplicationContext? app component.OnEffectApplied(Owner, activeEffect.EffectEvaluatedData); } + OnEffectApplied?.Invoke(activeEffect.EffectEvaluatedData); + EffectEvaluatedData effectEvaluatedData = activeEffect.EffectEvaluatedData; bool triggerApplyCuesEarly = effect.EffectData.PeriodicData.HasValue @@ -575,6 +674,14 @@ private ActiveEffect ApplyNewEffect(Effect effect, EffectApplicationContext? app activeEffect.ExecutionCount)); } + // A component reacting to the application can take the effect straight back off — an effect it applies from + // OnActiveEffectAdded removing this one. Announcing an addition that already ended would leave every listener + // holding an entry nothing ever removes, so the already-raised removal is left as the last word. + if (activeEffect.Handle.IsValid) + { + OnActiveEffectAdded?.Invoke(activeEffect.Handle); + } + return activeEffect; } @@ -654,6 +761,10 @@ private void RemoveActiveEffect(ActiveEffect effectToRemove, EffectRemovalReason reason); } + // Raised before the handle is freed so that handlers can still read what ended, and not before the component + // callbacks so that the effect's own components — the ones that can still change the outcome — go first. + OnActiveEffectRemoved?.Invoke(effectToRemove.Handle, reason); + effectToRemove.Handle.Free(); effectToRemove.EffectEvaluatedData.Target.Attributes.ApplyPendingValueChanges(); From ba626861e03e1c9630ee33b9699f7d6f14296ced Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 4 Aug 2026 17:00:36 -0300 Subject: [PATCH 5/6] Reviewed existing docs and added observability --- README.md | 5 +- docs/README.md | 2 +- docs/abilities.md | 52 ++++++++++++++++++--- docs/cues.md | 25 ++++++++-- docs/effects/README.md | 57 +++++++++++++++++++++- docs/effects/stacking.md | 2 + docs/quick-start.md | 68 ++++++++++++++++++++++++++- docs/statescript/custom-resolvers.md | 70 ++++++++++++++++++++++++++++ docs/tags.md | 15 ++++++ 9 files changed, 279 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 73d0d875..deb77b0a 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,9 @@ An Unreal GAS-like gameplay framework for developing games in 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, abilities, events, and cues in your games. -Forge is an engine-agnostic, data-driven system inspired by Unreal Engine’s Gameplay Ability System (GAS), designed to manage attributes, effects, abilities, tags, events, and cues in a structured way. +Forge is an engine-agnostic, data-driven gameplay framework for building robust game systems in C#. Inspired by Unreal Engine's Gameplay Ability System (GAS), it 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. +The framework eliminates the need to rebuild status systems for every game project by offering a flexible 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 framework, C#, engine-agnostic, data-driven, attributes, gameplay effects, abilities, gameplay tags diff --git a/docs/README.md b/docs/README.md index b43e89b5..6eebf037 100644 --- a/docs/README.md +++ b/docs/README.md @@ -151,4 +151,4 @@ For more detailed information about specific systems, refer to these documentati To start using Forge in your project, see the [Quick Start Guide](quick-start.md) for basic setup and examples of common gameplay mechanics. -For integrating Forge into your workflow, check the installation instructions and API reference in the main [README](../README.md) file. +For installation options and an architecture overview, see the main [README](../README.md). diff --git a/docs/abilities.md b/docs/abilities.md index 6890e3b1..6d953df5 100644 --- a/docs/abilities.md +++ b/docs/abilities.md @@ -266,12 +266,14 @@ entity.EffectsManager.RemoveEffect(effectHandle2); EntityAbilities abilities = entity.Abilities; // Get all granted abilities -HashSet granted = abilities.GrantedAbilities; +IReadOnlyCollection granted = abilities.GrantedAbilities; // Get blocked ability tags (used internally for ability blocking) EntityTags blockedTags = abilities.BlockedAbilityTags; ``` +`GrantedAbilities` is read-only and live: the manager keeps it in step with the grant sources behind each ability, so abilities are added and removed through the granting API and the [effect components](effects/components/grant-ability-effect-component.md), never through the set itself. Copy it before iterating when the loop body can remove abilities. + Abilities whose `AbilityTags` overlap `BlockedAbilityTags` fail activation with `AbilityActivationFailures.BlockedByTags`. The container is populated by `BlockAbilitiesWithTag` while an ability is running, and by [`BlockAbilityTagsEffectComponent`](effects/components/block-ability-tags-effect-component.md) while an effect is active. ### Finding Abilities @@ -358,17 +360,41 @@ Each container is an independent filter, and a `null` or empty one means "don't To drive this from an effect instead of calling it directly, use [`CancelAbilityTagsEffectComponent`](effects/components/cancel-ability-tags-effect-component.md), which wraps `CancelAbilities` and can fire on application or on each periodic execution. -### Ability Events +### Observing Abilities + +`EntityAbilities` reports the whole ability lifecycle to whoever asks, which is what an ability bar needs to stay in sync without polling `GrantedAbilities` every frame. These are plain C# `event` members — **change notifications**, not to be confused with the tag-routed [Events system](events.md) that drives [event triggers](#event-trigger): + +| Notification | Raised when | Payload | +|---|---|---| +| `OnAbilityGranted` | an ability is granted, once per ability | `AbilityHandle` | +| `OnAbilityChanged` | a granted ability's level or inhibition changes | `AbilityHandle` | +| `OnAbilityRemoved` | an ability loses its last grant source | `AbilityHandle` | +| `OnAbilityActivated` | an ability becomes active | `AbilityHandle` | +| `OnAbilityEnded` | an ability's last active instance ends | `AbilityEndedData` | +| `OnAbilityActivationFailed` | an activation attempt is refused | `AbilityHandle`, `AbilityActivationFailures` | + +```csharp +entity.Abilities.OnAbilityGranted += handle => _slots.Add(handle, CreateSlot(handle)); +entity.Abilities.OnAbilityChanged += handle => _slots[handle].SetEnabled(!handle.IsInhibited); +entity.Abilities.OnAbilityRemoved += handle => +{ + _slots[handle].Dispose(); + _slots.Remove(handle); +}; +``` + +The handle is safe to use as a key: it is created once per ability and stays the same object for that ability's whole life. Inside the removed handler it can still be read, and becomes invalid immediately after. + +**Granted vs. changed.** Granting an ability the entity already has adds a [grant source](#grant-sources-and-policies) rather than a second ability, so it raises `OnAbilityChanged` — and only if the [level override policy](#level-override-policy) actually moved the level, or the new source flipped inhibition. A repeat grant that resolves to the same values is silent. -Subscribe to `OnAbilityEnded` to react when abilities end: +**Activated and ended are a matched pair.** Both track the *ability*, not its instances, so a second concurrent instance of a [`PerExecution`](#perexecution) ability raises neither: activation reports the inactive-to-active transition, ending reports the last instance going away. `OnAbilityActivated` is raised before the behavior starts, so it always arrives before the matching `OnAbilityEnded` — including for a behavior that finishes inside `OnStarted`. ```csharp entity.Abilities.OnAbilityEnded += data => { AbilityHandle ability = data.Ability; - bool wasCanceled = data.WasCanceled; - if (wasCanceled) + if (data.WasCanceled) { // Ability was interrupted ShowInterruptedFeedback(); @@ -381,7 +407,21 @@ entity.Abilities.OnAbilityEnded += data => }; ``` -`OnAbilityEnded` fires **exactly once** each time an ability deactivates, when its last active instance ends. `WasCanceled` is `true` when the ability was canceled (via `AbilityHandle.Cancel()` or `CancelAbilities`) and `false` when it ended gracefully (reaching its natural end, or a Statescript Exit node). +`WasCanceled` is `true` when the ability was canceled (via `AbilityHandle.Cancel()` or `CancelAbilities`) and `false` when it ended gracefully (reaching its natural end, or a Statescript Exit node). `AbilityEndedData` also carries `AbilityData`, captured before the handle can be freed, because an ability granted with `RemoveOnEnd` is removed by the very same call. + +**Failed activations.** Whoever calls the activation API already receives [`AbilityActivationFailures`](#activation-failures) as an out parameter. `OnAbilityActivationFailed` exists for the activations nobody holds the result of — those driven by [ability triggers](#ability-triggers) and by the Statescript activation nodes — which are otherwise completely silent: + +```csharp +entity.Abilities.OnAbilityActivationFailed += (handle, failures) => +{ + if (failures.HasFlag(AbilityActivationFailures.Cooldown)) + { + FlashCooldown(handle); + } +}; +``` + +Handlers run inside the ability pipeline, synchronously, so keep them cheap. The [attribute](attributes.md#from-outside-the-attributeset), [tag](tags.md#reacting-to-tag-changes) and [effect](effects/README.md#observing-effects) change notifications cover the rest of an entity's observable state. ## Ability Handle diff --git a/docs/cues.md b/docs/cues.md index ed149345..e7507ed7 100644 --- a/docs/cues.md +++ b/docs/cues.md @@ -310,14 +310,29 @@ cuesManager.UpdateCue(burningTag, targetEntity, updatedParameters); cuesManager.RemoveCue(burningTag, targetEntity, interrupted: false); ``` -## Cues vs Events +## Cues, the Events System, and Change Notifications -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. +Three mechanisms carry "something happened" out of the simulation, and they are not interchangeable. Note that two different things are called "events" in a C# codebase, and only one of them is the Forge [Events system](events.md): -- **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. +- **Cues** handle *reactions to a transition*: particle effects, sounds, UI animations, floating numbers. Authored per effect, routed by tag, one-to-many. In a networked context they can use unreliable replication. +- **The [Events system](events.md)** handles *simulation*: damage calculations, ability triggers, state changes. It is a tag-routed event bus with `EventData` payloads, raised through `entity.Events.Raise(...)`. In a networked context it requires reliable replication. +- **Change notifications** — plain C# `event` members such as `EffectsManager.OnActiveEffectAdded`, `EntityAbilities.OnAbilityGranted`, `EntityTags.OnTagsChanged` and `EntityAttribute.OnValueChanged` — handle *views of state*: buff bars, ability bars, character sheets, anything that renders "what is currently true" rather than reacting to a change. They are subscribed with `+=`, never raised by game code, and carry no tags or payloads. See [Observing Effects](effects/README.md#observing-effects). -A common pattern is to raise an Event for gameplay logic, then trigger the corresponding Cue for feedback: +The cue / Events-system split is about **presentation vs. simulation**. The cue / change-notification split is a different axis — both are presentation — and it is about **reaction vs. roster**. + +### Why a Buff Bar Is Not a Cue + +Reaching for cues to build a buff bar is a natural first instinct, and it does not work. Three structural reasons: + +- **Cues cannot initialize a view.** `CuesManager` has no enumeration API, so a UI created while effects are already active — after a respawn, a scene load, or opening a character panel — never receives the `OnApply` calls that already happened. `EffectsManager.GetActiveEffects()` plus the change notifications give you the standard pattern instead: enumerate what is there on start, then subscribe for the deltas. +- **Cues are opt-in per effect.** An effect with no `CueData` triggers nothing, so a cue-driven bar silently omits every effect nobody tagged. A view of state has to be complete. +- **`CueParameters` carries one number.** `CueMagnitudeType.StackCount` can make that number the stack count, but then the remaining duration has nowhere to come from. An `ActiveEffectHandle` exposes stacks, remaining and total duration, level and inhibition at once, and `OnRemove` receives no parameters at all. + +Use both, split by role: the change notifications tell the bar **which icons exist and what they say**; cues tell each effect **what it does when it lands or leaves** — the stack-gain pop, the shield-shatter, the signature treatment a particular debuff gets. `OnRemove(target, interrupted)` even distinguishes a dispel from a natural expiry for free. + +### Pairing the Events System with Cues + +A common pattern is to raise a gameplay event for the logic, then trigger the corresponding cue for feedback: ```csharp // Event for gameplay (reliable, affects game state) diff --git a/docs/effects/README.md b/docs/effects/README.md index fa2180fe..b1a676e1 100644 --- a/docs/effects/README.md +++ b/docs/effects/README.md @@ -109,7 +109,7 @@ ActiveEffectHandle? handle = entity.EffectsManager.ApplyEffect(effect); // Remove an effect by its handle if (handle is not null) { - bool removed = entity.EffectsManager.RemoveEffect(handle); + entity.EffectsManager.RemoveEffect(handle); } // Update all active effects on the entity @@ -328,6 +328,61 @@ Effects that deny themselves through their own components never reach the blocke [`ImmunityEffectComponent`](components/immunity-effect-component.md) is the data-driven implementation of this interface: it registers while its effect is active and matches incoming effects against a set of `EffectQuery` filters. +#### Observing Effects + +[Effect components](components/README.md) are the right seam for anything that *reacts* to an effect as gameplay, and [cues](../cues.md) are the right seam for anything that reacts to it as *presentation*: both are authored on the effect that cares. But a buff bar, a combat log or an analytics hook cares about *every* effect on an entity, and cannot be authored on effects it has never heard of. The `EffectsManager` reports the same lifecycle to whoever asks, through plain C# `event` members — these are **change notifications**, unrelated to the tag-routed [Events system](../events.md) that carries `EventData` through the simulation: + +| Notification | Raised when | Payload | +|---|---|---| +| `OnEffectApplied` | any effect lands, instant ones included, and again on each successful stack | `EffectEvaluatedData` | +| `OnEffectExecuted` | an instant or periodic effect executes, once per periodic tick | `EffectEvaluatedData` | +| `OnActiveEffectAdded` | an effect becomes active, once per `ActiveEffect` | `ActiveEffectHandle` | +| `OnActiveEffectChanged` | an active effect's stacks, level, magnitudes or inhibition change | `ActiveEffectHandle` | +| `OnActiveEffectRemoved` | an active effect ends | `ActiveEffectHandle`, `EffectRemovalReason` | +| `OnEffectApplicationBlocked` | a registered blocker denied an application | `Effect`, `IEffectApplicationBlocker` | +| `OnEffectStackDenied` | an active effect's `StackingData` refused an application | `Effect`, `ActiveEffectHandle` | + +A buff bar needs three of them: + +```csharp +entity.EffectsManager.OnActiveEffectAdded += handle => _icons.Add(handle, CreateIcon(handle)); +entity.EffectsManager.OnActiveEffectChanged += handle => _icons[handle].Refresh(handle); +entity.EffectsManager.OnActiveEffectRemoved += (handle, reason) => +{ + _icons[handle].PlayEndAnimation(reason == EffectRemovalReason.Expired); + _icons.Remove(handle); +}; +``` + +The handle is safe to use as a key: it is created once per active effect and stays the same object for that effect's whole life. Inside the removed handler it can still be read, and becomes invalid immediately after — so read what you need there rather than storing the handle and reading it later. + +##### Cues or change notifications? + +Both are presentation-facing, so the split is not "gameplay vs. visuals" — it is **reaction vs. roster**: + +- A **cue** reacts to a transition. It is authored on the effect, so the designer who writes the poison decides what poison looks like, and it fires and is done. Hit flashes, sounds, floating numbers, the pop when a stack lands. +- These **notifications** describe the roster. They pair with `GetActiveEffects()` to answer "what is on this entity right now", which is what a bar, a character sheet or a tooltip renders. + +The distinction has teeth. A cue-driven buff bar cannot initialize — `CuesManager` has no enumeration, so a UI created after effects are already active never hears about them — it silently omits every effect with no `CueData`, and `CueParameters` carries a single number where an icon needs stacks *and* remaining duration at once. See [Cues, the Events System, and Change Notifications](../cues.md#cues-the-events-system-and-change-notifications). + +Most real buff bars use both: these notifications for which icons exist and what they say, cues for what each individual effect does when it lands or leaves. + +Two distinctions decide which notification you want: + +- **Applied vs. added.** *Applied* is the [application phase](#application-vs-execution) and fires for every landing, including instant effects that never become active and each new stack of an existing one. *Added* fires once, when an `ActiveEffect` starts existing. A buff bar wants *added*; a combat log wants *applied*. +- **When the numbers are ready.** `OnEffectApplied` runs before the effect has changed anything — an instant effect has not executed yet, a duration effect has not applied its modifiers yet. `OnEffectExecuted` and the three `OnActiveEffect*` notifications run once the state has settled. To follow a value rather than an effect, subscribe to [`EntityAttribute.OnValueChanged`](../attributes.md#from-outside-the-attributeset) instead. + +Denials are reported by the last two. `OnEffectApplicationBlocked` covers the [blocker registry](#blocking-effect-application); `OnEffectStackDenied` covers every [stacking](stacking.md) refusal — the stack limit under `StackOverflowPolicy.DenyApplication`, `LevelDenialPolicy`, and `StackOwnerDenialPolicy.DenyIfDifferent`. That last one is the only way to see those applications at all, since `ApplyEffect` still hands back the handle of the effect already in place: + +```csharp +entity.EffectsManager.OnEffectStackDenied += (deniedEffect, existing) => + ShowFloater($"{deniedEffect.EffectData.Name} at max stacks ({existing.StackCount})"); +``` + +An effect that denies itself through its own `CanApplyEffect` components raises nothing, on either path. + +Handlers run inside the effect pipeline, synchronously, so keep them cheap. Applying or removing effects from a handler works, and the same 16-level cascade guard that protects [components](components/additional-effects-effect-component.md#lifecycle-hooks) bounds it — but the ordering it produces is easier to reason about when the handler only records what happened and acts on it later. + ## Effect Lifecycle ### Application vs. Execution diff --git a/docs/effects/stacking.md b/docs/effects/stacking.md index c69276c6..a081fe1f 100644 --- a/docs/effects/stacking.md +++ b/docs/effects/stacking.md @@ -69,6 +69,8 @@ An "overflow" occurs when an effect has reached its maximum stack count (defined - With `AllowApplication`, the new application is processed (refreshing duration, triggering events, etc.) but the stack count remains at the limit. - With `DenyApplication`, the new application is completely rejected as if it never happened. +A denied application is invisible to its caller: `ApplyEffect` still returns the handle of the effect already in place. Subscribe to [`EffectsManager.OnEffectStackDenied`](README.md#observing-effects) to see them — it reports every stacking-driven refusal, including `LevelDenialPolicy` and `StackOwnerDenialPolicy` denials. + ## Key Stacking Policies ### Stack Aggregation diff --git a/docs/quick-start.md b/docs/quick-start.md index 1299cc8a..5b805375 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -1258,6 +1258,70 @@ var enrageEffect = new EffectData( player.EffectsManager.ApplyEffect(new Effect(enrageEffect, new EffectOwnership(player, player))); ``` +## Statescript + +An `IAbilityBehavior` written by hand, like the ones above, is the imperative way to give an ability its logic. [Statescript](statescript/README.md) is the declarative one: a graph of nodes describing what happens, in what order, and for how long. + +Statescript is designed as a **visual** scripting language, but the core library has nothing visual in it — by design. The core owns the graph model and the processor that executes it; the graph *editors* live in the engine integrations, each rendering the same model in its own editor UI. This section shows the C# API those editors ultimately write to, which is also how you build a graph without any editor at all. + +### Building a Graph + +A graph is nodes plus the connections between them. This one waits one second, then applies an effect to the ability's target: + +```csharp +var graph = new Graph(); + +// Inputs the nodes read from. Variables hold values; properties are computed on read. +graph.VariableDefinitions.DefineVariable("delay", 1.0); +graph.VariableDefinitions.DefineObjectProperty("damageEffect", new EffectFromDataResolver(damageEffectData)); +graph.VariableDefinitions.DefineObjectProperty("target", new AbilityTargetResolver()); + +// A state node: stays active for its bound duration, then emits OnTimerEnd. +var timer = new TimerNode(); +timer.BindInput(TimerNode.DurationInput, "delay"); +graph.AddNode(timer); + +// An action node: fires once and continues. +var applyEffect = new ApplyEffectNode(); +applyEffect.BindInput(ApplyEffectNode.EffectInput, "damageEffect"); +applyEffect.BindInput(ApplyEffectNode.TargetInput, "target"); +graph.AddNode(applyEffect); + +// Entry -> Timer, and Timer's OnTimerEnd -> ApplyEffect. +graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + +graph.AddConnection(new Connection( + timer.OutputPorts[TimerNode.OnTimerEndPort], + applyEffect.InputPorts[ActionNode.InputPort])); +``` + +Three things to notice, because they are the whole model: + +- **Nodes read their inputs through the variable definitions**, never from each other. `BindInput` names a variable or property; `AbilityTargetResolver` pulls the target out of the ability that is running the graph, so the same graph works for whoever activates it. +- **Ports are numbered and typed.** A state node like `TimerNode` has several output ports — `OnActivate`, `OnDeactivate`, `OnAbort`, `OnTimerEnd` — and connecting to the right one is what expresses "only when the timer finished on its own". +- **State nodes own time; action nodes do not.** The timer stays active across frames, the effect application happens and is done. + +### Running It as an Ability + +`GraphAbilityBehavior` implements `IAbilityBehavior`, so a graph plugs into an ability exactly like a hand-written behavior: + +```csharp +var abilityData = new AbilityData( + "Delayed Strike", + behaviorFactory: () => new GraphAbilityBehavior(graph)); + +AbilityHandle handle = player.Abilities.GrantAbilityPermanently( + abilityData, 1, LevelComparison.None, player); + +handle.Activate(out AbilityActivationFailures failureFlags); +``` + +Activating the ability starts the graph at its Entry node; `UpdateAbilities` ticks it each frame, and the ability instance ends automatically when the graph finishes. Everything you already know about costs, cooldowns, tag requirements and triggers applies unchanged — the graph replaces the behavior, not the ability. + +From here the [Statescript overview](statescript/README.md) covers the [node catalog](statescript/nodes/README.md), the [resolvers](statescript/resolvers/README.md) that feed node inputs, [variables and data flow](statescript/variables.md), and [ability integration](statescript/ability-integration.md) in depth. + ## Next Steps Now that you've seen the basics of Forge, you can: @@ -1271,6 +1335,8 @@ Now that you've seen the basics of Forge, you can: 7. Integrate [Cues](cues.md) for visual and audio feedback. 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). +10. Author ability behavior as node graphs with [Statescript](statescript/README.md). +11. Drive your UI from gameplay state with the [attribute](attributes.md#from-outside-the-attributeset), [tag](tags.md#reacting-to-tag-changes), [effect](effects/README.md#observing-effects) and [ability](abilities.md#observing-abilities) change notifications. +12. 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/statescript/custom-resolvers.md b/docs/statescript/custom-resolvers.md index 317a6a5a..c79c0186 100644 --- a/docs/statescript/custom-resolvers.md +++ b/docs/statescript/custom-resolvers.md @@ -250,6 +250,76 @@ graph.VariableDefinitions.DefineObjectArrayProperty("participants", new AbilitySourceResolver())); ``` +## Implementing Object-Lane Resolvers + +`IPropertyResolver` and `IArrayPropertyResolver` cover the **value lane**: numbers, booleans, vectors and quaternions, all packed into `Variant128`. References — entities, effects, handles — travel the **object lane** instead, through a parallel pair of interfaces. Anything that returns a reference type belongs here; a spatial query returning `IForgeEntity[]` is the canonical case. + +```csharp +// Each generic interface adds a strongly-typed Resolve to a non-generic base that carries the Type. +public interface IObjectResolver +{ + Type ValueType { get; } + object? Resolve(GraphContext graphContext); +} + +public interface IObjectResolver : IObjectResolver +{ + new T Resolve(GraphContext graphContext); +} + +public interface IObjectArrayResolver +{ + Type ElementType { get; } + object?[] ResolveArray(GraphContext graphContext); +} + +public interface IObjectArrayResolver : IObjectArrayResolver +{ + new T[] ResolveArray(GraphContext graphContext); +} +``` + +Derive from the abstract bases rather than implementing the interfaces directly — `ObjectResolver` and `ObjectArrayResolver` supply `ValueType` / `ElementType` and the non-generic bridging for you: + +```csharp +// A spatial query is engine-side, so this is exactly the kind of resolver a game supplies. +public class EnemiesInRangeResolver : ObjectArrayResolver +{ + private readonly float _range; + + public EnemiesInRangeResolver(float range) + { + _range = range; + } + + public override IForgeEntity[] ResolveArray(GraphContext graphContext) + { + if (!graphContext.TryGetActivationContext(out var context)) + { + return []; + } + + return YourSpatialSystem.QueryEnemies(context.Owner, _range); + } +} +``` + +Bind them with the object-lane counterparts of the `Define*` methods: + +```csharp +graph.VariableDefinitions.DefineObjectProperty("nearestEnemy", + new ObjectFirstResolver(new EnemiesInRangeResolver(10f))); + +graph.VariableDefinitions.DefineObjectArrayProperty("enemiesInRange", + new EnemiesInRangeResolver(10f)); +``` + +Three things follow from being on the object lane: + +- **It composes with the built-in object-lane [array operations](resolvers/README.md#array-operations)** — `ObjectWhereResolver`, `ObjectOrderByResolver`, `ObjectExceptResolver` and the rest — so filtering and sorting a custom entity query needs no further code. +- **Returning `IForgeEntity` is worth one more step.** Implement [`IEntityResolver`](resolvers/README.md#entity-resolvers) as well (or derive from a built-in that already does) so the result plugs straight into `AttributeResolver`, `TagQueryResolver` and the other entity-aware resolvers. +- **Arrays never return `null`.** Return an empty array when there is nothing to produce; single-value object resolvers may return `null`, which the graph reads as an absent value. + ## Composing Resolvers Custom resolvers compose with built-in resolvers. The most common pattern is using a custom resolver as an operand in a `ComparisonResolver` to create data-driven conditions: diff --git a/docs/tags.md b/docs/tags.md index ca0a8002..f7e5ebbe 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -271,6 +271,21 @@ bool isStunned = entity.Tags.AllTags.HasTag(Tag.RequestTag(tagsManager, "status. **Note:** While the `EntityTags` class provides methods for adding and removing tags, these are internal. Tags should be modified through proper channels: base tags during entity initialization and modifier tags through the [Effects system](effects/README.md). +#### Reacting to Tag Changes + +`OnTagsChanged` fires whenever `AllTags` changes, carrying that same container: + +```csharp +entity.Tags.OnTagsChanged += allTags => + _stunOverlay.Visible = allTags.HasTag(Tag.RequestTag(tagsManager, "status.stunned")); +``` + +- Raised **after** the change has landed, so the container already reflects it. +- Modifier tags are reference-counted, so a second effect granting a tag the entity already has raises nothing — `AllTags` did not change. The notification is about the tag set, not about the effects behind it. +- The argument is the live `AllTags` container, not a copy. It keeps changing after the handler returns, so read what you need inside the handler rather than storing it. + +Inside a Statescript graph, use [`TagListenerNode`](statescript/nodes/state/tag-listener-node.md) instead; inside an effect, use [`TargetTagRequirementsEffectComponent`](effects/components/target-tag-requirements-effect-component.md). `OnTagsChanged` is for the code that owns neither — UI, audio, analytics. + ## Tag Queries TagQueries provide powerful logical operations for matching tag containers against complex conditions. They allow you to create sophisticated, reusable rules using nested logical expressions. From f911893b56bc7ae6de8a64137a8a85fc21f05bc9 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 4 Aug 2026 17:12:59 -0300 Subject: [PATCH 6/6] Fixed PR comments --- Forge/Effects/EffectsManager.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Forge/Effects/EffectsManager.cs b/Forge/Effects/EffectsManager.cs index 269a173d..a4edef65 100644 --- a/Forge/Effects/EffectsManager.cs +++ b/Forge/Effects/EffectsManager.cs @@ -44,10 +44,12 @@ public class EffectsManager(IForgeEntity owner, CuesManager cuesManager) /// raises it again on each successful application. /// /// - /// Application is the registration phase, so the effect's attribute changes have not necessarily landed yet: an - /// instant effect has yet to execute, and a duration effect has yet to apply its modifiers. Read values from - /// , or - /// instead. + /// For newly applied effects, this is the registration phase, so the effect's attribute changes have not + /// necessarily landed yet: an instant effect has yet to execute, and a duration effect has yet to apply its + /// modifiers. For stack applications, the existing active effect may already have re-evaluated and applied its + /// modifiers by the time this event fires. Use , , + /// or when you need a + /// settled post-change view. /// /// /// For the buff-bar lifecycle — one event per active effect appearing and disappearing — use