diff --git a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs index 12bbd23..4c31928 100644 --- a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs +++ b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs @@ -9,6 +9,7 @@ using Gamesmiths.Forge.Effects.Magnitudes; using Gamesmiths.Forge.Effects.Modifiers; using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Statescript; using Gamesmiths.Forge.Tags; using Gamesmiths.Forge.Tests.Core; using Gamesmiths.Forge.Tests.Helpers; @@ -1016,6 +1017,8 @@ private sealed class NoAttributesEntity : IForgeEntity public EventManager Events { get; } + public Variables SharedVariables { get; } = new Variables(); + public NoAttributesEntity(TagsManager tagsManager, CuesManager cuesManager) { EffectsManager = new(this, cuesManager); diff --git a/Forge.Tests/Helpers/StatescriptTestHelpers.cs b/Forge.Tests/Helpers/StatescriptTestHelpers.cs new file mode 100644 index 0000000..dc45c82 --- /dev/null +++ b/Forge.Tests/Helpers/StatescriptTestHelpers.cs @@ -0,0 +1,126 @@ +// Copyright © Gamesmiths Guild. +#pragma warning disable SA1649, SA1402 // File name should match first type name + +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Nodes; + +namespace Gamesmiths.Forge.Tests.Helpers; + +internal sealed class TrackingActionNode(string? name = null, List? executionLog = null) : ActionNode +{ + private readonly string? _name = name; + private readonly List? _executionLog = executionLog; + + public int ExecutionCount { get; private set; } + + protected override void Execute(GraphContext graphContext) + { + ExecutionCount++; + + if (_name is not null) + { + _executionLog?.Add(_name); + } + } +} + +internal sealed class FixedConditionNode(bool result) : ConditionNode +{ + private readonly bool _result = result; + + protected override bool Test(GraphContext graphContext) + { + return _result; + } +} + +internal sealed class ThresholdConditionNode : ConditionNode +{ + private readonly string _variableName; + private readonly string? _thresholdVariableName; + private readonly int _fixedThreshold; + + public ThresholdConditionNode(string variableName, string thresholdVariableName) + { + _variableName = variableName; + _thresholdVariableName = thresholdVariableName; + } + + public ThresholdConditionNode(string variableName, int threshold) + { + _variableName = variableName; + _fixedThreshold = threshold; + } + + protected override bool Test(GraphContext graphContext) + { + graphContext.GraphVariables.TryGetVar(_variableName, out int value); + + var threshold = _fixedThreshold; + if (_thresholdVariableName is not null) + { + graphContext.GraphVariables.TryGetVar(_thresholdVariableName, out threshold); + } + + return value > threshold; + } +} + +internal sealed class IncrementCounterNode(string variableName) : ActionNode +{ + private readonly string _variableName = variableName; + + protected override void Execute(GraphContext graphContext) + { + graphContext.GraphVariables.TryGetVar(_variableName, out int currentValue); + graphContext.GraphVariables.SetVar(_variableName, currentValue + 1); + } +} + +internal sealed class ReadVariableNode(string variableName) : ActionNode + where T : unmanaged +{ + private readonly string _variableName = variableName; + + public T LastReadValue { get; private set; } + + protected override void Execute(GraphContext graphContext) + { + graphContext.GraphVariables.TryGetVar(_variableName, out T value); + LastReadValue = value; + } +} + +internal sealed class CaptureActivationContextNode : ActionNode +{ + public object? CapturedActivationContext { get; private set; } + + protected override void Execute(GraphContext graphContext) + { + CapturedActivationContext = graphContext.ActivationContext; + } +} + +internal sealed class TryGetActivationContextNode : ActionNode + where T : class +{ + public bool Found { get; private set; } + + public T? CapturedContext { get; private set; } + + protected override void Execute(GraphContext graphContext) + { + Found = graphContext.TryGetActivationContext(out T? data); + CapturedContext = data; + } +} + +internal sealed class CaptureGraphContextNode : ActionNode +{ + public GraphContext? CapturedGraphContext { get; private set; } + + protected override void Execute(GraphContext graphContext) + { + CapturedGraphContext = graphContext; + } +} diff --git a/Forge.Tests/Helpers/TestEntity.cs b/Forge.Tests/Helpers/TestEntity.cs index e596257..8cac909 100644 --- a/Forge.Tests/Helpers/TestEntity.cs +++ b/Forge.Tests/Helpers/TestEntity.cs @@ -4,6 +4,7 @@ using Gamesmiths.Forge.Cues; using Gamesmiths.Forge.Effects; using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Statescript; using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Tests.Helpers; @@ -22,6 +23,8 @@ public class TestEntity : IForgeEntity public EventManager Events { get; } + public Variables SharedVariables { get; } = new Variables(); + public TestEntity(TagsManager tagsManager, CuesManager cuesManager) { PlayerAttributeSet = new TestAttributeSet(); diff --git a/Forge.Tests/Samples/QuickStartTests.cs b/Forge.Tests/Samples/QuickStartTests.cs index bc302eb..391320c 100644 --- a/Forge.Tests/Samples/QuickStartTests.cs +++ b/Forge.Tests/Samples/QuickStartTests.cs @@ -22,6 +22,7 @@ using Gamesmiths.Forge.Tags; using Gamesmiths.Forge.Tests; using Gamesmiths.Forge.Tests.Core; +using Gamesmiths.Forge.Statescript; using static Gamesmiths.Forge.Tests.Samples.QuickStartTests; namespace Gamesmiths.Forge.Tests.Samples; @@ -1085,6 +1086,8 @@ public class Player : IForgeEntity public EventManager Events { get; } + public Variables SharedVariables { get; } = new Variables(); + public Player(TagsManager tagsManager, CuesManager cuesManager) { // Initialize base tags during construction @@ -1209,12 +1212,12 @@ public override ModifierEvaluatedData[] EvaluateExecution( int sourceHealth = CaptureAttributeMagnitude( SourceHealth, effect, - effect.Ownership.Owner, + effect.Ownership.Source, effectEvaluatedData); int sourceStrength = CaptureAttributeMagnitude( SourceStrength, effect, - effect.Ownership.Owner, + effect.Ownership.Source, effectEvaluatedData); // Calculate health drain amount based on source strength diff --git a/Forge.Tests/Statescript/ActionNodeTests.cs b/Forge.Tests/Statescript/ActionNodeTests.cs new file mode 100644 index 0000000..ed9acea --- /dev/null +++ b/Forge.Tests/Statescript/ActionNodeTests.cs @@ -0,0 +1,218 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Nodes; +using Gamesmiths.Forge.Statescript.Nodes.Action; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Statescript; + +public class ActionNodeTests +{ + [Fact] + [Trait("Graph", "SetVariable")] + public void Set_variable_node_copies_value_from_source_to_target() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("source", 42); + graph.VariableDefinitions.DefineVariable("target", 0); + + var setNode = new SetVariableNode("source", "target"); + var readNode = new ReadVariableNode("target"); + + graph.AddNode(setNode); + graph.AddNode(readNode); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + setNode.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + setNode.OutputPorts[ActionNode.OutputPort], + readNode.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + readNode.LastReadValue.Should().Be(42); + } + + [Fact] + [Trait("Graph", "SetVariable")] + public void Set_variable_node_copies_value_between_different_variables() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("counter", 0); + graph.VariableDefinitions.DefineVariable("result", 0); + + var incrementNode = new IncrementCounterNode("counter"); + var setNode = new SetVariableNode("counter", "result"); + var readNode = new ReadVariableNode("result"); + + graph.AddNode(incrementNode); + graph.AddNode(setNode); + graph.AddNode(readNode); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + incrementNode.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + incrementNode.OutputPorts[ActionNode.OutputPort], + setNode.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + setNode.OutputPorts[ActionNode.OutputPort], + readNode.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + readNode.LastReadValue.Should().Be(1); + } + + [Fact] + [Trait("Graph", "SetVariable")] + public void Set_variable_node_does_not_modify_source() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("source", 99); + graph.VariableDefinitions.DefineVariable("target", 0); + + var setNode = new SetVariableNode("source", "target"); + var readSource = new ReadVariableNode("source"); + var readTarget = new ReadVariableNode("target"); + + graph.AddNode(setNode); + graph.AddNode(readTarget); + graph.AddNode(readSource); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + setNode.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + setNode.OutputPorts[ActionNode.OutputPort], + readTarget.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + readTarget.OutputPorts[ActionNode.OutputPort], + readSource.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + readTarget.LastReadValue.Should().Be(99); + readSource.LastReadValue.Should().Be(99); + } + + [Fact] + [Trait("Graph", "SetVariable")] + public void Set_variable_node_with_nonexistent_source_does_not_modify_target() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("target", 77); + + var setNode = new SetVariableNode("nonexistent", "target"); + var readNode = new ReadVariableNode("target"); + + graph.AddNode(setNode); + graph.AddNode(readNode); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + setNode.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + setNode.OutputPorts[ActionNode.OutputPort], + readNode.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + readNode.LastReadValue.Should().Be(77); + } + + [Fact] + [Trait("Graph", "SetVariable")] + public void Set_variable_node_works_with_double_values() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 3.5); + graph.VariableDefinitions.DefineVariable("cachedDuration", 0.0); + + var setNode = new SetVariableNode("duration", "cachedDuration"); + var readNode = new ReadVariableNode("cachedDuration"); + + graph.AddNode(setNode); + graph.AddNode(readNode); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + setNode.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + setNode.OutputPorts[ActionNode.OutputPort], + readNode.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + readNode.LastReadValue.Should().Be(3.5); + } + + [Fact] + [Trait("Graph", "SetVariable")] + public void Set_variable_node_works_with_bool_values() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("flag", true); + graph.VariableDefinitions.DefineVariable("copy", false); + + var setNode = new SetVariableNode("flag", "copy"); + var readNode = new ReadVariableNode("copy"); + + graph.AddNode(setNode); + graph.AddNode(readNode); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + setNode.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + setNode.OutputPorts[ActionNode.OutputPort], + readNode.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + readNode.LastReadValue.Should().BeTrue(); + } + + [Fact] + [Trait("Graph", "SetVariable")] + public void Two_processors_using_set_variable_have_independent_state() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("source", 10); + graph.VariableDefinitions.DefineVariable("target", 0); + + var incrementNode = new IncrementCounterNode("source"); + var setNode = new SetVariableNode("source", "target"); + + graph.AddNode(incrementNode); + graph.AddNode(setNode); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + incrementNode.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + incrementNode.OutputPorts[ActionNode.OutputPort], + setNode.InputPorts[ActionNode.InputPort])); + + var processor1 = new GraphProcessor(graph); + var processor2 = new GraphProcessor(graph); + + processor1.StartGraph(); + processor2.StartGraph(); + + processor1.GraphContext.GraphVariables.TryGetVar("target", out int value1); + processor2.GraphContext.GraphVariables.TryGetVar("target", out int value2); + + value1.Should().Be(11); + value2.Should().Be(11); + } +} diff --git a/Forge.Tests/Statescript/ExpressionResolverTests.cs b/Forge.Tests/Statescript/ExpressionResolverTests.cs new file mode 100644 index 0000000..2b29780 --- /dev/null +++ b/Forge.Tests/Statescript/ExpressionResolverTests.cs @@ -0,0 +1,381 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Abilities; +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Cues; +using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Effects.Components; +using Gamesmiths.Forge.Effects.Duration; +using Gamesmiths.Forge.Effects.Magnitudes; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Nodes; +using Gamesmiths.Forge.Statescript.Nodes.Condition; +using Gamesmiths.Forge.Statescript.Properties; +using Gamesmiths.Forge.Tags; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Statescript; + +public class ExpressionResolverTests(TagsAndCuesFixture tagsAndCuesFixture) : IClassFixture +{ + private readonly TagsManager _tagsManager = tagsAndCuesFixture.TagsManager; + private readonly CuesManager _cuesManager = tagsAndCuesFixture.CuesManager; + + [Fact] + [Trait("Graph", "Expression")] + public void Comparison_resolver_greater_than_returns_true_when_left_exceeds_right() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineProperty( + "isAboveThreshold", + new ComparisonResolver( + new VariantResolver(new Variant128(15.0), typeof(double)), + ComparisonOperation.GreaterThan, + new VariantResolver(new Variant128(10.0), typeof(double)))); + + var condition = new ExpressionConditionNode("isAboveThreshold"); + var trueAction = new TrackingActionNode(); + var falseAction = new TrackingActionNode(); + + graph.AddNode(condition); + graph.AddNode(trueAction); + graph.AddNode(falseAction); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.TruePort], + trueAction.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.FalsePort], + falseAction.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + trueAction.ExecutionCount.Should().Be(1); + falseAction.ExecutionCount.Should().Be(0); + } + + [Fact] + [Trait("Graph", "Expression")] + public void Comparison_resolver_greater_than_returns_false_when_left_is_less() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineProperty( + "isAboveThreshold", + new ComparisonResolver( + new VariantResolver(new Variant128(5.0), typeof(double)), + ComparisonOperation.GreaterThan, + new VariantResolver(new Variant128(10.0), typeof(double)))); + + var condition = new ExpressionConditionNode("isAboveThreshold"); + var trueAction = new TrackingActionNode(); + var falseAction = new TrackingActionNode(); + + graph.AddNode(condition); + graph.AddNode(trueAction); + graph.AddNode(falseAction); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.TruePort], + trueAction.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.FalsePort], + falseAction.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + trueAction.ExecutionCount.Should().Be(0); + falseAction.ExecutionCount.Should().Be(1); + } + + [Fact] + [Trait("Graph", "Expression")] + public void Comparison_resolver_equal_returns_true_for_matching_values() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineProperty( + "isEqual", + new ComparisonResolver( + new VariantResolver(new Variant128(42.0), typeof(double)), + ComparisonOperation.Equal, + new VariantResolver(new Variant128(42.0), typeof(double)))); + + var condition = new ExpressionConditionNode("isEqual"); + var trueAction = new TrackingActionNode(); + var falseAction = new TrackingActionNode(); + + graph.AddNode(condition); + graph.AddNode(trueAction); + graph.AddNode(falseAction); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.TruePort], + trueAction.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.FalsePort], + falseAction.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + trueAction.ExecutionCount.Should().Be(1); + falseAction.ExecutionCount.Should().Be(0); + } + + [Fact] + [Trait("Graph", "Expression")] + public void Comparison_resolver_compares_two_graph_variables() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("health", 25.0); + graph.VariableDefinitions.DefineVariable("threshold", 50.0); + + graph.VariableDefinitions.DefineProperty( + "isHealthAboveThreshold", + new ComparisonResolver( + new VariableResolver("health", typeof(double)), + ComparisonOperation.GreaterThan, + new VariableResolver("threshold", typeof(double)))); + + var condition = new ExpressionConditionNode("isHealthAboveThreshold"); + var trueAction = new TrackingActionNode(); + var falseAction = new TrackingActionNode(); + + graph.AddNode(condition); + graph.AddNode(trueAction); + graph.AddNode(falseAction); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.TruePort], + trueAction.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.FalsePort], + falseAction.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + trueAction.ExecutionCount.Should().Be(0, "health (25) is NOT above threshold (50)"); + falseAction.ExecutionCount.Should().Be(1); + } + + [Fact] + [Trait("Graph", "Expression")] + public void Comparison_resolver_less_than_or_equal_at_boundary() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineProperty( + "atBoundary", + new ComparisonResolver( + new VariantResolver(new Variant128(10.0), typeof(double)), + ComparisonOperation.LessThanOrEqual, + new VariantResolver(new Variant128(10.0), typeof(double)))); + + var condition = new ExpressionConditionNode("atBoundary"); + var trueAction = new TrackingActionNode(); + var falseAction = new TrackingActionNode(); + + graph.AddNode(condition); + graph.AddNode(trueAction); + graph.AddNode(falseAction); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.TruePort], + trueAction.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.FalsePort], + falseAction.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + trueAction.ExecutionCount.Should().Be(1, "10 <= 10 is true"); + falseAction.ExecutionCount.Should().Be(0); + } + + [Fact] + [Trait("Graph", "Expression")] + public void Comparison_resolver_not_equal_returns_true_for_different_values() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineProperty( + "isDifferent", + new ComparisonResolver( + new VariantResolver(new Variant128(1.0), typeof(double)), + ComparisonOperation.NotEqual, + new VariantResolver(new Variant128(2.0), typeof(double)))); + + var condition = new ExpressionConditionNode("isDifferent"); + var trueAction = new TrackingActionNode(); + var falseAction = new TrackingActionNode(); + + graph.AddNode(condition); + graph.AddNode(trueAction); + graph.AddNode(falseAction); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.TruePort], + trueAction.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.FalsePort], + falseAction.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + trueAction.ExecutionCount.Should().Be(1); + falseAction.ExecutionCount.Should().Be(0); + } + + [Fact] + [Trait("Graph", "Expression")] + public void Expression_condition_node_returns_false_for_missing_property() + { + var graph = new Graph(); + + var condition = new ExpressionConditionNode("nonexistent"); + var trueAction = new TrackingActionNode(); + var falseAction = new TrackingActionNode(); + + graph.AddNode(condition); + graph.AddNode(trueAction); + graph.AddNode(falseAction); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.TruePort], + trueAction.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.FalsePort], + falseAction.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + trueAction.ExecutionCount.Should().Be(0); + falseAction.ExecutionCount.Should().Be(1); + } + + [Fact] + [Trait("Graph", "Expression")] + public void Comparison_resolver_works_with_int_operands() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("score", 100); + graph.VariableDefinitions.DefineVariable("requiredScore", 50); + + graph.VariableDefinitions.DefineProperty( + "hasEnoughScore", + new ComparisonResolver( + new VariableResolver("score", typeof(int)), + ComparisonOperation.GreaterThanOrEqual, + new VariableResolver("requiredScore", typeof(int)))); + + var condition = new ExpressionConditionNode("hasEnoughScore"); + var trueAction = new TrackingActionNode(); + var falseAction = new TrackingActionNode(); + + graph.AddNode(condition); + graph.AddNode(trueAction); + graph.AddNode(falseAction); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.TruePort], + trueAction.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.FalsePort], + falseAction.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + trueAction.ExecutionCount.Should().Be(1, "score (100) >= requiredScore (50)"); + falseAction.ExecutionCount.Should().Be(0); + } + + [Fact] + [Trait("Graph", "Expression")] + public void Comparison_resolver_works_with_attribute_resolver() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("required", 3); + + graph.VariableDefinitions.DefineProperty( + "hasEnoughAttribute", + new ComparisonResolver( + new AttributeResolver("TestAttributeSet.Attribute5"), + ComparisonOperation.GreaterThanOrEqual, + new VariableResolver("required", typeof(int)))); + + var condition = new ExpressionConditionNode("hasEnoughAttribute"); + var trueAction = new TrackingActionNode(); + var falseAction = new TrackingActionNode(); + + graph.AddNode(condition); + graph.AddNode(trueAction); + graph.AddNode(falseAction); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.TruePort], + trueAction.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.FalsePort], + falseAction.InputPorts[ActionNode.InputPort])); + + var behavior = new GraphAbilityBehavior(graph); + + var abilityData = new AbilityData("AttrResolverTest", behaviorFactory: () => behavior); + + var grantConfig = new GrantAbilityConfig( + abilityData, + new ScalableInt(1), + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.CancelImmediately, + false, + false, + LevelComparison.Higher); + + var grantEffectData = new EffectData( + "Grant", + new DurationData(DurationType.Infinite), + effectComponents: [new GrantAbilityEffectComponent([grantConfig])]); + + var grantEffect = new Effect(grantEffectData, new EffectOwnership(null, null)); + _ = entity.EffectsManager.ApplyEffect(grantEffect); + entity.Abilities.TryGetAbility(abilityData, out AbilityHandle? handle); + handle!.Activate(out _); + + trueAction.ExecutionCount.Should().Be(1); + falseAction.ExecutionCount.Should().Be(0); + } +} diff --git a/Forge.Tests/Statescript/GraphAbilityBehaviorTests.cs b/Forge.Tests/Statescript/GraphAbilityBehaviorTests.cs new file mode 100644 index 0000000..1003ba6 --- /dev/null +++ b/Forge.Tests/Statescript/GraphAbilityBehaviorTests.cs @@ -0,0 +1,370 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Abilities; +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Cues; +using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Effects.Components; +using Gamesmiths.Forge.Effects.Duration; +using Gamesmiths.Forge.Effects.Magnitudes; +using Gamesmiths.Forge.Effects.Modifiers; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Nodes; +using Gamesmiths.Forge.Statescript.Nodes.State; +using Gamesmiths.Forge.Tags; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Statescript; + +public class GraphAbilityBehaviorTests(TagsAndCuesFixture fixture) : IClassFixture +{ + private readonly TagsManager _tagsManager = fixture.TagsManager; + private readonly CuesManager _cuesManager = fixture.CuesManager; + + [Fact] + [Trait("GraphBehavior", "Lifecycle")] + public void Action_only_graph_ends_ability_instance_on_start() + { + var graph = new Graph(); + var actionNode = new TrackingActionNode(); + + graph.AddNode(actionNode); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + actionNode.InputPorts[ActionNode.InputPort])); + + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new GraphAbilityBehavior(graph); + + AbilityData abilityData = CreateAbilityData("ActionGraph", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, abilityData); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + actionNode.ExecutionCount.Should().Be(1); + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("GraphBehavior", "Lifecycle")] + public void Timer_graph_keeps_ability_active_until_timer_completes() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 2.0); + + var timer = new TimerNode("duration"); + graph.AddNode(timer); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new GraphAbilityBehavior(graph); + + AbilityData abilityData = CreateAbilityData("TimerGraph", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, abilityData); + handle.Should().NotBeNull(); + + handle!.Activate(out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + handle.IsActive.Should().BeTrue(); + + behavior.Processor.UpdateGraph(1.0); + handle.IsActive.Should().BeTrue(); + + behavior.Processor.UpdateGraph(1.0); + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("GraphBehavior", "Lifecycle")] + public void Canceling_ability_stops_graph() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 10.0); + + var timer = new TimerNode("duration"); + graph.AddNode(timer); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new GraphAbilityBehavior(graph); + + AbilityData abilityData = CreateAbilityData("CancelGraph", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, abilityData); + handle.Should().NotBeNull(); + + handle!.Activate(out _).Should().BeTrue(); + handle.IsActive.Should().BeTrue(); + behavior.Processor.GraphContext.IsActive.Should().BeTrue(); + + handle.Cancel(); + handle.IsActive.Should().BeFalse(); + behavior.Processor.GraphContext.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("GraphBehavior", "Variables")] + public void Graph_variables_are_initialized_from_definitions() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("counter", 0); + + var incrementNode = new IncrementCounterNode("counter"); + graph.AddNode(incrementNode); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + incrementNode.InputPorts[ActionNode.InputPort])); + + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new GraphAbilityBehavior(graph); + + AbilityData abilityData = CreateAbilityData("VarGraph", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, abilityData); + handle.Should().NotBeNull(); + + handle!.Activate(out _).Should().BeTrue(); + + behavior.Processor.GraphContext.GraphVariables.TryGetVar("counter", out int value).Should().BeTrue(); + value.Should().Be(1); + } + + [Fact] + [Trait("GraphBehavior", "SharedVariables")] + public void Shared_variables_are_set_from_ability_context_owner() + { + var graph = new Graph(); + var actionNode = new TrackingActionNode(); + + graph.AddNode(actionNode); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + actionNode.InputPorts[ActionNode.InputPort])); + + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new GraphAbilityBehavior(graph); + + AbilityData abilityData = CreateAbilityData("SharedVarsGraph", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, abilityData); + handle.Should().NotBeNull(); + + handle!.Activate(out _).Should().BeTrue(); + + behavior.Processor.GraphContext.SharedVariables.Should().BeSameAs(entity.SharedVariables); + } + + [Fact] + [Trait("GraphBehavior", "TypedData")] + public void Typed_data_binder_writes_activation_data_into_graph_variables() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("damage", 0); + graph.VariableDefinitions.DefineVariable("multiplier", 1.0); + + var readDamage = new ReadVariableNode("damage"); + var readMultiplier = new ReadVariableNode("multiplier"); + + graph.AddNode(readDamage); + graph.AddNode(readMultiplier); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + readDamage.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + readDamage.OutputPorts[ActionNode.OutputPort], + readMultiplier.InputPorts[ActionNode.InputPort])); + + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new GraphAbilityBehavior( + graph, + dataBinder: (data, vars) => + { + vars.SetVar("damage", data.Amount); + vars.SetVar("multiplier", data.Multiplier); + }); + + AbilityData abilityData = CreateAbilityData("TypedGraph", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, abilityData); + handle.Should().NotBeNull(); + + var activationData = new DamageData(50, 2.5); + handle!.Activate(activationData, out AbilityActivationFailures failureFlags).Should().BeTrue(); + failureFlags.Should().Be(AbilityActivationFailures.None); + + readDamage.LastReadValue.Should().Be(50); + readMultiplier.LastReadValue.Should().Be(2.5); + } + + [Fact] + [Trait("GraphBehavior", "ExitNode")] + public void Exit_node_ends_ability_instance() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 1.0); + + var timer = new TimerNode("duration"); + var exitNode = new ExitNode(); + + graph.AddNode(timer); + graph.AddNode(exitNode); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + timer.OutputPorts[StateNode.OnActivatePort], + exitNode.InputPorts[ExitNode.InputPort])); + + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new GraphAbilityBehavior(graph); + + AbilityData abilityData = CreateAbilityData("ExitGraph", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, abilityData); + handle.Should().NotBeNull(); + + handle!.Activate(out _).Should().BeTrue(); + handle.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("GraphBehavior", "ActivationContext")] + public void Activation_context_contains_ability_behavior_context() + { + var graph = new Graph(); + var captureNode = new CaptureActivationContextNode(); + + graph.AddNode(captureNode); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + captureNode.InputPorts[ActionNode.InputPort])); + + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new GraphAbilityBehavior(graph); + + AbilityData abilityData = CreateAbilityData("ActivationContextGraph", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, abilityData); + handle.Should().NotBeNull(); + + handle!.Activate(out _).Should().BeTrue(); + + captureNode.CapturedActivationContext.Should().NotBeNull(); + captureNode.CapturedActivationContext.Should().BeOfType(); + + var capturedContext = (AbilityBehaviorContext)captureNode.CapturedActivationContext!; + capturedContext.Owner.Should().Be(entity); + capturedContext.AbilityHandle.Should().Be(handle); + } + + [Fact] + [Trait("GraphBehavior", "ActivationContext")] + public void TryGetActivationContext_returns_typed_ability_behavior_context() + { + var graph = new Graph(); + var typedCaptureNode = new TryGetActivationContextNode(); + + graph.AddNode(typedCaptureNode); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + typedCaptureNode.InputPorts[ActionNode.InputPort])); + + var entity = new TestEntity(_tagsManager, _cuesManager); + var behavior = new GraphAbilityBehavior(graph); + + AbilityData abilityData = CreateAbilityData("TypedContextGraph", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, abilityData); + handle.Should().NotBeNull(); + + handle!.Activate(out _).Should().BeTrue(); + + typedCaptureNode.Found.Should().BeTrue(); + typedCaptureNode.CapturedContext.Should().NotBeNull(); + typedCaptureNode.CapturedContext!.AbilityHandle.Should().Be(handle); + } + + [Fact] + [Trait("GraphBehavior", "ActivationContext")] + public void Standalone_graph_has_null_activation_context() + { + var graph = new Graph(); + var typedCaptureNode = new TryGetActivationContextNode(); + + graph.AddNode(typedCaptureNode); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + typedCaptureNode.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + typedCaptureNode.Found.Should().BeFalse(); + typedCaptureNode.CapturedContext.Should().BeNull(); + } + + private static AbilityHandle? Grant( + TestEntity target, + AbilityData data, + IForgeEntity? sourceEntity = null) + { + var grantConfig = new GrantAbilityConfig( + data, + new ScalableInt(1), + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.CancelImmediately, + false, + false, + LevelComparison.Higher); + + var effectData = new EffectData( + "Grant", + new DurationData(DurationType.Infinite), + effectComponents: [new GrantAbilityEffectComponent([grantConfig])]); + + var grantEffect = new Effect(effectData, new EffectOwnership(null, sourceEntity)); + _ = target.EffectsManager.ApplyEffect(grantEffect); + target.Abilities.TryGetAbility(data, out AbilityHandle? handle, sourceEntity); + return handle; + } + + private AbilityData CreateAbilityData( + string name, + Func behaviorFactory) + { + EffectData[] cooldownEffectData = [new EffectData( + $"{name} Cooldown", + new DurationData( + DurationType.HasDuration, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + scalableFloatMagnitude: new ScalableFloat(3f))), + effectComponents: + [ + new ModifierTagsEffectComponent( + new TagContainer(_tagsManager, TestUtils.StringToTag(_tagsManager, ["simple.tag"]))) + ])]; + + var costEffectData = new EffectData( + $"{name} Cost", + new DurationData(DurationType.Instant), + [ + new Modifier( + "TestAttributeSet.Attribute90", + ModifierOperation.FlatBonus, + new ModifierMagnitude( + MagnitudeCalculationType.ScalableFloat, + scalableFloatMagnitude: new ScalableFloat(-1f))) + ]); + + return new AbilityData( + name, + costEffectData, + cooldownEffectData, + behaviorFactory: behaviorFactory); + } + + private sealed record DamageData(int Amount, double Multiplier); +} diff --git a/Forge.Tests/Statescript/GraphLoopDetectionTests.cs b/Forge.Tests/Statescript/GraphLoopDetectionTests.cs new file mode 100644 index 0000000..e872032 --- /dev/null +++ b/Forge.Tests/Statescript/GraphLoopDetectionTests.cs @@ -0,0 +1,767 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Nodes; +using Gamesmiths.Forge.Statescript.Nodes.State; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Statescript; + +public sealed class GraphLoopDetectionTests : IDisposable +{ + public GraphLoopDetectionTests() + { + Validation.Enabled = true; + } + + public void Dispose() + { + Validation.Enabled = false; + GC.SuppressFinalize(this); + } + + [Fact] + [Trait("LoopDetection", "Action node")] + public void Action_node_output_connected_to_own_input_is_rejected() + { + var graph = new Graph(); + var action = new TrackingActionNode(); + graph.AddNode(action); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + action.InputPorts[ActionNode.InputPort])); + + Action act = () => graph.AddConnection(new Connection( + action.OutputPorts[ActionNode.OutputPort], + action.InputPorts[ActionNode.InputPort])); + + act.Should().Throw(); + } + + [Fact] + [Trait("LoopDetection", "Action node")] + public void Two_action_nodes_forming_a_cycle_is_rejected() + { + var graph = new Graph(); + var action1 = new TrackingActionNode(); + var action2 = new TrackingActionNode(); + graph.AddNode(action1); + graph.AddNode(action2); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + action1.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + action1.OutputPorts[ActionNode.OutputPort], + action2.InputPorts[ActionNode.InputPort])); + + Action act = () => graph.AddConnection(new Connection( + action2.OutputPorts[ActionNode.OutputPort], + action1.InputPorts[ActionNode.InputPort])); + + act.Should().Throw(); + } + + [Fact] + [Trait("LoopDetection", "Action node")] + public void Three_action_nodes_forming_a_cycle_is_rejected() + { + var graph = new Graph(); + var action1 = new TrackingActionNode(); + var action2 = new TrackingActionNode(); + var action3 = new TrackingActionNode(); + graph.AddNode(action1); + graph.AddNode(action2); + graph.AddNode(action3); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + action1.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + action1.OutputPorts[ActionNode.OutputPort], + action2.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + action2.OutputPorts[ActionNode.OutputPort], + action3.InputPorts[ActionNode.InputPort])); + + Action act = () => graph.AddConnection(new Connection( + action3.OutputPorts[ActionNode.OutputPort], + action1.InputPorts[ActionNode.InputPort])); + + act.Should().Throw(); + } + + [Fact] + [Trait("LoopDetection", "Action node")] + public void Linear_action_chain_is_allowed() + { + var graph = new Graph(); + var action1 = new TrackingActionNode(); + var action2 = new TrackingActionNode(); + var action3 = new TrackingActionNode(); + graph.AddNode(action1); + graph.AddNode(action2); + graph.AddNode(action3); + + Action act = () => + { + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + action1.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + action1.OutputPorts[ActionNode.OutputPort], + action2.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + action2.OutputPorts[ActionNode.OutputPort], + action3.InputPorts[ActionNode.InputPort])); + }; + + act.Should().NotThrow(); + } + + [Fact] + [Trait("LoopDetection", "Condition node")] + public void Condition_true_port_looping_back_to_condition_input_is_rejected() + { + var graph = new Graph(); + var condition = new FixedConditionNode(result: true); + graph.AddNode(condition); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + + Action act = () => graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.TruePort], + condition.InputPorts[ConditionNode.InputPort])); + + act.Should().Throw(); + } + + [Fact] + [Trait("LoopDetection", "Condition node")] + public void Condition_false_port_looping_back_to_condition_input_is_rejected() + { + var graph = new Graph(); + var condition = new FixedConditionNode(result: false); + graph.AddNode(condition); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + + Action act = () => graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.FalsePort], + condition.InputPorts[ConditionNode.InputPort])); + + act.Should().Throw(); + } + + [Fact] + [Trait("LoopDetection", "Condition node")] + public void Condition_branching_to_separate_actions_is_allowed() + { + var graph = new Graph(); + var condition = new FixedConditionNode(result: true); + var trueAction = new TrackingActionNode(); + var falseAction = new TrackingActionNode(); + graph.AddNode(condition); + graph.AddNode(trueAction); + graph.AddNode(falseAction); + + Action act = () => + { + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.TruePort], + trueAction.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.FalsePort], + falseAction.InputPorts[ActionNode.InputPort])); + }; + + act.Should().NotThrow(); + } + + [Fact] + [Trait("LoopDetection", "Condition node")] + public void Condition_true_through_action_looping_back_is_rejected() + { + var graph = new Graph(); + var condition = new FixedConditionNode(result: true); + var action = new TrackingActionNode(); + graph.AddNode(condition); + graph.AddNode(action); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.TruePort], + action.InputPorts[ActionNode.InputPort])); + + Action act = () => graph.AddConnection(new Connection( + action.OutputPorts[ActionNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + + act.Should().Throw(); + } + + [Fact] + [Trait("LoopDetection", "State node")] + public void State_on_activate_looping_back_to_own_input_is_rejected() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 5.0); + var timer = new TimerNode("duration"); + graph.AddNode(timer); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + + Action act = () => graph.AddConnection(new Connection( + timer.OutputPorts[StateNode.OnActivatePort], + timer.InputPorts[StateNode.InputPort])); + + act.Should().Throw(); + } + + [Fact] + [Trait("LoopDetection", "State node")] + public void State_on_deactivate_through_action_looping_back_to_own_input_is_rejected() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 5.0); + var timer = new TimerNode("duration"); + var action = new TrackingActionNode(); + graph.AddNode(timer); + graph.AddNode(action); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + timer.OutputPorts[StateNode.OnDeactivatePort], + action.InputPorts[ActionNode.InputPort])); + + Action act = () => graph.AddConnection(new Connection( + action.OutputPorts[ActionNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + + act.Should().Throw(); + } + + [Fact] + [Trait("LoopDetection", "State node")] + public void State_on_deactivate_to_exit_node_is_allowed() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 5.0); + var timer = new TimerNode("duration"); + var exitNode = new ExitNode(); + graph.AddNode(timer); + graph.AddNode(exitNode); + + Action act = () => + { + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + timer.OutputPorts[StateNode.OnDeactivatePort], + exitNode.InputPorts[ExitNode.InputPort])); + }; + + act.Should().NotThrow(); + } + + [Fact] + [Trait("LoopDetection", "State node")] + public void State_on_activate_to_action_is_allowed() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 5.0); + var timer = new TimerNode("duration"); + var action = new TrackingActionNode(); + graph.AddNode(timer); + graph.AddNode(action); + + Action act = () => + { + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + timer.OutputPorts[StateNode.OnActivatePort], + action.InputPorts[ActionNode.InputPort])); + }; + + act.Should().NotThrow(); + } + + [Fact] + [Trait("LoopDetection", "Cross-channel (safe)")] + public void State_on_deactivate_to_another_state_abort_is_allowed() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("short", 1.0); + graph.VariableDefinitions.DefineVariable("long", 10.0); + var shortTimer = new TimerNode("short"); + var longTimer = new TimerNode("long"); + graph.AddNode(shortTimer); + graph.AddNode(longTimer); + + Action act = () => + { + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + shortTimer.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + longTimer.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + shortTimer.OutputPorts[StateNode.OnDeactivatePort], + longTimer.InputPorts[StateNode.AbortPort])); + }; + + act.Should().NotThrow(); + } + + [Fact] + [Trait("LoopDetection", "Cross-channel (safe)")] + public void State_abort_output_to_another_state_input_is_allowed() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("d1", 5.0); + graph.VariableDefinitions.DefineVariable("d2", 5.0); + var timer1 = new TimerNode("d1"); + var timer2 = new TimerNode("d2"); + graph.AddNode(timer1); + graph.AddNode(timer2); + + Action act = () => + { + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer1.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + timer1.OutputPorts[StateNode.OnAbortPort], + timer2.InputPorts[StateNode.InputPort])); + }; + + act.Should().NotThrow(); + } + + [Fact] + [Trait("LoopDetection", "Abort channel")] + public void State_on_abort_looping_back_to_own_abort_is_rejected() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 5.0); + var timer = new TimerNode("duration"); + graph.AddNode(timer); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + + Action act = () => graph.AddConnection(new Connection( + timer.OutputPorts[StateNode.OnAbortPort], + timer.InputPorts[StateNode.AbortPort])); + + act.Should().Throw(); + } + + [Fact] + [Trait("LoopDetection", "Abort channel")] + public void Two_states_abort_cycle_is_rejected() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("d1", 5.0); + graph.VariableDefinitions.DefineVariable("d2", 5.0); + var timer1 = new TimerNode("d1"); + var timer2 = new TimerNode("d2"); + graph.AddNode(timer1); + graph.AddNode(timer2); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer1.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer2.InputPorts[StateNode.InputPort])); + + // timer1 abort → fires OnAbortPort → timer2 abort input + graph.AddConnection(new Connection( + timer1.OutputPorts[StateNode.OnAbortPort], + timer2.InputPorts[StateNode.AbortPort])); + + // timer2 abort → fires OnAbortPort → timer1 abort input (loop!) + Action act = () => graph.AddConnection(new Connection( + timer2.OutputPorts[StateNode.OnAbortPort], + timer1.InputPorts[StateNode.AbortPort])); + + act.Should().Throw(); + } + + [Fact] + [Trait("LoopDetection", "Abort channel")] + public void Abort_on_deactivate_cycle_through_action_is_rejected() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 5.0); + var timer = new TimerNode("duration"); + var action = new TrackingActionNode(); + graph.AddNode(timer); + graph.AddNode(action); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + + // Abort fires OnAbortPort → but also fires OnDeactivatePort (via BeforeDisable) + // So: abort input → OnDeactivatePort → action → back to abort input + graph.AddConnection(new Connection( + timer.OutputPorts[StateNode.OnDeactivatePort], + action.InputPorts[ActionNode.InputPort])); + + Action act = () => graph.AddConnection(new Connection( + action.OutputPorts[ActionNode.OutputPort], + timer.InputPorts[StateNode.AbortPort])); + + act.Should().Throw(); + } + + [Fact] + [Trait("LoopDetection", "Subgraph port")] + public void State_subgraph_port_looping_back_to_own_input_is_rejected() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 5.0); + var timer = new TimerNode("duration"); + graph.AddNode(timer); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + + Action act = () => graph.AddConnection(new Connection( + timer.OutputPorts[StateNode.SubgraphPort], + timer.InputPorts[StateNode.InputPort])); + + act.Should().Throw(); + } + + [Fact] + [Trait("LoopDetection", "Disable-subgraph cascade")] + public void Disable_cascade_through_state_on_deactivate_looping_back_is_rejected() + { + // StateNode1 subgraph → ActionNode → StateNode2 input + // StateNode2 on_deactivate → ActionNode2 → StateNode1 input + // When StateNode1 is disabled (via subgraph cascade), ActionNode receives disable, + // then ActionNode propagates disable to StateNode2. StateNode2's BeforeDisable fires + // OnDeactivatePort.EmitMessage (regular message) → ActionNode2 → back to StateNode1 input. + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("d1", 5.0); + graph.VariableDefinitions.DefineVariable("d2", 5.0); + var timer1 = new TimerNode("d1"); + var timer2 = new TimerNode("d2"); + var action1 = new TrackingActionNode(); + var action2 = new TrackingActionNode(); + graph.AddNode(timer1); + graph.AddNode(timer2); + graph.AddNode(action1); + graph.AddNode(action2); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer1.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + timer1.OutputPorts[StateNode.SubgraphPort], + action1.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + action1.OutputPorts[ActionNode.OutputPort], + timer2.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + timer2.OutputPorts[StateNode.OnDeactivatePort], + action2.InputPorts[ActionNode.InputPort])); + + Action act = () => graph.AddConnection(new Connection( + action2.OutputPorts[ActionNode.OutputPort], + timer1.InputPorts[StateNode.InputPort])); + + act.Should().Throw(); + } + + [Fact] + [Trait("LoopDetection", "Disable-subgraph cascade")] + public void Disable_cascade_through_action_chain_without_state_is_allowed() + { + // StateNode subgraph → Action1 → Action2 (no loop back) + // Disable cascades through actions but they don't emit regular messages on disable. + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 5.0); + var timer = new TimerNode("duration"); + var action1 = new TrackingActionNode(); + var action2 = new TrackingActionNode(); + graph.AddNode(timer); + graph.AddNode(action1); + graph.AddNode(action2); + + Action act = () => + { + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + timer.OutputPorts[StateNode.SubgraphPort], + action1.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + action1.OutputPorts[ActionNode.OutputPort], + action2.InputPorts[ActionNode.InputPort])); + }; + + act.Should().NotThrow(); + } + + [Fact] + [Trait("LoopDetection", "Disable-subgraph cascade")] + public void Nested_state_nodes_on_deactivate_chain_loop_is_rejected() + { + // Entry → Timer1 (subgraph → Timer2) + // Timer2 OnDeactivate → Timer1 input (loop via disable cascade) + // When Timer1 disables, its SubgraphPort emits disable to Timer2. + // Timer2's BeforeDisable fires OnDeactivatePort.EmitMessage → back to Timer1 input. + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("d1", 5.0); + graph.VariableDefinitions.DefineVariable("d2", 3.0); + var timer1 = new TimerNode("d1"); + var timer2 = new TimerNode("d2"); + graph.AddNode(timer1); + graph.AddNode(timer2); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer1.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + timer1.OutputPorts[StateNode.SubgraphPort], + timer2.InputPorts[StateNode.InputPort])); + + Action act = () => graph.AddConnection(new Connection( + timer2.OutputPorts[StateNode.OnDeactivatePort], + timer1.InputPorts[StateNode.InputPort])); + + act.Should().Throw(); + } + + [Fact] + [Trait("LoopDetection", "Complex topologies")] + public void Diamond_topology_without_loop_is_allowed() + { + var graph = new Graph(); + var condition = new FixedConditionNode(result: true); + var action1 = new TrackingActionNode(); + var action2 = new TrackingActionNode(); + var merge = new TrackingActionNode(); + graph.AddNode(condition); + graph.AddNode(action1); + graph.AddNode(action2); + graph.AddNode(merge); + + Action act = () => + { + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.TruePort], + action1.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.FalsePort], + action2.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + action1.OutputPorts[ActionNode.OutputPort], + merge.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + action2.OutputPorts[ActionNode.OutputPort], + merge.InputPorts[ActionNode.InputPort])); + }; + + act.Should().NotThrow(); + } + + [Fact] + [Trait("LoopDetection", "Complex topologies")] + public void Multiple_outputs_to_same_node_without_loop_is_allowed() + { + var graph = new Graph(); + var action1 = new TrackingActionNode(); + var action2 = new TrackingActionNode(); + graph.AddNode(action1); + graph.AddNode(action2); + + Action act = () => + { + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + action1.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + action2.InputPorts[ActionNode.InputPort])); + }; + + act.Should().NotThrow(); + } + + [Fact] + [Trait("LoopDetection", "Complex topologies")] + public void Condition_in_loop_with_action_chain_is_rejected() + { + var graph = new Graph(); + var condition = new FixedConditionNode(result: true); + var action1 = new TrackingActionNode(); + var action2 = new TrackingActionNode(); + graph.AddNode(condition); + graph.AddNode(action1); + graph.AddNode(action2); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.FalsePort], + action1.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + action1.OutputPorts[ActionNode.OutputPort], + action2.InputPorts[ActionNode.InputPort])); + + Action act = () => graph.AddConnection(new Connection( + action2.OutputPorts[ActionNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + + act.Should().Throw(); + } + + [Fact] + [Trait("LoopDetection", "Complex topologies")] + public void State_on_activate_through_condition_and_action_looping_back_is_rejected() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 5.0); + var timer = new TimerNode("duration"); + var condition = new FixedConditionNode(result: true); + var action = new TrackingActionNode(); + graph.AddNode(timer); + graph.AddNode(condition); + graph.AddNode(action); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + timer.OutputPorts[StateNode.OnActivatePort], + condition.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.TruePort], + action.InputPorts[ActionNode.InputPort])); + + Action act = () => graph.AddConnection(new Connection( + action.OutputPorts[ActionNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + + act.Should().Throw(); + } + + [Fact] + [Trait("LoopDetection", "Connection removal")] + public void Rejected_connection_is_removed_from_graph() + { + var graph = new Graph(); + var action = new TrackingActionNode(); + graph.AddNode(action); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + action.InputPorts[ActionNode.InputPort])); + + var loopConnection = new Connection( + action.OutputPorts[ActionNode.OutputPort], + action.InputPorts[ActionNode.InputPort]); + + try + { + graph.AddConnection(loopConnection); + } + catch (ValidationException) + { + // Expected. + } + + graph.Connections.Should().NotContain(loopConnection); + } + + [Fact] + [Trait("LoopDetection", "Connection removal")] + public void Rejected_connection_is_disconnected_from_port() + { + var graph = new Graph(); + var action1 = new TrackingActionNode(); + var action2 = new TrackingActionNode(); + graph.AddNode(action1); + graph.AddNode(action2); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + action1.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + action1.OutputPorts[ActionNode.OutputPort], + action2.InputPorts[ActionNode.InputPort])); + + var loopConnection = new Connection( + action2.OutputPorts[ActionNode.OutputPort], + action1.InputPorts[ActionNode.InputPort]); + + try + { + graph.AddConnection(loopConnection); + } + catch (ValidationException) + { + // Expected. + } + + // The graph should still work normally without the loop. + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + action1.ExecutionCount.Should().Be(1); + action2.ExecutionCount.Should().Be(1); + } + + [Fact] + [Trait("LoopDetection", "Disabled validation")] + public void Loop_is_not_detected_when_validation_is_disabled() + { + Validation.Enabled = false; + + var graph = new Graph(); + var action = new TrackingActionNode(); + graph.AddNode(action); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + action.InputPorts[ActionNode.InputPort])); + + Action act = () => graph.AddConnection(new Connection( + action.OutputPorts[ActionNode.OutputPort], + action.InputPorts[ActionNode.InputPort])); + + act.Should().NotThrow(); + } +} diff --git a/Forge.Tests/Statescript/GraphProcessorTests.cs b/Forge.Tests/Statescript/GraphProcessorTests.cs new file mode 100644 index 0000000..07a9ff6 --- /dev/null +++ b/Forge.Tests/Statescript/GraphProcessorTests.cs @@ -0,0 +1,697 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Nodes; +using Gamesmiths.Forge.Statescript.Nodes.State; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Statescript; + +public class GraphProcessorTests +{ + [Fact] + [Trait("Graph", "Initialization")] + public void New_graph_has_an_entry_node() + { + var graph = new Graph(); + + graph.EntryNode.Should().NotBeNull(); + graph.Nodes.Should().BeEmpty(); + graph.Connections.Should().BeEmpty(); + } + + [Fact] + [Trait("Graph", "Initialization")] + public void Graph_processor_initializes_variables_on_start() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("health", 100); + + var processor = new GraphProcessor(graph); + + processor.StartGraph(); + + processor.GraphContext.GraphVariables.TryGetVar("health", out int value).Should().BeTrue(); + value.Should().Be(100); + } + + [Fact] + [Trait("Graph", "Execution")] + public void Starting_graph_executes_connected_action_node() + { + var graph = new Graph(); + var actionNode = new TrackingActionNode(); + + graph.AddNode(actionNode); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + actionNode.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + actionNode.ExecutionCount.Should().Be(1); + } + + [Fact] + [Trait("Graph", "Execution")] + public void Action_nodes_execute_in_sequence() + { + var executionOrder = new List(); + + var graph = new Graph(); + var action1 = new TrackingActionNode("A", executionOrder); + var action2 = new TrackingActionNode("B", executionOrder); + var action3 = new TrackingActionNode("C", executionOrder); + + graph.AddNode(action1); + graph.AddNode(action2); + graph.AddNode(action3); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + action1.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + action1.OutputPorts[ActionNode.OutputPort], + action2.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + action2.OutputPorts[ActionNode.OutputPort], + action3.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + executionOrder.Should().ContainInOrder("A", "B", "C"); + } + + [Fact] + [Trait("Graph", "Condition")] + public void Condition_node_routes_to_true_port_when_condition_is_met() + { + var graph = new Graph(); + var condition = new FixedConditionNode(result: true); + var trueAction = new TrackingActionNode(); + var falseAction = new TrackingActionNode(); + + graph.AddNode(condition); + graph.AddNode(trueAction); + graph.AddNode(falseAction); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.TruePort], + trueAction.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.FalsePort], + falseAction.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + trueAction.ExecutionCount.Should().Be(1); + falseAction.ExecutionCount.Should().Be(0); + } + + [Fact] + [Trait("Graph", "Condition")] + public void Condition_node_routes_to_false_port_when_condition_is_not_met() + { + var graph = new Graph(); + var condition = new FixedConditionNode(result: false); + var trueAction = new TrackingActionNode(); + var falseAction = new TrackingActionNode(); + + graph.AddNode(condition); + graph.AddNode(trueAction); + graph.AddNode(falseAction); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.TruePort], + trueAction.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.FalsePort], + falseAction.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + trueAction.ExecutionCount.Should().Be(0); + falseAction.ExecutionCount.Should().Be(1); + } + + [Fact] + [Trait("Graph", "Variables")] + public void Action_node_can_read_and_write_graph_variables() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("counter", 0); + + var incrementNode = new IncrementCounterNode("counter"); + var readNode = new ReadVariableNode("counter"); + + graph.AddNode(incrementNode); + graph.AddNode(readNode); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + incrementNode.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + incrementNode.OutputPorts[ActionNode.OutputPort], + readNode.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + readNode.LastReadValue.Should().Be(1); + } + + [Fact] + [Trait("Graph", "Variables")] + public void Condition_node_can_branch_based_on_graph_variables() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("threshold", 10); + graph.VariableDefinitions.DefineVariable("value", 15); + + var condition = new ThresholdConditionNode("value", "threshold"); + var aboveAction = new TrackingActionNode(); + var belowAction = new TrackingActionNode(); + + graph.AddNode(condition); + graph.AddNode(aboveAction); + graph.AddNode(belowAction); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.TruePort], + aboveAction.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.FalsePort], + belowAction.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + aboveAction.ExecutionCount.Should().Be(1, "value (15) is above threshold (10)"); + belowAction.ExecutionCount.Should().Be(0); + } + + [Fact] + [Trait("Graph", "Branching")] + public void Output_port_can_connect_to_multiple_input_ports() + { + var graph = new Graph(); + var action1 = new TrackingActionNode(); + var action2 = new TrackingActionNode(); + + graph.AddNode(action1); + graph.AddNode(action2); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + action1.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + action2.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + action1.ExecutionCount.Should().Be(1); + action2.ExecutionCount.Should().Be(1); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Stopping_graph_does_not_throw() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("counter", 0); + + var incrementNode = new IncrementCounterNode("counter"); + + graph.AddNode(incrementNode); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + incrementNode.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + processor.GraphContext.GraphVariables.TryGetVar("counter", out int valueAfterStart).Should().BeTrue(); + valueAfterStart.Should().Be(1); + + // StopGraph cleans up node contexts; verify it doesn't throw. + Action act = processor.StopGraph; + act.Should().NotThrow(); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Stopping_graph_fires_on_graph_completed() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 5.0); + + var timer = new TimerNode("duration"); + graph.AddNode(timer); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + + var processor = new GraphProcessor(graph); + var completed = false; + processor.OnGraphCompleted = () => completed = true; + processor.StartGraph(); + + processor.GraphContext.IsActive.Should().BeTrue(); + completed.Should().BeFalse(); + + processor.StopGraph(); + + processor.GraphContext.IsActive.Should().BeFalse(); + completed.Should().BeTrue(); + } + + [Fact] + [Trait("Graph", "Complex")] + public void Complex_graph_with_condition_and_multiple_actions_executes_correctly() + { + var executionOrder = new List(); + + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("counter", 0); + + var incrementNode = new IncrementCounterNode("counter"); + var condition = new ThresholdConditionNode("counter", threshold: 0); + var trackA = new TrackingActionNode("TrueAction", executionOrder); + var trackB = new TrackingActionNode("FalseAction", executionOrder); + + graph.AddNode(incrementNode); + graph.AddNode(condition); + graph.AddNode(trackA); + graph.AddNode(trackB); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + incrementNode.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + incrementNode.OutputPorts[ActionNode.OutputPort], + condition.InputPorts[ConditionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.TruePort], + trackA.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + condition.OutputPorts[ConditionNode.FalsePort], + trackB.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + trackA.ExecutionCount.Should().Be(1); + trackB.ExecutionCount.Should().Be(0); + executionOrder.Should().ContainSingle().Which.Should().Be("TrueAction"); + } + + [Fact] + [Trait("Graph", "Node")] + public void Disconnected_node_is_not_executed() + { + var graph = new Graph(); + var connectedAction = new TrackingActionNode(); + var disconnectedAction = new TrackingActionNode(); + + graph.AddNode(connectedAction); + graph.AddNode(disconnectedAction); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + connectedAction.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + connectedAction.ExecutionCount.Should().Be(1); + disconnectedAction.ExecutionCount.Should().Be(0); + } + + [Fact] + [Trait("Graph", "Node")] + public void Each_graph_processor_has_independent_variable_state() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("counter", 0); + + var incrementNode = new IncrementCounterNode("counter"); + graph.AddNode(incrementNode); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + incrementNode.InputPorts[ActionNode.InputPort])); + + var processor1 = new GraphProcessor(graph); + var processor2 = new GraphProcessor(graph); + + processor1.StartGraph(); + processor2.StartGraph(); + + processor1.GraphContext.GraphVariables.TryGetVar("counter", out int value1); + processor2.GraphContext.GraphVariables.TryGetVar("counter", out int value2); + + value1.Should().Be(1); + value2.Should().Be(1); + } + + [Fact] + [Trait("Graph", "Validation")] + public void Validate_property_type_returns_true_for_matching_type() + { + var definitions = new GraphVariableDefinitions(); + definitions.DefineVariable("duration", 2.0); + + definitions.ValidatePropertyType("duration", typeof(double)).Should().BeTrue(); + } + + [Fact] + [Trait("Graph", "Validation")] + public void Validate_property_type_returns_false_for_mismatched_type() + { + var definitions = new GraphVariableDefinitions(); + definitions.DefineVariable("flag", true); + + definitions.ValidatePropertyType("flag", typeof(double)).Should().BeFalse(); + } + + [Fact] + [Trait("Graph", "Validation")] + public void Validate_property_type_returns_false_for_nonexistent_property() + { + var definitions = new GraphVariableDefinitions(); + + definitions.ValidatePropertyType("missing", typeof(double)).Should().BeFalse(); + } + + [Fact] + [Trait("Graph", "ExitNode")] + public void Exit_node_stops_graph_execution() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 5.0); + + var timer = new TimerNode("duration"); + var exitNode = new ExitNode(); + + graph.AddNode(timer); + graph.AddNode(exitNode); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + timer.OutputPorts[StateNode.OnDeactivatePort], + exitNode.InputPorts[ExitNode.InputPort])); + + var processor = new GraphProcessor(graph); + var completed = false; + processor.OnGraphCompleted = () => completed = true; + processor.StartGraph(); + + processor.GraphContext.IsActive.Should().BeTrue(); + completed.Should().BeFalse(); + + processor.UpdateGraph(5.0); + + processor.GraphContext.IsActive.Should().BeFalse(); + completed.Should().BeTrue(); + } + + [Fact] + [Trait("Graph", "ExitNode")] + public void Exit_node_connected_to_action_stops_graph_after_action() + { + var graph = new Graph(); + var actionNode = new TrackingActionNode(); + var exitNode = new ExitNode(); + + graph.AddNode(actionNode); + graph.AddNode(exitNode); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + actionNode.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + actionNode.OutputPorts[ActionNode.OutputPort], + exitNode.InputPorts[ExitNode.InputPort])); + + var processor = new GraphProcessor(graph); + var completed = false; + processor.OnGraphCompleted = () => completed = true; + processor.StartGraph(); + + actionNode.ExecutionCount.Should().Be(1); + completed.Should().BeTrue(); + } + + [Fact] + [Trait("Graph", "ExitNode")] + public void Exit_node_stops_all_active_state_nodes() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("shortDuration", 1.0); + graph.VariableDefinitions.DefineVariable("longDuration", 10.0); + + var shortTimer = new TimerNode("shortDuration"); + var longTimer = new TimerNode("longDuration"); + var exitNode = new ExitNode(); + + graph.AddNode(shortTimer); + graph.AddNode(longTimer); + graph.AddNode(exitNode); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + shortTimer.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + longTimer.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + shortTimer.OutputPorts[StateNode.OnDeactivatePort], + exitNode.InputPorts[ExitNode.InputPort])); + + var processor = new GraphProcessor(graph); + var completed = false; + processor.OnGraphCompleted = () => completed = true; + processor.StartGraph(); + + processor.GraphContext.IsActive.Should().BeTrue(); + + processor.UpdateGraph(1.0); + + processor.GraphContext.IsActive.Should().BeFalse(); + completed.Should().BeTrue(); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Action_only_graph_completes_immediately_after_start() + { + var graph = new Graph(); + var actionNode = new TrackingActionNode(); + + graph.AddNode(actionNode); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + actionNode.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + var completed = false; + processor.OnGraphCompleted = () => completed = true; + processor.StartGraph(); + + actionNode.ExecutionCount.Should().Be(1); + processor.GraphContext.IsActive.Should().BeFalse(); + completed.Should().BeTrue(); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Timer_graph_completes_when_last_state_node_deactivates() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 2.0); + + var timer = new TimerNode("duration"); + graph.AddNode(timer); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + + var processor = new GraphProcessor(graph); + var completed = false; + processor.OnGraphCompleted = () => completed = true; + processor.StartGraph(); + + processor.GraphContext.IsActive.Should().BeTrue(); + completed.Should().BeFalse(); + + processor.UpdateGraph(2.0); + + processor.GraphContext.IsActive.Should().BeFalse(); + completed.Should().BeTrue(); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Multiple_timers_complete_only_after_all_deactivate() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("shortDuration", 1.0); + graph.VariableDefinitions.DefineVariable("longDuration", 3.0); + + var shortTimer = new TimerNode("shortDuration"); + var longTimer = new TimerNode("longDuration"); + + graph.AddNode(shortTimer); + graph.AddNode(longTimer); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + shortTimer.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + longTimer.InputPorts[StateNode.InputPort])); + + var processor = new GraphProcessor(graph); + var completed = false; + processor.OnGraphCompleted = () => completed = true; + processor.StartGraph(); + + processor.GraphContext.IsActive.Should().BeTrue(); + completed.Should().BeFalse(); + + processor.UpdateGraph(1.0); + processor.GraphContext.IsActive.Should().BeTrue(); + completed.Should().BeFalse(); + + processor.UpdateGraph(2.0); + processor.GraphContext.IsActive.Should().BeFalse(); + completed.Should().BeTrue(); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Update_graph_does_nothing_after_completion() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 1.0); + + var timer = new TimerNode("duration"); + graph.AddNode(timer); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + + var processor = new GraphProcessor(graph); + var completedCount = 0; + processor.OnGraphCompleted = () => completedCount++; + processor.StartGraph(); + + processor.UpdateGraph(1.0); + completedCount.Should().Be(1); + + // Subsequent updates should be no-ops and not throw or fire the callback again. + processor.UpdateGraph(1.0); + processor.UpdateGraph(1.0); + completedCount.Should().Be(1); + processor.GraphContext.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Empty_graph_completes_immediately() + { + var graph = new Graph(); + var processor = new GraphProcessor(graph); + var completed = false; + processor.OnGraphCompleted = () => completed = true; + + processor.StartGraph(); + + processor.GraphContext.IsActive.Should().BeFalse(); + completed.Should().BeTrue(); + } + + [Fact] + [Trait("Graph", "ArrayVariables")] + public void Array_variable_is_initialized_from_definition() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineArrayVariable("targets", 10, 20, 30); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + processor.GraphContext.GraphVariables.GetArrayLength("targets").Should().Be(3); + processor.GraphContext.GraphVariables.TryGetArrayElement("targets", 0, out int v0).Should().BeTrue(); + processor.GraphContext.GraphVariables.TryGetArrayElement("targets", 1, out int v1).Should().BeTrue(); + processor.GraphContext.GraphVariables.TryGetArrayElement("targets", 2, out int v2).Should().BeTrue(); + v0.Should().Be(10); + v1.Should().Be(20); + v2.Should().Be(30); + } + + [Fact] + [Trait("Graph", "ArrayVariables")] + public void Array_variable_has_independent_state_per_processor() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineArrayVariable("ids", 1, 2, 3); + + var processor1 = new GraphProcessor(graph); + processor1.StartGraph(); + + var processor2 = new GraphProcessor(graph); + processor2.StartGraph(); + + processor1.GraphContext.GraphVariables.SetArrayElement("ids", 0, 99); + + processor1.GraphContext.GraphVariables.TryGetArrayElement("ids", 0, out int val1); + processor2.GraphContext.GraphVariables.TryGetArrayElement("ids", 0, out int val2); + + val1.Should().Be(99); + val2.Should().Be(1); + } + + [Fact] + [Trait("Graph", "ArrayVariables")] + public void Array_variable_returns_negative_length_for_nonexistent_variable() + { + var graph = new Graph(); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + processor.GraphContext.GraphVariables.GetArrayLength("nonexistent").Should().Be(-1); + } + + [Fact] + [Trait("Graph", "ArrayVariables")] + public void Array_variable_try_get_returns_false_for_out_of_range_index() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineArrayVariable("data", 1.0, 2.0); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + processor.GraphContext.GraphVariables.TryGetArrayElement("data", 5, out double _).Should().BeFalse(); + processor.GraphContext.GraphVariables.TryGetArrayElement("data", -1, out double _).Should().BeFalse(); + } +} diff --git a/Forge.Tests/Statescript/PropertyResolverTests.cs b/Forge.Tests/Statescript/PropertyResolverTests.cs new file mode 100644 index 0000000..f8126f1 --- /dev/null +++ b/Forge.Tests/Statescript/PropertyResolverTests.cs @@ -0,0 +1,596 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Abilities; +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Cues; +using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Effects.Components; +using Gamesmiths.Forge.Effects.Duration; +using Gamesmiths.Forge.Effects.Magnitudes; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Nodes; +using Gamesmiths.Forge.Statescript.Properties; +using Gamesmiths.Forge.Tags; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Statescript; + +public class PropertyResolverTests(TagsAndCuesFixture tagsAndCuesFixture) : IClassFixture +{ + private readonly TagsManager _tagsManager = tagsAndCuesFixture.TagsManager; + private readonly CuesManager _cuesManager = tagsAndCuesFixture.CuesManager; + + [Fact] + [Trait("Resolver", "Attribute")] + public void Attribute_resolver_returns_current_value_of_existing_attribute() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var resolver = new AttributeResolver("TestAttributeSet.Attribute5"); + + GraphContext context = CreateAbilityGraphContext(entity); + + Variant128 result = resolver.Resolve(context); + + result.AsInt().Should().Be(5); + } + + [Fact] + [Trait("Resolver", "Attribute")] + public void Attribute_resolver_returns_default_for_missing_attribute() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var resolver = new AttributeResolver("TestAttributeSet.NonExistent"); + + GraphContext context = CreateAbilityGraphContext(entity); + + Variant128 result = resolver.Resolve(context); + + result.AsInt().Should().Be(0); + } + + [Fact] + [Trait("Resolver", "Attribute")] + public void Attribute_resolver_returns_default_when_no_activation_context() + { + var resolver = new AttributeResolver("TestAttributeSet.Attribute5"); + + var context = new GraphContext(); + + Variant128 result = resolver.Resolve(context); + + result.AsInt().Should().Be(0); + } + + [Fact] + [Trait("Resolver", "Attribute")] + public void Attribute_resolver_value_type_is_int() + { + var resolver = new AttributeResolver("TestAttributeSet.Attribute5"); + + resolver.ValueType.Should().Be(typeof(int)); + } + + [Fact] + [Trait("Resolver", "Attribute")] + public void Attribute_resolver_reads_different_attributes() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var resolver1 = new AttributeResolver("TestAttributeSet.Attribute1"); + var resolver90 = new AttributeResolver("TestAttributeSet.Attribute90"); + + GraphContext context = CreateAbilityGraphContext(entity); + + resolver1.Resolve(context).AsInt().Should().Be(1); + resolver90.Resolve(context).AsInt().Should().Be(90); + } + + [Fact] + [Trait("Resolver", "Tag")] + public void Tag_resolver_returns_true_when_entity_has_tag() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var tag = Tag.RequestTag(_tagsManager, "enemy.undead.zombie"); + var resolver = new TagResolver(tag); + + GraphContext context = CreateAbilityGraphContext(entity); + + Variant128 result = resolver.Resolve(context); + + result.AsBool().Should().BeTrue(); + } + + [Fact] + [Trait("Resolver", "Tag")] + public void Tag_resolver_returns_false_when_entity_does_not_have_tag() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var tag = Tag.RequestTag(_tagsManager, "enemy.beast.wolf"); + var resolver = new TagResolver(tag); + + GraphContext context = CreateAbilityGraphContext(entity); + + Variant128 result = resolver.Resolve(context); + + result.AsBool().Should().BeFalse(); + } + + [Fact] + [Trait("Resolver", "Tag")] + public void Tag_resolver_returns_false_when_no_activation_context() + { + var tag = Tag.RequestTag(_tagsManager, "enemy.undead.zombie"); + var resolver = new TagResolver(tag); + + var context = new GraphContext(); + + Variant128 result = resolver.Resolve(context); + + result.AsBool().Should().BeFalse(); + } + + [Fact] + [Trait("Resolver", "Tag")] + public void Tag_resolver_value_type_is_bool() + { + var tag = Tag.RequestTag(_tagsManager, "enemy.undead.zombie"); + var resolver = new TagResolver(tag); + + resolver.ValueType.Should().Be(typeof(bool)); + } + + [Fact] + [Trait("Resolver", "Tag")] + public void Tag_resolver_matches_parent_tag() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var parentTag = Tag.RequestTag(_tagsManager, "enemy.undead"); + var resolver = new TagResolver(parentTag); + + GraphContext context = CreateAbilityGraphContext(entity); + + Variant128 result = resolver.Resolve(context); + + result.AsBool().Should().BeTrue(); + } + + [Fact] + [Trait("Resolver", "Variant")] + public void Variant_resolver_returns_stored_value() + { + var resolver = new VariantResolver(new Variant128(42.0), typeof(double)); + + var context = new GraphContext(); + + Variant128 result = resolver.Resolve(context); + + result.AsDouble().Should().Be(42.0); + } + + [Fact] + [Trait("Resolver", "Variant")] + public void Variant_resolver_value_can_be_updated() + { + var resolver = new VariantResolver(new Variant128(10), typeof(int)); + + resolver.Set(25); + + var context = new GraphContext(); + Variant128 result = resolver.Resolve(context); + + result.AsInt().Should().Be(25); + } + + [Fact] + [Trait("Resolver", "Variant")] + public void Variant_resolver_reports_correct_value_type() + { + var intResolver = new VariantResolver(new Variant128(0), typeof(int)); + var boolResolver = new VariantResolver(new Variant128(false), typeof(bool)); + var doubleResolver = new VariantResolver(new Variant128(0.0), typeof(double)); + + intResolver.ValueType.Should().Be(typeof(int)); + boolResolver.ValueType.Should().Be(typeof(bool)); + doubleResolver.ValueType.Should().Be(typeof(double)); + } + + [Fact] + [Trait("Resolver", "Variable")] + public void Variable_resolver_reads_value_from_graph_variables() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("speed", 7.5); + + var context = new GraphContext(); + context.GraphVariables.InitializeFrom(graph.VariableDefinitions); + + var resolver = new VariableResolver("speed", typeof(double)); + + Variant128 result = resolver.Resolve(context); + + result.AsDouble().Should().Be(7.5); + } + + [Fact] + [Trait("Resolver", "Variable")] + public void Variable_resolver_returns_default_for_missing_variable() + { + var graph = new Graph(); + + var context = new GraphContext(); + context.GraphVariables.InitializeFrom(graph.VariableDefinitions); + + var resolver = new VariableResolver("nonexistent", typeof(double)); + + Variant128 result = resolver.Resolve(context); + + result.AsDouble().Should().Be(0); + } + + [Fact] + [Trait("Resolver", "Variable")] + public void Variable_resolver_reflects_runtime_variable_changes() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("counter", 0); + + var context = new GraphContext(); + context.GraphVariables.InitializeFrom(graph.VariableDefinitions); + + var resolver = new VariableResolver("counter", typeof(int)); + + resolver.Resolve(context).AsInt().Should().Be(0); + + context.GraphVariables.SetVar("counter", 42); + + resolver.Resolve(context).AsInt().Should().Be(42); + } + + [Fact] + [Trait("Resolver", "Variable")] + public void Variable_resolver_value_type_is_double() + { + var resolver = new VariableResolver("anything", typeof(double)); + + resolver.ValueType.Should().Be(typeof(double)); + } + + [Fact] + [Trait("Resolver", "Comparison")] + public void Comparison_resolver_value_type_is_bool() + { + var resolver = new ComparisonResolver( + new VariantResolver(new Variant128(0.0), typeof(double)), + ComparisonOperation.Equal, + new VariantResolver(new Variant128(0.0), typeof(double))); + + resolver.ValueType.Should().Be(typeof(bool)); + } + + [Fact] + [Trait("Resolver", "Comparison")] + public void Comparison_resolver_equal_returns_true_for_same_values() + { + var resolver = new ComparisonResolver( + new VariantResolver(new Variant128(5.0), typeof(double)), + ComparisonOperation.Equal, + new VariantResolver(new Variant128(5.0), typeof(double))); + + var context = new GraphContext(); + + resolver.Resolve(context).AsBool().Should().BeTrue(); + } + + [Fact] + [Trait("Resolver", "Comparison")] + public void Comparison_resolver_equal_returns_false_for_different_values() + { + var resolver = new ComparisonResolver( + new VariantResolver(new Variant128(5.0), typeof(double)), + ComparisonOperation.Equal, + new VariantResolver(new Variant128(10.0), typeof(double))); + + var context = new GraphContext(); + + resolver.Resolve(context).AsBool().Should().BeFalse(); + } + + [Fact] + [Trait("Resolver", "Comparison")] + public void Comparison_resolver_not_equal_returns_true_for_different_values() + { + var resolver = new ComparisonResolver( + new VariantResolver(new Variant128(1.0), typeof(double)), + ComparisonOperation.NotEqual, + new VariantResolver(new Variant128(2.0), typeof(double))); + + var context = new GraphContext(); + + resolver.Resolve(context).AsBool().Should().BeTrue(); + } + + [Fact] + [Trait("Resolver", "Comparison")] + public void Comparison_resolver_less_than_returns_true_when_left_is_smaller() + { + var resolver = new ComparisonResolver( + new VariantResolver(new Variant128(3.0), typeof(double)), + ComparisonOperation.LessThan, + new VariantResolver(new Variant128(10.0), typeof(double))); + + var context = new GraphContext(); + + resolver.Resolve(context).AsBool().Should().BeTrue(); + } + + [Fact] + [Trait("Resolver", "Comparison")] + public void Comparison_resolver_less_than_returns_false_at_boundary() + { + var resolver = new ComparisonResolver( + new VariantResolver(new Variant128(10.0), typeof(double)), + ComparisonOperation.LessThan, + new VariantResolver(new Variant128(10.0), typeof(double))); + + var context = new GraphContext(); + + resolver.Resolve(context).AsBool().Should().BeFalse(); + } + + [Fact] + [Trait("Resolver", "Comparison")] + public void Comparison_resolver_greater_than_returns_true_when_left_is_larger() + { + var resolver = new ComparisonResolver( + new VariantResolver(new Variant128(20.0), typeof(double)), + ComparisonOperation.GreaterThan, + new VariantResolver(new Variant128(10.0), typeof(double))); + + var context = new GraphContext(); + + resolver.Resolve(context).AsBool().Should().BeTrue(); + } + + [Fact] + [Trait("Resolver", "Comparison")] + public void Comparison_resolver_greater_than_or_equal_returns_true_at_boundary() + { + var resolver = new ComparisonResolver( + new VariantResolver(new Variant128(10.0), typeof(double)), + ComparisonOperation.GreaterThanOrEqual, + new VariantResolver(new Variant128(10.0), typeof(double))); + + var context = new GraphContext(); + + resolver.Resolve(context).AsBool().Should().BeTrue(); + } + + [Fact] + [Trait("Resolver", "Comparison")] + public void Comparison_resolver_supports_nested_resolvers() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + + var resolver = new ComparisonResolver( + new AttributeResolver("TestAttributeSet.Attribute5"), + ComparisonOperation.GreaterThan, + new VariantResolver(new Variant128(3.0), typeof(double))); + + GraphContext context = CreateAbilityGraphContext(entity); + + resolver.Resolve(context).AsBool().Should().BeTrue("Attribute5 (5) > 3"); + } + + [Fact] + [Trait("Resolver", "SharedVariable")] + public void Shared_variable_resolver_reads_value_from_shared_variables() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + entity.SharedVariables.DefineVariable("abilityLock", true); + + var resolver = new SharedVariableResolver("abilityLock", typeof(bool)); + + var context = new GraphContext { SharedVariables = entity.SharedVariables }; + + resolver.Resolve(context).AsBool().Should().BeTrue(); + } + + [Fact] + [Trait("Resolver", "SharedVariable")] + public void Shared_variable_resolver_returns_default_when_shared_variables_is_null() + { + var resolver = new SharedVariableResolver("abilityLock", typeof(double)); + + var context = new GraphContext(); + + resolver.Resolve(context).AsDouble().Should().Be(0); + } + + [Fact] + [Trait("Resolver", "SharedVariable")] + public void Shared_variable_resolver_returns_default_for_missing_variable() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + var resolver = new SharedVariableResolver("nonexistent", typeof(double)); + + var context = new GraphContext { SharedVariables = entity.SharedVariables }; + + resolver.Resolve(context).AsDouble().Should().Be(0); + } + + [Fact] + [Trait("Resolver", "SharedVariable")] + public void Shared_variable_resolver_value_type_is_double() + { + var resolver = new SharedVariableResolver("anything", typeof(double)); + + resolver.ValueType.Should().Be(typeof(double)); + } + + [Fact] + [Trait("Resolver", "SharedVariable")] + public void Shared_variable_resolver_reflects_changes_across_graph_contexts() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + entity.SharedVariables.DefineVariable("sharedCounter", 0); + + var resolver = new SharedVariableResolver("sharedCounter", typeof(int)); + + var context1 = new GraphContext { SharedVariables = entity.SharedVariables }; + var context2 = new GraphContext { SharedVariables = entity.SharedVariables }; + + resolver.Resolve(context1).AsInt().Should().Be(0); + + entity.SharedVariables.SetVar("sharedCounter", 42); + + resolver.Resolve(context1).AsInt().Should().Be(42); + resolver.Resolve(context2).AsInt().Should().Be(42); + } + + [Fact] + [Trait("Resolver", "Array")] + public void Array_resolver_returns_first_element_on_resolve() + { + var resolver = new ArrayVariableResolver( + [new Variant128(10), new Variant128(20), new Variant128(30)], + typeof(int)); + + var context = new GraphContext(); + + resolver.Resolve(context).AsInt().Should().Be(10); + } + + [Fact] + [Trait("Resolver", "Array")] + public void Array_resolver_returns_default_when_empty() + { + var resolver = new ArrayVariableResolver([], typeof(int)); + + var context = new GraphContext(); + + resolver.Resolve(context).AsInt().Should().Be(0); + } + + [Fact] + [Trait("Resolver", "Array")] + public void Array_resolver_get_element_returns_correct_value() + { + var resolver = new ArrayVariableResolver( + [new Variant128(10), new Variant128(20), new Variant128(30)], + typeof(int)); + + resolver.GetElement(0).AsInt().Should().Be(10); + resolver.GetElement(1).AsInt().Should().Be(20); + resolver.GetElement(2).AsInt().Should().Be(30); + } + + [Fact] + [Trait("Resolver", "Array")] + public void Array_resolver_set_element_updates_value() + { + var resolver = new ArrayVariableResolver( + [new Variant128(10), new Variant128(20)], + typeof(int)); + + resolver.SetElement(1, new Variant128(99)); + + resolver.GetElement(1).AsInt().Should().Be(99); + } + + [Fact] + [Trait("Resolver", "Array")] + public void Array_resolver_add_appends_element() + { + var resolver = new ArrayVariableResolver( + [new Variant128(10)], + typeof(int)); + + resolver.Add(new Variant128(20)); + + resolver.Length.Should().Be(2); + resolver.GetElement(1).AsInt().Should().Be(20); + } + + [Fact] + [Trait("Resolver", "Array")] + public void Array_resolver_remove_at_removes_element() + { + var resolver = new ArrayVariableResolver( + [new Variant128(10), new Variant128(20), new Variant128(30)], + typeof(int)); + + resolver.RemoveAt(1); + + resolver.Length.Should().Be(2); + resolver.GetElement(0).AsInt().Should().Be(10); + resolver.GetElement(1).AsInt().Should().Be(30); + } + + [Fact] + [Trait("Resolver", "Array")] + public void Array_resolver_clear_removes_all_elements() + { + var resolver = new ArrayVariableResolver( + [new Variant128(10), new Variant128(20)], + typeof(int)); + + resolver.Clear(); + + resolver.Length.Should().Be(0); + } + + [Fact] + [Trait("Resolver", "Array")] + public void Array_resolver_reports_correct_value_type() + { + var resolver = new ArrayVariableResolver([], typeof(double)); + + resolver.ValueType.Should().Be(typeof(double)); + } + + private static GraphContext CreateAbilityGraphContext(TestEntity entity) + { + var graph = new Graph(); + var captureNode = new CaptureGraphContextNode(); + + graph.AddNode(captureNode); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + captureNode.InputPorts[ActionNode.InputPort])); + + var behavior = new GraphAbilityBehavior(graph); + + AbilityData abilityData = CreateAbilityData("ResolverTest", () => behavior); + AbilityHandle? handle = Grant(entity, abilityData); + handle!.Activate(out _); + + return captureNode.CapturedGraphContext!; + } + + private static AbilityHandle? Grant(TestEntity target, AbilityData data) + { + var grantConfig = new GrantAbilityConfig( + data, + new ScalableInt(1), + AbilityDeactivationPolicy.CancelImmediately, + AbilityDeactivationPolicy.CancelImmediately, + false, + false, + LevelComparison.Higher); + + var effectData = new EffectData( + "Grant", + new DurationData(DurationType.Infinite), + effectComponents: [new GrantAbilityEffectComponent([grantConfig])]); + + var grantEffect = new Effect(effectData, new EffectOwnership(null, null)); + _ = target.EffectsManager.ApplyEffect(grantEffect); + target.Abilities.TryGetAbility(data, out AbilityHandle? handle); + return handle; + } + + private static AbilityData CreateAbilityData(string name, Func behaviorFactory) + { + return new AbilityData(name, behaviorFactory: behaviorFactory); + } +} diff --git a/Forge.Tests/Statescript/StateNodeTests.cs b/Forge.Tests/Statescript/StateNodeTests.cs new file mode 100644 index 0000000..7e76d9d --- /dev/null +++ b/Forge.Tests/Statescript/StateNodeTests.cs @@ -0,0 +1,130 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Nodes; +using Gamesmiths.Forge.Statescript.Nodes.State; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Statescript; + +public class StateNodeTests +{ + [Fact] + [Trait("Graph", "Timer")] + public void Timer_node_stays_active_until_duration_elapses() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 2.0); + + var timer = new TimerNode("duration"); + + graph.AddNode(timer); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + processor.GraphContext.IsActive.Should().BeTrue(); + + // Not enough time has passed. + processor.UpdateGraph(1.0); + processor.GraphContext.IsActive.Should().BeTrue(); + + // Still not enough. + processor.UpdateGraph(0.5); + processor.GraphContext.IsActive.Should().BeTrue(); + + // Now it should deactivate (total: 1.0 + 0.5 + 0.5 = 2.0). + processor.UpdateGraph(0.5); + processor.GraphContext.IsActive.Should().BeFalse(); + } + + [Fact] + [Trait("Graph", "Timer")] + public void Timer_node_fires_on_deactivate_event_when_completed() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 1.0); + + var timer = new TimerNode("duration"); + var onDeactivateAction = new TrackingActionNode(); + + graph.AddNode(timer); + graph.AddNode(onDeactivateAction); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + timer.OutputPorts[TimerNode.OnDeactivatePort], + onDeactivateAction.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + onDeactivateAction.ExecutionCount.Should().Be(0); + + processor.UpdateGraph(1.0); + + onDeactivateAction.ExecutionCount.Should().Be(1); + } + + [Fact] + [Trait("Graph", "Timer")] + public void Timer_node_fires_on_activate_event_on_start() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 5.0); + + var timer = new TimerNode("duration"); + var onActivateAction = new TrackingActionNode(); + + graph.AddNode(timer); + graph.AddNode(onActivateAction); + + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[ActionNode.InputPort])); + graph.AddConnection(new Connection( + timer.OutputPorts[TimerNode.OnActivatePort], + onActivateAction.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + onActivateAction.ExecutionCount.Should().Be(1); + } + + [Fact] + [Trait("Graph", "Timer")] + public void Two_processors_with_same_timer_graph_have_independent_elapsed_time() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 2.0); + + var timer = new TimerNode("duration"); + + graph.AddNode(timer); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[ActionNode.InputPort])); + + var processor1 = new GraphProcessor(graph); + var processor2 = new GraphProcessor(graph); + + processor1.StartGraph(); + processor2.StartGraph(); + + processor1.UpdateGraph(2.0); + processor2.UpdateGraph(1.0); + + processor1.GraphContext.IsActive.Should().BeFalse(); + processor2.GraphContext.IsActive.Should().BeTrue(); + + processor2.UpdateGraph(1.0); + processor2.GraphContext.IsActive.Should().BeFalse(); + } +} diff --git a/Forge/Core/IForgeEntity.cs b/Forge/Core/IForgeEntity.cs index 39e306f..15b4527 100644 --- a/Forge/Core/IForgeEntity.cs +++ b/Forge/Core/IForgeEntity.cs @@ -2,6 +2,7 @@ using Gamesmiths.Forge.Effects; using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Statescript; namespace Gamesmiths.Forge.Core; @@ -34,4 +35,10 @@ public interface IForgeEntity /// Gets the event bus for this entity. /// EventManager Events { get; } + + /// + /// Gets the shared variables for this entity. Shared variables are accessible by all graph instances running on + /// this entity, providing a communication channel between abilities and scripts besides tags and attributes. + /// + Variables SharedVariables { get; } } diff --git a/Forge/Statescript/Connection.cs b/Forge/Statescript/Connection.cs new file mode 100644 index 0000000..c8f5378 --- /dev/null +++ b/Forge/Statescript/Connection.cs @@ -0,0 +1,12 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Statescript.Ports; + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Represents a connection between an output port and an input port. +/// +/// The output port. +/// The input port. +public record struct Connection(OutputPort OutputPort, InputPort InputPort); diff --git a/Forge/Statescript/Graph.cs b/Forge/Statescript/Graph.cs new file mode 100644 index 0000000..138432a --- /dev/null +++ b/Forge/Statescript/Graph.cs @@ -0,0 +1,302 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Statescript.Nodes; +using Gamesmiths.Forge.Statescript.Ports; + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Represents a Statescript graph definition consisting of nodes and connections. A instance is +/// constructed once and can then be shared across multiple instances (Flyweight pattern). +/// Each runner pairs the shared graph with its own , which holds all mutable runtime state. +/// +/// +/// The Flyweight boundary is at the level, not the individual level. +/// Nodes own their ports, and ports store connection data, so a given instance is bound to the +/// graph that wired it. Do not add the same instance to multiple graphs. +/// All mutable runtime state (variable values, node contexts, activation status) lives in +/// . +/// +public class Graph +{ + /// + /// Sentinel value indicating the node was entered via disable-subgraph, not a specific input port. + /// + private const byte DisableSubgraphEntry = byte.MaxValue; + + /// + /// Gets the entry node of the graph. + /// + public EntryNode EntryNode { get; } + + /// + /// Gets the list of nodes in the graph. + /// + public List Nodes { get; } + + /// + /// Gets the list of connections between nodes in the graph. + /// + public List Connections { get; } + + /// + /// Gets the variable and property definitions for the graph. These define the schema (names, initial values, and + /// property resolvers) that will be used to initialize runtime instances when a graph + /// execution starts. + /// + public GraphVariableDefinitions VariableDefinitions { get; } + + /// + /// Initializes a new instance of the class. + /// + public Graph() + { + EntryNode = new EntryNode(); + Nodes = []; + Connections = []; + VariableDefinitions = new GraphVariableDefinitions(); + } + + /// + /// Adds a node to the graph. + /// + /// The node to add. + public virtual void AddNode(Node node) + { + Nodes.Add(node); + } + + /// + /// Adds a connection between nodes in the graph. + /// + /// The connection to add. + public virtual void AddConnection(Connection connection) + { + Connections.Add(connection); + + connection.OutputPort.Connect(connection.InputPort); + + if (Validation.Enabled) + { + ValidateNoLoops(connection); + } + } + + internal void FinalizeConnections() + { + FinalizeNodePorts(EntryNode); + + for (var i = 0; i < Nodes.Count; i++) + { + FinalizeNodePorts(Nodes[i]); + } + } + + private static void FinalizeNodePorts(Node node) + { + for (var i = 0; i < node.OutputPorts.Length; i++) + { + node.OutputPorts[i].FinalizeConnections(); + } + } + + private static bool IsLoopDetected(Node nextNode, byte nextEntryMode, Node targetNode, byte targetInputIndex) + { + return nextNode.NodeID == targetNode.NodeID && nextEntryMode == targetInputIndex; + } + + private void ValidateNoLoops(Connection newConnection) + { + Node? targetNode = newConnection.InputPort.OwnerNode; + + if (targetNode is null) + { + return; + } + + var targetInputIndex = newConnection.InputPort.Index; + + // The visited set tracks (NodeId, EntryMode) where EntryMode is either a specific input port index (for message + // propagation) or DisableSubgraphEntry (for disable-subgraph cascades). + var visited = new HashSet(); + var stack = new Stack(); + + stack.Push(new NodeEntryState(targetNode, targetInputIndex)); + + while (stack.Count > 0) + { + NodeEntryState entry = stack.Pop(); + + if (!visited.Add(new NodeEntryKey(entry.Node.NodeID, entry.EntryMode))) + { + continue; + } + + if (entry.EntryMode == DisableSubgraphEntry) + { + // Disable-subgraph entry: the node's BeforeDisable may fire regular messages on some ports, then ALL + // output ports propagate the disable-subgraph signal downstream. + EnqueueMessagePortEdges( + entry.Node.GetMessagePortsOnDisable(), + entry.Node, + stack, + targetNode, + targetInputIndex, + newConnection); + + EnqueueDisableSubgraphEdges( + entry.Node, + stack, + targetNode, + targetInputIndex, + newConnection); + } + else + { + // Message entry on a specific input port: follow the declared reachable output ports. + EnqueueReachablePortEdges( + entry.EntryMode, + entry.Node, + stack, + targetNode, + targetInputIndex, + newConnection); + } + } + } + + private void EnqueueReachablePortEdges( + byte inputIndex, + Node current, + Stack stack, + Node targetNode, + byte targetInputIndex, + Connection newConnection) + { + foreach (var outputIndex in current.GetReachableOutputPorts(inputIndex)) + { + if (outputIndex < 0 || outputIndex >= current.OutputPorts.Length) + { + continue; + } + + OutputPort outputPort = current.OutputPorts[outputIndex]; + + for (var connectionIndex = 0; connectionIndex < outputPort.ConnectionCount; connectionIndex++) + { + InputPort connectedInput = outputPort.GetConnectedPort(connectionIndex); + Node? nextNode = connectedInput.OwnerNode; + + if (nextNode is null) + { + continue; + } + + // SubgraphPorts that appear in GetReachableOutputPorts use EmitMessage (regular message), not + // EmitDisableSubgraphMessage. The caller (HandleMessage) calls OutputPorts[SubgraphPort].EmitMessage() + // which triggers ReceiveMessage on the target. + var nextEntryMode = connectedInput.Index; + + if (IsLoopDetected(nextNode, nextEntryMode, targetNode, targetInputIndex)) + { + RejectConnection(newConnection, current, outputIndex, targetNode, targetInputIndex); + return; + } + + stack.Push(new NodeEntryState(nextNode, nextEntryMode)); + } + } + } + + private void EnqueueMessagePortEdges( + IEnumerable messagePortIndices, + Node current, + Stack stack, + Node targetNode, + byte targetInputIndex, + Connection newConnection) + { + foreach (var outputIndex in messagePortIndices) + { + if (outputIndex < 0 || outputIndex >= current.OutputPorts.Length) + { + continue; + } + + OutputPort outputPort = current.OutputPorts[outputIndex]; + + for (var connectionIndex = 0; connectionIndex < outputPort.ConnectionCount; connectionIndex++) + { + InputPort connectedInput = outputPort.GetConnectedPort(connectionIndex); + Node? nextNode = connectedInput.OwnerNode; + + if (nextNode is null) + { + continue; + } + + var nextEntryMode = connectedInput.Index; + + if (IsLoopDetected(nextNode, nextEntryMode, targetNode, targetInputIndex)) + { + RejectConnection(newConnection, current, outputIndex, targetNode, targetInputIndex); + return; + } + + stack.Push(new NodeEntryState(nextNode, nextEntryMode)); + } + } + } + + private void EnqueueDisableSubgraphEdges( + Node current, + Stack stack, + Node targetNode, + byte targetInputIndex, + Connection newConnection) + { + for (var outputIndex = 0; outputIndex < current.OutputPorts.Length; outputIndex++) + { + OutputPort outputPort = current.OutputPorts[outputIndex]; + + for (var connectionIndex = 0; connectionIndex < outputPort.ConnectionCount; connectionIndex++) + { + InputPort connectedInput = outputPort.GetConnectedPort(connectionIndex); + Node? nextNode = connectedInput.OwnerNode; + + if (nextNode is null) + { + continue; + } + + if (IsLoopDetected(nextNode, DisableSubgraphEntry, targetNode, targetInputIndex)) + { + RejectConnection(newConnection, current, outputIndex, targetNode, targetInputIndex); + return; + } + + stack.Push(new NodeEntryState(nextNode, DisableSubgraphEntry)); + } + } + } + + private void RejectConnection( + Connection connection, + Node throughNode, + int outputIndex, + Node targetNode, + byte targetInputIndex) + { + Connections.Remove(connection); + connection.OutputPort.Disconnect(connection.InputPort); + Validation.Fail( + $"Adding this connection creates a loop: the message path from node '{targetNode.GetType().Name}' (input " + + $"port {targetInputIndex}) reaches back to itself through node '{throughNode.GetType().Name}' (output " + + $"port {outputIndex})."); + } + + private readonly record struct NodeEntryKey(Guid NodeId, byte EntryMode); + + private readonly record struct NodeEntryState(Node Node, byte EntryMode); +} diff --git a/Forge/Statescript/GraphAbilityBehavior.Data.cs b/Forge/Statescript/GraphAbilityBehavior.Data.cs new file mode 100644 index 0000000..11a7b8a --- /dev/null +++ b/Forge/Statescript/GraphAbilityBehavior.Data.cs @@ -0,0 +1,28 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Abilities; + +namespace Gamesmiths.Forge.Statescript; + +/// +/// An implementation that drives an ability's lifecycle through a +/// , with support for strongly-typed activation data. The maps +/// fields into graph variables before the graph begins processing, allowing nodes to +/// consume activation data through the standard property/variable system. +/// +/// The type of the activation data expected from the ability system. +/// The graph definition to execute when the ability activates. +/// A delegate that writes fields into graph +/// before the graph starts. Use to map each relevant field to +/// the graph variable expected by the graph's nodes. +public class GraphAbilityBehavior(Graph graph, Action dataBinder) + : GraphAbilityBehavior(graph), IAbilityBehavior +{ + private readonly Action _dataBinder = dataBinder; + + /// + public void OnStarted(AbilityBehaviorContext context, TData data) + { + StartGraph(context, x => _dataBinder(data, x)); + } +} diff --git a/Forge/Statescript/GraphAbilityBehavior.cs b/Forge/Statescript/GraphAbilityBehavior.cs new file mode 100644 index 0000000..d9c87eb --- /dev/null +++ b/Forge/Statescript/GraphAbilityBehavior.cs @@ -0,0 +1,67 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Abilities; + +namespace Gamesmiths.Forge.Statescript; + +/// +/// An implementation that drives an ability's lifecycle through a . +/// When the ability starts, the graph begins processing; when the graph completes naturally (all state nodes +/// deactivate) or reaches an , the ability instance ends automatically. Canceling the +/// ability stops the graph immediately. +/// +/// +/// Because has no update method, the caller must tick the +/// each frame via . A typical pattern is to call it from the entity's game loop +/// alongside . +/// +/// The graph definition to execute when the ability activates. +public class GraphAbilityBehavior(Graph graph) : IAbilityBehavior +{ + /// + /// Gets the that drives this behavior. Callers must invoke + /// each frame to advance time-dependent state nodes. + /// + public GraphProcessor Processor { get; } = new GraphProcessor(graph); + + /// + public void OnStarted(AbilityBehaviorContext context) + { + StartGraph(context); + } + + /// + public void OnEnded(AbilityBehaviorContext context) + { + StopGraph(); + } + + /// + /// Starts the graph processor, wiring up the callback to + /// automatically end the ability instance when the graph finishes. + /// + /// The ability behavior context for the current activation. + /// An optional callback to overwrite graph variable values with runtime data before + /// the graph's entry node fires. + protected void StartGraph(AbilityBehaviorContext context, Action? variableOverrides = null) + { + Processor.GraphContext.SharedVariables = context.Owner.SharedVariables; + Processor.GraphContext.ActivationContext = context; + Processor.OnGraphCompleted = context.InstanceHandle.End; + Processor.StartGraph(variableOverrides); + } + + /// + /// Stops the graph processor if it is currently running. Clears the + /// callback first to prevent re-entrant calls when triggers during the stop cascade. + /// + protected void StopGraph() + { + Processor.OnGraphCompleted = null; + + if (Processor.GraphContext.HasStarted) + { + Processor.StopGraph(); + } + } +} diff --git a/Forge/Statescript/GraphContext.cs b/Forge/Statescript/GraphContext.cs new file mode 100644 index 0000000..2e6e061 --- /dev/null +++ b/Forge/Statescript/GraphContext.cs @@ -0,0 +1,124 @@ +// Copyright © Gamesmiths Guild. + +using System.Diagnostics.CodeAnalysis; +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Holds all mutable runtime state for a single graph execution instance. Each owns one +/// , providing independent state (variables, node contexts, activation flags) so that +/// multiple processors can share the same immutable definition (Flyweight pattern). +/// +public sealed class GraphContext +{ + private readonly Dictionary _nodeContexts = []; + + /// + /// Gets a value indicating whether the graph is currently active. A graph is considered active if it has at least + /// one active state node. + /// + public bool IsActive => ActiveStateNodes.Count > 0; + + /// + /// Gets or sets the optional shared variables for this graph execution. When the graph is driven by an ability, + /// this is set to the owner entity's , allowing property resolvers and + /// nodes to access entity-level shared state. For standalone graphs this may be or set to + /// a custom instance. + /// + public Variables? SharedVariables { get; set; } + + /// + /// Gets or sets optional activation context data for this graph execution. This provides a generic extensibility + /// point that allows external systems to pass contextual data into the graph without coupling + /// to specific subsystems. For example, when a graph is driven by an ability, + /// is stored here so that ability-aware nodes can access the ability + /// handle for operations like committing cooldowns or costs. + /// + public object? ActivationContext { get; set; } + + /// + /// Gets the runtime variables for this graph execution instance. These are initialized from the graph's variable + /// definitions when the graph starts, providing each execution with independent state. + /// + public Variables GraphVariables { get; } = new Variables(); + + internal Dictionary InternalNodeActivationStatus { get; } = []; + + internal HashSet ActiveStateNodes { get; } = []; + + internal GraphProcessor? Processor { get; set; } + + internal bool HasStarted { get; set; } + + internal int NodeContextCount => _nodeContexts.Count; + + /// + /// Attempts to retrieve the as a specific type. This is the recommended way for + /// nodes to access activation context data, providing a safe pattern that gracefully handles both missing and + /// mismatched data. + /// + /// The expected type of the activation context. + /// When this method returns , contains the activation context cast to + /// ; otherwise, . + /// if is not and is of type + /// ; otherwise, . + public bool TryGetActivationContext([NotNullWhen(true)] out T? data) + where T : class + { + if (ActivationContext is T typed) + { + data = typed; + return true; + } + + data = null; + return false; + } + + /// + /// Gets the node context of type T for the specified node ID. The context is guaranteed to exist because the + /// framework creates it automatically when the node first receives a message, before any user code runs. + /// + /// The type of the node context to get. Must implement and have a + /// parameterless constructor. + /// The unique identifier of the node for which to get the context. + /// The node context associated with the specified node ID. + /// Thrown if no context exists for the given node ID. This indicates + /// a framework bug, as contexts are created automatically during node activation. + public T GetNodeContext(Guid nodeID) + where T : class, INodeContext, new() + { + if (_nodeContexts.TryGetValue(nodeID, out INodeContext? context)) + { + return (T)context; + } + + throw new InvalidOperationException( + $"Node context of type {typeof(T).Name} not found for node ID {nodeID}. " + + "This should never happen as contexts are created automatically by the framework."); + } + + internal T GetOrCreateNodeContext(Guid nodeID) + where T : INodeContext, new() + { + if (_nodeContexts.TryGetValue(nodeID, out INodeContext? context)) + { + return (T)context; + } + + var newContext = new T(); + _nodeContexts[nodeID] = newContext; + return newContext; + } + + internal bool HasNodeContext(Guid nodeID) + { + return _nodeContexts.ContainsKey(nodeID); + } + + internal void RemoveAllNodeContext() + { + _nodeContexts.Clear(); + } +} diff --git a/Forge/Statescript/GraphProcessor.cs b/Forge/Statescript/GraphProcessor.cs new file mode 100644 index 0000000..481ad45 --- /dev/null +++ b/Forge/Statescript/GraphProcessor.cs @@ -0,0 +1,138 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Provides functionality to execute and manage the lifecycle of a graph within a specified context. +/// +/// +/// The class pairs a shared, immutable definition with a +/// per-execution that holds all mutable runtime state (variable values, node contexts, +/// activation flags). Multiple processors can share the same instance, each with its own context +/// (Flyweight pattern). +/// A is reusable: after a graph completes naturally or is explicitly stopped, +/// it can be started again with a fresh execution cycle. +/// +public class GraphProcessor +{ + private readonly List _updateBuffer = []; + + /// + /// Gets the graph that this processor is responsible for executing. + /// + public Graph Graph { get; } + + /// + /// Gets the context in which the graph is executed. The context holds all mutable runtime state including variable + /// values, node contexts, and activation status. + /// + public GraphContext GraphContext { get; } + + /// + /// Gets or sets an optional callback that is invoked when the graph completes naturally (i.e., all state nodes + /// have deactivated) or when the graph is explicitly stopped. This allows external systems to react to graph + /// completion without polling. + /// + public Action? OnGraphCompleted { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The graph to be executed by this processor. + /// Optional shared variables for this graph execution. When set, property resolvers + /// such as can read entity-level shared state. + public GraphProcessor(Graph graph, Variables? sharedVariables = null) + { + Graph = graph; + GraphContext = new GraphContext { SharedVariables = sharedVariables }; + } + + /// + /// Starts the execution of the graph. This method initializes the context's runtime variables from the graph's + /// variable definitions, ensuring that each execution instance has independent state, and then initiates the + /// graph's entry node to begin processing. + /// + /// An optional callback invoked after variables are initialized from definitions + /// but before the graph's entry node begins processing. Use this to overwrite specific variable values with + /// runtime data (e.g., activation context from an ability). + public void StartGraph(Action? variableOverrides = null) + { + GraphContext.Processor = this; + GraphContext.HasStarted = true; + GraphContext.GraphVariables.InitializeFrom(Graph.VariableDefinitions); + variableOverrides?.Invoke(GraphContext.GraphVariables); + Graph.FinalizeConnections(); + Graph.EntryNode.StartGraph(GraphContext); + + // If no state nodes were activated during the initial message propagation (e.g., action-only graphs), the graph + // is already complete. + if (GraphContext.HasStarted && !GraphContext.IsActive) + { + FinalizeGraph(); + } + } + + /// + /// Updates all active state nodes in the graph with the given delta time. Only state nodes that are currently + /// active are updated, avoiding unnecessary iteration over inactive nodes. Call this method in your game loop to + /// drive time-dependent state node logic such as timers, animations, or continuous evaluation. + /// + /// The time elapsed since the last update, in seconds. + public void UpdateGraph(double deltaTime) + { + if (!GraphContext.HasStarted) + { + return; + } + + _updateBuffer.Clear(); + _updateBuffer.AddRange(GraphContext.ActiveStateNodes); + + for (var i = 0; i < _updateBuffer.Count; i++) + { + _updateBuffer[i].Update(deltaTime, GraphContext); + } + } + + /// + /// Stops the execution of the graph. This method calls the entry node's stop method to halt the graph's processing + /// and then removes all node contexts from the graph context to clean up any state associated with the graph's + /// execution. This method is safe to call re-entrantly (e.g., from an triggered + /// during the disable cascade). + /// + public void StopGraph() + { + if (GraphContext.Processor != this) + { + return; + } + + GraphContext.Processor = null; + GraphContext.HasStarted = false; + Graph.EntryNode.StopGraph(GraphContext); + GraphContext.ActiveStateNodes.Clear(); + GraphContext.InternalNodeActivationStatus.Clear(); + GraphContext.RemoveAllNodeContext(); + OnGraphCompleted?.Invoke(); + } + + /// + /// Finalizes the graph execution after all state nodes have naturally deactivated. Unlike , + /// this does not propagate disable messages through the entry node since all nodes have already deactivated through + /// their normal lifecycle. This method clears remaining runtime state (node contexts, activation status) so the GC + /// can reclaim memory. + /// + internal void FinalizeGraph() + { + if (!GraphContext.HasStarted) + { + return; + } + + GraphContext.HasStarted = false; + GraphContext.Processor = null; + GraphContext.InternalNodeActivationStatus.Clear(); + GraphContext.RemoveAllNodeContext(); + OnGraphCompleted?.Invoke(); + } +} diff --git a/Forge/Statescript/GraphVariableDefinitions.cs b/Forge/Statescript/GraphVariableDefinitions.cs new file mode 100644 index 0000000..d4c6496 --- /dev/null +++ b/Forge/Statescript/GraphVariableDefinitions.cs @@ -0,0 +1,135 @@ +// Copyright © Gamesmiths Guild. + +using System.Numerics; +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Statescript.Properties; + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Defines the schema of variables and properties for a graph. This is immutable definition data that belongs to the +/// , it contains no runtime state. When a graph execution starts, a runtime +/// instance is created from these definitions and placed into the . +/// +/// +/// Variables and properties are both stored as s. A variable is simply a property +/// backed by a (mutable ), while read-only properties use other +/// implementations. +/// +public class GraphVariableDefinitions +{ + /// + /// Gets the list of property definitions for the graph. + /// + public List Definitions { get; } = []; + + /// + /// Adds a mutable variable definition with the specified name and initial value. At runtime, a fresh + /// is created for each graph execution with the given initial value. + /// + /// The type of the initial value. Must be supported by . + /// The name of the variable. + /// The initial value of the variable. + /// Thrown if the type T is not supported by . + /// + public void DefineVariable(StringKey name, T initialValue) + { + Variant128 variant = initialValue switch + { + bool @bool => new Variant128(@bool), + byte @byte => new Variant128(@byte), + sbyte @sbyte => new Variant128(@sbyte), + char @char => new Variant128(@char), + decimal @decimal => new Variant128(@decimal), + double @double => new Variant128(@double), + float @float => new Variant128(@float), + int @int => new Variant128(@int), + uint @uint => new Variant128(@uint), + long @long => new Variant128(@long), + ulong @ulong => new Variant128(@ulong), + short @short => new Variant128(@short), + ushort @ushort => new Variant128(@ushort), + Vector2 vector2 => new Variant128(vector2), + Vector3 vector3 => new Variant128(vector3), + Vector4 vector4 => new Variant128(vector4), + Plane plane => new Variant128(plane), + Quaternion quaternion => new Variant128(quaternion), + _ => throw new ArgumentException($"{typeof(T)} is not supported by Variant128"), + }; + + Definitions.Add(new PropertyDefinition(name, new VariantResolver(variant, typeof(T)))); + } + + /// + /// Adds a mutable array variable definition with the specified name and initial values. At runtime, a fresh + /// is created for each graph execution with copies of the initial values. + /// + /// The type of each element. Must be supported by . + /// The name of the array variable. + /// The initial values for the array elements. + /// Thrown if the type T is not supported by . + /// + public void DefineArrayVariable(StringKey name, params T[] initialValues) + { + var variants = new Variant128[initialValues.Length]; + for (var i = 0; i < initialValues.Length; i++) + { + variants[i] = initialValues[i] switch + { + bool @bool => new Variant128(@bool), + byte @byte => new Variant128(@byte), + sbyte @sbyte => new Variant128(@sbyte), + char @char => new Variant128(@char), + decimal @decimal => new Variant128(@decimal), + double @double => new Variant128(@double), + float @float => new Variant128(@float), + int @int => new Variant128(@int), + uint @uint => new Variant128(@uint), + long @long => new Variant128(@long), + ulong @ulong => new Variant128(@ulong), + short @short => new Variant128(@short), + ushort @ushort => new Variant128(@ushort), + Vector2 vector2 => new Variant128(vector2), + Vector3 vector3 => new Variant128(vector3), + Vector4 vector4 => new Variant128(vector4), + Plane plane => new Variant128(plane), + Quaternion quaternion => new Variant128(quaternion), + _ => throw new ArgumentException($"{typeof(T)} is not supported by Variant128"), + }; + } + + Definitions.Add(new PropertyDefinition(name, new ArrayVariableResolver(variants, typeof(T)))); + } + + /// + /// Adds a read-only property definition with the specified name and resolver. + /// + /// The name of the property. + /// The resolver used to compute the property's value at runtime. + public void DefineProperty(StringKey name, IPropertyResolver resolver) + { + Definitions.Add(new PropertyDefinition(name, resolver)); + } + + /// + /// Validates that the property or variable with the specified name produces a value assignable to the expected + /// type. This should be called at graph construction time to catch type mismatches early (e.g., binding a + /// to a timer duration that expects a numeric type). + /// + /// The name of the property or variable to validate. + /// The expected value type. + /// if the property exists and its value type is assignable to the expected type; + /// otherwise. + public bool ValidatePropertyType(StringKey name, Type expectedType) + { + foreach (PropertyDefinition definition in Definitions) + { + if (definition.Name == name) + { + return expectedType.IsAssignableFrom(definition.Resolver.ValueType); + } + } + + return false; + } +} diff --git a/Forge/Statescript/INodeContext.cs b/Forge/Statescript/INodeContext.cs new file mode 100644 index 0000000..2e74cdd --- /dev/null +++ b/Forge/Statescript/INodeContext.cs @@ -0,0 +1,9 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Interface representing the context of a node during graph execution, providing access to necessary information and +/// services for node processing. +/// +public interface INodeContext; diff --git a/Forge/Statescript/Node.cs b/Forge/Statescript/Node.cs new file mode 100644 index 0000000..ce43882 --- /dev/null +++ b/Forge/Statescript/Node.cs @@ -0,0 +1,196 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Statescript.Ports; + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Base class for all Nodes in the Statescript system. +/// +public abstract class Node +{ + /// + /// Defines the input and output ports for this node by adding them to the provided lists. + /// + /// + /// Subclasses must override this method to declare their ports. The base class handles finalization. + /// + /// The list to add input ports to. + /// The list to add output ports to. + protected abstract void DefinePorts(List inputPorts, List outputPorts); + + /// + /// Gets or sets the unique identifier for this node. + /// + public Guid NodeID { get; set; } + + /// + /// Gets the input ports of this node. + /// + public InputPort[] InputPorts { get; } + + /// + /// Gets the output ports of this node. + /// + public OutputPort[] OutputPorts { get; } + + /// + /// Gets the subgraph output ports of this node. This is a cached subset of containing + /// only instances, built once during construction to avoid runtime type checks. + /// + internal SubgraphPort[] SubgraphPorts { get; } + + /// + /// Initializes a new instance of the class. + /// + protected Node() + { + NodeID = Guid.NewGuid(); + + var inputPorts = new List(); + var outputPorts = new List(); + +#pragma warning disable S1699 // Constructors should only call non-overridable methods + DefinePorts(inputPorts, outputPorts); +#pragma warning restore S1699 // Constructors should only call non-overridable methods + + foreach (InputPort inputPort in inputPorts) + { + inputPort.SetOwnerNode(this); + } + + InputPorts = [.. inputPorts]; + OutputPorts = [.. outputPorts]; + SubgraphPorts = [.. OutputPorts.OfType()]; + } + + internal void OnMessageReceived( + InputPort receiverPort, + GraphContext graphContext) + { + graphContext.InternalNodeActivationStatus[NodeID] = true; + + HandleMessage(receiverPort, graphContext); + } + + internal void OnSubgraphDisabledMessageReceived(GraphContext graphContext) + { + if (!graphContext.InternalNodeActivationStatus.TryAdd(NodeID, false)) + { + if (!graphContext.InternalNodeActivationStatus[NodeID]) + { + return; + } + + graphContext.InternalNodeActivationStatus[NodeID] = false; + } + + BeforeDisable(graphContext); + + foreach (OutputPort outputPort in OutputPorts) + { + outputPort.InternalEmitDisableSubgraphMessage(graphContext); + } + + AfterDisable(graphContext); + } + + /// + /// Returns the indices of output ports that may emit a message when the specified input port receives a message. + /// Used at graph construction time for static loop detection. + /// + /// + /// The default implementation returns all output ports for any input, which is a safe over-approximation. + /// Subclasses should override this to provide precise mappings for accurate cycle detection. + /// + /// The index of the input port that received the message. + /// An enumerable of output port indices that may fire in response. + internal virtual IEnumerable GetReachableOutputPorts(byte inputPortIndex) + { + for (var i = 0; i < OutputPorts.Length; i++) + { + yield return i; + } + } + + /// + /// Returns the output ports that emit regular messages (not disable-subgraph messages) when this node + /// receives a disable-subgraph signal. Used at graph construction time for static loop detection. + /// + /// + /// When a node receives a disable-subgraph signal, two things happen: (1) some output ports may emit regular + /// messages (e.g., OnDeactivatePort on a ), and (2) all output ports + /// propagate the disable-subgraph signal downstream. This method returns only category (1): the ports that emit + /// regular messages, which can trigger on downstream nodes. + /// The default implementation returns empty (no regular messages emitted during disable), which is correct + /// for and . + /// + /// An enumerable of output port indices that emit regular messages during disable. + internal virtual IEnumerable GetMessagePortsOnDisable() + { + return []; + } + + /// + /// Updates this node with the given delta time. The default implementation does nothing. + /// Override in subclasses that need per-frame or per-tick logic (e.g., ). + /// + /// The time elapsed since the last update, in seconds. + /// The graph context. + internal virtual void Update(double deltaTime, GraphContext graphContext) + { + } + + /// + /// Creates a port of the specified type with the given index. + /// + /// The type of port to create. + /// The index of the port. + /// The created port. + protected static T CreatePort(byte index) + where T : Port, new() + { + return new T + { + Index = index, + }; + } + + /// + /// Emits a message from the specified output ports. + /// + /// The graph context. + /// The IDs of the output ports to emit the message from. + protected virtual void EmitMessage(GraphContext graphContext, params int[] portIds) + { + foreach (var portId in portIds) + { + OutputPorts[portId].EmitMessage(graphContext); + } + } + + /// + /// Handles an incoming message on the specified input port. + /// + /// The input port that received the message. + /// The graph context. + protected virtual void HandleMessage(InputPort receiverPort, GraphContext graphContext) + { + } + + /// + /// Called before the node is disabled. + /// + /// The graph context. + protected virtual void BeforeDisable(GraphContext graphContext) + { + } + + /// + /// Called after the node is disabled. + /// + /// The graph context. + protected virtual void AfterDisable(GraphContext graphContext) + { + } +} diff --git a/Forge/Statescript/Nodes/Action/SetVariableNode.cs b/Forge/Statescript/Nodes/Action/SetVariableNode.cs new file mode 100644 index 0000000..1969a4f --- /dev/null +++ b/Forge/Statescript/Nodes/Action/SetVariableNode.cs @@ -0,0 +1,36 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Statescript.Nodes.Action; + +/// +/// An action node that writes a value to a graph variable. The value is read from a source property (which can be a +/// fixed variable, an entity attribute, or any other ) and written to a +/// target variable (which must be backed by a ). +/// +/// +/// The source and target are bound by property name at graph construction time. At runtime, the node resolves the +/// source property and writes its value to the target variable. +/// The source property is an input, it is only read. The target variable is an output, it is +/// only written. +/// +/// The name of the graph property to read from. +/// The name of the graph variable to write to. +public class SetVariableNode(StringKey sourcePropertyName, StringKey targetVariableName) : ActionNode +{ + private readonly StringKey _sourcePropertyName = sourcePropertyName; + + private readonly StringKey _targetVariableName = targetVariableName; + + /// + protected override void Execute(GraphContext graphContext) + { + if (!graphContext.GraphVariables.TryGetVariant(_sourcePropertyName, graphContext, out Variant128 value)) + { + return; + } + + graphContext.GraphVariables.SetVariant(_targetVariableName, value); + } +} diff --git a/Forge/Statescript/Nodes/ActionNode.cs b/Forge/Statescript/Nodes/ActionNode.cs new file mode 100644 index 0000000..12b045e --- /dev/null +++ b/Forge/Statescript/Nodes/ActionNode.cs @@ -0,0 +1,50 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Statescript.Ports; + +namespace Gamesmiths.Forge.Statescript.Nodes; + +/// +/// Node representing an action in the graph. It has a single input port that triggers the execution of the action and a +/// single output port that emits a message after the action is executed. +/// +public abstract class ActionNode : Node +{ + /// + /// Port index for the input port. + /// + public const byte InputPort = 0; + + /// + /// Port index for the output port. + /// + public const byte OutputPort = 0; + + /// + /// Executes the action associated with this node. This method is called when the input port receives a message. + /// + /// The current graph context. + protected abstract void Execute(GraphContext graphContext); + + /// +#pragma warning disable SA1202 // Elements should be ordered by access + internal override IEnumerable GetReachableOutputPorts(byte inputPortIndex) +#pragma warning restore SA1202 // Elements should be ordered by access + { + yield return OutputPort; + } + + /// + protected override void DefinePorts(List inputPorts, List outputPorts) + { + inputPorts.Add(CreatePort(InputPort)); + outputPorts.Add(CreatePort(OutputPort)); + } + + /// + protected override void HandleMessage(InputPort receiverPort, GraphContext graphContext) + { + Execute(graphContext); + OutputPorts[OutputPort].EmitMessage(graphContext); + } +} diff --git a/Forge/Statescript/Nodes/Condition/ExpressionConditionNode.cs b/Forge/Statescript/Nodes/Condition/ExpressionConditionNode.cs new file mode 100644 index 0000000..1fecf55 --- /dev/null +++ b/Forge/Statescript/Nodes/Condition/ExpressionConditionNode.cs @@ -0,0 +1,34 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Statescript.Nodes.Condition; + +/// +/// A concrete that evaluates a graph property to determine which +/// output port to activate. The property can be a simple variable, a , a +/// , or any arbitrarily nested +/// chain that produces a . +/// +/// +/// This node eliminates the need to create custom subclasses for data-driven +/// conditions. Instead of writing C# logic, the scripter composes an expression from resolvers at graph construction +/// time. +/// +/// The name of the graph property that provides the condition result. Must resolve +/// to a value. +public class ExpressionConditionNode(StringKey conditionPropertyName) : ConditionNode +{ + private readonly StringKey _conditionPropertyName = conditionPropertyName; + + /// + protected override bool Test(GraphContext graphContext) + { + if (!graphContext.GraphVariables.TryGet(_conditionPropertyName, graphContext, out bool result)) + { + return false; + } + + return result; + } +} diff --git a/Forge/Statescript/Nodes/ConditionNode.cs b/Forge/Statescript/Nodes/ConditionNode.cs new file mode 100644 index 0000000..4dabde9 --- /dev/null +++ b/Forge/Statescript/Nodes/ConditionNode.cs @@ -0,0 +1,64 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Statescript.Ports; + +namespace Gamesmiths.Forge.Statescript.Nodes; + +/// +/// Node representing a condition in the graph. It has a single input port that triggers the evaluation of the condition +/// and two output ports: one for the true result and one for the false result. +/// +public abstract class ConditionNode : Node +{ + /// + /// Port index for the input port. + /// + public const byte InputPort = 0; + + /// + /// Port index for the true output port. + /// + public const byte TruePort = 0; + + /// + /// Port index for the false output port. + /// + public const byte FalsePort = 1; + + /// + /// Tests the condition and returns true or false. The result determines which output port will emit a message. + /// + /// The current graph context. + /// if the condition is met; otherwise, . + protected abstract bool Test(GraphContext graphContext); + + /// +#pragma warning disable SA1202 // Elements should be ordered by access + internal override IEnumerable GetReachableOutputPorts(byte inputPortIndex) +#pragma warning restore SA1202 // Elements should be ordered by access + { + yield return TruePort; + yield return FalsePort; + } + + /// + protected override void DefinePorts(List inputPorts, List outputPorts) + { + inputPorts.Add(CreatePort(InputPort)); + outputPorts.Add(CreatePort(TruePort)); + outputPorts.Add(CreatePort(FalsePort)); + } + + /// + protected sealed override void HandleMessage(InputPort receiverPort, GraphContext graphContext) + { + if (Test(graphContext)) + { + OutputPorts[TruePort].EmitMessage(graphContext); + } + else + { + OutputPorts[FalsePort].EmitMessage(graphContext); + } + } +} diff --git a/Forge/Statescript/Nodes/EntryNode.cs b/Forge/Statescript/Nodes/EntryNode.cs new file mode 100644 index 0000000..f25a834 --- /dev/null +++ b/Forge/Statescript/Nodes/EntryNode.cs @@ -0,0 +1,53 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Statescript.Ports; + +namespace Gamesmiths.Forge.Statescript.Nodes; + +/// +/// Node representing the entry point of a graph. It has a single output port that emits a message to start the graph +/// execution. +/// +public class EntryNode : Node +{ + /// + /// Port index for the output port. + /// + public const byte OutputPort = 0; + + /// + /// Starts the graph execution by emitting a message through the output port. + /// + /// The graph context providing the runtime variables and execution state. + public void StartGraph(GraphContext graphContext) + { + OutputPorts[OutputPort].EmitMessage(graphContext); + } + + /// + /// Stops the graph execution by emitting a disable message through the output port. + /// + /// The graph context providing the runtime variables and execution state. + public void StopGraph(GraphContext graphContext) + { + ((SubgraphPort)OutputPorts[OutputPort]).EmitDisableSubgraphMessage(graphContext); + } + + /// + internal override IEnumerable GetReachableOutputPorts(byte inputPortIndex) + { + yield return OutputPort; + } + + /// + protected override void DefinePorts(List inputPorts, List outputPorts) + { + outputPorts.Add(CreatePort(OutputPort)); + } + + /// + protected override void HandleMessage(InputPort receiverPort, GraphContext graphContext) + { + throw new InvalidOperationException("EntryNode does not accept incoming messages."); + } +} diff --git a/Forge/Statescript/Nodes/ExitNode.cs b/Forge/Statescript/Nodes/ExitNode.cs new file mode 100644 index 0000000..7eefa8a --- /dev/null +++ b/Forge/Statescript/Nodes/ExitNode.cs @@ -0,0 +1,39 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Statescript.Ports; + +namespace Gamesmiths.Forge.Statescript.Nodes; + +/// +/// Node representing the exit point of a graph. When a message reaches this node, the entire graph execution is +/// stopped, all active state nodes are disabled, node contexts are removed, and the execution is finalized. +/// +/// +/// Place an at any point in the graph where you want to force the execution to end. This +/// has the same effect as calling externally. +/// +public class ExitNode : Node +{ + /// + /// Port index for the input port. + /// + public const byte InputPort = 0; + + /// + internal override IEnumerable GetReachableOutputPorts(byte inputPortIndex) + { + return []; + } + + /// + protected override void DefinePorts(List inputPorts, List outputPorts) + { + inputPorts.Add(CreatePort(InputPort)); + } + + /// + protected override void HandleMessage(InputPort receiverPort, GraphContext graphContext) + { + graphContext.Processor?.StopGraph(); + } +} diff --git a/Forge/Statescript/Nodes/State/TimerNode.cs b/Forge/Statescript/Nodes/State/TimerNode.cs new file mode 100644 index 0000000..315f317 --- /dev/null +++ b/Forge/Statescript/Nodes/State/TimerNode.cs @@ -0,0 +1,53 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Statescript.Nodes.State; + +/// +/// A state node that remains active for a configured duration, then deactivates. The duration is read from a graph +/// variable or property by name, allowing it to be a fixed value, driven by an entity attribute, or any other +/// . +/// +/// +/// The duration property must resolve to a value representing seconds. +/// The node accumulates elapsed time in its during +/// calls. When the elapsed time reaches or exceeds the duration, the node +/// deactivates itself. +/// +/// The name of the graph variable or property that provides the timer duration in +/// seconds. +public class TimerNode(StringKey durationPropertyName) : StateNode +{ + private readonly StringKey _durationPropertyName = durationPropertyName; + + /// + protected override void OnActivate(GraphContext graphContext) + { + TimerNodeContext nodeContext = graphContext.GetNodeContext(NodeID); + nodeContext.ElapsedTime = 0; + } + + /// + protected override void OnDeactivate(GraphContext graphContext) + { + } + + /// + protected override void OnUpdate(double deltaTime, GraphContext graphContext) + { + TimerNodeContext nodeContext = graphContext.GetNodeContext(NodeID); + + nodeContext.ElapsedTime += deltaTime; + + if (!graphContext.GraphVariables.TryGet(_durationPropertyName, graphContext, out double duration)) + { + return; + } + + if (nodeContext.ElapsedTime >= duration) + { + DeactivateNode(graphContext); + } + } +} diff --git a/Forge/Statescript/Nodes/State/TimerNodeContext.cs b/Forge/Statescript/Nodes/State/TimerNodeContext.cs new file mode 100644 index 0000000..e2e4f0b --- /dev/null +++ b/Forge/Statescript/Nodes/State/TimerNodeContext.cs @@ -0,0 +1,15 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Statescript.Nodes.State; + +/// +/// The context for a . Tracks elapsed time since activation so the node can determine when +/// its configured duration has been reached. +/// +public class TimerNodeContext : StateNodeContext +{ + /// + /// Gets or sets the elapsed time in seconds since the timer was activated. + /// + public double ElapsedTime { get; set; } +} diff --git a/Forge/Statescript/Nodes/StateNode.cs b/Forge/Statescript/Nodes/StateNode.cs new file mode 100644 index 0000000..07cdf19 --- /dev/null +++ b/Forge/Statescript/Nodes/StateNode.cs @@ -0,0 +1,308 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Statescript.Ports; + +namespace Gamesmiths.Forge.Statescript.Nodes; + +/// +/// Node representing a state in the graph. It has input ports for activation and abortion, output ports for activation, +/// deactivation, and abortion events, as well as a subgraph output port. +/// +/// The type of the state node context. +public abstract class StateNode : Node + where T : StateNodeContext, new() +{ + /// + /// Port index for the input port. + /// +#pragma warning disable RCS1158 // Static member in generic type should use a type parameter + public const byte InputPort = 0; + + /// + /// Port index for the abort port. + /// + public const byte AbortPort = 1; + + /// + /// Port index for the on activate port. + /// + public const byte OnActivatePort = 0; + + /// + /// Port index for the on deactivate port. + /// + public const byte OnDeactivatePort = 1; + + /// + /// Port index for the on abort port. + /// + public const byte OnAbortPort = 2; + + /// + /// Port index for the subgraph port. + /// + public const byte SubgraphPort = 3; +#pragma warning restore RCS1158 // Static member in generic type should use a type parameter + + /// + /// Called when the node is activated. + /// + /// The graph's context. + protected abstract void OnActivate(GraphContext graphContext); + + /// + /// Called when the node is deactivated. + /// + /// The graph's context. + protected abstract void OnDeactivate(GraphContext graphContext); + + /// + /// Updates this state node with the given delta time. Only processes the update if the node is currently active. + /// + /// The time elapsed since the last update, in seconds. + /// The graph's context. +#pragma warning disable SA1202 // Elements should be ordered by access + internal override void Update(double deltaTime, GraphContext graphContext) +#pragma warning restore SA1202 // Elements should be ordered by access + { + if (!graphContext.HasNodeContext(NodeID)) + { + return; + } + + StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); + + if (!nodeContext.Active) + { + return; + } + + OnUpdate(deltaTime, graphContext); + } + + /// + internal override IEnumerable GetReachableOutputPorts(byte inputPortIndex) + { + if (inputPortIndex == InputPort) + { + // InputPort fires OnActivatePort and SubgraphPort directly, and may fire OnDeactivatePort and custom + // EventPorts via deferred deactivation. + yield return OnActivatePort; + yield return OnDeactivatePort; + yield return SubgraphPort; + + for (var i = SubgraphPort + 1; i < OutputPorts.Length; i++) + { + yield return i; + } + } + else if (inputPortIndex == AbortPort) + { + // AbortPort fires OnAbortPort directly, then DeactivateNode fires OnDeactivatePort and all SubgraphPorts + // via BeforeDisable. + yield return OnDeactivatePort; + yield return OnAbortPort; + + for (var i = 0; i < SubgraphPorts.Length; i++) + { + yield return SubgraphPorts[i].Index; + } + } + } + + /// + internal override IEnumerable GetMessagePortsOnDisable() + { + // BeforeDisable fires OnDeactivatePort.EmitMessage() as a regular message. + yield return OnDeactivatePort; + } + + /// + /// Called every update tick while the node is active. Override this method to implement per-frame or per-tick logic + /// such as timers, animations, or continuous state evaluation. + /// + /// The time elapsed since the last update, in seconds. + /// The graph's context. + protected virtual void OnUpdate(double deltaTime, GraphContext graphContext) + { + } + + /// + protected override void DefinePorts(List inputPorts, List outputPorts) + { + inputPorts.Add(CreatePort(InputPort)); + inputPorts.Add(CreatePort(AbortPort)); + outputPorts.Add(CreatePort(OnActivatePort)); + outputPorts.Add(CreatePort(OnDeactivatePort)); + outputPorts.Add(CreatePort(OnAbortPort)); + outputPorts.Add(CreatePort(SubgraphPort)); + } + + /// + protected sealed override void HandleMessage(InputPort receiverPort, GraphContext graphContext) + { + if (receiverPort.Index == InputPort) + { + var nodeContext = (StateNodeContext)graphContext.GetOrCreateNodeContext(NodeID); + + nodeContext.Activating = true; + ActivateNode(graphContext); + OutputPorts[OnActivatePort].EmitMessage(graphContext); + OutputPorts[SubgraphPort].EmitMessage(graphContext); + nodeContext.Activating = false; + + HandleDeferredEmitMessages(graphContext, nodeContext); + HandleDeferredDeactivationMessages(graphContext, nodeContext); + } + else if (receiverPort.Index == AbortPort) + { + OutputPorts[OnAbortPort].EmitMessage(graphContext); + DeactivateNode(graphContext); + } + } + + /// + protected override void EmitMessage(GraphContext graphContext, params int[] portIds) + { + StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); + + if (nodeContext.Activating) + { + nodeContext.DeferredEmitMessageData.AddRange(portIds); + + return; + } + + base.EmitMessage(graphContext, portIds); + } + + /// + /// Deactivates the node and emits messages through the specified event ports. + /// + /// + /// If the node is currently in the process of activating, the deactivation and message emissions will be + /// deferred until activation is complete. This prevents race conditions during the activation process. + /// Use this method because it guarantees that the messages are fired in the right order. + /// OutputPort[OnDeactivatePort] (OnDeactivate) will always be called upon node deactivation and should not be + /// used here. + /// + /// The graph's context. + /// ID of ports you want to Emit a message to. + protected void DeactivateNodeAndEmitMessage(GraphContext graphContext, params int[] eventPortIds) + { + StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); + + if (nodeContext.Activating) + { + nodeContext.DeferredDeactivationEventPortIds = eventPortIds; + return; + } + + DeactivateNode(graphContext); + + for (var i = 0; i < eventPortIds.Length; i++) + { + Validation.Assert( + eventPortIds[i] > OnAbortPort, + "DeactivateNodeAndEmitMessage should be used only with custom ports."); + Validation.Assert( + OutputPorts[eventPortIds[i]] is EventPort, + "Only EventPorts can be used for deactivation events."); + OutputPorts[eventPortIds[i]].EmitMessage(graphContext); + } + } + + /// + /// Deactivates the node without emitting any custom messages. + /// + /// The graph's context. + protected void DeactivateNode(GraphContext graphContext) + { + BeforeDisable(graphContext); + + foreach (SubgraphPort subgraphPort in SubgraphPorts) + { + subgraphPort.EmitDisableSubgraphMessage(graphContext); + } + + AfterDisable(graphContext); + } + + /// + protected sealed override void BeforeDisable(GraphContext graphContext) + { + if (!graphContext.HasNodeContext(NodeID)) + { + return; + } + + StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); + + if (!nodeContext.Active) + { + return; + } + + base.BeforeDisable(graphContext); + + OutputPorts[OnDeactivatePort].EmitMessage(graphContext); + } + + /// + protected sealed override void AfterDisable(GraphContext graphContext) + { + if (!graphContext.HasNodeContext(NodeID)) + { + return; + } + + StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); + + if (!nodeContext.Active) + { + return; + } + + base.AfterDisable(graphContext); + + nodeContext.Active = false; + graphContext.ActiveStateNodes.Remove(this); + OnDeactivate(graphContext); + + if (graphContext.ActiveStateNodes.Count == 0) + { + graphContext.Processor?.FinalizeGraph(); + } + } + + private void ActivateNode(GraphContext graphContext) + { + StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); + nodeContext.Active = true; + graphContext.ActiveStateNodes.Add(this); + OnActivate(graphContext); + } + + private void HandleDeferredEmitMessages(GraphContext graphContext, StateNodeContext nodeContext) + { + if (nodeContext.DeferredEmitMessageData.Count > 0) + { + foreach (var emitEvent in nodeContext.DeferredEmitMessageData) + { + OutputPorts[emitEvent].EmitMessage(graphContext); + } + + nodeContext.DeferredEmitMessageData.Clear(); + } + } + + private void HandleDeferredDeactivationMessages(GraphContext graphContext, StateNodeContext nodeContext) + { + if (nodeContext.DeferredDeactivationEventPortIds is not null) + { + DeactivateNodeAndEmitMessage(graphContext, nodeContext.DeferredDeactivationEventPortIds); + nodeContext.DeferredDeactivationEventPortIds = null; + } + } +} diff --git a/Forge/Statescript/Nodes/StateNodeContext.cs b/Forge/Statescript/Nodes/StateNodeContext.cs new file mode 100644 index 0000000..48cc6f2 --- /dev/null +++ b/Forge/Statescript/Nodes/StateNodeContext.cs @@ -0,0 +1,22 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Statescript.Nodes; + +/// +/// The context for a . This class holds the state of the node, such as whether it is active +/// or in the process of activating, as well as any deferred messages that need to be emitted after activation is +/// complete. +/// +public class StateNodeContext : INodeContext +{ + /// + /// Gets a value indicating whether the node is active. + /// + public bool Active { get; internal set; } + + internal bool Activating { get; set; } + + internal int[]? DeferredDeactivationEventPortIds { get; set; } + + internal List DeferredEmitMessageData { get; set; } = []; +} diff --git a/Forge/Statescript/Port.cs b/Forge/Statescript/Port.cs new file mode 100644 index 0000000..cb91300 --- /dev/null +++ b/Forge/Statescript/Port.cs @@ -0,0 +1,19 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Base class for all Ports in the Statescript system. +/// +public abstract class Port +{ + /// + /// Gets or sets the unique identifier for this port. + /// + public Guid PortID { get; set; } + + /// + /// Gets or sets the index of this port. + /// + public byte Index { get; set; } +} diff --git a/Forge/Statescript/Ports/EventPort.cs b/Forge/Statescript/Ports/EventPort.cs new file mode 100644 index 0000000..d31525f --- /dev/null +++ b/Forge/Statescript/Ports/EventPort.cs @@ -0,0 +1,17 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Statescript.Ports; + +/// +/// Defines an event output port that can emit messages to connected input ports in the Statescript system. +/// +public class EventPort : OutputPort +{ + /// + /// Initializes a new instance of the class. + /// + public EventPort() + { + PortID = Guid.NewGuid(); + } +} diff --git a/Forge/Statescript/Ports/InputPort.cs b/Forge/Statescript/Ports/InputPort.cs new file mode 100644 index 0000000..6e8a8a2 --- /dev/null +++ b/Forge/Statescript/Ports/InputPort.cs @@ -0,0 +1,49 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Statescript.Ports; + +/// +/// Defines an input port that can receive messages in the Statescript system. +/// +public class InputPort : Port +{ + /// + /// Gets the node that owns this input port. + /// + internal Node? OwnerNode { get; private set; } + + /// + /// Initializes a new instance of the class. + /// + public InputPort() + { + PortID = Guid.NewGuid(); + } + + /// + /// Sets the owner node of this input port. + /// + /// The owner node to set. + public void SetOwnerNode(Node ownerNode) + { + OwnerNode = ownerNode; + } + + /// + /// Receives a message and notifies the owner node. + /// + /// The graph context for the message. + public void ReceiveMessage(GraphContext graphContext) + { + OwnerNode?.OnMessageReceived(this, graphContext); + } + + /// + /// Receives a disable subgraph message and notifies the owner node. + /// + /// The graph context for the message. + public void ReceiveDisableSubgraphMessage(GraphContext graphContext) + { + OwnerNode?.OnSubgraphDisabledMessageReceived(graphContext); + } +} diff --git a/Forge/Statescript/Ports/OutputPort.cs b/Forge/Statescript/Ports/OutputPort.cs new file mode 100644 index 0000000..cebf82a --- /dev/null +++ b/Forge/Statescript/Ports/OutputPort.cs @@ -0,0 +1,102 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Statescript.Ports; + +/// +/// Defines an output port that can emit messages to connected input ports in the Statescript system. +/// +public class OutputPort : Port +{ + /// + /// Event triggered when a message is emitted from this output port. + /// + public event Action? OnEmitMessage; + + /// + /// Event triggered when a disable subgraph message is emitted from this output port. + /// + public event Action? OnEmitDisableSubgraphMessage; + + /// + /// Gets the number of input ports connected to this output port. + /// + internal int ConnectionCount => FinalizedConnectedPorts?.Length ?? PendingConnectedPorts.Count; + + /// + /// Gets the finalized array of connected input ports. Only available after + /// has been called. + /// + protected internal InputPort[]? FinalizedConnectedPorts { get; private set; } + + private List PendingConnectedPorts { get; } + + /// + /// Initializes a new instance of the class. + /// + protected OutputPort() + { + PendingConnectedPorts = []; + } + + /// + /// Connects an input port to this output port. + /// + /// The input port to connect. + public void Connect(InputPort inputPort) + { + PendingConnectedPorts.Add(inputPort); + } + + /// + /// Gets the connected input port at the specified index. + /// + /// The zero-based index of the connected port. + /// The connected input port. + internal InputPort GetConnectedPort(int index) + { + return PendingConnectedPorts[index]; + } + + /// + /// Disconnects an input port from this output port. + /// + /// The input port to disconnect. + /// if the port was found and removed; otherwise. + internal bool Disconnect(InputPort inputPort) + { + return PendingConnectedPorts.Remove(inputPort); + } + + /// + /// Finalizes the connected ports list into a fixed array for optimal iteration performance. + /// Should be called after all connections have been established. + /// + internal void FinalizeConnections() + { + FinalizedConnectedPorts = [.. PendingConnectedPorts]; + } + + internal void EmitMessage(GraphContext graphContext) + { + InputPort[] ports = FinalizedConnectedPorts!; + + for (var i = 0; i < ports.Length; i++) + { + ports[i].ReceiveMessage(graphContext); + } + + OnEmitMessage?.Invoke(PortID); + } + + internal void InternalEmitDisableSubgraphMessage(GraphContext graphContext) + { + InputPort[] ports = FinalizedConnectedPorts!; + + for (var i = 0; i < ports.Length; i++) + { + ports[i].ReceiveDisableSubgraphMessage(graphContext); + } + + OnEmitDisableSubgraphMessage?.Invoke(PortID); + } +} diff --git a/Forge/Statescript/Ports/SubgraphPort.cs b/Forge/Statescript/Ports/SubgraphPort.cs new file mode 100644 index 0000000..3f09d6c --- /dev/null +++ b/Forge/Statescript/Ports/SubgraphPort.cs @@ -0,0 +1,31 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Statescript.Ports; + +/// +/// Defines a subgraph output port that can emit disable subgraph messages to connected input ports. +/// +public class SubgraphPort : OutputPort +{ + /// + /// Initializes a new instance of the class. + /// + public SubgraphPort() + { + PortID = Guid.NewGuid(); + } + + /// + /// Emits a disable subgraph message to all connected input ports. + /// + /// The graph context for the message. + public void EmitDisableSubgraphMessage(GraphContext graphContext) + { + InputPort[] ports = FinalizedConnectedPorts!; + + for (var i = 0; i < ports.Length; i++) + { + ports[i].ReceiveDisableSubgraphMessage(graphContext); + } + } +} diff --git a/Forge/Statescript/Properties/ArrayVariableResolver.cs b/Forge/Statescript/Properties/ArrayVariableResolver.cs new file mode 100644 index 0000000..1400bde --- /dev/null +++ b/Forge/Statescript/Properties/ArrayVariableResolver.cs @@ -0,0 +1,94 @@ +// Copyright © Gamesmiths Guild. + +using System.Collections.ObjectModel; + +namespace Gamesmiths.Forge.Statescript.Properties; + +/// +/// A mutable property resolver that stores an array of values. This enables graph variables +/// that hold multiple values, such as a list of entity IDs returned by a query node or an array of projectile +/// positions. +/// +/// +/// The method returns the first element of the array (or a default +/// if the array is empty). Use and for indexed access. +/// At graph initialization time, a fresh copy of the array is created for each execution instance so that +/// multiple processors sharing the same graph have independent array state. +/// +/// The initial values for the array elements. +/// The type of each element in the array. +public class ArrayVariableResolver(Variant128[] initialValues, Type elementType) : IPropertyResolver +{ + private readonly List _values = [.. initialValues]; + + /// + public Type ValueType { get; } = elementType; + + /// + /// Gets the number of elements in the array. + /// + public int Length => _values.Count; + + /// + /// Gets a read-only view of the current array values. + /// + public ReadOnlyCollection Values => _values.AsReadOnly(); + + /// + /// + /// Returns the first element of the array, or a default if the array is empty. + /// + public Variant128 Resolve(GraphContext graphContext) + { + return _values.Count > 0 ? _values[0] : default; + } + + /// + /// Gets the value at the specified index. + /// + /// The zero-based index of the element to get. + /// The value at the specified index. + /// Thrown if the index is out of range. + public Variant128 GetElement(int index) + { + return _values[index]; + } + + /// + /// Sets the value at the specified index. + /// + /// The zero-based index of the element to set. + /// The value to set. + /// Thrown if the index is out of range. + public void SetElement(int index, Variant128 value) + { + _values[index] = value; + } + + /// + /// Appends a value to the end of the array. + /// + /// The value to add. + public void Add(Variant128 value) + { + _values.Add(value); + } + + /// + /// Removes the element at the specified index. + /// + /// The zero-based index of the element to remove. + /// Thrown if the index is out of range. + public void RemoveAt(int index) + { + _values.RemoveAt(index); + } + + /// + /// Removes all elements from the array. + /// + public void Clear() + { + _values.Clear(); + } +} diff --git a/Forge/Statescript/Properties/AttributeResolver.cs b/Forge/Statescript/Properties/AttributeResolver.cs new file mode 100644 index 0000000..d27dcbc --- /dev/null +++ b/Forge/Statescript/Properties/AttributeResolver.cs @@ -0,0 +1,43 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Abilities; +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Statescript.Properties; + +/// +/// Resolves a property value by reading the current value of a specific attribute from the ability owner's entity. +/// Returns the attribute's as an stored in a +/// . +/// +/// +/// This resolver requires the graph to be driven by an ability. It retrieves the owner entity from the +/// stored in the graph's . +/// If the graph has no activation context, the activation context is not an , +/// or the owner does not have the specified attribute, the resolver returns a default +/// (zero). +/// +/// The fully qualified attribute key (e.g., "CombatAttributeSet.Health"). +public class AttributeResolver(StringKey attributeKey) : IPropertyResolver +{ + private readonly StringKey _attributeKey = attributeKey; + + /// + public Type ValueType => typeof(int); + + /// + public Variant128 Resolve(GraphContext graphContext) + { + if (!graphContext.TryGetActivationContext(out AbilityBehaviorContext? abilityContext)) + { + return default; + } + + if (!abilityContext.Owner.Attributes.ContainsAttribute(_attributeKey)) + { + return default; + } + + return new Variant128(abilityContext.Owner.Attributes[_attributeKey].CurrentValue); + } +} diff --git a/Forge/Statescript/Properties/ComparisonOperation.cs b/Forge/Statescript/Properties/ComparisonOperation.cs new file mode 100644 index 0000000..243bbdc --- /dev/null +++ b/Forge/Statescript/Properties/ComparisonOperation.cs @@ -0,0 +1,39 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Statescript.Properties; + +/// +/// Specifies the comparison operation to perform between two resolved values. +/// +public enum ComparisonOperation : byte +{ + /// + /// Tests whether the left operand is equal to the right operand. + /// + Equal = 0, + + /// + /// Tests whether the left operand is not equal to the right operand. + /// + NotEqual = 1, + + /// + /// Tests whether the left operand is less than the right operand. + /// + LessThan = 2, + + /// + /// Tests whether the left operand is less than or equal to the right operand. + /// + LessThanOrEqual = 3, + + /// + /// Tests whether the left operand is greater than the right operand. + /// + GreaterThan = 4, + + /// + /// Tests whether the left operand is greater than or equal to the right operand. + /// + GreaterThanOrEqual = 5, +} diff --git a/Forge/Statescript/Properties/ComparisonResolver.cs b/Forge/Statescript/Properties/ComparisonResolver.cs new file mode 100644 index 0000000..344114b --- /dev/null +++ b/Forge/Statescript/Properties/ComparisonResolver.cs @@ -0,0 +1,120 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Statescript.Properties; + +/// +/// Resolves a value by comparing the results of two nested instances +/// using a specified . Both operands are converted to for +/// comparison based on each resolver's , allowing any numeric property (int +/// attributes, float variables, etc.) to be compared directly. +/// +/// +/// This resolver enables data-driven expressions such as "is the entity's health greater than 50" or "is variable +/// A less than or equal to variable B" without requiring custom subclasses. +/// Operand resolvers can be any implementation, including other +/// instances, enabling arbitrarily nested expressions. +/// +/// The resolver for the left operand of the comparison. +/// The comparison operation to apply. +/// The resolver for the right operand of the comparison. +public class ComparisonResolver( + IPropertyResolver left, + ComparisonOperation operation, + IPropertyResolver right) : IPropertyResolver +{ + private readonly IPropertyResolver _left = left; + + private readonly ComparisonOperation _operation = operation; + + private readonly IPropertyResolver _right = right; + + /// + public Type ValueType => typeof(bool); + + /// + public Variant128 Resolve(GraphContext graphContext) + { + var leftValue = ResolveAsDouble(_left, graphContext); + var rightValue = ResolveAsDouble(_right, graphContext); + + var result = _operation switch + { +#pragma warning disable S1244 // Floating point numbers should not be tested for equality + ComparisonOperation.Equal => leftValue == rightValue, + ComparisonOperation.NotEqual => leftValue != rightValue, +#pragma warning restore S1244 // Floating point numbers should not be tested for equality + ComparisonOperation.LessThan => leftValue < rightValue, + ComparisonOperation.LessThanOrEqual => leftValue <= rightValue, + ComparisonOperation.GreaterThan => leftValue > rightValue, + ComparisonOperation.GreaterThanOrEqual => leftValue >= rightValue, + _ => false, + }; + + return new Variant128(result); + } + + private static double ResolveAsDouble(IPropertyResolver resolver, GraphContext graphContext) + { + Variant128 value = resolver.Resolve(graphContext); + + Type type = resolver.ValueType; + + if (type == typeof(double)) + { + return value.AsDouble(); + } + + if (type == typeof(int)) + { + return value.AsInt(); + } + + if (type == typeof(float)) + { + return value.AsFloat(); + } + + if (type == typeof(long)) + { + return value.AsLong(); + } + + if (type == typeof(short)) + { + return value.AsShort(); + } + + if (type == typeof(byte)) + { + return value.AsByte(); + } + + if (type == typeof(uint)) + { + return value.AsUInt(); + } + + if (type == typeof(ulong)) + { + return value.AsULong(); + } + + if (type == typeof(ushort)) + { + return value.AsUShort(); + } + + if (type == typeof(sbyte)) + { + return value.AsSByte(); + } + + if (type == typeof(decimal)) + { + return (double)value.AsDecimal(); + } + + throw new ArgumentException( + $"ComparisonResolver does not support operand type '{type}'. Only numeric types are allowed."); + } +} diff --git a/Forge/Statescript/Properties/IPropertyResolver.cs b/Forge/Statescript/Properties/IPropertyResolver.cs new file mode 100644 index 0000000..0770364 --- /dev/null +++ b/Forge/Statescript/Properties/IPropertyResolver.cs @@ -0,0 +1,22 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Statescript.Properties; + +/// +/// Interface for resolving a property value at runtime. +/// +public interface IPropertyResolver +{ + /// + /// Gets the of the value this resolver produces. Used for compile-time validation when binding + /// properties to node parameters (e.g., ensuring a timer duration is bound to a numeric property, not a bool). + /// + Type ValueType { get; } + + /// + /// Resolves the current value of the property. + /// + /// The graph context providing the runtime state and owner entity. + /// The resolved value as a . + Variant128 Resolve(GraphContext graphContext); +} diff --git a/Forge/Statescript/Properties/SharedVariableResolver.cs b/Forge/Statescript/Properties/SharedVariableResolver.cs new file mode 100644 index 0000000..1a12392 --- /dev/null +++ b/Forge/Statescript/Properties/SharedVariableResolver.cs @@ -0,0 +1,43 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Statescript.Properties; + +/// +/// Resolves a property value by reading a named variable from the graph context's +/// . When a graph is driven by an ability, these are the owner entity's +/// , enabling cross-ability communication (e.g., an "ability lock" flag +/// shared by all abilities on a hero). +/// +/// +/// If the graph context has no shared variables or the shared variables do not contain the specified name, the +/// resolver returns a default (zero). +/// Unlike which reads from per-graph-instance variables, this resolver reads from +/// the shared bag, allowing one ability's graph to read values written by another. +/// +/// The name of the shared variable to read. +/// The type of the value this resolver produces. +public class SharedVariableResolver(StringKey variableName, Type valueType) : IPropertyResolver +{ + private readonly StringKey _variableName = variableName; + + /// + public Type ValueType { get; } = valueType; + + /// + public Variant128 Resolve(GraphContext graphContext) + { + if (graphContext.SharedVariables is null) + { + return default; + } + + if (!graphContext.SharedVariables.TryGetVariant(_variableName, graphContext, out Variant128 value)) + { + return default; + } + + return value; + } +} diff --git a/Forge/Statescript/Properties/TagResolver.cs b/Forge/Statescript/Properties/TagResolver.cs new file mode 100644 index 0000000..2a81e1c --- /dev/null +++ b/Forge/Statescript/Properties/TagResolver.cs @@ -0,0 +1,37 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Abilities; +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Statescript.Properties; + +/// +/// Resolves a property value by checking whether the ability owner entity has a specific tag. Returns +/// stored in a ; if the entity has the tag, +/// otherwise. +/// +/// +/// This resolver requires the graph to be driven by an ability. It retrieves the owner entity from the +/// stored in the graph's . +/// If the graph has no activation context or the activation context is not an +/// , the resolver always returns . +/// +/// The tag to check for on the owner entity. +public class TagResolver(Tag tag) : IPropertyResolver +{ + private readonly Tag _tag = tag; + + /// + public Type ValueType => typeof(bool); + + /// + public Variant128 Resolve(GraphContext graphContext) + { + if (!graphContext.TryGetActivationContext(out AbilityBehaviorContext? abilityContext)) + { + return new Variant128(false); + } + + return new Variant128(abilityContext.Owner.Tags.CombinedTags.HasTag(_tag)); + } +} diff --git a/Forge/Statescript/Properties/VariableResolver.cs b/Forge/Statescript/Properties/VariableResolver.cs new file mode 100644 index 0000000..bcaec73 --- /dev/null +++ b/Forge/Statescript/Properties/VariableResolver.cs @@ -0,0 +1,38 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Statescript.Properties; + +/// +/// Resolves a property value by looking up another named variable or property from the graph's runtime +/// . This enables nested property references: a resolver that reads its value from another +/// resolver by name at runtime. +/// +/// +/// Unlike which holds a value directly, this resolver delegates to whatever +/// resolver is registered under the given name at runtime. The referenced property can be a mutable variable, an +/// entity attribute, or any other . +/// If the referenced property does not exist, the resolver returns a default +/// (zero). +/// +/// The name of the graph variable or property to resolve at runtime. +/// The type of the value this resolver produces. +public class VariableResolver(StringKey referencedPropertyName, Type valueType) : IPropertyResolver +{ + private readonly StringKey _referencedPropertyName = referencedPropertyName; + + /// + public Type ValueType { get; } = valueType; + + /// + public Variant128 Resolve(GraphContext graphContext) + { + if (!graphContext.GraphVariables.TryGetVariant(_referencedPropertyName, graphContext, out Variant128 value)) + { + return default; + } + + return value; + } +} diff --git a/Forge/Statescript/Properties/VariantResolver.cs b/Forge/Statescript/Properties/VariantResolver.cs new file mode 100644 index 0000000..e5c6d08 --- /dev/null +++ b/Forge/Statescript/Properties/VariantResolver.cs @@ -0,0 +1,74 @@ +// Copyright © Gamesmiths Guild. + +using System.Numerics; + +namespace Gamesmiths.Forge.Statescript.Properties; + +/// +/// A mutable property resolver that stores a value directly. This is the resolver used for +/// graph variables: properties that can be both read and written at runtime. +/// +/// The initial value of the variable. +/// The type of the value this variable holds. +public class VariantResolver(Variant128 initialValue, Type valueType) : IPropertyResolver +{ + /// + /// Gets or sets the current value of this variable. + /// + public Variant128 Value { get; set; } = initialValue; + + /// + public Type ValueType { get; } = valueType; + + /// + /// Creates a from a typed value. + /// + /// The type of the value. Must be supported by . + /// The value to convert. + /// A containing the value. + /// Thrown if the type T is not supported by . + /// + public static Variant128 CreateVariant(T value) + { + return value switch + { + bool @bool => new Variant128(@bool), + byte @byte => new Variant128(@byte), + sbyte @sbyte => new Variant128(@sbyte), + char @char => new Variant128(@char), + decimal @decimal => new Variant128(@decimal), + double @double => new Variant128(@double), + float @float => new Variant128(@float), + int @int => new Variant128(@int), + uint @uint => new Variant128(@uint), + long @long => new Variant128(@long), + ulong @ulong => new Variant128(@ulong), + short @short => new Variant128(@short), + ushort @ushort => new Variant128(@ushort), + Vector2 vector2 => new Variant128(vector2), + Vector3 vector3 => new Variant128(vector3), + Vector4 vector4 => new Variant128(vector4), + Plane plane => new Variant128(plane), + Quaternion quaternion => new Variant128(quaternion), + _ => throw new ArgumentException($"{typeof(T)} is not supported by Variant128"), + }; + } + + /// + public Variant128 Resolve(GraphContext graphContext) + { + return Value; + } + + /// + /// Sets the value from a typed input, converting it to a . + /// + /// The type of the value to set. Must be supported by . + /// The value to set. + /// Thrown if the type T is not supported by . + /// + public void Set(T value) + { + Value = CreateVariant(value); + } +} diff --git a/Forge/Statescript/PropertyDefinition.cs b/Forge/Statescript/PropertyDefinition.cs new file mode 100644 index 0000000..d29f1bc --- /dev/null +++ b/Forge/Statescript/PropertyDefinition.cs @@ -0,0 +1,14 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Statescript.Properties; + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Represents the definition of a graph property, including its name and the resolver used to compute its value at +/// runtime. This is immutable definition data that belongs to the graph. +/// +/// The name of the property, used as the lookup key at runtime. +/// The resolver used to provide the property's value at runtime. +public readonly record struct PropertyDefinition(StringKey Name, IPropertyResolver Resolver); diff --git a/Forge/Statescript/Variables.cs b/Forge/Statescript/Variables.cs new file mode 100644 index 0000000..4a9b898 --- /dev/null +++ b/Forge/Statescript/Variables.cs @@ -0,0 +1,290 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Statescript.Properties; + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Represents the runtime state of variables and properties during a graph execution. +/// +public class Variables +{ + private readonly Dictionary _propertyResolvers = []; + + /// + /// Initializes the runtime resolvers from a instance. For each variable + /// definition (backed by a ), a fresh resolver is created with the initial value so + /// that each graph execution has independent mutable state. Read-only property resolvers are shared directly since + /// they carry no mutable state. + /// + /// The graph variable definitions to initialize from. + public void InitializeFrom(GraphVariableDefinitions definitions) + { + _propertyResolvers.Clear(); + + foreach (PropertyDefinition definition in definitions.Definitions) + { + switch (definition.Resolver) + { + case VariantResolver variantResolver: + _propertyResolvers[definition.Name] = + new VariantResolver(variantResolver.Value, variantResolver.ValueType); + break; + + case ArrayVariableResolver arrayResolver: + var copy = new Variant128[arrayResolver.Length]; + for (var i = 0; i < arrayResolver.Length; i++) + { + copy[i] = arrayResolver.GetElement(i); + } + + _propertyResolvers[definition.Name] = + new ArrayVariableResolver(copy, arrayResolver.ValueType); + break; + + default: + _propertyResolvers[definition.Name] = definition.Resolver; + break; + } + } + } + + /// + /// Tries to get the resolved value of a variable or property with the given name. Variables return their stored + /// value; properties are resolved on demand from external sources. + /// + /// The type to interpret the value as. Must be supported by . + /// + /// The name of the variable or property to get. + /// The graph context used by resolvers to access external state. + /// The resolved value if the entry was found. + /// if the entry was found and resolved successfully, + /// otherwise. + public bool TryGet(StringKey name, GraphContext graphContext, out T value) + where T : unmanaged + { + value = default; + + if (!_propertyResolvers.TryGetValue(name, out IPropertyResolver? resolver)) + { + return false; + } + + Variant128 resolved = resolver.Resolve(graphContext); + value = resolved.Get(); + + return true; + } + + /// + /// Tries to get the resolved value of a variable or property as a raw . + /// + /// The name of the variable or property to get. + /// The graph context used by resolvers to access external state. + /// The resolved value if the entry was found. + /// if the entry was found and resolved successfully, + /// otherwise. + public bool TryGetVariant(StringKey name, GraphContext graphContext, out Variant128 value) + { + value = default; + + if (!_propertyResolvers.TryGetValue(name, out IPropertyResolver? resolver)) + { + return false; + } + + value = resolver.Resolve(graphContext); + return true; + } + + /// + /// Tries to get the value of a mutable variable with the given name. + /// + /// + /// This is a convenience overload for variables + /// (backed by ) that don't need the graph context for resolution. + /// + /// The type of the variable to get. Must be supported by . + /// The name of the variable to get. + /// The value of the variable if it was found. + /// if the variable was found and retrieved successfully, + /// otherwise. + public bool TryGetVar(StringKey name, out T value) + where T : unmanaged + { + value = default; + + if (!_propertyResolvers.TryGetValue(name, out IPropertyResolver? resolver)) + { + return false; + } + + Variant128 resolved = resolver.Resolve(null!); + value = resolved.Get(); + + return true; + } + + /// + /// Sets the value of a mutable variable with the given name. Only entries backed by a + /// can be set; attempting to set a read-only property will throw. + /// + /// The type of the value to set. Must be supported by . + /// The name of the variable to set. + /// The value to set the variable to. + /// if the variable was set successfully. + /// Thrown if the type T is not supported by . + /// + /// Thrown if the name refers to a read-only property. + public bool SetVar(StringKey name, T value) + { + if (!_propertyResolvers.TryGetValue(name, out IPropertyResolver? resolver)) + { + throw new InvalidOperationException( + $"Cannot set '{name}': no variable or property with this name exists."); + } + + if (resolver is not VariantResolver variableResolver) + { + throw new InvalidOperationException( + $"Cannot set '{name}': it is a read-only property. Only variables can be set at runtime."); + } + + variableResolver.Set(value); + return true; + } + + /// + /// Sets the raw value of a mutable variable with the given name. + /// + /// The name of the variable to set. + /// The raw variant value to set. + /// Thrown if the name does not exist or refers to a read-only property. + /// + public void SetVariant(StringKey name, Variant128 value) + { + if (!_propertyResolvers.TryGetValue(name, out IPropertyResolver? resolver)) + { + throw new InvalidOperationException( + $"Cannot set '{name}': no variable or property with this name exists."); + } + + if (resolver is not VariantResolver variableResolver) + { + throw new InvalidOperationException( + $"Cannot set '{name}': it is a read-only property. Only variables can be set at runtime."); + } + + variableResolver.Value = value; + } + + /// + /// Defines a new mutable variable directly in this bag. If a variable with the given name + /// already exists, its value is updated instead. + /// + /// + /// This is intended for entity-level shared variables that are not defined through a graph's + /// . For graph instance variables, use + /// instead. + /// + /// The type of the variable value. Must be supported by . + /// The name of the variable. + /// The initial value of the variable. + public void DefineVariable(StringKey name, T value) + { + Variant128 variant = VariantResolver.CreateVariant(value); + + if (_propertyResolvers.TryGetValue(name, out IPropertyResolver? existing) + && existing is VariantResolver variantResolver) + { + variantResolver.Value = variant; + return; + } + + _propertyResolvers[name] = new VariantResolver(variant, typeof(T)); + } + + /// + /// Gets the element at the specified index from an array variable. + /// + /// The type to interpret the element as. Must be supported by . + /// + /// The name of the array variable. + /// The zero-based index of the element to get. + /// The value of the element if found. + /// if the array variable was found and the index was valid, + /// otherwise. + public bool TryGetArrayElement(StringKey name, int index, out T value) + where T : unmanaged + { + value = default; + + if (!_propertyResolvers.TryGetValue(name, out IPropertyResolver? resolver)) + { + return false; + } + + if (resolver is not ArrayVariableResolver arrayResolver) + { + return false; + } + + if (index < 0 || index >= arrayResolver.Length) + { + return false; + } + + value = arrayResolver.GetElement(index).Get(); + return true; + } + + /// + /// Sets the element at the specified index in an array variable. + /// + /// The type of the value to set. Must be supported by . + /// The name of the array variable. + /// The zero-based index of the element to set. + /// The value to set. + /// Thrown if the name does not exist or is not an array variable. + /// + /// Thrown if the index is out of range. + public void SetArrayElement(StringKey name, int index, T value) + { + if (!_propertyResolvers.TryGetValue(name, out IPropertyResolver? resolver)) + { + throw new InvalidOperationException( + $"Cannot set array element for '{name}': no variable with this name exists."); + } + + if (resolver is not ArrayVariableResolver arrayResolver) + { + throw new InvalidOperationException( + $"Cannot set array element for '{name}': it is not an array variable."); + } + + Variant128 variant = VariantResolver.CreateVariant(value); + arrayResolver.SetElement(index, variant); + } + + /// + /// Gets the length of an array variable. + /// + /// The name of the array variable. + /// The number of elements in the array, or -1 if the variable does not exist or is not an array. + /// + public int GetArrayLength(StringKey name) + { + if (!_propertyResolvers.TryGetValue(name, out IPropertyResolver? resolver)) + { + return -1; + } + + if (resolver is not ArrayVariableResolver arrayResolver) + { + return -1; + } + + return arrayResolver.Length; + } +} diff --git a/Forge/Statescript/Variant128.cs b/Forge/Statescript/Variant128.cs new file mode 100644 index 0000000..6261323 --- /dev/null +++ b/Forge/Statescript/Variant128.cs @@ -0,0 +1,538 @@ +// Copyright © Gamesmiths Guild. + +using System.Numerics; +using System.Runtime.InteropServices; + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Represents a 128-bit variant type that can hold different data types. +/// +[StructLayout(LayoutKind.Explicit, Size = 16)] +public struct Variant128 +{ + [FieldOffset(0)] + private readonly bool _bool; + + [FieldOffset(0)] + private readonly byte _byte; + + [FieldOffset(0)] + private readonly sbyte _sbyte; + + [FieldOffset(0)] + private readonly char _char; + + [FieldOffset(0)] + private readonly decimal _decimal; + + [FieldOffset(0)] + private readonly double _double; + + [FieldOffset(0)] + private readonly float _float; + + [FieldOffset(0)] + private readonly int _int; + + [FieldOffset(0)] + private readonly uint _uint; + + [FieldOffset(0)] + private readonly long _long; + + [FieldOffset(0)] + private readonly ulong _ulong; + + [FieldOffset(0)] + private readonly short _short; + + [FieldOffset(0)] + private readonly ushort _ushort; + + [FieldOffset(0)] + private readonly Vector2 _vector2; + + [FieldOffset(0)] + private readonly Vector3 _vector3; + + [FieldOffset(0)] + private readonly Vector4 _vector4; + + [FieldOffset(0)] + private readonly Plane _plane; + + [FieldOffset(0)] + private Quaternion _quaternion; + + /// + /// Initializes a new instance of the struct with the specified bool value. + /// + /// The bool value to store. + public Variant128(bool value) + : this() + { + _bool = value; + } + + /// + /// Initializes a new instance of the struct with the specified byte value. + /// + /// The byte value to store. + public Variant128(byte value) + : this() + { + _byte = value; + } + + /// + /// Initializes a new instance of the struct with the specified sbyte value. + /// + /// The sbyte value to store. + public Variant128(sbyte value) + : this() + { + _sbyte = value; + } + + /// + /// Initializes a new instance of the struct with the specified char value. + /// + /// The char value to store. + public Variant128(char value) + : this() + { + _char = value; + } + + /// + /// Initializes a new instance of the struct with the specified decimal value. + /// + /// The decimal value to store. + public Variant128(decimal value) + : this() + { + _decimal = value; + } + + /// + /// Initializes a new instance of the struct with the specified double value. + /// + /// The double value to store. + public Variant128(double value) + : this() + { + _double = value; + } + + /// + /// Initializes a new instance of the struct with the specified float value. + /// + /// The float value to store. + public Variant128(float value) + : this() + { + _float = value; + } + + /// + /// Initializes a new instance of the struct with the specified int value. + /// + /// The int value to store. + public Variant128(int value) + : this() + { + _int = value; + } + + /// + /// Initializes a new instance of the struct with the specified uint value. + /// + /// The uint value to store. + public Variant128(uint value) + : this() + { + _uint = value; + } + + /// + /// Initializes a new instance of the struct with the specified long value. + /// + /// The long value to store. + public Variant128(long value) + : this() + { + _long = value; + } + + /// + /// Initializes a new instance of the struct with the specified ulong value. + /// + /// The ulong value to store. + public Variant128(ulong value) + : this() + { + _ulong = value; + } + + /// + /// Initializes a new instance of the struct with the specified short value. + /// + /// The short value to store. + public Variant128(short value) + : this() + { + _short = value; + } + + /// + /// Initializes a new instance of the struct with the specified ushort value. + /// + /// The ushort value to store. + public Variant128(ushort value) + : this() + { + _ushort = value; + } + + /// + /// Initializes a new instance of the struct with the specified Vector2 value. + /// + /// The Vector2 value to store. + public Variant128(Vector2 value) + : this() + { + _vector2 = value; + } + + /// + /// Initializes a new instance of the struct with the specified Vector3 value. + /// + /// The Vector3 value to store. + public Variant128(Vector3 value) + : this() + { + _vector3 = value; + } + + /// + /// Initializes a new instance of the struct with the specified Vector4 value. + /// + /// The Vector4 value to store. + public Variant128(Vector4 value) + : this() + { + _vector4 = value; + } + + /// + /// Initializes a new instance of the struct with the specified Plane value. + /// + /// The Plane value to store. + public Variant128(Plane value) + : this() + { + _plane = value; + } + + /// + /// Initializes a new instance of the struct with the specified Quaternion value. + /// + /// The Quaternion value to store. + public Variant128(Quaternion value) + : this() + { + _quaternion = value; + } + + /// + /// Creates a instance from a byte array. + /// + /// The byte array to reconstruct from. + /// + /// Always reconstruct from Quaternion bytes, regardless of what is actually stored. + /// + /// The reconstructed instance. + public static Variant128 FromBytes(byte[] bytes) + { + Quaternion v = MemoryMarshal.Read(bytes); + return new Variant128(v); + } + + /// + /// Converts the stored value to a byte array. + /// + /// The byte array representation of the stored value. + public byte[] ToBytes() + { + var result = new byte[16]; +#pragma warning disable CS9191 // Suppress "in" argument + MemoryMarshal.Write(result.AsSpan(), ref _quaternion); +#pragma warning restore CS9191 + return result; + } + + /// + /// Gets the stored value as the specified unmanaged type. + /// + /// The unmanaged type to retrieve. + /// The stored value as type T. + /// Exception thrown if the type T is unsupported. + public readonly T Get() + where T : unmanaged + { + if (typeof(T) == typeof(bool)) + { + return (T)(object)_bool; + } + + if (typeof(T) == typeof(byte)) + { + return (T)(object)_byte; + } + + if (typeof(T) == typeof(sbyte)) + { + return (T)(object)_sbyte; + } + + if (typeof(T) == typeof(char)) + { + return (T)(object)_char; + } + + if (typeof(T) == typeof(decimal)) + { + return (T)(object)_decimal; + } + + if (typeof(T) == typeof(double)) + { + return (T)(object)_double; + } + + if (typeof(T) == typeof(float)) + { + return (T)(object)_float; + } + + if (typeof(T) == typeof(int)) + { + return (T)(object)_int; + } + + if (typeof(T) == typeof(uint)) + { + return (T)(object)_uint; + } + + if (typeof(T) == typeof(long)) + { + return (T)(object)_long; + } + + if (typeof(T) == typeof(ulong)) + { + return (T)(object)_ulong; + } + + if (typeof(T) == typeof(short)) + { + return (T)(object)_short; + } + + if (typeof(T) == typeof(ushort)) + { + return (T)(object)_ushort; + } + + if (typeof(T) == typeof(Vector2)) + { + return (T)(object)_vector2; + } + + if (typeof(T) == typeof(Vector3)) + { + return (T)(object)_vector3; + } + + if (typeof(T) == typeof(Vector4)) + { + return (T)(object)_vector4; + } + + if (typeof(T) == typeof(Plane)) + { + return (T)(object)_plane; + } + + if (typeof(T) == typeof(Quaternion)) + { + return (T)(object)_quaternion; + } + + throw new InvalidOperationException($"Unsupported type {typeof(T)}"); + } + + /// + /// Retrieves the stored value as a boolean. + /// + /// The stored boolean value. + public readonly bool AsBool() + { + return _bool; + } + + /// + /// Retrieves the stored value as a byte. + /// + /// The stored byte value. + public readonly byte AsByte() + { + return _byte; + } + + /// + /// Retrieves the stored value as an sbyte. + /// + /// The stored sbyte value. + public readonly sbyte AsSByte() + { + return _sbyte; + } + + /// + /// Retrieves the stored value as a char. + /// + /// The stored char value. + public readonly char AsChar() + { + return _char; + } + + /// + /// Retrieves the stored value as a decimal. + /// + /// The stored decimal value. + public readonly decimal AsDecimal() + { + return _decimal; + } + + /// + /// Retrieves the stored value as a double. + /// + /// The stored double value. + public readonly double AsDouble() + { + return _double; + } + + /// + /// Retrieves the stored value as a float. + /// + /// The stored float value. + public readonly float AsFloat() + { + return _float; + } + + /// + /// Retrieves the stored value as an int. + /// + /// The stored int value. + public readonly int AsInt() + { + return _int; + } + + /// + /// Retrieves the stored value as a uint. + /// + /// The stored uint value. + public readonly uint AsUInt() + { + return _uint; + } + + /// + /// Retrieves the stored value as a long. + /// + /// The stored long value. + public readonly long AsLong() + { + return _long; + } + + /// + /// Retrieves the stored value as a ulong. + /// + /// The stored ulong value. + public readonly ulong AsULong() + { + return _ulong; + } + + /// + /// Retrieves the stored value as a short. + /// + /// The stored short value. + public readonly short AsShort() + { + return _short; + } + + /// + /// Retrieves the stored value as a ushort. + /// + /// The stored ushort value. + public readonly ushort AsUShort() + { + return _ushort; + } + + /// + /// Retrieves the stored value as a Vector2. + /// + /// The stored Vector2 value. + public readonly Vector2 AsVector2() + { + return _vector2; + } + + /// + /// Retrieves the stored value as a Vector3. + /// + /// The stored Vector3 value. + public readonly Vector3 AsVector3() + { + return _vector3; + } + + /// + /// Retrieves the stored value as a Vector4. + /// + /// The stored Vector4 value. + public readonly Vector4 AsVector4() + { + return _vector4; + } + + /// + /// Retrieves the stored value as a Plane. + /// + /// The stored Plane value. + public readonly Plane AsPlane() + { + return _plane; + } + + /// + /// Retrieves the stored value as a Quaternion. + /// + /// The stored Quaternion value. + public readonly Quaternion AsQuaternion() + { + return _quaternion; + } +} diff --git a/Forge/Tags/Tag.cs b/Forge/Tags/Tag.cs index 191d4c4..65a3dcf 100644 --- a/Forge/Tags/Tag.cs +++ b/Forge/Tags/Tag.cs @@ -73,7 +73,7 @@ public static bool NetSerialize(TagsManager tagsManager, Tag tag, out ushort net // Tags at this point should always have a designated manager. Validation.Assert( tag.TagsManager is not null, - $"Tag \"{tag.TagKey}\" isn't properly registred in a {typeof(TagsManager)}."); + $"Tag \"{tag.TagKey}\" isn't properly registered in a {typeof(TagsManager)}."); if (tagsManager != tag.TagsManager) { diff --git a/docs/quick-start.md b/docs/quick-start.md index 3f177ef..5678216 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -682,8 +682,8 @@ public class HealthDrainExecution : CustomExecution // Get attribute values int targetHealth = CaptureAttributeMagnitude(TargetHealth, effect, target, effectEvaluatedData); - int sourceHealth = CaptureAttributeMagnitude(SourceHealth, effect, effect.Ownership.Owner, effectEvaluatedData); - int sourceStrength = CaptureAttributeMagnitude(SourceStrength, effect, effect.Ownership.Owner, effectEvaluatedData); + int sourceHealth = CaptureAttributeMagnitude(SourceHealth, effect, effect.Ownership.Source, effectEvaluatedData); + int sourceStrength = CaptureAttributeMagnitude(SourceStrength, effect, effect.Ownership.Source, effectEvaluatedData); // Calculate health drain amount based on source strength float drainAmount = sourceStrength * 0.5f;