Skip to content
778 changes: 778 additions & 0 deletions Forge.Tests/Attributes/AttributeSetRemovalTests.cs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion Forge.Tests/Helpers/TestEntity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion Forge.Tests/Helpers/VitalTestEntity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion Forge.Tests/Samples/QuickStartTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
65 changes: 65 additions & 0 deletions Forge.Tests/Statescript/Nodes/State/ListenerNodesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IForgeEntity>("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<AttributeListenerNodeContext>.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(
Expand Down Expand Up @@ -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(
Expand Down
178 changes: 159 additions & 19 deletions Forge/Core/EntityAttributes.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -10,17 +12,53 @@ namespace Gamesmiths.Forge.Core;
/// entity.
/// Attributes can be accessed with the indexer.
/// </summary>
public class EntityAttributes : IEnumerable<EntityAttribute>
/// <remarks>
/// Initializes a new instance of the <see cref="EntityAttributes"/> class.
/// </remarks>
/// <param name="owner">The owner of this manager.</param>
public class EntityAttributes(IForgeEntity owner) : IEnumerable<EntityAttribute>
Comment thread
lextatic marked this conversation as resolved.
{
private readonly Dictionary<StringKey, EntityAttribute> _attributes = [];
private readonly List<AttributeSet> _attributeSets = [];
private readonly HashSet<ActiveEffect> _dependentEffects = [];

/// <summary>
/// Event invoked when an attribute set is added to this entity, carrying the set.
/// </summary>
/// <remarks>
/// Raised after the entity's attributes and its active effects have settled around the new set, so handlers
/// observe the finished state.
/// </remarks>
public event Action<AttributeSet>? OnAttributeSetAdded;

/// <summary>
/// Event invoked when an attribute set is removed from this entity, carrying the set.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public event Action<AttributeSet>? OnAttributeSetRemoved;

/// <summary>
/// Gets the owner of this manager.
/// </summary>
public IForgeEntity Owner { get; } = owner;

/// <summary>
/// Gets the attribute sets of this entity.
/// </summary>
public List<AttributeSet> AttributeSets { get; } = [];
/// <remarks>
/// Read-only: the manager keeps this list in step with the attribute mapping behind the indexer, so sets are
/// added and removed through <see cref="AddAttributeSet"/> and <see cref="RemoveAttributeSet"/> rather than
/// through this list.
/// </remarks>
public IReadOnlyList<AttributeSet> AttributeSets => _attributeSets;

internal IReadOnlyDictionary<StringKey, EntityAttribute> AttributesMap => _attributes;

internal IReadOnlyCollection<ActiveEffect> DependentEffects => _dependentEffects;

/// <summary>
/// Gets the mapping for the attributes of this container.
/// </summary>
Expand All @@ -31,45 +69,117 @@ public class EntityAttributes : IEnumerable<EntityAttribute>
/// <summary>
/// Initializes a new instance of the <see cref="EntityAttributes"/> class.
/// </summary>
public EntityAttributes()
{
}

/// <summary>
/// Initializes a new instance of the <see cref="EntityAttributes"/> class.
/// </summary>
/// <param name="owner">The owner of this manager.</param>
/// <param name="attributeSet">An initial attribute set for initialization.</param>
public EntityAttributes(AttributeSet attributeSet)
public EntityAttributes(IForgeEntity owner, AttributeSet attributeSet)
: this(owner)
{
AddAttributeSet(attributeSet);
AttachAttributeSet(attributeSet);
}

/// <summary>
/// Initializes a new instance of the <see cref="EntityAttributes"/> class.
/// </summary>
/// <param name="owner">The owner of this manager.</param>
/// <param name="attributeSets">A number of attribute sets for initialization.</param>
public EntityAttributes(AttributeSet[] attributeSets)
public EntityAttributes(IForgeEntity owner, AttributeSet[] attributeSets)
: this(owner)
{
foreach (AttributeSet attributeSet in attributeSets)
{
AddAttributeSet(attributeSet);
AttachAttributeSet(attributeSet);
}
}

/// <summary>
/// Adds an attribute set to this managers's attribute sets while handling the mapping of <see cref="EntityAttributes"/>.
/// Adds an attribute set to this manager's attribute sets while handling the mapping of
/// <see cref="EntityAttributes"/>.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="attributeSet">The attribute set to be added.</param>
/// <exception cref="ArgumentException">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
/// <see cref="AttributeSet"/> subclass. Nothing is changed when this throws.</exception>
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<StringKey, EntityAttribute> 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);
}

/// <summary>
/// Removes an attribute set from this manager's attribute sets while handling the mapping of
/// <see cref="EntityAttributes"/>.
/// </summary>
/// <remarks>
/// <para>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.</para>
/// <para>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.</para>
/// <para>The set is not modified: it keeps its attributes and their current values, so it can be added back to
/// this entity later.</para>
/// </remarks>
/// <param name="attributeSet">The attribute set to be removed.</param>
/// <returns><see langword="true"/> if the attribute set was found and removed; otherwise,
/// <see langword="false"/>.</returns>
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;
}

/// <summary>
/// Tries to get an attribute of this entity from its key.
/// </summary>
/// <param name="key">The attribute key.</param>
/// <param name="attribute">The attribute for the given key.</param>
/// <returns><see langword="true"/> if the entity has an attribute for that key; otherwise,
/// <see langword="false"/>.</returns>
public bool TryGetAttribute(StringKey key, [NotNullWhen(true)] out EntityAttribute? attribute)
{
return _attributes.TryGetValue(key, out attribute);
}

/// <inheritdoc/>
Expand All @@ -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<StringKey, EntityAttribute> 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);
}
}
Loading
Loading