diff --git a/Forge.Tests/Attributes/AttributeSetRemovalTests.cs b/Forge.Tests/Attributes/AttributeSetRemovalTests.cs new file mode 100644 index 0000000..0e99fd6 --- /dev/null +++ b/Forge.Tests/Attributes/AttributeSetRemovalTests.cs @@ -0,0 +1,778 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Abilities; +using Gamesmiths.Forge.Attributes; +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.Tags; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Attributes; + +public class AttributeSetRemovalTests(TagsAndCuesFixture tagsAndCuesFixture) : IClassFixture +{ + private const string KeptAttribute = "TestAttributeSet.Attribute1000"; + private const string DepartingAttribute = "VitalAttributeSet.CurrentHealth"; + + private readonly TagsManager _tagsManager = tagsAndCuesFixture.TagsManager; + private readonly CuesManager _cuesManager = tagsAndCuesFixture.CuesManager; + + [Fact] + [Trait("Removal", null)] + public void Removing_a_set_that_is_not_present_reports_failure() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + + entity.Attributes.RemoveAttributeSet(new VitalAttributeSet()).Should().BeFalse(); + entity.Attributes.AttributeSets.Should().ContainSingle(); + } + + [Fact] + [Trait("Removal", null)] + public void Removing_a_set_detaches_its_attributes_and_keeps_the_others() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var vitalSet = new VitalAttributeSet(); + + entity.Attributes.AddAttributeSet(vitalSet); + entity.Attributes.AttributeSets.Should().HaveCount(2); + entity.Attributes.TryGetAttribute(DepartingAttribute, out _).Should().BeTrue(); + + entity.Attributes.RemoveAttributeSet(vitalSet).Should().BeTrue(); + + entity.Attributes.AttributeSets.Should().ContainSingle(); + entity.Attributes.TryGetAttribute(DepartingAttribute, out _).Should().BeFalse(); + entity.Attributes.TryGetAttribute(KeptAttribute, out _).Should().BeTrue(); + } + + [Fact] + [Trait("Removal", null)] + public void An_effect_keeps_the_modifiers_for_the_attributes_that_remain() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var vitalSet = new VitalAttributeSet(); + + entity.Attributes.AddAttributeSet(vitalSet); + entity.EffectsManager.ApplyEffect(CreateCrossSetEffect(entity)).Should().NotBeNull(); + + entity.Attributes[KeptAttribute].CurrentValue.Should().Be(10); + entity.Attributes[DepartingAttribute].CurrentValue.Should().Be(90); + + entity.Attributes.RemoveAttributeSet(vitalSet).Should().BeTrue(); + + // The effect survives with its remaining modifier still applied. + entity.EffectsManager.GetActiveEffects().Should().ContainSingle(); + entity.Attributes[KeptAttribute].CurrentValue.Should().Be(10); + } + + [Fact] + [Trait("Removal", null)] + public void Re_adding_a_set_reapplies_the_modifier_exactly_once() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var vitalSet = new VitalAttributeSet(); + + entity.Attributes.AddAttributeSet(vitalSet); + entity.EffectsManager.ApplyEffect(CreateCrossSetEffect(entity)).Should().NotBeNull(); + + entity.Attributes[DepartingAttribute].CurrentValue.Should().Be(90); + + entity.Attributes.RemoveAttributeSet(vitalSet).Should().BeTrue(); + entity.Attributes.AddAttributeSet(vitalSet); + + // 90 and not 80: the modifier was unwound on the way out, so coming back applies it once, not twice. + entity.Attributes[DepartingAttribute].CurrentValue.Should().Be(90); + } + + [Fact] + [Trait("Removal", null)] + public void A_set_removed_while_an_effect_is_active_comes_back_clean_once_the_effect_is_gone() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var vitalSet = new VitalAttributeSet(); + + entity.Attributes.AddAttributeSet(vitalSet); + + ActiveEffectHandle? handle = entity.EffectsManager.ApplyEffect(CreateCrossSetEffect(entity)); + handle.Should().NotBeNull(); + entity.Attributes[DepartingAttribute].CurrentValue.Should().Be(90); + + entity.Attributes.RemoveAttributeSet(vitalSet).Should().BeTrue(); + + // Removing the effect while the set is detached must not leave anything behind on the departed attribute. + entity.EffectsManager.RemoveEffect(handle!); + + entity.Attributes.AddAttributeSet(vitalSet); + + entity.Attributes[DepartingAttribute].CurrentValue.Should().Be(100); + entity.Attributes[DepartingAttribute].Modifier.Should().Be(0); + } + + [Fact] + [Trait("Removal", null)] + public void Adding_a_set_mid_life_lets_an_active_effect_start_modifying_it() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + + // Applied while the entity does not have the set at all, so the modifier is skipped. + entity.EffectsManager.ApplyEffect(CreateCrossSetEffect(entity)).Should().NotBeNull(); + entity.Attributes[KeptAttribute].CurrentValue.Should().Be(10); + entity.Attributes.TryGetAttribute(DepartingAttribute, out _).Should().BeFalse(); + + entity.Attributes.AddAttributeSet(new VitalAttributeSet()); + + // The active effect picks the new attribute up instead of waiting for something else to re-evaluate it. + entity.Attributes[DepartingAttribute].CurrentValue.Should().Be(90); + } + + [Fact] + [Trait("Removal", null)] + public void The_last_change_to_a_departing_attribute_still_reaches_its_listeners() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var vitalSet = new VitalAttributeSet(); + + entity.Attributes.AddAttributeSet(vitalSet); + + int observed = 0; + entity.Attributes[DepartingAttribute].OnValueChanged += (_, change) => observed += change; + + entity.EffectsManager.ApplyEffect(CreateCrossSetEffect(entity)).Should().NotBeNull(); + observed.Should().Be(-10); + + entity.Attributes.RemoveAttributeSet(vitalSet).Should().BeTrue(); + + // Unwinding the modifier on the way out is itself a change, and it has to be flushed before the attribute is + // detached rather than left pending on an object nothing enumerates any more. + observed.Should().Be(0); + } + + [Fact] + [Trait("Removal", null)] + public void A_listener_removing_an_effect_as_the_set_leaves_does_not_unapply_it_twice() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var vitalSet = new VitalAttributeSet(); + + entity.Attributes.AddAttributeSet(vitalSet); + + ActiveEffectHandle? handle = entity.EffectsManager.ApplyEffect(CreateCrossSetEffect(entity)); + handle.Should().NotBeNull(); + entity.Attributes[KeptAttribute].Modifier.Should().Be(10); + + // An ordinary listener that reacts to the departing attribute's last change by dropping the effect. It must + // not be able to observe the rebuild's temporary state, where the modifiers are already off. + entity.Attributes[DepartingAttribute].OnValueChanged += (_, _) => + { + if (handle!.IsValid) + { + entity.EffectsManager.RemoveEffect(handle); + } + }; + + entity.Attributes.RemoveAttributeSet(vitalSet).Should().BeTrue(); + + // Exactly once: unapplying an effect whose modifiers the rebuild had already taken off would subtract them a + // second time and leave the kept attribute holding a phantom penalty. + entity.Attributes[KeptAttribute].Modifier.Should().Be(0); + entity.EffectsManager.GetActiveEffects().Should().BeEmpty(); + } + + [Fact] + [Trait("Removal", null)] + public void An_effect_re_evaluated_by_a_membership_change_reports_itself_as_changed() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var vitalSet = new VitalAttributeSet(); + + entity.Attributes.AddAttributeSet(vitalSet); + + // Its magnitude reads the departing attribute, so losing the set genuinely changes what it applies. + var dependentData = new EffectData( + "Reads Health", + new DurationData(DurationType.Infinite), + [ + new Modifier( + KeptAttribute, + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.AttributeBased, + attributeBasedFloat: new AttributeBasedFloat( + new AttributeCaptureDefinition( + DepartingAttribute, + AttributeCaptureSource.Target, + Snapshot: false), + AttributeCalculationType.CurrentValue, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))) + ]); + + // This one is untouched by the change and must stay quiet. + var independentData = new EffectData( + "Flat Buff", + new DurationData(DurationType.Infinite), + [ + new Modifier( + "TestAttributeSet.Attribute100", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(5))) + ]); + + ActiveEffectHandle? dependentHandle = entity.EffectsManager.ApplyEffect( + new Effect(dependentData, new EffectOwnership(entity, entity))); + + entity.EffectsManager.ApplyEffect(new Effect(independentData, new EffectOwnership(entity, entity))) + .Should().NotBeNull(); + + var changed = new List(); + entity.EffectsManager.OnActiveEffectChanged += changed.Add; + + entity.Attributes.RemoveAttributeSet(vitalSet).Should().BeTrue(); + + // Its magnitudes moved, so manager subscribers and persistent cues have to hear about it — and only it. + changed.Should().ContainSingle().Which.Should().BeSameAs(dependentHandle); + } + + [Fact] + [Trait("Removal", null)] + public void Removing_a_set_raises_the_membership_events() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var vitalSet = new VitalAttributeSet(); + + var added = new List(); + var removed = new List(); + + entity.Attributes.OnAttributeSetAdded += added.Add; + entity.Attributes.OnAttributeSetRemoved += removed.Add; + + entity.Attributes.AddAttributeSet(vitalSet); + entity.Attributes.RemoveAttributeSet(vitalSet).Should().BeTrue(); + + added.Should().ContainSingle().Which.Should().BeSameAs(vitalSet); + removed.Should().ContainSingle().Which.Should().BeSameAs(vitalSet); + } + + [Fact] + [Trait("Removal", null)] + public void An_ongoing_requirement_on_a_departing_attribute_inhibits_its_effect() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var vitalSet = new VitalAttributeSet(); + + entity.Attributes.AddAttributeSet(vitalSet); + + var effectData = new EffectData( + "Gated Buff", + new DurationData(DurationType.Infinite), + [ + new Modifier( + KeptAttribute, + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) + ], + effectComponents: + [ + new AttributeRequirementsEffectComponent( + ongoingRequirements: [new AttributeRequirement(DepartingAttribute, MinValue: 1)]) + ]); + + ActiveEffectHandle? handle = entity.EffectsManager.ApplyEffect( + new Effect(effectData, new EffectOwnership(entity, entity))); + + handle.Should().NotBeNull(); + handle!.IsInhibited.Should().BeFalse(); + entity.Attributes[KeptAttribute].CurrentValue.Should().Be(10); + + entity.Attributes.RemoveAttributeSet(vitalSet).Should().BeTrue(); + + // A requirement naming an attribute the entity does not have is never met, and nothing else would re-check it + // once the attribute that used to drive it is detached. + handle.IsInhibited.Should().BeTrue(); + entity.Attributes[KeptAttribute].CurrentValue.Should().Be(0); + + entity.Attributes.AddAttributeSet(vitalSet); + + handle.IsInhibited.Should().BeFalse(); + entity.Attributes[KeptAttribute].CurrentValue.Should().Be(10); + } + + [Fact] + [Trait("Removal", null)] + public void An_accumulator_stops_at_its_total_when_its_attribute_leaves_and_resumes_when_it_returns() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var vitalSet = new VitalAttributeSet(); + var tallyTag = Tag.RequestTag(_tagsManager, "simple.tag"); + + entity.Attributes.AddAttributeSet(vitalSet); + + // The effect drains the tracked attribute by 10 on every tick, and the accumulator tallies those losses. + var effectData = new EffectData( + "Tally", + new DurationData(DurationType.Infinite), + [ + new Modifier( + DepartingAttribute, + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-10))) + ], + periodicData: new PeriodicData( + new ScalableFloat(1), + true, + PeriodInhibitionRemovedPolicy.NeverReset), + effectComponents: + [ + new AttributeAccumulatorEffectComponent(DepartingAttribute, tallyTag, AccumulationPolicy.Losses) + ]); + + ActiveEffectHandle? handle = entity.EffectsManager.ApplyEffect( + new Effect(effectData, new EffectOwnership(entity, entity))); + + handle.Should().NotBeNull(); + + AttributeAccumulatorEffectComponent accumulator = + handle!.GetComponent()!; + + accumulator.Total.Should().Be(10); + + // The set leaves mid-flight: the running total stands, and nothing throws on the orphaned attribute. + FluentActions.Invoking(() => entity.Attributes.RemoveAttributeSet(vitalSet)).Should().NotThrow(); + + entity.EffectsManager.UpdateEffects(1); + entity.EffectsManager.GetActiveEffects().Should().ContainSingle(); + + // The total is a record of what already happened, so it survives the attribute going away — and nothing is + // added while there is no attribute to drain. + accumulator.Total.Should().Be(10); + + // A *different* set instance supplying the same keys, so the attribute objects are new ones. That is what makes + // the rebind load-bearing: a component still holding the old instance would tally nothing from here on. + entity.Attributes.AddAttributeSet(new VitalAttributeSet()); + entity.EffectsManager.UpdateEffects(1); + + accumulator.Total.Should().Be(20); + } + + [Fact] + [Trait("Removal", null)] + public void An_ability_cost_charged_against_a_departing_attribute_makes_it_uncastable() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var vitalSet = new VitalAttributeSet(); + + entity.Attributes.AddAttributeSet(vitalSet); + + var costEffectData = new EffectData( + "Health Cost", + new DurationData(DurationType.Instant), + [ + new Modifier( + DepartingAttribute, + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-10))) + ]); + + var abilityData = new AbilityData("Bloodcast", costEffectData); + + AbilityHandle handle = entity.Abilities.GrantAbilityPermanently( + abilityData, + 1, + LevelComparison.None, + sourceEntity: null); + + handle.TryActivate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + handle.Cancel(); + + entity.Attributes.RemoveAttributeSet(vitalSet).Should().BeTrue(); + + // A cost that can never be paid is refused rather than quietly skipped, so this is the one place where a + // missing attribute fails loudly instead of being ignored. + handle.TryActivate(out failureFlags).Should().BeFalse(); + failureFlags.Should().Be(AbilityActivationFailures.InsufficientResources); + + entity.Attributes.AddAttributeSet(vitalSet); + + handle.TryActivate(out failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + } + + [Fact] + [Trait("Removal", null)] + public void An_effect_whose_duration_is_backed_by_a_departing_attribute_expires() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var vitalSet = new VitalAttributeSet(); + + entity.Attributes.AddAttributeSet(vitalSet); + + // Duration reads the departing attribute live, so losing it re-evaluates the duration to zero. + var effectData = new EffectData( + "Timed Buff", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.AttributeBased, + attributeBasedFloat: new AttributeBasedFloat( + new AttributeCaptureDefinition( + DepartingAttribute, + AttributeCaptureSource.Target, + Snapshot: false), + AttributeCalculationType.CurrentValue, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))), + [ + new Modifier( + KeptAttribute, + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) + ]); + + entity.EffectsManager.ApplyEffect(new Effect(effectData, new EffectOwnership(entity, entity))) + .Should().NotBeNull(); + + entity.EffectsManager.GetActiveEffects().Should().ContainSingle(); + entity.Attributes[KeptAttribute].CurrentValue.Should().Be(10); + + entity.Attributes.RemoveAttributeSet(vitalSet).Should().BeTrue(); + + // Its duration is now zero, so it must expire rather than run on for the time it had left. + entity.EffectsManager.GetActiveEffects().Should().BeEmpty(); + entity.Attributes[KeptAttribute].CurrentValue.Should().Be(0); + } + + [Fact] + [Trait("Removal", null)] + public void Adding_a_set_whose_key_collides_leaves_the_entity_untouched() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + + entity.EffectsManager.ApplyEffect(CreateCrossSetEffect(entity)).Should().NotBeNull(); + entity.Attributes[KeptAttribute].CurrentValue.Should().Be(10); + + // Keys derive from the set's runtime type name, so a second TestAttributeSet collides with the one the entity + // already has. That has to be refused before anything is unwound, not halfway through the rebuild. + FluentActions.Invoking(() => entity.Attributes.AddAttributeSet(new TestAttributeSet())) + .Should().Throw(); + + entity.Attributes.AttributeSets.Should().ContainSingle(); + entity.Attributes[KeptAttribute].CurrentValue.Should().Be(10); + entity.EffectsManager.GetActiveEffects().Should().ContainSingle(); + } + + [Fact] + [Trait("Removal", null)] + public void Adding_a_set_that_already_satisfies_a_removal_requirement_removes_the_effect() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + + var effectData = new EffectData( + "Dispellable", + new DurationData(DurationType.Infinite), + [ + new Modifier( + KeptAttribute, + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) + ], + effectComponents: + [ + new AttributeRequirementsEffectComponent( + removalRequirements: [new AttributeRequirement(DepartingAttribute, MinValue: 1)]) + ]); + + entity.EffectsManager.ApplyEffect(new Effect(effectData, new EffectOwnership(entity, entity))) + .Should().NotBeNull(); + + entity.EffectsManager.GetActiveEffects().Should().ContainSingle(); + + // CurrentHealth arrives at 100, which already meets the removal requirement. Subscribing does not itself raise + // a value change, so the membership change is the only chance to notice. + entity.Attributes.AddAttributeSet(new VitalAttributeSet()); + + entity.EffectsManager.GetActiveEffects().Should().BeEmpty(); + } + + [Fact] + [Trait("Cross entity", null)] + public void An_effect_on_another_entity_reevaluates_when_its_source_loses_the_captured_attribute() + { + var source = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + var vitalSet = new VitalAttributeSet(); + + source.Attributes.AddAttributeSet(vitalSet); + + // The effect lives on the target but reads the *source's* health, live, to size its modifier. + var effectData = new EffectData( + "Sympathetic Buff", + new DurationData(DurationType.Infinite), + [ + new Modifier( + KeptAttribute, + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.AttributeBased, + attributeBasedFloat: new AttributeBasedFloat( + new AttributeCaptureDefinition( + DepartingAttribute, + AttributeCaptureSource.Source, + Snapshot: false), + AttributeCalculationType.CurrentValue, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))) + ]); + + target.EffectsManager.ApplyEffect(new Effect(effectData, new EffectOwnership(source, source))) + .Should().NotBeNull(); + + target.Attributes[KeptAttribute].CurrentValue.Should().Be(100); + + // Changing the *source's* sets has to reach an effect living on a different entity's manager. + source.Attributes.RemoveAttributeSet(vitalSet).Should().BeTrue(); + + target.Attributes[KeptAttribute].CurrentValue.Should().Be(0); + + source.Attributes.AddAttributeSet(vitalSet); + + target.Attributes[KeptAttribute].CurrentValue.Should().Be(100); + } + + [Fact] + [Trait("Cross entity", null)] + public void A_source_requirement_rebinds_when_the_source_entity_changes_sets() + { + var source = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + var vitalSet = new VitalAttributeSet(); + + source.Attributes.AddAttributeSet(vitalSet); + + var effectData = new EffectData( + "Gated By Source", + new DurationData(DurationType.Infinite), + [ + new Modifier( + KeptAttribute, + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) + ], + effectComponents: + [ + new SourceAttributeRequirementsEffectComponent( + ongoingRequirements: [new AttributeRequirement(DepartingAttribute, MinValue: 1)], + ownershipEntity: OwnershipEntity.Source) + ]); + + ActiveEffectHandle? handle = target.EffectsManager.ApplyEffect( + new Effect(effectData, new EffectOwnership(source, source))); + + handle.Should().NotBeNull(); + handle!.IsInhibited.Should().BeFalse(); + target.Attributes[KeptAttribute].CurrentValue.Should().Be(10); + + // The requirement watches the source, so the source losing the attribute must inhibit an effect that lives on + // the target. Only the dependent registry can carry that across. + source.Attributes.RemoveAttributeSet(vitalSet).Should().BeTrue(); + + handle.IsInhibited.Should().BeTrue(); + target.Attributes[KeptAttribute].CurrentValue.Should().Be(0); + + source.Attributes.AddAttributeSet(vitalSet); + + handle.IsInhibited.Should().BeFalse(); + target.Attributes[KeptAttribute].CurrentValue.Should().Be(10); + } + + [Fact] + [Trait("Cross entity", null)] + public void A_source_requirement_ignores_set_changes_on_the_ownership_entity_it_does_not_watch() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var source = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var ownerVitalSet = new VitalAttributeSet(); + var sourceVitalSet = new VitalAttributeSet(); + + owner.Attributes.AddAttributeSet(ownerVitalSet); + source.Attributes.AddAttributeSet(sourceVitalSet); + + // Owner and source are different entities, and the requirement names the source. + var effectData = new EffectData( + "Gated By Source", + new DurationData(DurationType.Infinite), + [ + new Modifier( + KeptAttribute, + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))) + ], + effectComponents: + [ + new SourceAttributeRequirementsEffectComponent( + ongoingRequirements: [new AttributeRequirement(DepartingAttribute, MinValue: 1)], + ownershipEntity: OwnershipEntity.Source) + ]); + + ActiveEffectHandle? handle = target.EffectsManager.ApplyEffect( + new Effect(effectData, new EffectOwnership(owner, source))); + + handle.Should().NotBeNull(); + handle!.IsInhibited.Should().BeFalse(); + + // The owner is not what this component watches, so its sets changing must leave the effect alone. + owner.Attributes.RemoveAttributeSet(ownerVitalSet).Should().BeTrue(); + + handle.IsInhibited.Should().BeFalse(); + target.Attributes[KeptAttribute].CurrentValue.Should().Be(10); + + // The source is, so its sets changing must reach it. + source.Attributes.RemoveAttributeSet(sourceVitalSet).Should().BeTrue(); + + handle.IsInhibited.Should().BeTrue(); + target.Attributes[KeptAttribute].CurrentValue.Should().Be(0); + } + + [Fact] + [Trait("Cross entity", null)] + public void An_effect_registers_only_with_the_entity_its_component_names() + { + var owner = new TestEntity(_tagsManager, _cuesManager); + var source = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + + var ownerVitalSet = new VitalAttributeSet(); + var sourceVitalSet = new VitalAttributeSet(); + + owner.Attributes.AddAttributeSet(ownerVitalSet); + source.Attributes.AddAttributeSet(sourceVitalSet); + + var probe = new MembershipProbeComponent(AttributeCaptureSource.Source); + + var effectData = new EffectData( + "Probed", + new DurationData(DurationType.Infinite), + effectComponents: [probe]); + + target.EffectsManager.ApplyEffect(new Effect(effectData, new EffectOwnership(owner, source))) + .Should().NotBeNull(); + + // The probe names the source, so a change on the owner must not reach it at all — not merely be ignored by a + // guard inside the component, but never be delivered, so the effect is not rebuilt for nothing. + owner.Attributes.RemoveAttributeSet(ownerVitalSet).Should().BeTrue(); + + probe.NotificationCount.Should().Be(0); + + source.Attributes.RemoveAttributeSet(sourceVitalSet).Should().BeTrue(); + + probe.NotificationCount.Should().Be(1); + } + + [Fact] + [Trait("Cross entity", null)] + public void A_removed_effect_stops_being_a_dependent_of_the_entity_it_read() + { + var source = new TestEntity(_tagsManager, _cuesManager); + var target = new TestEntity(_tagsManager, _cuesManager); + var vitalSet = new VitalAttributeSet(); + + source.Attributes.AddAttributeSet(vitalSet); + + var effectData = new EffectData( + "Sympathetic Buff", + new DurationData(DurationType.Infinite), + [ + new Modifier( + KeptAttribute, + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.AttributeBased, + attributeBasedFloat: new AttributeBasedFloat( + new AttributeCaptureDefinition( + DepartingAttribute, + AttributeCaptureSource.Source, + Snapshot: false), + AttributeCalculationType.CurrentValue, + new ScalableFloat(1), + new ScalableFloat(0), + new ScalableFloat(0)))) + ]); + + ActiveEffectHandle? handle = target.EffectsManager.ApplyEffect( + new Effect(effectData, new EffectOwnership(source, source))); + + handle.Should().NotBeNull(); + + target.EffectsManager.RemoveEffect(handle!); + target.Attributes[KeptAttribute].CurrentValue.Should().Be(0); + + // The registration has to come off with the effect, or the source keeps rebuilding a dead one forever. + FluentActions.Invoking(() => source.Attributes.RemoveAttributeSet(vitalSet)).Should().NotThrow(); + + target.Attributes[KeptAttribute].CurrentValue.Should().Be(0); + target.EffectsManager.GetActiveEffects().Should().BeEmpty(); + } + + [Fact] + [Trait("Removal", null)] + public void The_attribute_sets_collection_is_exposed_read_only() + { + // Pins the declared type rather than the runtime one: the guarantee is that a caller cannot add or remove a + // set without a deliberate cast, matching how EntityAbilities exposes its granted abilities. + typeof(EntityAttributes).GetProperty(nameof(EntityAttributes.AttributeSets))! + .PropertyType.Should().Be>(); + } + + // An infinite effect straddling two sets: one modifier on an attribute the entity keeps, one on an attribute that + // leaves with the set under test. + private static Effect CreateCrossSetEffect(TestEntity entity) + { + var effectData = new EffectData( + "Cross Set Buff", + new DurationData(DurationType.Infinite), + [ + new Modifier( + KeptAttribute, + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(10))), + new Modifier( + DepartingAttribute, + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(-10))) + ]); + + return new Effect(effectData, new EffectOwnership(entity, entity)); + } + + private sealed class MembershipProbeComponent(AttributeCaptureSource watchedSource) : IEffectComponent + { + public AttributeCaptureSource WatchedAttributeSource { get; } = watchedSource; + + public int NotificationCount { get; private set; } + + // Deliberately shares the instance so the test can read the count off the object it passed in. + public IEffectComponent CreateInstance() + { + return this; + } + + public void OnAttributeMembershipChanged( + IForgeEntity changedEntity, + in ActiveEffectEvaluatedData activeEffectEvaluatedData) + { + NotificationCount++; + } + } +} diff --git a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs index f0e72c3..04a8ce5 100644 --- a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs +++ b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs @@ -1439,7 +1439,7 @@ public NoAttributesEntity(TagsManager tagsManager, CuesManager cuesManager) { EffectsManager = new(this, cuesManager); CuesManager = cuesManager; - Attributes = new(); + Attributes = new(this); Tags = new(new TagContainer(tagsManager)); Abilities = new(this); Events = new(); diff --git a/Forge.Tests/Helpers/TestEntity.cs b/Forge.Tests/Helpers/TestEntity.cs index 75a1955..d4985ff 100644 --- a/Forge.Tests/Helpers/TestEntity.cs +++ b/Forge.Tests/Helpers/TestEntity.cs @@ -39,7 +39,7 @@ public TestEntity(TagsManager tagsManager, CuesManager cuesManager) EffectsManager = new(this, cuesManager); CuesManager = cuesManager; - Attributes = new(PlayerAttributeSet); + Attributes = new(this, PlayerAttributeSet); Tags = new(originalTags); Abilities = new(this); Events = new(); diff --git a/Forge.Tests/Helpers/VitalTestEntity.cs b/Forge.Tests/Helpers/VitalTestEntity.cs index 6c2ee2e..78d68b5 100644 --- a/Forge.Tests/Helpers/VitalTestEntity.cs +++ b/Forge.Tests/Helpers/VitalTestEntity.cs @@ -34,7 +34,7 @@ public VitalTestEntity(TagsManager tagsManager, CuesManager cuesManager) EffectsManager = new(this, cuesManager); CuesManager = cuesManager; - Attributes = new(VitalAttributeSet); + Attributes = new(this, VitalAttributeSet); Tags = new(originalTags); Abilities = new(this); Events = new(); diff --git a/Forge.Tests/Samples/QuickStartTests.cs b/Forge.Tests/Samples/QuickStartTests.cs index 48ec29d..81edd4d 100644 --- a/Forge.Tests/Samples/QuickStartTests.cs +++ b/Forge.Tests/Samples/QuickStartTests.cs @@ -1169,7 +1169,7 @@ public Player(TagsManager tagsManager, CuesManager cuesManager) Tag.RequestTag(tagsManager, "class.warrior") ]); - Attributes = new EntityAttributes(new PlayerAttributeSet()); + Attributes = new EntityAttributes(this, new PlayerAttributeSet()); Tags = new EntityTags(baseTags); EffectsManager = new EffectsManager(this, cuesManager); CuesManager = cuesManager; diff --git a/Forge.Tests/Statescript/Nodes/State/ListenerNodesTests.cs b/Forge.Tests/Statescript/Nodes/State/ListenerNodesTests.cs index 3d64778..8f50368 100644 --- a/Forge.Tests/Statescript/Nodes/State/ListenerNodesTests.cs +++ b/Forge.Tests/Statescript/Nodes/State/ListenerNodesTests.cs @@ -372,6 +372,56 @@ public void Ability_end_listener_node_matches_filter_when_ability_is_removed_on_ onEnded.ExecutionCount.Should().Be(1); } + [Fact] + [Trait("Graph", "AttributeListener")] + public void Attribute_listener_node_follows_its_attribute_across_set_removal_and_re_addition() + { + const string VitalAttribute = "VitalAttributeSet.CurrentHealth"; + + var entity = new TestEntity(_tagsManager, _cuesManager); + var vitalSet = new VitalAttributeSet(); + + entity.Attributes.AddAttributeSet(vitalSet); + + var graph = new Graph(); + graph.VariableDefinitions.DefineObjectVariable("entity", entity); + + var listener = new AttributeListenerNode(VitalAttribute); + listener.BindInput(AttributeListenerNode.EntityInput, "entity"); + + var onChanged = new TrackingActionNode(); + + graph.AddNode(listener); + graph.AddNode(onChanged); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + listener.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + listener.OutputPorts[AttributeListenerNode.OnChangedPort], + onChanged.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + ChangeVitalAttribute(entity, -5); + onChanged.ExecutionCount.Should().Be(1); + + // While the set is away the node has nothing to listen to, but it must not be stuck on the orphan either. + entity.Attributes.RemoveAttributeSet(vitalSet).Should().BeTrue(); + + // A different instance supplying the same keys, so the attribute objects are new ones. A node still holding + // the old instance would never hear from it again, which is what makes the rebind load-bearing here. + entity.Attributes.AddAttributeSet(new VitalAttributeSet()); + + ChangeVitalAttribute(entity, -5); + onChanged.ExecutionCount.Should().Be(2); + + processor.StopGraph(); + + ChangeVitalAttribute(entity, -5); + onChanged.ExecutionCount.Should().Be(2); + } + private static EffectData CreateInfiniteEffectData() { return new EffectData( @@ -401,6 +451,21 @@ private static AbilityHandle GrantInstantAbility(TestEntity owner, string name) return owner.Abilities.GrantAbilityPermanently(abilityData, 1, LevelComparison.None, sourceEntity: null); } + private static void ChangeVitalAttribute(TestEntity entity, int magnitude) + { + var effectData = new EffectData( + "Vital Change", + new DurationData(DurationType.Instant), + [ + new Modifier( + "VitalAttributeSet.CurrentHealth", + ModifierOperation.FlatBonus, + new ModifierMagnitude(MagnitudeCalculationType.ScalableFloat, new ScalableFloat(magnitude))) + ]); + + entity.EffectsManager.ApplyEffect(new Effect(effectData, new EffectOwnership(entity, entity))); + } + private void ApplyInstantDamage(TestEntity entity, int magnitude) { var effectData = new EffectData( diff --git a/Forge/Core/EntityAttributes.cs b/Forge/Core/EntityAttributes.cs index c482faf..9b2cf32 100644 --- a/Forge/Core/EntityAttributes.cs +++ b/Forge/Core/EntityAttributes.cs @@ -1,7 +1,9 @@ // Copyright © Gamesmiths Guild. using System.Collections; +using System.Diagnostics.CodeAnalysis; using Gamesmiths.Forge.Attributes; +using Gamesmiths.Forge.Effects; namespace Gamesmiths.Forge.Core; @@ -10,17 +12,53 @@ namespace Gamesmiths.Forge.Core; /// entity. /// Attributes can be accessed with the indexer. /// -public class EntityAttributes : IEnumerable +/// +/// Initializes a new instance of the class. +/// +/// The owner of this manager. +public class EntityAttributes(IForgeEntity owner) : IEnumerable { private readonly Dictionary _attributes = []; + private readonly List _attributeSets = []; + private readonly HashSet _dependentEffects = []; + + /// + /// Event invoked when an attribute set is added to this entity, carrying the set. + /// + /// + /// Raised after the entity's attributes and its active effects have settled around the new set, so handlers + /// observe the finished state. + /// + public event Action? OnAttributeSetAdded; + + /// + /// Event invoked when an attribute set is removed from this entity, carrying the set. + /// + /// + /// Raised after the set's attributes have been detached and the active effects have been re-evaluated without + /// them. The set itself keeps its attributes and their values, so it can be added back later. + /// + public event Action? OnAttributeSetRemoved; + + /// + /// Gets the owner of this manager. + /// + public IForgeEntity Owner { get; } = owner; /// /// Gets the attribute sets of this entity. /// - public List AttributeSets { get; } = []; + /// + /// Read-only: the manager keeps this list in step with the attribute mapping behind the indexer, so sets are + /// added and removed through and rather than + /// through this list. + /// + public IReadOnlyList AttributeSets => _attributeSets; internal IReadOnlyDictionary AttributesMap => _attributes; + internal IReadOnlyCollection DependentEffects => _dependentEffects; + /// /// Gets the mapping for the attributes of this container. /// @@ -31,45 +69,117 @@ public class EntityAttributes : IEnumerable /// /// Initializes a new instance of the class. /// - public EntityAttributes() - { - } - - /// - /// Initializes a new instance of the class. - /// + /// The owner of this manager. /// An initial attribute set for initialization. - public EntityAttributes(AttributeSet attributeSet) + public EntityAttributes(IForgeEntity owner, AttributeSet attributeSet) + : this(owner) { - AddAttributeSet(attributeSet); + AttachAttributeSet(attributeSet); } /// /// Initializes a new instance of the class. /// + /// The owner of this manager. /// A number of attribute sets for initialization. - public EntityAttributes(AttributeSet[] attributeSets) + public EntityAttributes(IForgeEntity owner, AttributeSet[] attributeSets) + : this(owner) { foreach (AttributeSet attributeSet in attributeSets) { - AddAttributeSet(attributeSet); + AttachAttributeSet(attributeSet); } } /// - /// Adds an attribute set to this managers's attribute sets while handling the mapping of . + /// Adds an attribute set to this manager's attribute sets while handling the mapping of + /// . /// + /// + /// Adding a set to a live entity re-evaluates its active effects, so an effect carrying a modifier for one of the + /// new attributes starts contributing immediately instead of waiting for something else to trigger a + /// re-evaluation. The set keeps whatever values its attributes already hold, so a set that was removed earlier + /// comes back exactly as it left. + /// /// The attribute set to be added. + /// Thrown when the entity already has an attribute for one of this set's keys. + /// Keys derive from the set's type name, so an entity cannot hold two instances of the same + /// subclass. Nothing is changed when this throws. public void AddAttributeSet(AttributeSet attributeSet) { - Validation.Assert(attributeSet is not null, "AttributeSets is not initialized."); + Validation.Assert(attributeSet is not null, "AttributeSet is not initialized."); + Validation.Assert( + Owner.EffectsManager is not null, + "The owner's EffectsManager must exist before its attribute sets can change at runtime."); - AttributeSets.Add(attributeSet); + StringKey[] collisions = [.. attributeSet.AttributesMap.Keys.Where(_attributes.ContainsKey)]; - foreach (KeyValuePair attribute in attributeSet.AttributesMap) + if (collisions.Length > 0) { - _attributes.Add(attribute.Key, attribute.Value); + throw new ArgumentException( + $"The attribute '{collisions[0]}' is already present on this entity. Attribute keys derive from the " + + "set's type name, so an entity cannot hold two instances of the same AttributeSet.", + nameof(attributeSet)); } + + Owner.EffectsManager.RebuildAroundAttributeChange(() => AttachAttributeSet(attributeSet)); + + OnAttributeSetAdded?.Invoke(attributeSet); + } + + /// + /// Removes an attribute set from this manager's attribute sets while handling the mapping of + /// . + /// + /// + /// Active effects survive the removal. Their modifiers for the departing attributes are unwound first and + /// then dropped on re-evaluation, so an effect that also modifies attributes the entity keeps goes on applying + /// those. This matches how the rest of the system treats a modifier naming an attribute the target does not have: + /// it is skipped, not an error. + /// Two consequences are worth knowing. Values already captured into an effect's snapshots are **not** + /// rolled back, since a snapshot is a reading taken at a point in time. And an ability whose cost is charged + /// against a departing attribute becomes uncastable, because a cost that can never be paid is refused rather than + /// quietly skipped. + /// The set is not modified: it keeps its attributes and their current values, so it can be added back to + /// this entity later. + /// + /// The attribute set to be removed. + /// if the attribute set was found and removed; otherwise, + /// . + public bool RemoveAttributeSet(AttributeSet attributeSet) + { + Validation.Assert(attributeSet is not null, "AttributeSet is not initialized."); + Validation.Assert( + Owner.EffectsManager is not null, + "The owner's EffectsManager must exist before its attribute sets can change at runtime."); + + if (!_attributeSets.Contains(attributeSet)) + { + return false; + } + + Owner.EffectsManager.RebuildAroundAttributeChange(() => DetachAttributeSet(attributeSet)); + + foreach (EntityAttribute attribute in attributeSet.AttributesMap.Values) + { + attribute.ApplyPendingValueChanges(); + } + + OnAttributeSetRemoved?.Invoke(attributeSet); + + return true; + } + + /// + /// Tries to get an attribute of this entity from its key. + /// + /// The attribute key. + /// The attribute for the given key. + /// if the entity has an attribute for that key; otherwise, + /// . + public bool TryGetAttribute(StringKey key, [NotNullWhen(true)] out EntityAttribute? attribute) + { + return _attributes.TryGetValue(key, out attribute); } /// @@ -94,8 +204,38 @@ internal void ApplyPendingValueChanges() } internal bool ContainsAttribute(StringKey attributeKey) -#pragma warning restore T0009 { return _attributes.ContainsKey(attributeKey); } + + internal void RegisterDependent(ActiveEffect activeEffect) + { + _dependentEffects.Add(activeEffect); + } + + internal void UnregisterDependent(ActiveEffect activeEffect) +#pragma warning restore T0009 // Internal Styling Rule T0009 + { + _dependentEffects.Remove(activeEffect); + } + + private void AttachAttributeSet(AttributeSet attributeSet) + { + foreach (KeyValuePair attribute in attributeSet.AttributesMap) + { + _attributes.Add(attribute.Key, attribute.Value); + } + + _attributeSets.Add(attributeSet); + } + + private void DetachAttributeSet(AttributeSet attributeSet) + { + foreach (StringKey attributeKey in attributeSet.AttributesMap.Keys) + { + _attributes.Remove(attributeKey); + } + + _attributeSets.Remove(attributeSet); + } } diff --git a/Forge/Effects/ActiveEffect.cs b/Forge/Effects/ActiveEffect.cs index 964821c..89927aa 100644 --- a/Forge/Effects/ActiveEffect.cs +++ b/Forge/Effects/ActiveEffect.cs @@ -21,6 +21,8 @@ internal sealed class ActiveEffect private readonly HashSet _nonSnapshotSetByCallerTags; + private readonly List _attributeDependencies = []; + private double _internalTime; internal ActiveEffectHandle Handle { get; } @@ -118,6 +120,8 @@ internal void Apply(bool reApplication = false, bool inhibited = false) attribute.OnValueChanged += Attribute_OnValueChanged; } + RegisterAttributeDependencies(); + Effect.OnSetByCallerFloatChanged += Effect_OnSetByCallerFloatChanged; } @@ -156,6 +160,8 @@ internal void Unapply(bool reApplication = false) attribute.OnValueChanged -= Attribute_OnValueChanged; } + UnregisterAttributeDependencies(); + if (!EffectData.SnapshotLevel) { Effect.OnLevelChanged -= Effect_OnLevelChanged; @@ -372,6 +378,59 @@ internal void Update(double deltaTime) EffectEvaluatedData.Target.Attributes.ApplyPendingValueChanges(); } + internal void DetachAttributeBindings() + { + foreach (EntityAttribute attribute in EffectEvaluatedData.AttributesToCapture) + { + attribute.OnValueChanged -= Attribute_OnValueChanged; + } + } + + internal bool RebuildAfterAttributeChange() + { + float previousDuration = EffectEvaluatedData.Duration; + ModifierEvaluatedData[] previousModifiers = EffectEvaluatedData.ModifiersEvaluatedData; + + EffectEvaluatedData.ReEvaluate(Effect, StackCount); + EffectEvaluatedData.RefreshAttributesToCapture(); + + foreach (EntityAttribute attribute in EffectEvaluatedData.AttributesToCapture) + { + attribute.OnValueChanged += Attribute_OnValueChanged; + } + + RegisterAttributeDependencies(); + + Apply(reApplication: true); + + bool durationChanged = false; + + if (EffectData.DurationData.DurationMagnitude.HasValue) + { + float updatedDuration = EffectEvaluatedData.Duration; + + durationChanged = previousDuration > updatedDuration + Epsilon + || previousDuration < updatedDuration - Epsilon; + + if (durationChanged) + { + RemainingDuration += updatedDuration - previousDuration; + } + + if (RemainingDuration <= 0 + && EffectData.DurationData.DurationType == DurationType.HasDuration + && StackCount == 1) + { + Unapply(); + EffectEvaluatedData.Target.EffectsManager.RemoveActiveEffect_InternalCall(this); + + return false; + } + } + + return durationChanged || ModifiersChanged(previousModifiers, EffectEvaluatedData.ModifiersEvaluatedData); + } + internal void SetInhibit(bool value) { if (IsInhibited == value) @@ -408,6 +467,24 @@ internal void SetInhibit(bool value) EffectEvaluatedData.Target.EffectsManager.OnActiveEffectChanged_InternalCall(this); } + private static bool ModifiersChanged(ModifierEvaluatedData[] previous, ModifierEvaluatedData[] current) + { + if (previous.Length != current.Length) + { + return true; + } + + for (int i = 0; i < previous.Length; i++) + { + if (!previous[i].Equals(current[i])) + { + return true; + } + } + + return false; + } + private void ExecutePeriodicEffects(double deltaTime) { _internalTime += deltaTime; @@ -426,6 +503,43 @@ private void ExecutePeriodicEffects(double deltaTime) } } + private void RegisterAttributeDependencies() + { + UnregisterAttributeDependencies(); + + HashSet captureSources = EffectEvaluatedData.CollectLiveCaptureSources(); + + foreach (IEffectComponent component in ComponentInstances) + { + captureSources.Add(component.WatchedAttributeSource); + } + + foreach (AttributeCaptureSource captureSource in captureSources) + { + IForgeEntity? entity = captureSource.Resolve(EffectEvaluatedData.Target, Effect.Ownership); + + if (entity is null + || entity == EffectEvaluatedData.Target + || _attributeDependencies.Contains(entity.Attributes)) + { + continue; + } + + entity.Attributes.RegisterDependent(this); + _attributeDependencies.Add(entity.Attributes); + } + } + + private void UnregisterAttributeDependencies() + { + foreach (EntityAttributes attributeDependency in _attributeDependencies) + { + attributeDependency.UnregisterDependent(this); + } + + _attributeDependencies.Clear(); + } + private void ReapplyEffect(Effect effect, int? level = null, bool isStackingCall = false) { Unapply(true); diff --git a/Forge/Effects/Components/AttributeAccumulatorEffectComponent.cs b/Forge/Effects/Components/AttributeAccumulatorEffectComponent.cs index efc6762..ae2d38f 100644 --- a/Forge/Effects/Components/AttributeAccumulatorEffectComponent.cs +++ b/Forge/Effects/Components/AttributeAccumulatorEffectComponent.cs @@ -168,6 +168,39 @@ public void OnActiveEffectUnapplied( _handler = null; } + /// + public void OnAttributeMembershipChanged( + IForgeEntity changedEntity, + in ActiveEffectEvaluatedData activeEffectEvaluatedData) + { + IForgeEntity target = activeEffectEvaluatedData.EffectEvaluatedData.Target; + + if (changedEntity != target) + { + return; + } + + if (_trackedAttribute is not null && _handler is not null) + { + _trackedAttribute.OnValueChanged -= _handler; + } + + _trackedAttribute = null; + + if (!target.Attributes.TryGetAttribute(Attribute, out EntityAttribute? trackedAttribute)) + { + return; + } + + // Deliberately bypasses the _tracking guard, which exists to stop a stack application from resetting the + // total. A fresh baseline is taken because the accumulator reports change since it started watching, and it + // was not watching while the attribute was away. + _handler ??= (changedAttribute, _) => _baseline = changedAttribute.CurrentValue; + _trackedAttribute = trackedAttribute; + _baseline = trackedAttribute.CurrentValue; + trackedAttribute.OnValueChanged += _handler; + } + private bool TryStartTracking(IForgeEntity target, Effect effect) { if (_tracking) diff --git a/Forge/Effects/Components/AttributeRequirementsEffectComponent.cs b/Forge/Effects/Components/AttributeRequirementsEffectComponent.cs index 3717d79..37b4901 100644 --- a/Forge/Effects/Components/AttributeRequirementsEffectComponent.cs +++ b/Forge/Effects/Components/AttributeRequirementsEffectComponent.cs @@ -121,6 +121,39 @@ public void OnActiveEffectUnapplied( _handler = null; } + /// + public void OnAttributeMembershipChanged( + IForgeEntity changedEntity, + in ActiveEffectEvaluatedData activeEffectEvaluatedData) + { + IForgeEntity target = activeEffectEvaluatedData.EffectEvaluatedData.Target; + + if (_handler is null || changedEntity != target) + { + return; + } + + foreach (EntityAttribute attribute in _subscribedAttributes) + { + attribute.OnValueChanged -= _handler; + } + + _subscribedAttributes.Clear(); + SubscribeToWatchedAttributes(target, _handler); + + if (AttributeRequirement.RequirementsMet(RemovalRequirements, target, emptyResult: false)) + { + target.EffectsManager.RemoveEffect(activeEffectEvaluatedData.ActiveEffectHandle, true); + return; + } + + if (OngoingRequirements.Length > 0) + { + activeEffectEvaluatedData.ActiveEffectHandle.SetInhibit( + !AttributeRequirement.RequirementsMet(OngoingRequirements, target)); + } + } + private void SubscribeToWatchedAttributes(IForgeEntity target, Action handler) { // Only the removal and ongoing buckets are reactive. Application requirements are consulted once, in diff --git a/Forge/Effects/Components/IEffectComponent.cs b/Forge/Effects/Components/IEffectComponent.cs index fefb178..bfa5dce 100644 --- a/Forge/Effects/Components/IEffectComponent.cs +++ b/Forge/Effects/Components/IEffectComponent.cs @@ -1,6 +1,7 @@ // Copyright © Gamesmiths Guild. using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Effects.Magnitudes; namespace Gamesmiths.Forge.Effects.Components; @@ -22,6 +23,18 @@ namespace Gamesmiths.Forge.Effects.Components; /// public interface IEffectComponent { + /// + /// Gets which entity's attributes this component watches. + /// + /// + /// Effects register as dependents of every entity whose attributes they read live, so that a change to that + /// entity's attribute sets reaches them. Capture definitions declare their own source, but a component that + /// subscribes to attributes of its own accord cannot be discovered that way — naming the entity here is how it + /// says so. The default, , needs no registration: the target's own + /// manager already holds the effect and rebuilds it directly. + /// + AttributeCaptureSource WatchedAttributeSource => AttributeCaptureSource.Target; + /// /// Creates an instance of this component for a specific effect application. /// @@ -111,6 +124,26 @@ void OnActiveEffectChanged(IForgeEntity target, in ActiveEffectEvaluatedData act // This method is intentionally left blank. } + /// + /// Executes and implements extra functionality for when the attribute set membership of an entity this effect + /// reads changes, so a component watching individual attributes can rebind to the ones that entity now has. + /// + /// + /// A component that subscribed to instances must drop the ones that left + /// — they are detached and will never raise again, and holding them keeps the whole set alive — and pick up any + /// it had been waiting for. Components that address attributes purely by key have nothing to do here. + /// + /// The entity whose attribute set membership changed. Not necessarily the effect's + /// target: an effect that reads a source or owner attribute is notified when that entity changes too, so a + /// component must check which entity it is looking at before reacting. + /// The evaluated data for the active effect. + void OnAttributeMembershipChanged( + IForgeEntity changedEntity, + in ActiveEffectEvaluatedData activeEffectEvaluatedData) + { + // This method is intentionally left blank. + } + /// /// Executes and implements extra functionality for when a effect is applied to a target. /// diff --git a/Forge/Effects/Components/SourceAttributeRequirementsEffectComponent.cs b/Forge/Effects/Components/SourceAttributeRequirementsEffectComponent.cs index 2842ab8..f3b35d8 100644 --- a/Forge/Effects/Components/SourceAttributeRequirementsEffectComponent.cs +++ b/Forge/Effects/Components/SourceAttributeRequirementsEffectComponent.cs @@ -2,6 +2,7 @@ using Gamesmiths.Forge.Attributes; using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Effects.Magnitudes; namespace Gamesmiths.Forge.Effects.Components; @@ -43,6 +44,15 @@ public class SourceAttributeRequirementsEffectComponent( private Action? _handler; + /// + /// + /// Whichever ownership entity selects, and only that one: this is how the effect + /// knows to register as a dependent of that entity even when it is not the target. + /// + public AttributeCaptureSource WatchedAttributeSource => OwnershipEntity == OwnershipEntity.Owner + ? AttributeCaptureSource.Owner + : AttributeCaptureSource.Source; + internal AttributeRequirement[] ApplicationRequirements { get; } = applicationRequirements ?? []; internal AttributeRequirement[] RemovalRequirements { get; } = removalRequirements ?? []; @@ -105,7 +115,6 @@ public bool OnActiveEffectAdded(IForgeEntity target, in ActiveEffectEvaluatedDat } }; - // A null source has no attributes to watch, so the requirements stay frozen at their initial evaluation. if (sourceEntity is not null) { SubscribeToWatchedAttributes(sourceEntity, _handler); @@ -135,6 +144,42 @@ public void OnActiveEffectUnapplied( _handler = null; } + /// + public void OnAttributeMembershipChanged( + IForgeEntity changedEntity, + in ActiveEffectEvaluatedData activeEffectEvaluatedData) + { + IForgeEntity? sourceEntity = ResolveEntity(activeEffectEvaluatedData.EffectEvaluatedData.Effect.Ownership); + + if (_handler is null || sourceEntity != changedEntity) + { + return; + } + + foreach (EntityAttribute attribute in _subscribedAttributes) + { + attribute.OnValueChanged -= _handler; + } + + _subscribedAttributes.Clear(); + SubscribeToWatchedAttributes(changedEntity, _handler); + + if (AttributeRequirement.RequirementsMet(RemovalRequirements, sourceEntity, emptyResult: false)) + { + activeEffectEvaluatedData.EffectEvaluatedData.Target.EffectsManager.RemoveEffect( + activeEffectEvaluatedData.ActiveEffectHandle, + true); + + return; + } + + if (OngoingRequirements.Length > 0) + { + activeEffectEvaluatedData.ActiveEffectHandle.SetInhibit( + !AttributeRequirement.RequirementsMet(OngoingRequirements, sourceEntity)); + } + } + private IForgeEntity? ResolveEntity(EffectOwnership ownership) { return OwnershipEntity == OwnershipEntity.Owner ? ownership.Owner : ownership.Source; diff --git a/Forge/Effects/EffectEvaluatedData.cs b/Forge/Effects/EffectEvaluatedData.cs index d8bb5e5..48a5d2f 100644 --- a/Forge/Effects/EffectEvaluatedData.cs +++ b/Forge/Effects/EffectEvaluatedData.cs @@ -64,7 +64,12 @@ public sealed class EffectEvaluatedData /// /// Gets an array of the attributes to be captured by an active effect. /// - public EntityAttribute[] AttributesToCapture { get; } + /// + /// Unlike this is deliberately not recomputed by a re-evaluation: it is the + /// subscription list, and the active effect's handlers are attached to exactly these instances. It only changes + /// when the target's attribute set membership does, through . + /// + public EntityAttribute[] AttributesToCapture { get; private set; } /// /// Gets an array of custom cue parameters. @@ -168,6 +173,47 @@ internal void RefreshCustomCueParameters() CustomCueParameters = EvaluateCustomCueParameters(); } + internal HashSet CollectLiveCaptureSources() + { + var sources = new HashSet(); + + if (Effect.EffectData.DurationData.DurationType == DurationType.Instant) + { + return sources; + } + + foreach (ModifierMagnitude modifierMagnitude in Effect.EffectData.Modifiers.Select(x => x.Magnitude)) + { + CollectLiveCaptureSources(modifierMagnitude, sources); + } + + if (Effect.EffectData.DurationData.DurationType == DurationType.HasDuration + && Effect.EffectData.DurationData.DurationMagnitude.HasValue) + { + CollectLiveCaptureSources(Effect.EffectData.DurationData.DurationMagnitude.Value, sources); + } + + foreach (CustomExecution execution in Effect.EffectData.CustomExecutions) + { + foreach (AttributeCaptureDefinition attributeCaptureDefinition in execution.AttributesToCapture) + { + if (!attributeCaptureDefinition.Snapshot) + { + sources.Add(attributeCaptureDefinition.Source); + } + } + } + + return sources; + } + + internal void RefreshAttributesToCapture() + { + AttributesToCapture = Effect.EffectData.DurationData.DurationType == DurationType.Instant + ? [] + : EvaluateAttributesToCapture(); + } + internal float EvaluateDuration(DurationData durationData) { if (!durationData.DurationMagnitude.HasValue) @@ -178,6 +224,34 @@ internal float EvaluateDuration(DurationData durationData) return durationData.DurationMagnitude.Value.GetMagnitude(Effect, Target, Level, this); } + private static void CollectLiveCaptureSources( + ModifierMagnitude modifierMagnitude, + HashSet sources) + { + if (modifierMagnitude.MagnitudeCalculationType == MagnitudeCalculationType.AttributeBased + && modifierMagnitude.AttributeBasedFloat.HasValue + && !modifierMagnitude.AttributeBasedFloat.Value.BackingAttribute.Snapshot) + { + sources.Add(modifierMagnitude.AttributeBasedFloat.Value.BackingAttribute.Source); + return; + } + + if (modifierMagnitude.MagnitudeCalculationType != MagnitudeCalculationType.CustomCalculatorClass + || !modifierMagnitude.CustomCalculationBasedFloat.HasValue) + { + return; + } + + foreach (AttributeCaptureDefinition attributeCaptureDefinition in + modifierMagnitude.CustomCalculationBasedFloat.Value.MagnitudeCalculatorClass.AttributesToCapture) + { + if (!attributeCaptureDefinition.Snapshot) + { + sources.Add(attributeCaptureDefinition.Source); + } + } + } + private float EvaluatePeriod(PeriodicData? periodicData) { if (!periodicData.HasValue) diff --git a/Forge/Effects/EffectsManager.cs b/Forge/Effects/EffectsManager.cs index 192aa24..b71063d 100644 --- a/Forge/Effects/EffectsManager.cs +++ b/Forge/Effects/EffectsManager.cs @@ -434,6 +434,90 @@ internal void RemoveActiveEffect_InternalCall(ActiveEffect effect) return FilterEffectsByData(effectData).FirstOrDefault(); } + /// + /// Unwinds every active effect's modifiers, runs a change to the entity's attribute set membership, then + /// re-evaluates and re-applies them. The unwind has to precede the detach, or a departing attribute keeps this + /// entity's modifiers baked into its channels and overrides and comes back dirty if the set is re-added. Every + /// effect participates, not just those whose modifiers name a departing attribute, because an effect can depend on + /// one indirectly through an attribute-based magnitude, a custom calculator or its own duration. + /// + /// + /// Covers this entity's own effects and, through the dependent registry, effects living on other entities that + /// read this one's attributes — a non-snapshot capture resolving to the source or owner, or a source-requirement + /// component watching this entity. A dependent effect's modifiers sit on its own target, not here, so it is + /// rebuilt in place: its magnitudes are re-evaluated and its capture subscriptions move to the attributes this + /// entity has now. + /// + /// The action that applies the attribute set membership change. + internal void RebuildAroundAttributeChange(Action applyChange) + { + ActiveEffect[] activeEffects = [.. _activeEffects, .. Owner.Attributes.DependentEffects]; + + foreach (ActiveEffect activeEffect in activeEffects) + { + activeEffect.DetachAttributeBindings(); + activeEffect.Unapply(reApplication: true); + } + + applyChange(); + + var changedEffects = new List(); + + foreach (ActiveEffect activeEffect in activeEffects) + { + if (!IsStillActive(activeEffect)) + { + continue; + } + + if (activeEffect.RebuildAfterAttributeChange()) + { + changedEffects.Add(activeEffect); + } + } + + Owner.Attributes.ApplyPendingValueChanges(); + + foreach (ActiveEffect activeEffect in activeEffects) + { + activeEffect.EffectEvaluatedData.Target.Attributes.ApplyPendingValueChanges(); + } + + foreach (ActiveEffect activeEffect in changedEffects) + { + if (!IsStillActive(activeEffect)) + { + continue; + } + + EffectsManager effectsManager = activeEffect.EffectEvaluatedData.Target.EffectsManager; + effectsManager.OnActiveEffectChanged_InternalCall(activeEffect); + + EffectEvaluatedData effectEvaluatedData = activeEffect.EffectEvaluatedData; + effectsManager.TriggerCuesUpdate_InternalCall(in effectEvaluatedData); + } + + foreach (ActiveEffect activeEffect in activeEffects) + { + foreach (IEffectComponent component in activeEffect.ComponentInstances) + { + if (!IsStillActive(activeEffect)) + { + break; + } + + component.OnAttributeMembershipChanged( + Owner, + new ActiveEffectEvaluatedData( + activeEffect.Handle, + activeEffect.EffectEvaluatedData, + activeEffect.RemainingDuration, + activeEffect.NextPeriodicTick, + activeEffect.ExecutionCount)); + } + } + } + internal ActiveEffectHandle? ApplyEffectInternal(Effect effect, EffectApplicationContext? applicationContext) { return ApplyEffectInternal(effect, applicationContext, out _); @@ -459,6 +543,11 @@ internal bool TryApplyEffect(Effect effect, out ActiveEffectHandle? activeEffect return applied; } + private static bool IsStillActive(ActiveEffect activeEffect) + { + return activeEffect.EffectEvaluatedData.Target.EffectsManager._activeEffects.Contains(activeEffect); + } + private static bool MatchesStackPolicy(ActiveEffect existingEffect, Effect newEffect) { Validation.Assert( diff --git a/Forge/Statescript/Nodes/State/AttributeListenerNode.cs b/Forge/Statescript/Nodes/State/AttributeListenerNode.cs index fafb9cf..e732fdd 100644 --- a/Forge/Statescript/Nodes/State/AttributeListenerNode.cs +++ b/Forge/Statescript/Nodes/State/AttributeListenerNode.cs @@ -67,27 +67,31 @@ protected override void OnActivate(GraphContext graphContext) AttributeListenerNodeContext nodeContext = graphContext.GetNodeContext(NodeID); nodeContext.SubscribedAttribute = null; nodeContext.Handler = null; + nodeContext.MembershipHandler = null; IForgeEntity? entity = AbilityNodeUtilities.ResolveEntityOrOwner( graphContext, InputProperties[EntityInput].BoundName); - if (entity?.Attributes.ContainsAttribute(_attributeKey) != true) + nodeContext.WatchedEntity = entity; + + if (entity is null) { return; } - EntityAttribute attribute = entity.Attributes[_attributeKey]; - - void Handler(EntityAttribute changedAttribute, int change) - { + nodeContext.Handler = (changedAttribute, change) => OnAttributeChanged(graphContext, changedAttribute, change); - } - nodeContext.SubscribedAttribute = attribute; - nodeContext.Handler = Handler; + // The attribute is looked up again whenever the entity's sets change. Without this the node would keep + // listening to an attribute that has been detached — going quiet for good — and would never pick up the + // attribute it is configured for if the set carrying it arrives later. + nodeContext.MembershipHandler = _ => BindAttribute(nodeContext); + + entity.Attributes.OnAttributeSetAdded += nodeContext.MembershipHandler; + entity.Attributes.OnAttributeSetRemoved += nodeContext.MembershipHandler; - attribute.OnValueChanged += Handler; + BindAttribute(nodeContext); } /// @@ -100,8 +104,16 @@ protected override void OnDeactivate(GraphContext graphContext) nodeContext.SubscribedAttribute.OnValueChanged -= nodeContext.Handler; } + if (nodeContext.WatchedEntity is not null && nodeContext.MembershipHandler is not null) + { + nodeContext.WatchedEntity.Attributes.OnAttributeSetAdded -= nodeContext.MembershipHandler; + nodeContext.WatchedEntity.Attributes.OnAttributeSetRemoved -= nodeContext.MembershipHandler; + } + nodeContext.SubscribedAttribute = null; nodeContext.Handler = null; + nodeContext.MembershipHandler = null; + nodeContext.WatchedEntity = null; } private static void WriteIntOutput(GraphContext graphContext, OutputVariable output, int value) @@ -118,6 +130,29 @@ private static void WriteIntOutput(GraphContext graphContext, OutputVariable out variables?.SetVar(output.BoundName, value); } + private void BindAttribute(AttributeListenerNodeContext nodeContext) + { + if (nodeContext.Handler is null) + { + return; + } + + if (nodeContext.SubscribedAttribute is not null) + { + nodeContext.SubscribedAttribute.OnValueChanged -= nodeContext.Handler; + nodeContext.SubscribedAttribute = null; + } + + if (nodeContext.WatchedEntity is null + || !nodeContext.WatchedEntity.Attributes.TryGetAttribute(_attributeKey, out EntityAttribute? attribute)) + { + return; + } + + nodeContext.SubscribedAttribute = attribute; + attribute.OnValueChanged += nodeContext.Handler; + } + private void OnAttributeChanged(GraphContext graphContext, EntityAttribute attribute, int change) { if (!graphContext.HasNodeContext(NodeID) diff --git a/Forge/Statescript/Nodes/State/AttributeListenerNodeContext.cs b/Forge/Statescript/Nodes/State/AttributeListenerNodeContext.cs index d71bf92..11ad731 100644 --- a/Forge/Statescript/Nodes/State/AttributeListenerNodeContext.cs +++ b/Forge/Statescript/Nodes/State/AttributeListenerNodeContext.cs @@ -1,12 +1,14 @@ // Copyright © Gamesmiths Guild. using Gamesmiths.Forge.Attributes; +using Gamesmiths.Forge.Core; namespace Gamesmiths.Forge.Statescript.Nodes.State; /// /// The context for an . Tracks the subscribed attribute and handler so the -/// subscription can be removed on deactivation. +/// subscription can be removed on deactivation, and the watched entity so the node can follow the attribute across +/// changes to that entity's attribute sets. /// public class AttributeListenerNodeContext : StateNodeContext { @@ -15,5 +17,12 @@ public class AttributeListenerNodeContext : StateNodeContext /// public EntityAttribute? SubscribedAttribute { get; set; } + /// + /// Gets or sets the entity whose attribute this node is watching. + /// + public IForgeEntity? WatchedEntity { get; set; } + internal Action? Handler { get; set; } + + internal Action? MembershipHandler { get; set; } } diff --git a/docs/attributes.md b/docs/attributes.md index 5869662..0dc9f70 100644 --- a/docs/attributes.md +++ b/docs/attributes.md @@ -145,11 +145,22 @@ public class PlayerCharacter : IForgeEntity var resourceStats = new ResourceAttributeSet(); // Initialize EntityAttributes with the attribute sets - Attributes = new EntityAttributes([combatStats, resourceStats]); + Attributes = new EntityAttributes(this, [combatStats, resourceStats]); } } ``` +The container takes the entity that owns it, like `EntityAbilities` and `EffectsManager` do. That is what lets it keep the entity's active effects in step when its [sets change at runtime](#adding-and-removing-attribute-sets); assign it in any order relative to the other managers, since the owner is only used later. + +Use `TryGetAttribute` to reach an attribute that may not be present — the indexer throws for an unknown key, and attributes can come and go with their sets: + +```csharp +if (entity.Attributes.TryGetAttribute("CombatAttributeSet.CurrentHealth", out EntityAttribute? health)) +{ + Console.WriteLine(health.CurrentValue); +} +``` + ## Attribute Identification Attributes are identified by their fully qualified name using the pattern: `AttributeSetName.AttributeName` @@ -453,7 +464,7 @@ public class PlayerCharacter : IForgeEntity var movementStats = new MovementAttributeSet(); // Initialize entity attributes with all sets - Attributes = new EntityAttributes([combatStats, resourceStats, movementStats]); + Attributes = new EntityAttributes(this, [combatStats, resourceStats, movementStats]); } // Example of accessing an attribute @@ -465,6 +476,50 @@ public class PlayerCharacter : IForgeEntity } ``` +### Adding and Removing Attribute Sets + +Sets are not fixed at construction. `AddAttributeSet` and `RemoveAttributeSet` change an entity's attributes at runtime — for transformations, mounts, possession, modular gear that carries its own stats, or recycling a pooled entity: + +```csharp +// Werewolf form brings its own stats along +entity.Attributes.AddAttributeSet(werewolfSet); + +// ...and takes them away again +bool removed = entity.Attributes.RemoveAttributeSet(werewolfSet); +``` + +`RemoveAttributeSet` returns `false` when the set is not on the entity. `OnAttributeSetAdded` and `OnAttributeSetRemoved` announce both, after the change has fully settled. + +**The set is not modified.** It keeps its attributes and their current values, so removing and re-adding the same instance restores it exactly as it left. Note that keys derive from the set's runtime type name, so an entity cannot hold two instances of the same `AttributeSet` subclass at once. + +**Active effects survive the change.** They are not removed, cancelled, or reapplied from scratch. On removal, an effect's modifiers for the departing attributes are unwound and then dropped when it re-evaluates, while its modifiers for attributes the entity keeps go on applying: + +```csharp +// One effect, modifiers on two sets +entity.Attributes.RemoveAttributeSet(movementStats); +// -> the MovementAttributeSet.Speed modifier is gone +// -> the CombatAttributeSet.Attack modifier is untouched +// -> the effect is still active +``` + +This matches how the rest of the system treats a modifier naming an attribute the target does not have: it is skipped, not an error. Adding a set works the same way in reverse — an active effect that carries a modifier for one of the arriving attributes starts contributing immediately, rather than waiting for something else to trigger a re-evaluation. + +Three consequences are worth knowing: + +- **Requirements re-evaluate.** An [attribute requirement](effects/components/attribute-requirements-effect-component.md) naming a departed attribute is never met, so an effect with an *ongoing* requirement on one becomes inhibited, and un-inhibits when the set comes back. +- **Snapshots are not rolled back.** A value already captured into an effect's snapshot stays as it was read. A snapshot is a reading taken at a point in time, not a live link. +- **Ability costs fail loudly.** An ability whose cost is charged against a departed attribute becomes uncastable, failing with `AbilityActivationFailures.InsufficientResources`. This is the one place where a missing attribute is an error rather than a skip — a cost that can never be paid is refused instead of being quietly ignored, which is what stops the ability from being cast for free. + +`AttributeSets` is read-only: the manager keeps it in step with the attribute mapping behind the indexer, so sets are added and removed through these methods rather than through the list. + +**Adding a set whose keys collide throws.** An entity cannot hold two instances of the same `AttributeSet` subclass, since keys derive from the set's type name. The collision is detected before anything changes, so a rejected add leaves the entity exactly as it was. + +**Effects on other entities are covered too.** An effect that reads this entity's attributes without living on it — through a non-snapshot capture resolving to `Source` or `Owner`, or a [source attribute requirement](effects/components/source-attribute-requirements-effect-component.md) watching it — is rebuilt as well. Those effects register as dependents when they are applied, so a buff on a minion sized from its summoner's health re-evaluates when the summoner's sets change, and a requirement gated on the summoner re-inhibits. + +An effect's modifiers always land on its own target, so only the *reading* crosses entities: a dependent effect is re-evaluated and its subscriptions move, but its modifiers stay where they were. + +A custom component that watches attributes on the owner or source rather than the target names it with `WatchedAttributeSource`, which is how the effect learns to register with that entity — and only that one, so a component watching the source is not woken by the owner's sets changing. Capture definitions declare their own source and need no such hint. The default, `AttributeCaptureSource.Target`, registers nothing, because the target's own manager already rebuilds the effect. Components are told which entity changed through `OnAttributeMembershipChanged`, so one that reads more than a single entity should check before reacting. + ## Integration with Other Systems While detailed relationships with other systems are covered in their respective documentation, attributes are designed to work seamlessly with them: @@ -484,4 +539,5 @@ While detailed relationships with other systems are covered in their respective 7. **Respect Encapsulation**: Never attempt to directly modify attributes outside of AttributeSets or the Effects system. 8. **Use ValidModifier for UI**: When showing modifier values in UI, consider whether to show the total modifier or the ValidModifier. 9. **Pick a Scale and Declare It**: Attributes are integers. For stats that need decimals, choose a fixed scale (x10, x100, ...), pass it as `decimalPlaces` so presentation code can read it off the attribute, apply it consistently to every effect touching that attribute, and convert only when displaying. -10. **Unsubscribe from `OnValueChanged`**: Attributes live as long as the entity, so an observer that subscribes must detach when it goes away. +10. **Unsubscribe from `OnValueChanged`**: An observer that subscribes must detach when it goes away — and an attribute can outlive its place on the entity, since [removing its set](#adding-and-removing-attribute-sets) detaches it while leaving the object itself alive. Follow `OnAttributeSetAdded`/`OnAttributeSetRemoved` if the observer has to survive that. +11. **Probe with `TryGetAttribute`**: The indexer throws for an unknown key. Anywhere an attribute might not be present — optional sets, or sets that come and go — probe rather than index. diff --git a/docs/effects/components/attribute-requirements-effect-component.md b/docs/effects/components/attribute-requirements-effect-component.md index 730c6a8..472478b 100644 --- a/docs/effects/components/attribute-requirements-effect-component.md +++ b/docs/effects/components/attribute-requirements-effect-component.md @@ -66,6 +66,10 @@ Only the attributes named in the **removal** and **ongoing** buckets are watched A requirement naming an attribute the entity does not have is **never met**. A gate on health cannot be satisfied by an entity with no health. +That includes an attribute the entity had and lost: [removing an attribute set](../../attributes.md#adding-and-removing-attribute-sets) re-evaluates the requirements, so an effect with an *ongoing* requirement on a departed attribute becomes inhibited, and un-inhibits if the set is added back. The component follows the attributes across the change rather than staying subscribed to the detached ones. Both buckets are re-checked, not just the ongoing one — an attribute arriving in a state that already satisfies a *removal* requirement removes the effect, which no value-change event would have reported. + +The same applies to the [source variant](source-attribute-requirements-effect-component.md) when the entity it watches changes sets, even though the effect lives elsewhere. + Attribute changes are flushed at the end of an effect application, so a component reacts to values that have already settled — including cascades, such as a `MaxHealth` change clamping `CurrentHealth`. ## Validation diff --git a/docs/effects/components/source-attribute-requirements-effect-component.md b/docs/effects/components/source-attribute-requirements-effect-component.md index f940b1f..545b40b 100644 --- a/docs/effects/components/source-attribute-requirements-effect-component.md +++ b/docs/effects/components/source-attribute-requirements-effect-component.md @@ -99,6 +99,7 @@ var channelledBeamData = new EffectData( - The target's own attributes never satisfy these requirements — that is the whole point. Use [AttributeRequirementsEffectComponent](attribute-requirements-effect-component.md) for the target side, and both together when a condition spans the two. - Reacts to the source's attribute changes, so a link can follow its caster's state after application. +- Also reacts to the source [gaining or losing an attribute set](../../attributes.md#adding-and-removing-attribute-sets), even though the effect lives on the target. The effect registers as a dependent of the entity this component watches, which is what carries the change across. - A null source satisfies no non-empty bucket, and never becomes reactive. - Completes the four-way symmetry: target/source × tags/attributes. diff --git a/docs/effects/modifiers.md b/docs/effects/modifiers.md index 066944e..17435fd 100644 --- a/docs/effects/modifiers.md +++ b/docs/effects/modifiers.md @@ -28,6 +28,8 @@ public readonly record struct Modifier( - **Channel**: Which attribute [channel](../attributes.md#attribute-channels) to affect (defaults to 0). - **AggregationMode**: How this modifier combines with the other modifiers of its group — all of them summed (the default), or only the strongest one (see [Modifier Aggregation](#modifier-aggregation)). +A modifier naming an attribute the target does not have is **skipped**, not an error: the rest of the effect applies normally. That also covers an attribute that goes away mid-effect — [removing an attribute set](../attributes.md#adding-and-removing-attribute-sets) unwinds the affected modifiers and drops them, leaving the effect's other modifiers applied, and adding the set back brings them into play again. + ## Modifier Operations The `ModifierOperation` enum defines how a modifier changes an attribute's value: diff --git a/docs/quick-start.md b/docs/quick-start.md index 9bf40e3..baa9ae2 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -62,7 +62,7 @@ public class Player : IForgeEntity Tag.RequestTag(tagsManager, "class.warrior") }); - Attributes = new EntityAttributes(new PlayerAttributeSet()); + Attributes = new EntityAttributes(this, new PlayerAttributeSet()); Tags = new EntityTags(baseTags); EffectsManager = new EffectsManager(this, cuesManager); CuesManager = cuesManager; diff --git a/docs/statescript/nodes/state/attribute-listener-node.md b/docs/statescript/nodes/state/attribute-listener-node.md index 7ddae4e..e761847 100644 --- a/docs/statescript/nodes/state/attribute-listener-node.md +++ b/docs/statescript/nodes/state/attribute-listener-node.md @@ -45,6 +45,8 @@ new AttributeListenerNode(attributeKey) 2. On each change, writes **New Value** and **Delta**, then emits `OnChanged` synchronously. 3. Unsubscribes on deactivation. +The node follows its attribute across changes to the entity's [attribute sets](../../../attributes.md#adding-and-removing-attribute-sets). If the set carrying the attribute is removed the node goes quiet rather than staying attached to the detached instance, and it rebinds when that set — or any set providing the key — is added back. A node whose attribute is not present on activation is not inert either: it picks the attribute up if a set later brings it in. + ## Usage ```csharp