From bf3439f1bc44a2930128f9b455367cd4088bdd97 Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 3 Feb 2026 22:25:23 -0300 Subject: [PATCH 01/19] Fixed simple typo --- Forge/Tags/Tag.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) { From 9c39121ea11a23df2e3714691f751d48a84d34d4 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 5 Feb 2026 21:49:55 -0300 Subject: [PATCH 02/19] Created Variant and Variables types --- Forge/Statescript/Variables.cs | 135 ++++++++ Forge/Statescript/Variant128.cs | 538 ++++++++++++++++++++++++++++++++ 2 files changed, 673 insertions(+) create mode 100644 Forge/Statescript/Variables.cs create mode 100644 Forge/Statescript/Variant128.cs diff --git a/Forge/Statescript/Variables.cs b/Forge/Statescript/Variables.cs new file mode 100644 index 0000000..face2d8 --- /dev/null +++ b/Forge/Statescript/Variables.cs @@ -0,0 +1,135 @@ +// Copyright © Gamesmiths Guild. + +using System.Numerics; +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Represents a collection of variables used within a Statescript graph. +/// +[Serializable] +public class Variables : ICloneable +{ + private Dictionary? _savedVariables; + + private Dictionary _variables; + + /// + /// Gets or sets the variable with the specified key. + /// + /// The key of the variable. + /// The variable associated with the specified key. + public Variant128 this[StringKey key] + { + get => _variables[key]; + set => _variables[key] = value; + } + + /// + /// Initializes a new instance of the class. + /// + public Variables() + { + _variables = []; + } + + /// + /// Saves the current variable values. + /// + public void SaveVariableValues() + { + _savedVariables = _variables; + } + + /// + /// Loads the saved variable values. + /// + public void LoadVariableValues() + { + if (_savedVariables is null) + { + return; + } + + _variables = _savedVariables; + } + + /// + /// Sets the variable with the given name to the given value. + /// + /// The type of the value to set. Must be supported by Variant128. + /// The name of the variable to set. + /// The value to set the variable to. + /// if the variable was set successfully, otherwise. + /// + /// Thrown if the type T is not supported by Variant128. + public bool SetVar(StringKey name, T value) + { + _variables[name] = 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"), + }; + return true; + } + + /// + /// Tries to get the variable with the given name. + /// + /// The type of the variable to get. Must be supported by Variant128. + /// 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 (!_variables.TryGetValue(name, out Variant128 variant)) + { + return false; + } + + value = variant.Get(); + + return true; + } + + /// + public object Clone() + { + var copy = new Variables(); + + if (_savedVariables is not null) + { + copy._savedVariables = new Dictionary(_savedVariables); + } + else + { + copy._savedVariables = null; + } + + copy._variables = new Dictionary(_variables); + + return copy; + } +} 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; + } +} From 51fa82935d0811c43c3765b04fd8fbc06903bb12 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 6 Feb 2026 23:57:22 -0300 Subject: [PATCH 03/19] Created node, ports and connections --- Forge/Statescript/Connection.cs | 13 ++ Forge/Statescript/Graph.cs | 63 +++++++++ Forge/Statescript/Node.cs | 164 ++++++++++++++++++++++++ Forge/Statescript/Port.cs | 19 +++ Forge/Statescript/Ports/EventPort.cs | 18 +++ Forge/Statescript/Ports/InputPort.cs | 49 +++++++ Forge/Statescript/Ports/OutputPort.cs | 62 +++++++++ Forge/Statescript/Ports/SubgraphPort.cs | 31 +++++ 8 files changed, 419 insertions(+) create mode 100644 Forge/Statescript/Connection.cs create mode 100644 Forge/Statescript/Graph.cs create mode 100644 Forge/Statescript/Node.cs create mode 100644 Forge/Statescript/Port.cs create mode 100644 Forge/Statescript/Ports/EventPort.cs create mode 100644 Forge/Statescript/Ports/InputPort.cs create mode 100644 Forge/Statescript/Ports/OutputPort.cs create mode 100644 Forge/Statescript/Ports/SubgraphPort.cs diff --git a/Forge/Statescript/Connection.cs b/Forge/Statescript/Connection.cs new file mode 100644 index 0000000..844c220 --- /dev/null +++ b/Forge/Statescript/Connection.cs @@ -0,0 +1,13 @@ +// 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. +[Serializable] +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..cf8e94d --- /dev/null +++ b/Forge/Statescript/Graph.cs @@ -0,0 +1,63 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Statescript.Nodes; + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Represents a Statescript graph consisting of nodes and connections. +/// +[Serializable] +public class Graph +{ + /// + /// 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 variables associated with the graph. + /// + public Variables GraphVariables { get; } + + /// + /// Initializes a new instance of the class. + /// + public Graph() + { + EntryNode = new EntryNode(); + Nodes = []; + Connections = []; + GraphVariables = new Variables(); + } + + /// + /// 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); + } +} diff --git a/Forge/Statescript/Node.cs b/Forge/Statescript/Node.cs new file mode 100644 index 0000000..60a77f5 --- /dev/null +++ b/Forge/Statescript/Node.cs @@ -0,0 +1,164 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Statescript.Ports; + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Base class for all Nodes in the Statescript system. +/// +public abstract class Node +{ + private readonly List _inputPorts; + private readonly 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; private set; } + + /// + /// Gets the output ports of this node. + /// + public OutputPort[]? OutputPorts { get; private set; } + + /// + /// Initializes a new instance of the class. + /// + protected Node() + { + NodeID = Guid.NewGuid(); + _inputPorts = []; + _outputPorts = []; + } + + /// + /// Adds an input port to this node. + /// + /// The input port to add. + public void AddPort(InputPort inputPort) + { + _inputPorts.Add(inputPort); + inputPort.SetOwnerNode(this); + } + + /// + /// Adds an output port to this node. + /// + /// The output port to add. + public void AddPort(OutputPort outputPort) + { + _outputPorts.Add(outputPort); + } + + /// + /// Finalizes the initialization of ports for this node. + /// + /// + /// No more ports can be added after calling this method. + /// + public void FinalizePortsInitialization() + { + InputPorts = [.. _inputPorts]; + OutputPorts = [.. _outputPorts]; + } + + internal void OnMessageReceived( + InputPort receiverPort, + Variables graphVariables, + IGraphContext graphContext) + { + graphContext.InternalNodeActivationStatus[NodeID] = true; + + HandleMessage(receiverPort, graphVariables, graphContext); + } + + internal void OnSubgraphDisabledMessageReceived(Variables graphVariables, IGraphContext graphContext) + { + Validation.Assert(OutputPorts is not null, "Output ports should have been initialized."); + + if (!graphContext.InternalNodeActivationStatus.TryAdd(NodeID, false)) + { + if (!graphContext.InternalNodeActivationStatus[NodeID]) + { + return; + } + + graphContext.InternalNodeActivationStatus[NodeID] = false; + } + + BeforeDisable(graphVariables, graphContext); + + foreach (OutputPort outputPort in OutputPorts) + { + outputPort.InternalEmitDisableSubgraphMessage(graphVariables, graphContext); + } + + AfterDisable(graphVariables, 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 variables. + /// The graph context. + /// The IDs of the output ports to emit the message from. + protected virtual void EmitMessage(Variables graphVariables, IGraphContext graphContext, params int[] portIds) + { + Validation.Assert(OutputPorts is not null, "Output ports should have been initialized."); + + foreach (var portId in portIds) + { + OutputPorts[portId].EmitMessage(graphVariables, graphContext); + } + } + + /// + /// Handles an incoming message on the specified input port. + /// + /// The input port that received the message. + /// The graph variables. + /// The graph context. + protected virtual void HandleMessage(InputPort receiverPort, Variables graphVariables, IGraphContext graphContext) + { + } + + /// + /// Called before the node is disabled. + /// + /// The graph variables. + /// The graph context. + protected virtual void BeforeDisable(Variables graphVariables, IGraphContext graphContext) + { + } + + /// + /// Called after the node is disabled. + /// + /// The graph variables. + /// The graph context. + protected virtual void AfterDisable(Variables graphVariables, IGraphContext graphContext) + { + } +} 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..5e8cf27 --- /dev/null +++ b/Forge/Statescript/Ports/EventPort.cs @@ -0,0 +1,18 @@ +// 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. +/// +[Serializable] +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..591ddab --- /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. +/// +[Serializable] +public class InputPort : Port +{ + private Node? _ownerNode; + + /// + /// 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 variables to include in the message. + /// The graph context for the message. + public void ReceiveMessage(Variables graphVariables, IGraphContext graphContext) + { + _ownerNode?.OnMessageReceived(this, graphVariables, graphContext); + } + + /// + /// Receives a disable subgraph message and notifies the owner node. + /// + /// The graph variables to include in the message. + /// The graph context for the message. + public void ReceiveDisableSubgraphMessage(Variables graphVariables, IGraphContext graphContext) + { + _ownerNode?.OnSubgraphDisabledMessageReceived(graphVariables, graphContext); + } +} diff --git a/Forge/Statescript/Ports/OutputPort.cs b/Forge/Statescript/Ports/OutputPort.cs new file mode 100644 index 0000000..4867cc9 --- /dev/null +++ b/Forge/Statescript/Ports/OutputPort.cs @@ -0,0 +1,62 @@ +// 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? OnEmitMessageDisableSubgraphMessage; + + /// + /// Gets the list of input ports connected to this output port. + /// + /// TODO: Convert to array. + protected List ConnectedPorts { get; } + + /// + /// Initializes a new instance of the class. + /// + protected OutputPort() + { + ConnectedPorts = []; + } + + /// + /// Connects an input port to this output port. + /// + /// The input port to connect. + public void Connect(InputPort inputPort) + { + ConnectedPorts.Add(inputPort); + } + + internal void EmitMessage(Variables graphVariables, IGraphContext graphContext) + { + foreach (InputPort inputPort in ConnectedPorts) + { + inputPort.ReceiveMessage(graphVariables, graphContext); + } + + OnEmitMessage?.Invoke(PortID); + } + + internal void InternalEmitDisableSubgraphMessage(Variables graphVariables, IGraphContext graphContext) + { + foreach (InputPort inputPort in ConnectedPorts) + { + inputPort.ReceiveDisableSubgraphMessage(graphVariables, graphContext); + } + + OnEmitMessageDisableSubgraphMessage?.Invoke(PortID); + } +} diff --git a/Forge/Statescript/Ports/SubgraphPort.cs b/Forge/Statescript/Ports/SubgraphPort.cs new file mode 100644 index 0000000..7084fc6 --- /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. +/// +[Serializable] +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 variables to include in the message. + /// The graph context for the message. + public void EmitDisableSubgraphMessage(Variables graphVariables, IGraphContext graphContext) + { + foreach (InputPort inputPort in ConnectedPorts) + { + inputPort.ReceiveDisableSubgraphMessage(graphVariables, graphContext); + } + } +} From f4a4bef7fdeb76cfe0e5aaed33b29843b7e7cf79 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 7 Feb 2026 21:23:15 -0300 Subject: [PATCH 04/19] Remove serializable attributes --- Forge/Statescript/Connection.cs | 1 - Forge/Statescript/Graph.cs | 1 - Forge/Statescript/Ports/EventPort.cs | 1 - Forge/Statescript/Ports/InputPort.cs | 1 - Forge/Statescript/Ports/SubgraphPort.cs | 1 - Forge/Statescript/Variables.cs | 1 - 6 files changed, 6 deletions(-) diff --git a/Forge/Statescript/Connection.cs b/Forge/Statescript/Connection.cs index 844c220..c8f5378 100644 --- a/Forge/Statescript/Connection.cs +++ b/Forge/Statescript/Connection.cs @@ -9,5 +9,4 @@ namespace Gamesmiths.Forge.Statescript; /// /// The output port. /// The input port. -[Serializable] public record struct Connection(OutputPort OutputPort, InputPort InputPort); diff --git a/Forge/Statescript/Graph.cs b/Forge/Statescript/Graph.cs index cf8e94d..90136cb 100644 --- a/Forge/Statescript/Graph.cs +++ b/Forge/Statescript/Graph.cs @@ -7,7 +7,6 @@ namespace Gamesmiths.Forge.Statescript; /// /// Represents a Statescript graph consisting of nodes and connections. /// -[Serializable] public class Graph { /// diff --git a/Forge/Statescript/Ports/EventPort.cs b/Forge/Statescript/Ports/EventPort.cs index 5e8cf27..d31525f 100644 --- a/Forge/Statescript/Ports/EventPort.cs +++ b/Forge/Statescript/Ports/EventPort.cs @@ -5,7 +5,6 @@ namespace Gamesmiths.Forge.Statescript.Ports; /// /// Defines an event output port that can emit messages to connected input ports in the Statescript system. /// -[Serializable] public class EventPort : OutputPort { /// diff --git a/Forge/Statescript/Ports/InputPort.cs b/Forge/Statescript/Ports/InputPort.cs index 591ddab..bc53786 100644 --- a/Forge/Statescript/Ports/InputPort.cs +++ b/Forge/Statescript/Ports/InputPort.cs @@ -5,7 +5,6 @@ namespace Gamesmiths.Forge.Statescript.Ports; /// /// Defines an input port that can receive messages in the Statescript system. /// -[Serializable] public class InputPort : Port { private Node? _ownerNode; diff --git a/Forge/Statescript/Ports/SubgraphPort.cs b/Forge/Statescript/Ports/SubgraphPort.cs index 7084fc6..7b660bb 100644 --- a/Forge/Statescript/Ports/SubgraphPort.cs +++ b/Forge/Statescript/Ports/SubgraphPort.cs @@ -5,7 +5,6 @@ namespace Gamesmiths.Forge.Statescript.Ports; /// /// Defines a subgraph output port that can emit disable subgraph messages to connected input ports. /// -[Serializable] public class SubgraphPort : OutputPort { /// diff --git a/Forge/Statescript/Variables.cs b/Forge/Statescript/Variables.cs index face2d8..9a35aba 100644 --- a/Forge/Statescript/Variables.cs +++ b/Forge/Statescript/Variables.cs @@ -8,7 +8,6 @@ namespace Gamesmiths.Forge.Statescript; /// /// Represents a collection of variables used within a Statescript graph. /// -[Serializable] public class Variables : ICloneable { private Dictionary? _savedVariables; From c97b6e7bb14c78f06f1b9b9df73b5c1c218d88b9 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 7 Feb 2026 22:04:53 -0300 Subject: [PATCH 05/19] First working version --- Forge/Statescript/GraphRunner.cs | 62 ++++++ Forge/Statescript/IGraphContext.cs | 55 +++++ Forge/Statescript/INodeContext.cs | 9 + Forge/Statescript/Node.cs | 62 +++--- Forge/Statescript/Nodes/ActionNode.cs | 36 ++++ Forge/Statescript/Nodes/ConditionNode.cs | 45 +++++ Forge/Statescript/Nodes/EntryNode.cs | 57 ++++++ Forge/Statescript/Nodes/ExitNode.cs | 27 +++ Forge/Statescript/Nodes/StateNode.cs | 211 ++++++++++++++++++++ Forge/Statescript/Nodes/StateNodeContext.cs | 23 +++ 10 files changed, 548 insertions(+), 39 deletions(-) create mode 100644 Forge/Statescript/GraphRunner.cs create mode 100644 Forge/Statescript/IGraphContext.cs create mode 100644 Forge/Statescript/INodeContext.cs create mode 100644 Forge/Statescript/Nodes/ActionNode.cs create mode 100644 Forge/Statescript/Nodes/ConditionNode.cs create mode 100644 Forge/Statescript/Nodes/EntryNode.cs create mode 100644 Forge/Statescript/Nodes/ExitNode.cs create mode 100644 Forge/Statescript/Nodes/StateNode.cs create mode 100644 Forge/Statescript/Nodes/StateNodeContext.cs diff --git a/Forge/Statescript/GraphRunner.cs b/Forge/Statescript/GraphRunner.cs new file mode 100644 index 0000000..1d58547 --- /dev/null +++ b/Forge/Statescript/GraphRunner.cs @@ -0,0 +1,62 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Provides functionality to execute and manage the lifecycle of a graph within a specified context. +/// +/// The class encapsulates a graph and its associated execution context, allowing for +/// starting and stopping the graph's execution. It maintains the graph's variables and ensures proper initialization +/// and cleanup when running or halting the graph. +/// +/// Initializes a new instance of the class with the specified graph and graph context. +/// +/// The graph to be executed by this runner. +/// The context in which the graph will be executed, providing necessary information and +/// services for graph processing. +public class GraphRunner(Graph graph, IGraphContext graphContext) +{ + /// + /// Gets the graph that this runner is responsible for executing. + /// + public Graph Graph { get; } = graph; + + /// + /// Gets the variables associated with the graph during execution. These variables are cloned from the graph's + /// original variables to ensure that each execution instance has its own set of variables, allowing for independent + /// graph runs without interference. + /// + public Variables? GraphVariables { get; private set; } + + /// + /// Gets the context in which the graph is executed. The context provides necessary information and services for + /// graph processing. + /// + public IGraphContext GraphContext { get; } = graphContext; + + /// + /// Starts the execution of the graph. This method clones the graph's variables to ensure that each execution + /// instance has its own set of variables, and then initiates the graph's entry node to begin processing the graph. + /// The entry node will use the cloned variables and the provided graph context to execute the graph's logic. + /// + public void StartGraph() + { + GraphVariables = (Variables)Graph.GraphVariables.Clone(); + Graph.EntryNode.StartGraph(GraphVariables, 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 ensures that the graph is properly reset and ready for future executions without any lingering + /// state from previous runs. + /// + public void StopGraph() + { + if (GraphVariables is not null) + { + Graph.EntryNode.StopGraph(GraphVariables, GraphContext); + GraphContext.RemoveAllNodeContext(); + } + } +} diff --git a/Forge/Statescript/IGraphContext.cs b/Forge/Statescript/IGraphContext.cs new file mode 100644 index 0000000..59b4a68 --- /dev/null +++ b/Forge/Statescript/IGraphContext.cs @@ -0,0 +1,55 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Interface representing the context of a graph during execution, providing access to necessary information and +/// services for graph processing. +public interface IGraphContext +{ + /// + /// Gets or sets the count of active state nodes in the graph. This property is used to track how many state nodes + /// are currently active during graph execution. + /// + int ActiveStateNodeCount { get; set; } + + /// + /// Gets a value indicating whether the graph is currently active. A graph is considered active if it has at least + /// one active state node. + /// + bool IsActive { get; } + + /// + /// Gets a dictionary mapping node IDs to their activation status. This allows tracking which nodes in the graph are + /// currently active or inactive during execution. + /// + Dictionary InternalNodeActivationStatus { get; } + + /// + /// Gets or creates a node context of type T for the specified node ID. If a context for the given node ID already + /// exists, it returns the existing context; otherwise, it creates a new instance of T and associates it with the + /// node ID. + /// + /// The type of the node context to get or create. Must implement INodeContext and have a + /// parameterless constructor. + /// The unique identifier of the node for which to get or create the context. + /// The node context associated with the specified node ID. + T GetOrCreateNodeContext(Guid nodeID) + where T : INodeContext, new(); + + /// + /// Gets the node context of type T for the specified node ID. If no context exists for the given node ID, it + /// returns null. + /// + /// The type of the node context to get. Must implement INodeContext. + /// The unique identifier of the node for which to get the context. + /// The node context associated with the specified node ID, or null if no context exists. + T GetNodeContext(Guid nodeID) + where T : INodeContext, new(); + + /// + /// Removes all node contexts from the graph context. This method is typically called when resetting the graph or + /// when the graph is no longer needed. + /// + void RemoveAllNodeContext(); +} 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 index 60a77f5..db7ef5c 100644 --- a/Forge/Statescript/Node.cs +++ b/Forge/Statescript/Node.cs @@ -1,6 +1,5 @@ // Copyright © Gamesmiths Guild. -using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Statescript.Ports; namespace Gamesmiths.Forge.Statescript; @@ -10,8 +9,15 @@ namespace Gamesmiths.Forge.Statescript; /// public abstract class Node { - private readonly List _inputPorts; - private readonly List _outputPorts; + /// + /// 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. @@ -21,12 +27,12 @@ public abstract class Node /// /// Gets the input ports of this node. /// - public InputPort[]? InputPorts { get; private set; } + public InputPort[] InputPorts { get; } /// /// Gets the output ports of this node. /// - public OutputPort[]? OutputPorts { get; private set; } + public OutputPort[] OutputPorts { get; } /// /// Initializes a new instance of the class. @@ -34,39 +40,21 @@ public abstract class Node protected Node() { NodeID = Guid.NewGuid(); - _inputPorts = []; - _outputPorts = []; - } - /// - /// Adds an input port to this node. - /// - /// The input port to add. - public void AddPort(InputPort inputPort) - { - _inputPorts.Add(inputPort); - inputPort.SetOwnerNode(this); - } + var inputPorts = new List(); + var outputPorts = new List(); - /// - /// Adds an output port to this node. - /// - /// The output port to add. - public void AddPort(OutputPort outputPort) - { - _outputPorts.Add(outputPort); - } +#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 - /// - /// Finalizes the initialization of ports for this node. - /// - /// - /// No more ports can be added after calling this method. - /// - public void FinalizePortsInitialization() - { - InputPorts = [.. _inputPorts]; - OutputPorts = [.. _outputPorts]; + foreach (InputPort inputPort in inputPorts) + { + inputPort.SetOwnerNode(this); + } + + InputPorts = [.. inputPorts]; + OutputPorts = [.. outputPorts]; } internal void OnMessageReceived( @@ -81,8 +69,6 @@ internal void OnMessageReceived( internal void OnSubgraphDisabledMessageReceived(Variables graphVariables, IGraphContext graphContext) { - Validation.Assert(OutputPorts is not null, "Output ports should have been initialized."); - if (!graphContext.InternalNodeActivationStatus.TryAdd(NodeID, false)) { if (!graphContext.InternalNodeActivationStatus[NodeID]) @@ -126,8 +112,6 @@ protected static T CreatePort(byte index) /// The IDs of the output ports to emit the message from. protected virtual void EmitMessage(Variables graphVariables, IGraphContext graphContext, params int[] portIds) { - Validation.Assert(OutputPorts is not null, "Output ports should have been initialized."); - foreach (var portId in portIds) { OutputPorts[portId].EmitMessage(graphVariables, graphContext); diff --git a/Forge/Statescript/Nodes/ActionNode.cs b/Forge/Statescript/Nodes/ActionNode.cs new file mode 100644 index 0000000..f69352f --- /dev/null +++ b/Forge/Statescript/Nodes/ActionNode.cs @@ -0,0 +1,36 @@ +// 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 +{ + private const byte InputPort = 0; + private 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 variables. + /// The current graph context. + protected abstract void Execute(Variables graphVariables, IGraphContext graphContext); + + /// + protected override void DefinePorts(List inputPorts, List outputPorts) + { + inputPorts.Add(CreatePort(InputPort)); + outputPorts.Add(CreatePort(OutputPort)); + } + + /// + protected override void HandleMessage(InputPort receiverPort, Variables graphVariables, IGraphContext graphContext) + { + Execute(graphVariables, graphContext); + OutputPorts[OutputPort].EmitMessage(graphVariables, graphContext); + } +} diff --git a/Forge/Statescript/Nodes/ConditionNode.cs b/Forge/Statescript/Nodes/ConditionNode.cs new file mode 100644 index 0000000..019019f --- /dev/null +++ b/Forge/Statescript/Nodes/ConditionNode.cs @@ -0,0 +1,45 @@ +// 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 +{ + private const byte InputPort = 0; + private const byte TruePort = 0; + private 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 variables. + /// The current graph context. + /// if the condition is met; otherwise, . + protected abstract bool Test(Variables graphVariables, IGraphContext graphContext); + + /// + 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, Variables graphVariables, IGraphContext graphContext) + { + if (Test(graphVariables, graphContext)) + { + OutputPorts[TruePort].EmitMessage(graphVariables, graphContext); + } + else + { + OutputPorts[FalsePort].EmitMessage(graphVariables, graphContext); + } + } +} diff --git a/Forge/Statescript/Nodes/EntryNode.cs b/Forge/Statescript/Nodes/EntryNode.cs new file mode 100644 index 0000000..dea56de --- /dev/null +++ b/Forge/Statescript/Nodes/EntryNode.cs @@ -0,0 +1,57 @@ +// 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 +{ + private const byte InputPort = 0; + + /// + /// 2 ideas: + /// 1. We can move GraphVariables for inside the EntryNode, and now we don't need + /// to send it as a parameter every time we fire a node + /// or + /// 2. We can create a simplified version of graph instances that only contains graphVariables + /// and we use the same Graph to execute based on the graphVariables passed. (I like this idea, it's + /// kind of like the "Flyweight" pattern that we thought at the beginning) + /// possible problems with this approach would be with the internal timer variable for the WaitNode for + /// example, but if we move that to the GraphVariables then I think it would work fine. + /// + /// The graph variables to be used when starting the graph. + /// The graph context providing information about the graph's execution state. + public void StartGraph(Variables graphVariables, IGraphContext graphContext) + { + graphVariables.SaveVariableValues(); + OutputPorts[InputPort].EmitMessage(graphVariables, graphContext); + } + + /// + /// Stops the graph execution by emitting a disable message through the output port and loading the previous + /// variable values. + /// + /// The graph variables to be used when stopping the graph. + /// The graph context providing information about the graph's execution state. + public void StopGraph(Variables graphVariables, IGraphContext graphContext) + { + ((SubgraphPort)OutputPorts[InputPort]).EmitDisableSubgraphMessage(graphVariables, graphContext); + graphVariables.LoadVariableValues(); + } + + /// + protected override void DefinePorts(List inputPorts, List outputPorts) + { + outputPorts.Add(CreatePort(InputPort)); + } + + /// + protected override void HandleMessage(InputPort receiverPort, Variables graphVariables, IGraphContext graphContext) + { + throw new NotImplementedException(); + } +} diff --git a/Forge/Statescript/Nodes/ExitNode.cs b/Forge/Statescript/Nodes/ExitNode.cs new file mode 100644 index 0000000..4a885a6 --- /dev/null +++ b/Forge/Statescript/Nodes/ExitNode.cs @@ -0,0 +1,27 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Statescript.Ports; + +namespace Gamesmiths.Forge.Statescript.Nodes; + +/// +/// Node representing the exit point of a graph. It has a single input port that receives a message to stop the graph +/// execution. +/// +public class ExitNode : Node +{ + private const byte InputPortIndex = 0; + + /// + protected override void DefinePorts(List inputPorts, List outputPorts) + { + inputPorts.Add(CreatePort(InputPortIndex)); + } + + /// + protected override void HandleMessage(InputPort receiverPort, Variables graphVariables, IGraphContext graphContext) + { + // TODO: Implement the logic to stop the graph execution when a message is received on the input port. + throw new NotImplementedException(); + } +} diff --git a/Forge/Statescript/Nodes/StateNode.cs b/Forge/Statescript/Nodes/StateNode.cs new file mode 100644 index 0000000..8d57fc4 --- /dev/null +++ b/Forge/Statescript/Nodes/StateNode.cs @@ -0,0 +1,211 @@ +// Copyright © Gamesmiths Guild. + +using System.Diagnostics; +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() +{ + private const byte InputPort = 0; + private const byte AbortPort = 1; + private const byte OnActivatePort = 0; + private const byte OnDeactivatePort = 1; + private const byte OnAbortPort = 2; + private const byte SubgraphPort = 3; + + /// + /// Called when the node is activated. + /// + /// The graph's variables. + /// The graph's context. + protected abstract void OnActivate(Variables graphVariables, IGraphContext graphContext); + + /// + /// Called when the node is deactivated. + /// + /// The graph's variables. + /// The graph's context. + protected abstract void OnDeactivate(Variables graphVariables, IGraphContext 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, Variables graphVariables, IGraphContext graphContext) + { + if (receiverPort.Index == InputPort) + { + var nodeContext = (StateNodeContext)graphContext.GetOrCreateNodeContext(NodeID); + + nodeContext.Activating = true; + ActivateNode(graphVariables, graphContext); + OutputPorts[OnActivatePort].EmitMessage(graphVariables, graphContext); + OutputPorts[SubgraphPort].EmitMessage(graphVariables, graphContext); + nodeContext.Activating = false; + + HandleDeferredEmitMessages(graphContext, nodeContext); + HandleDeferredDeactivationMessages(graphVariables, graphContext, nodeContext); + } + else if (receiverPort.Index == AbortPort) + { + OutputPorts[OnAbortPort].EmitMessage(graphVariables, graphContext); + DeactivateNode(graphVariables, graphContext); + } + } + + /// + protected override void EmitMessage(Variables graphVariables, IGraphContext graphContext, params int[] portIds) + { + StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); + + if (nodeContext.Activating) + { + foreach (var portId in portIds) + { + nodeContext.DeferredEmitMessageData.Add(new PortVariable(portId, (Variables)graphVariables.Clone())); + } + + return; + } + + base.EmitMessage(graphVariables, graphContext, portIds); + } + + /// + /// + /// This method should be used when you want to deactivate the node and Emit a message on custom event ports in + /// case of success of failure. It's important to use this method because it grantees that the messages are fired + /// in the right order. + /// + /// OutputPort[OutputOnDeactivatePortID] (OnDeactivate) will always be called upon node deactivation and + /// should not be used here. + /// + /// The graph's variables. + /// The graph's context. + /// ID of ports you want to Emit a message to. + protected void DeactivateNodeAndEmitMessage(Variables graphVariables, IGraphContext graphContext, params int[] eventPortIds) + { + StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); + + if (nodeContext.Activating) + { + nodeContext.DeferredDeactivationEventPortIds = eventPortIds; + return; + } + + DeactivateNode(graphVariables, graphContext); + + for (var i = 0; i < eventPortIds.Length; i++) + { + Debug.Assert(eventPortIds[i] > OnAbortPort, "DeactivateNodeAndEmitMessage should be used only with custom ports."); + Debug.Assert(OutputPorts[eventPortIds[i]] is EventPort, "Only EventPorts can be used for deactivation events."); + OutputPorts[eventPortIds[i]].EmitMessage(graphVariables, graphContext); + } + } + + /// + /// Deactivates the node without emitting any custom messages. + /// + /// The graph's variables. + /// The graph's context. + protected void DeactivateNode(Variables graphVariables, IGraphContext graphContext) + { + BeforeDisable(graphVariables, graphContext); + + foreach (OutputPort outputPort in OutputPorts) + { + if (outputPort is SubgraphPort subgraphPort) + { + subgraphPort.EmitDisableSubgraphMessage(graphVariables, graphContext); + } + } + + AfterDisable(graphVariables, graphContext); + } + + /// + protected sealed override void BeforeDisable(Variables graphVariables, IGraphContext graphContext) + { + StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); + if (nodeContext is null) + { + return; + } + + if (!nodeContext.Active) + { + return; + } + + base.BeforeDisable(graphVariables, graphContext); + + OutputPorts[OnDeactivatePort].EmitMessage(graphVariables, graphContext); + ((SubgraphPort)OutputPorts[SubgraphPort]).EmitDisableSubgraphMessage(graphVariables, graphContext); + } + + /// + protected sealed override void AfterDisable(Variables graphVariables, IGraphContext graphContext) + { + StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); + if (nodeContext is null) + { + return; + } + + if (!nodeContext.Active) + { + return; + } + + base.AfterDisable(graphVariables, graphContext); + + nodeContext.Active = false; + graphContext.ActiveStateNodeCount--; + OnDeactivate(graphVariables, graphContext); + } + + private void ActivateNode(Variables graphVariables, IGraphContext graphContext) + { + StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); + nodeContext.Active = true; + graphContext.ActiveStateNodeCount++; + OnActivate(graphVariables, graphContext); + } + + private void HandleDeferredEmitMessages(IGraphContext graphContext, StateNodeContext nodeContext) + { + if (nodeContext.DeferredEmitMessageData.Count > 0) + { + foreach (PortVariable emitEvent in nodeContext.DeferredEmitMessageData) + { + OutputPorts[emitEvent.PortId].EmitMessage(emitEvent.Variables, graphContext); + } + + nodeContext.DeferredEmitMessageData.Clear(); + } + } + + private void HandleDeferredDeactivationMessages(Variables graphVariables, IGraphContext graphContext, StateNodeContext nodeContext) + { + if (nodeContext.DeferredDeactivationEventPortIds is not null) + { + DeactivateNodeAndEmitMessage(graphVariables, 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..8a9f1e6 --- /dev/null +++ b/Forge/Statescript/Nodes/StateNodeContext.cs @@ -0,0 +1,23 @@ +// 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; } = []; +} + +internal record struct PortVariable(int PortId, Variables Variables); From 8ec87deb60306d36180327b6a93c0cd50b489418 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 8 Feb 2026 13:57:29 -0300 Subject: [PATCH 06/19] Moved Variables to GraphContext --- Forge.Tests/Statescript/StatescriptTests.cs | 466 ++++++++++++++++++++ Forge/Statescript/Graph.cs | 7 +- Forge/Statescript/GraphRunner.cs | 57 +-- Forge/Statescript/IGraphContext.cs | 7 + Forge/Statescript/Node.cs | 35 +- Forge/Statescript/Nodes/ActionNode.cs | 9 +- Forge/Statescript/Nodes/ConditionNode.cs | 11 +- Forge/Statescript/Nodes/EntryNode.cs | 31 +- Forge/Statescript/Nodes/ExitNode.cs | 2 +- Forge/Statescript/Nodes/StateNode.cs | 113 +++-- Forge/Statescript/Nodes/StateNodeContext.cs | 9 +- Forge/Statescript/Ports/InputPort.cs | 10 +- Forge/Statescript/Ports/OutputPort.cs | 8 +- Forge/Statescript/Ports/SubgraphPort.cs | 5 +- Forge/Statescript/Variables.cs | 12 + 15 files changed, 644 insertions(+), 138 deletions(-) create mode 100644 Forge.Tests/Statescript/StatescriptTests.cs diff --git a/Forge.Tests/Statescript/StatescriptTests.cs b/Forge.Tests/Statescript/StatescriptTests.cs new file mode 100644 index 0000000..d620cf3 --- /dev/null +++ b/Forge.Tests/Statescript/StatescriptTests.cs @@ -0,0 +1,466 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Nodes; + +namespace Gamesmiths.Forge.Tests.Statescript; + +public class StatescriptTests +{ + [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_runner_clones_variables_on_start() + { + var graph = new Graph(); + graph.GraphVariables.SetVar("health", 100); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + + runner.StartGraph(); + + context.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[0], actionNode.InputPorts[0])); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.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[0], action1.InputPorts[0])); + graph.AddConnection(new Connection(action1.OutputPorts[0], action2.InputPorts[0])); + graph.AddConnection(new Connection(action2.OutputPorts[0], action3.InputPorts[0])); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.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[0], condition.InputPorts[0])); + graph.AddConnection(new Connection(condition.OutputPorts[0], trueAction.InputPorts[0])); + graph.AddConnection(new Connection(condition.OutputPorts[1], falseAction.InputPorts[0])); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.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[0], condition.InputPorts[0])); + graph.AddConnection(new Connection(condition.OutputPorts[0], trueAction.InputPorts[0])); + graph.AddConnection(new Connection(condition.OutputPorts[1], falseAction.InputPorts[0])); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.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.GraphVariables.SetVar("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[0], incrementNode.InputPorts[0])); + graph.AddConnection(new Connection(incrementNode.OutputPorts[0], readNode.InputPorts[0])); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.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.GraphVariables.SetVar("threshold", 10); + graph.GraphVariables.SetVar("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[0], condition.InputPorts[0])); + graph.AddConnection(new Connection(condition.OutputPorts[0], aboveAction.InputPorts[0])); + graph.AddConnection(new Connection(condition.OutputPorts[1], belowAction.InputPorts[0])); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.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[0], action1.InputPorts[0])); + graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[0], action2.InputPorts[0])); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.StartGraph(); + + action1.ExecutionCount.Should().Be(1); + action2.ExecutionCount.Should().Be(1); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Stopping_graph_resets_variables_to_saved_state() + { + var graph = new Graph(); + graph.GraphVariables.SetVar("counter", 0); + + var incrementNode = new IncrementCounterNode("counter"); + + graph.AddNode(incrementNode); + graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[0], incrementNode.InputPorts[0])); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.StartGraph(); + + context.GraphVariables.TryGetVar("counter", out int valueAfterStart).Should().BeTrue(); + valueAfterStart.Should().Be(1); + + // StopGraph calls LoadVariableValues and cleans up - verify it doesn't throw. + runner.StopGraph(); + + // Original graph variables should remain at 0 (they were cloned). + graph.GraphVariables.TryGetVar("counter", out int originalValue).Should().BeTrue(); + originalValue.Should().Be(0); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Stopping_graph_removes_all_node_contexts() + { + var graph = new Graph(); + var actionNode = new TrackingActionNode(); + + graph.AddNode(actionNode); + graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[0], actionNode.InputPorts[0])); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.StartGraph(); + + // ActionNodes register in InternalNodeActivationStatus when they receive messages. + context.InternalNodeActivationStatus.Should().NotBeEmpty(); + + runner.StopGraph(); + + context.NodeContextCount.Should().Be(0); + } + + [Fact] + [Trait("Graph", "Complex")] + public void Complex_graph_with_condition_and_multiple_actions_executes_correctly() + { + var executionOrder = new List(); + + var graph = new Graph(); + graph.GraphVariables.SetVar("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[0], incrementNode.InputPorts[0])); + graph.AddConnection(new Connection(incrementNode.OutputPorts[0], condition.InputPorts[0])); + graph.AddConnection(new Connection(condition.OutputPorts[0], trackA.InputPorts[0])); + graph.AddConnection(new Connection(condition.OutputPorts[1], trackB.InputPorts[0])); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.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[0], connectedAction.InputPorts[0])); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.StartGraph(); + + connectedAction.ExecutionCount.Should().Be(1); + disconnectedAction.ExecutionCount.Should().Be(0); + } + + [Fact] + [Trait("Graph", "Node")] + public void Each_graph_runner_has_independent_variable_state() + { + var graph = new Graph(); + graph.GraphVariables.SetVar("counter", 0); + + var incrementNode = new IncrementCounterNode("counter"); + graph.AddNode(incrementNode); + graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[0], incrementNode.InputPorts[0])); + + var context1 = new TestGraphContext(); + var runner1 = new GraphRunner(graph, context1); + + var context2 = new TestGraphContext(); + var runner2 = new GraphRunner(graph, context2); + + runner1.StartGraph(); + runner2.StartGraph(); + + context1.GraphVariables.TryGetVar("counter", out int value1); + context2.GraphVariables.TryGetVar("counter", out int value2); + + value1.Should().Be(1); + value2.Should().Be(1); + + // Original graph variables should remain unchanged. + graph.GraphVariables.TryGetVar("counter", out int originalValue); + originalValue.Should().Be(0); + } + + private sealed class TestGraphContext : IGraphContext + { + private readonly Dictionary _nodeContexts = []; + + public int ActiveStateNodeCount { get; set; } + + public bool IsActive => ActiveStateNodeCount > 0; + + public Variables GraphVariables { get; } = new Variables(); + + public Dictionary InternalNodeActivationStatus { get; } = []; + + public int NodeContextCount => _nodeContexts.Count; + + public 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; + } + + public T GetNodeContext(Guid nodeID) + where T : INodeContext, new() + { + if (_nodeContexts.TryGetValue(nodeID, out INodeContext? context)) + { + return (T)context; + } + + return default!; + } + + public void RemoveAllNodeContext() + { + _nodeContexts.Clear(); + } + } + + private 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(IGraphContext graphContext) + { + ExecutionCount++; + + if (_name is not null) + { + _executionLog?.Add(_name); + } + } + } + + private sealed class FixedConditionNode(bool result) : ConditionNode + { + private readonly bool _result = result; + + protected override bool Test(IGraphContext graphContext) + { + return _result; + } + } + + private 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(IGraphContext graphContext) + { + graphContext.GraphVariables.TryGetVar(_variableName, out int value); + + int threshold = _fixedThreshold; + if (_thresholdVariableName is not null) + { + graphContext.GraphVariables.TryGetVar(_thresholdVariableName, out threshold); + } + + return value > threshold; + } + } + + private sealed class IncrementCounterNode(string variableName) : ActionNode + { + private readonly string _variableName = variableName; + + protected override void Execute(IGraphContext graphContext) + { + graphContext.GraphVariables.TryGetVar(_variableName, out int currentValue); + graphContext.GraphVariables.SetVar(_variableName, currentValue + 1); + } + } + + private sealed class ReadVariableNode(string variableName) : ActionNode + where T : unmanaged + { + private readonly string _variableName = variableName; + + public T LastReadValue { get; private set; } + + protected override void Execute(IGraphContext graphContext) + { + graphContext.GraphVariables.TryGetVar(_variableName, out T value); + LastReadValue = value; + } + } +} diff --git a/Forge/Statescript/Graph.cs b/Forge/Statescript/Graph.cs index 90136cb..5780518 100644 --- a/Forge/Statescript/Graph.cs +++ b/Forge/Statescript/Graph.cs @@ -5,7 +5,9 @@ namespace Gamesmiths.Forge.Statescript; /// -/// Represents a Statescript graph consisting of nodes and connections. +/// Represents a Statescript graph definition consisting of nodes and connections. This class is immutable after +/// construction and can be shared across multiple instances (Flyweight pattern). +/// All mutable runtime state lives in . /// public class Graph { @@ -25,7 +27,8 @@ public class Graph public List Connections { get; } /// - /// Gets the variables associated with the graph. + /// Gets the default variable definitions for the graph. These are cloned into each + /// when a graph execution starts, providing each runner with independent variable state. /// public Variables GraphVariables { get; } diff --git a/Forge/Statescript/GraphRunner.cs b/Forge/Statescript/GraphRunner.cs index 1d58547..7636604 100644 --- a/Forge/Statescript/GraphRunner.cs +++ b/Forge/Statescript/GraphRunner.cs @@ -5,15 +5,18 @@ namespace Gamesmiths.Forge.Statescript; /// /// Provides functionality to execute and manage the lifecycle of a graph within a specified context. /// -/// The class encapsulates a graph and its associated execution context, allowing for -/// starting and stopping the graph's execution. It maintains the graph's variables and ensures proper initialization -/// and cleanup when running or halting the graph. /// -/// Initializes a new instance of the class with the specified graph and graph context. +/// The class encapsulates a graph and its associated execution context, allowing for +/// starting, updating, and stopping the graph's execution. It ensures proper initialization and cleanup when running +/// or halting the graph. +/// The graph definition (nodes, connections, default variables) is immutable and shared. All mutable runtime +/// state — including variable values, node contexts, and activation status — lives in the . +/// This allows a single to be executed concurrently by multiple runners, each with its own +/// context (Flyweight pattern). /// /// The graph to be executed by this runner. -/// The context in which the graph will be executed, providing necessary information and -/// services for graph processing. +/// The context in which the graph will be executed, providing runtime state for this +/// execution instance. public class GraphRunner(Graph graph, IGraphContext graphContext) { /// @@ -22,41 +25,43 @@ public class GraphRunner(Graph graph, IGraphContext graphContext) public Graph Graph { get; } = graph; /// - /// Gets the variables associated with the graph during execution. These variables are cloned from the graph's - /// original variables to ensure that each execution instance has its own set of variables, allowing for independent - /// graph runs without interference. + /// 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 Variables? GraphVariables { get; private set; } + public IGraphContext GraphContext { get; } = graphContext; /// - /// Gets the context in which the graph is executed. The context provides necessary information and services for - /// graph processing. + /// Starts the execution of the graph. This method clones the graph's default variables into the context + /// to ensure that each execution instance has independent state, and then initiates the graph's entry node + /// to begin processing. /// - public IGraphContext GraphContext { get; } = graphContext; + public void StartGraph() + { + GraphContext.GraphVariables.LoadFrom(Graph.GraphVariables); + Graph.EntryNode.StartGraph(GraphContext); + } /// - /// Starts the execution of the graph. This method clones the graph's variables to ensure that each execution - /// instance has its own set of variables, and then initiates the graph's entry node to begin processing the graph. - /// The entry node will use the cloned variables and the provided graph context to execute the graph's logic. + /// Updates all active state nodes in the graph with the given delta time. Call this method in your game loop + /// to drive time-dependent state node logic such as timers, animations, or continuous evaluation. /// - public void StartGraph() + /// The time elapsed since the last update, in seconds. + public void UpdateGraph(double deltaTime) { - GraphVariables = (Variables)Graph.GraphVariables.Clone(); - Graph.EntryNode.StartGraph(GraphVariables, GraphContext); + foreach (Node node in Graph.Nodes) + { + node.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 ensures that the graph is properly reset and ready for future executions without any lingering - /// state from previous runs. + /// execution. /// public void StopGraph() { - if (GraphVariables is not null) - { - Graph.EntryNode.StopGraph(GraphVariables, GraphContext); - GraphContext.RemoveAllNodeContext(); - } + Graph.EntryNode.StopGraph(GraphContext); + GraphContext.RemoveAllNodeContext(); } } diff --git a/Forge/Statescript/IGraphContext.cs b/Forge/Statescript/IGraphContext.cs index 59b4a68..1ceb9e1 100644 --- a/Forge/Statescript/IGraphContext.cs +++ b/Forge/Statescript/IGraphContext.cs @@ -5,6 +5,7 @@ namespace Gamesmiths.Forge.Statescript; /// /// Interface representing the context of a graph during execution, providing access to necessary information and /// services for graph processing. +/// public interface IGraphContext { /// @@ -19,6 +20,12 @@ public interface IGraphContext /// bool IsActive { get; } + /// + /// Gets the runtime variables for this graph execution instance. These are cloned from the graph's default + /// variable definitions when the graph starts, ensuring each execution has independent state. + /// + Variables GraphVariables { get; } + /// /// Gets a dictionary mapping node IDs to their activation status. This allows tracking which nodes in the graph are /// currently active or inactive during execution. diff --git a/Forge/Statescript/Node.cs b/Forge/Statescript/Node.cs index db7ef5c..1362745 100644 --- a/Forge/Statescript/Node.cs +++ b/Forge/Statescript/Node.cs @@ -59,15 +59,14 @@ protected Node() internal void OnMessageReceived( InputPort receiverPort, - Variables graphVariables, IGraphContext graphContext) { graphContext.InternalNodeActivationStatus[NodeID] = true; - HandleMessage(receiverPort, graphVariables, graphContext); + HandleMessage(receiverPort, graphContext); } - internal void OnSubgraphDisabledMessageReceived(Variables graphVariables, IGraphContext graphContext) + internal void OnSubgraphDisabledMessageReceived(IGraphContext graphContext) { if (!graphContext.InternalNodeActivationStatus.TryAdd(NodeID, false)) { @@ -79,14 +78,24 @@ internal void OnSubgraphDisabledMessageReceived(Variables graphVariables, IGraph graphContext.InternalNodeActivationStatus[NodeID] = false; } - BeforeDisable(graphVariables, graphContext); + BeforeDisable(graphContext); foreach (OutputPort outputPort in OutputPorts) { - outputPort.InternalEmitDisableSubgraphMessage(graphVariables, graphContext); + outputPort.InternalEmitDisableSubgraphMessage(graphContext); } - AfterDisable(graphVariables, graphContext); + AfterDisable(graphContext); + } + + /// + /// 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, IGraphContext graphContext) + { } /// @@ -107,14 +116,13 @@ protected static T CreatePort(byte index) /// /// Emits a message from the specified output ports. /// - /// The graph variables. /// The graph context. /// The IDs of the output ports to emit the message from. - protected virtual void EmitMessage(Variables graphVariables, IGraphContext graphContext, params int[] portIds) + protected virtual void EmitMessage(IGraphContext graphContext, params int[] portIds) { foreach (var portId in portIds) { - OutputPorts[portId].EmitMessage(graphVariables, graphContext); + OutputPorts[portId].EmitMessage(graphContext); } } @@ -122,27 +130,24 @@ protected virtual void EmitMessage(Variables graphVariables, IGraphContext graph /// Handles an incoming message on the specified input port. /// /// The input port that received the message. - /// The graph variables. /// The graph context. - protected virtual void HandleMessage(InputPort receiverPort, Variables graphVariables, IGraphContext graphContext) + protected virtual void HandleMessage(InputPort receiverPort, IGraphContext graphContext) { } /// /// Called before the node is disabled. /// - /// The graph variables. /// The graph context. - protected virtual void BeforeDisable(Variables graphVariables, IGraphContext graphContext) + protected virtual void BeforeDisable(IGraphContext graphContext) { } /// /// Called after the node is disabled. /// - /// The graph variables. /// The graph context. - protected virtual void AfterDisable(Variables graphVariables, IGraphContext graphContext) + protected virtual void AfterDisable(IGraphContext graphContext) { } } diff --git a/Forge/Statescript/Nodes/ActionNode.cs b/Forge/Statescript/Nodes/ActionNode.cs index f69352f..9b9c3af 100644 --- a/Forge/Statescript/Nodes/ActionNode.cs +++ b/Forge/Statescript/Nodes/ActionNode.cs @@ -16,9 +16,8 @@ public abstract class ActionNode : Node /// /// Executes the action associated with this node. This method is called when the input port receives a message. /// - /// The current graph variables. /// The current graph context. - protected abstract void Execute(Variables graphVariables, IGraphContext graphContext); + protected abstract void Execute(IGraphContext graphContext); /// protected override void DefinePorts(List inputPorts, List outputPorts) @@ -28,9 +27,9 @@ protected override void DefinePorts(List inputPorts, List } /// - protected override void HandleMessage(InputPort receiverPort, Variables graphVariables, IGraphContext graphContext) + protected override void HandleMessage(InputPort receiverPort, IGraphContext graphContext) { - Execute(graphVariables, graphContext); - OutputPorts[OutputPort].EmitMessage(graphVariables, graphContext); + Execute(graphContext); + OutputPorts[OutputPort].EmitMessage(graphContext); } } diff --git a/Forge/Statescript/Nodes/ConditionNode.cs b/Forge/Statescript/Nodes/ConditionNode.cs index 019019f..ee1f49f 100644 --- a/Forge/Statescript/Nodes/ConditionNode.cs +++ b/Forge/Statescript/Nodes/ConditionNode.cs @@ -17,10 +17,9 @@ public abstract class ConditionNode : Node /// /// Tests the condition and returns true or false. The result determines which output port will emit a message. /// - /// The current graph variables. /// The current graph context. /// if the condition is met; otherwise, . - protected abstract bool Test(Variables graphVariables, IGraphContext graphContext); + protected abstract bool Test(IGraphContext graphContext); /// protected override void DefinePorts(List inputPorts, List outputPorts) @@ -31,15 +30,15 @@ protected override void DefinePorts(List inputPorts, List } /// - protected sealed override void HandleMessage(InputPort receiverPort, Variables graphVariables, IGraphContext graphContext) + protected sealed override void HandleMessage(InputPort receiverPort, IGraphContext graphContext) { - if (Test(graphVariables, graphContext)) + if (Test(graphContext)) { - OutputPorts[TruePort].EmitMessage(graphVariables, graphContext); + OutputPorts[TruePort].EmitMessage(graphContext); } else { - OutputPorts[FalsePort].EmitMessage(graphVariables, graphContext); + OutputPorts[FalsePort].EmitMessage(graphContext); } } } diff --git a/Forge/Statescript/Nodes/EntryNode.cs b/Forge/Statescript/Nodes/EntryNode.cs index dea56de..c53d5a7 100644 --- a/Forge/Statescript/Nodes/EntryNode.cs +++ b/Forge/Statescript/Nodes/EntryNode.cs @@ -13,34 +13,25 @@ public class EntryNode : Node private const byte InputPort = 0; /// - /// 2 ideas: - /// 1. We can move GraphVariables for inside the EntryNode, and now we don't need - /// to send it as a parameter every time we fire a node - /// or - /// 2. We can create a simplified version of graph instances that only contains graphVariables - /// and we use the same Graph to execute based on the graphVariables passed. (I like this idea, it's - /// kind of like the "Flyweight" pattern that we thought at the beginning) - /// possible problems with this approach would be with the internal timer variable for the WaitNode for - /// example, but if we move that to the GraphVariables then I think it would work fine. + /// Starts the graph execution by saving the current variable values and emitting a message through the output + /// port. /// - /// The graph variables to be used when starting the graph. - /// The graph context providing information about the graph's execution state. - public void StartGraph(Variables graphVariables, IGraphContext graphContext) + /// The graph context providing the runtime variables and execution state. + public void StartGraph(IGraphContext graphContext) { - graphVariables.SaveVariableValues(); - OutputPorts[InputPort].EmitMessage(graphVariables, graphContext); + graphContext.GraphVariables.SaveVariableValues(); + OutputPorts[InputPort].EmitMessage(graphContext); } /// /// Stops the graph execution by emitting a disable message through the output port and loading the previous /// variable values. /// - /// The graph variables to be used when stopping the graph. - /// The graph context providing information about the graph's execution state. - public void StopGraph(Variables graphVariables, IGraphContext graphContext) + /// The graph context providing the runtime variables and execution state. + public void StopGraph(IGraphContext graphContext) { - ((SubgraphPort)OutputPorts[InputPort]).EmitDisableSubgraphMessage(graphVariables, graphContext); - graphVariables.LoadVariableValues(); + ((SubgraphPort)OutputPorts[InputPort]).EmitDisableSubgraphMessage(graphContext); + graphContext.GraphVariables.LoadVariableValues(); } /// @@ -50,7 +41,7 @@ protected override void DefinePorts(List inputPorts, List } /// - protected override void HandleMessage(InputPort receiverPort, Variables graphVariables, IGraphContext graphContext) + protected override void HandleMessage(InputPort receiverPort, IGraphContext graphContext) { throw new NotImplementedException(); } diff --git a/Forge/Statescript/Nodes/ExitNode.cs b/Forge/Statescript/Nodes/ExitNode.cs index 4a885a6..d3b4d40 100644 --- a/Forge/Statescript/Nodes/ExitNode.cs +++ b/Forge/Statescript/Nodes/ExitNode.cs @@ -19,7 +19,7 @@ protected override void DefinePorts(List inputPorts, List } /// - protected override void HandleMessage(InputPort receiverPort, Variables graphVariables, IGraphContext graphContext) + protected override void HandleMessage(InputPort receiverPort, IGraphContext graphContext) { // TODO: Implement the logic to stop the graph execution when a message is received on the input port. throw new NotImplementedException(); diff --git a/Forge/Statescript/Nodes/StateNode.cs b/Forge/Statescript/Nodes/StateNode.cs index 8d57fc4..d2bb412 100644 --- a/Forge/Statescript/Nodes/StateNode.cs +++ b/Forge/Statescript/Nodes/StateNode.cs @@ -23,16 +23,43 @@ public abstract class StateNode : Node /// /// Called when the node is activated. /// - /// The graph's variables. /// The graph's context. - protected abstract void OnActivate(Variables graphVariables, IGraphContext graphContext); + protected abstract void OnActivate(IGraphContext graphContext); /// /// Called when the node is deactivated. /// - /// The graph's variables. /// The graph's context. - protected abstract void OnDeactivate(Variables graphVariables, IGraphContext graphContext); + protected abstract void OnDeactivate(IGraphContext 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, IGraphContext graphContext) +#pragma warning restore SA1202 // Elements should be ordered by access + { + StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); + + if (nodeContext?.Active != true) + { + return; + } + + OnUpdate(deltaTime, graphContext); + } + + /// + /// 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, IGraphContext graphContext) + { + } /// protected override void DefinePorts(List inputPorts, List outputPorts) @@ -46,59 +73,56 @@ protected override void DefinePorts(List inputPorts, List } /// - protected sealed override void HandleMessage(InputPort receiverPort, Variables graphVariables, IGraphContext graphContext) + protected sealed override void HandleMessage(InputPort receiverPort, IGraphContext graphContext) { if (receiverPort.Index == InputPort) { var nodeContext = (StateNodeContext)graphContext.GetOrCreateNodeContext(NodeID); nodeContext.Activating = true; - ActivateNode(graphVariables, graphContext); - OutputPorts[OnActivatePort].EmitMessage(graphVariables, graphContext); - OutputPorts[SubgraphPort].EmitMessage(graphVariables, graphContext); + ActivateNode(graphContext); + OutputPorts[OnActivatePort].EmitMessage(graphContext); + OutputPorts[SubgraphPort].EmitMessage(graphContext); nodeContext.Activating = false; HandleDeferredEmitMessages(graphContext, nodeContext); - HandleDeferredDeactivationMessages(graphVariables, graphContext, nodeContext); + HandleDeferredDeactivationMessages(graphContext, nodeContext); } else if (receiverPort.Index == AbortPort) { - OutputPorts[OnAbortPort].EmitMessage(graphVariables, graphContext); - DeactivateNode(graphVariables, graphContext); + OutputPorts[OnAbortPort].EmitMessage(graphContext); + DeactivateNode(graphContext); } } /// - protected override void EmitMessage(Variables graphVariables, IGraphContext graphContext, params int[] portIds) + protected override void EmitMessage(IGraphContext graphContext, params int[] portIds) { StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); if (nodeContext.Activating) { - foreach (var portId in portIds) - { - nodeContext.DeferredEmitMessageData.Add(new PortVariable(portId, (Variables)graphVariables.Clone())); - } + nodeContext.DeferredEmitMessageData.AddRange(portIds); return; } - base.EmitMessage(graphVariables, graphContext, portIds); + base.EmitMessage(graphContext, portIds); } /// - /// - /// This method should be used when you want to deactivate the node and Emit a message on custom event ports in - /// case of success of failure. It's important to use this method because it grantees that the messages are fired - /// in the right order. - /// + /// 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[OutputOnDeactivatePortID] (OnDeactivate) will always be called upon node deactivation and /// should not be used here. - /// - /// The graph's variables. + /// /// The graph's context. /// ID of ports you want to Emit a message to. - protected void DeactivateNodeAndEmitMessage(Variables graphVariables, IGraphContext graphContext, params int[] eventPortIds) + protected void DeactivateNodeAndEmitMessage(IGraphContext graphContext, params int[] eventPortIds) { StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); @@ -108,38 +132,37 @@ protected void DeactivateNodeAndEmitMessage(Variables graphVariables, IGraphCont return; } - DeactivateNode(graphVariables, graphContext); + DeactivateNode(graphContext); for (var i = 0; i < eventPortIds.Length; i++) { Debug.Assert(eventPortIds[i] > OnAbortPort, "DeactivateNodeAndEmitMessage should be used only with custom ports."); Debug.Assert(OutputPorts[eventPortIds[i]] is EventPort, "Only EventPorts can be used for deactivation events."); - OutputPorts[eventPortIds[i]].EmitMessage(graphVariables, graphContext); + OutputPorts[eventPortIds[i]].EmitMessage(graphContext); } } /// /// Deactivates the node without emitting any custom messages. /// - /// The graph's variables. /// The graph's context. - protected void DeactivateNode(Variables graphVariables, IGraphContext graphContext) + protected void DeactivateNode(IGraphContext graphContext) { - BeforeDisable(graphVariables, graphContext); + BeforeDisable(graphContext); foreach (OutputPort outputPort in OutputPorts) { if (outputPort is SubgraphPort subgraphPort) { - subgraphPort.EmitDisableSubgraphMessage(graphVariables, graphContext); + subgraphPort.EmitDisableSubgraphMessage(graphContext); } } - AfterDisable(graphVariables, graphContext); + AfterDisable(graphContext); } /// - protected sealed override void BeforeDisable(Variables graphVariables, IGraphContext graphContext) + protected sealed override void BeforeDisable(IGraphContext graphContext) { StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); if (nodeContext is null) @@ -152,14 +175,14 @@ protected sealed override void BeforeDisable(Variables graphVariables, IGraphCon return; } - base.BeforeDisable(graphVariables, graphContext); + base.BeforeDisable(graphContext); - OutputPorts[OnDeactivatePort].EmitMessage(graphVariables, graphContext); - ((SubgraphPort)OutputPorts[SubgraphPort]).EmitDisableSubgraphMessage(graphVariables, graphContext); + OutputPorts[OnDeactivatePort].EmitMessage(graphContext); + ((SubgraphPort)OutputPorts[SubgraphPort]).EmitDisableSubgraphMessage(graphContext); } /// - protected sealed override void AfterDisable(Variables graphVariables, IGraphContext graphContext) + protected sealed override void AfterDisable(IGraphContext graphContext) { StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); if (nodeContext is null) @@ -172,39 +195,39 @@ protected sealed override void AfterDisable(Variables graphVariables, IGraphCont return; } - base.AfterDisable(graphVariables, graphContext); + base.AfterDisable(graphContext); nodeContext.Active = false; graphContext.ActiveStateNodeCount--; - OnDeactivate(graphVariables, graphContext); + OnDeactivate(graphContext); } - private void ActivateNode(Variables graphVariables, IGraphContext graphContext) + private void ActivateNode(IGraphContext graphContext) { StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); nodeContext.Active = true; graphContext.ActiveStateNodeCount++; - OnActivate(graphVariables, graphContext); + OnActivate(graphContext); } private void HandleDeferredEmitMessages(IGraphContext graphContext, StateNodeContext nodeContext) { if (nodeContext.DeferredEmitMessageData.Count > 0) { - foreach (PortVariable emitEvent in nodeContext.DeferredEmitMessageData) + foreach (var emitEvent in nodeContext.DeferredEmitMessageData) { - OutputPorts[emitEvent.PortId].EmitMessage(emitEvent.Variables, graphContext); + OutputPorts[emitEvent].EmitMessage(graphContext); } nodeContext.DeferredEmitMessageData.Clear(); } } - private void HandleDeferredDeactivationMessages(Variables graphVariables, IGraphContext graphContext, StateNodeContext nodeContext) + private void HandleDeferredDeactivationMessages(IGraphContext graphContext, StateNodeContext nodeContext) { if (nodeContext.DeferredDeactivationEventPortIds is not null) { - DeactivateNodeAndEmitMessage(graphVariables, graphContext, nodeContext.DeferredDeactivationEventPortIds); + DeactivateNodeAndEmitMessage(graphContext, nodeContext.DeferredDeactivationEventPortIds); nodeContext.DeferredDeactivationEventPortIds = null; } } diff --git a/Forge/Statescript/Nodes/StateNodeContext.cs b/Forge/Statescript/Nodes/StateNodeContext.cs index 8a9f1e6..48cc6f2 100644 --- a/Forge/Statescript/Nodes/StateNodeContext.cs +++ b/Forge/Statescript/Nodes/StateNodeContext.cs @@ -3,8 +3,9 @@ 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. +/// 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 { @@ -17,7 +18,5 @@ public class StateNodeContext : INodeContext internal int[]? DeferredDeactivationEventPortIds { get; set; } - internal List DeferredEmitMessageData { get; set; } = []; + internal List DeferredEmitMessageData { get; set; } = []; } - -internal record struct PortVariable(int PortId, Variables Variables); diff --git a/Forge/Statescript/Ports/InputPort.cs b/Forge/Statescript/Ports/InputPort.cs index bc53786..1483633 100644 --- a/Forge/Statescript/Ports/InputPort.cs +++ b/Forge/Statescript/Ports/InputPort.cs @@ -29,20 +29,18 @@ public void SetOwnerNode(Node ownerNode) /// /// Receives a message and notifies the owner node. /// - /// The graph variables to include in the message. /// The graph context for the message. - public void ReceiveMessage(Variables graphVariables, IGraphContext graphContext) + public void ReceiveMessage(IGraphContext graphContext) { - _ownerNode?.OnMessageReceived(this, graphVariables, graphContext); + _ownerNode?.OnMessageReceived(this, graphContext); } /// /// Receives a disable subgraph message and notifies the owner node. /// - /// The graph variables to include in the message. /// The graph context for the message. - public void ReceiveDisableSubgraphMessage(Variables graphVariables, IGraphContext graphContext) + public void ReceiveDisableSubgraphMessage(IGraphContext graphContext) { - _ownerNode?.OnSubgraphDisabledMessageReceived(graphVariables, graphContext); + _ownerNode?.OnSubgraphDisabledMessageReceived(graphContext); } } diff --git a/Forge/Statescript/Ports/OutputPort.cs b/Forge/Statescript/Ports/OutputPort.cs index 4867cc9..ed1ef8a 100644 --- a/Forge/Statescript/Ports/OutputPort.cs +++ b/Forge/Statescript/Ports/OutputPort.cs @@ -40,21 +40,21 @@ public void Connect(InputPort inputPort) ConnectedPorts.Add(inputPort); } - internal void EmitMessage(Variables graphVariables, IGraphContext graphContext) + internal void EmitMessage(IGraphContext graphContext) { foreach (InputPort inputPort in ConnectedPorts) { - inputPort.ReceiveMessage(graphVariables, graphContext); + inputPort.ReceiveMessage(graphContext); } OnEmitMessage?.Invoke(PortID); } - internal void InternalEmitDisableSubgraphMessage(Variables graphVariables, IGraphContext graphContext) + internal void InternalEmitDisableSubgraphMessage(IGraphContext graphContext) { foreach (InputPort inputPort in ConnectedPorts) { - inputPort.ReceiveDisableSubgraphMessage(graphVariables, graphContext); + inputPort.ReceiveDisableSubgraphMessage(graphContext); } OnEmitMessageDisableSubgraphMessage?.Invoke(PortID); diff --git a/Forge/Statescript/Ports/SubgraphPort.cs b/Forge/Statescript/Ports/SubgraphPort.cs index 7b660bb..63b3eda 100644 --- a/Forge/Statescript/Ports/SubgraphPort.cs +++ b/Forge/Statescript/Ports/SubgraphPort.cs @@ -18,13 +18,12 @@ public SubgraphPort() /// /// Emits a disable subgraph message to all connected input ports. /// - /// The graph variables to include in the message. /// The graph context for the message. - public void EmitDisableSubgraphMessage(Variables graphVariables, IGraphContext graphContext) + public void EmitDisableSubgraphMessage(IGraphContext graphContext) { foreach (InputPort inputPort in ConnectedPorts) { - inputPort.ReceiveDisableSubgraphMessage(graphVariables, graphContext); + inputPort.ReceiveDisableSubgraphMessage(graphContext); } } } diff --git a/Forge/Statescript/Variables.cs b/Forge/Statescript/Variables.cs index 9a35aba..feffc52 100644 --- a/Forge/Statescript/Variables.cs +++ b/Forge/Statescript/Variables.cs @@ -113,6 +113,18 @@ public bool TryGetVar(StringKey name, out T value) return true; } + /// + /// Loads variable definitions and values from another instance, replacing the current + /// variable set. This is typically used to initialize runtime variables from a graph's default variable + /// definitions. + /// + /// The source variables to copy from. + public void LoadFrom(Variables source) + { + _variables = new Dictionary(source._variables); + _savedVariables = null; + } + /// public object Clone() { From 63aa69e45fd861553a565ded867f9d2caf92eaae Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 9 Feb 2026 22:11:05 -0300 Subject: [PATCH 07/19] Added property resolvers --- Forge.Tests/Statescript/StatescriptTests.cs | 412 ++++++++++++++++-- Forge/Statescript/Graph.cs | 13 +- Forge/Statescript/GraphRunner.cs | 16 +- Forge/Statescript/GraphVariableDefinitions.cs | 94 ++++ Forge/Statescript/IGraphContext.cs | 13 +- .../Nodes/Action/SetVariableNode.cs | 36 ++ Forge/Statescript/Nodes/ActionNode.cs | 11 +- Forge/Statescript/Nodes/ConditionNode.cs | 17 +- Forge/Statescript/Nodes/EntryNode.cs | 19 +- Forge/Statescript/Nodes/ExitNode.cs | 7 +- .../Nodes/State/TimerNodeContext.cs | 15 + .../Statescript/Nodes/State/TimerStateNode.cs | 53 +++ Forge/Statescript/Nodes/StateNode.cs | 52 ++- .../Properties/AttributeResolver.cs | 39 ++ .../Properties/IPropertyResolver.cs | 22 + Forge/Statescript/Properties/TagResolver.cs | 33 ++ .../Statescript/Properties/VariantResolver.cs | 61 +++ Forge/Statescript/PropertyDefinition.cs | 14 + Forge/Statescript/Variables.cs | 186 ++++---- 19 files changed, 946 insertions(+), 167 deletions(-) create mode 100644 Forge/Statescript/GraphVariableDefinitions.cs create mode 100644 Forge/Statescript/Nodes/Action/SetVariableNode.cs create mode 100644 Forge/Statescript/Nodes/State/TimerNodeContext.cs create mode 100644 Forge/Statescript/Nodes/State/TimerStateNode.cs create mode 100644 Forge/Statescript/Properties/AttributeResolver.cs create mode 100644 Forge/Statescript/Properties/IPropertyResolver.cs create mode 100644 Forge/Statescript/Properties/TagResolver.cs create mode 100644 Forge/Statescript/Properties/VariantResolver.cs create mode 100644 Forge/Statescript/PropertyDefinition.cs diff --git a/Forge.Tests/Statescript/StatescriptTests.cs b/Forge.Tests/Statescript/StatescriptTests.cs index d620cf3..7fbca59 100644 --- a/Forge.Tests/Statescript/StatescriptTests.cs +++ b/Forge.Tests/Statescript/StatescriptTests.cs @@ -1,8 +1,11 @@ // Copyright © Gamesmiths Guild. using FluentAssertions; +using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Statescript; using Gamesmiths.Forge.Statescript.Nodes; +using Gamesmiths.Forge.Statescript.Nodes.Action; +using Gamesmiths.Forge.Statescript.Nodes.State; namespace Gamesmiths.Forge.Tests.Statescript; @@ -24,7 +27,7 @@ public void New_graph_has_an_entry_node() public void Graph_runner_clones_variables_on_start() { var graph = new Graph(); - graph.GraphVariables.SetVar("health", 100); + graph.VariableDefinitions.DefineVariable("health", 100); var context = new TestGraphContext(); var runner = new GraphRunner(graph, context); @@ -43,7 +46,7 @@ public void Starting_graph_executes_connected_action_node() var actionNode = new TrackingActionNode(); graph.AddNode(actionNode); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[0], actionNode.InputPorts[0])); + graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], actionNode.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); var runner = new GraphRunner(graph, context); @@ -67,9 +70,9 @@ public void Action_nodes_execute_in_sequence() graph.AddNode(action2); graph.AddNode(action3); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[0], action1.InputPorts[0])); - graph.AddConnection(new Connection(action1.OutputPorts[0], action2.InputPorts[0])); - graph.AddConnection(new Connection(action2.OutputPorts[0], action3.InputPorts[0])); + 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 context = new TestGraphContext(); var runner = new GraphRunner(graph, context); @@ -91,9 +94,9 @@ public void Condition_node_routes_to_true_port_when_condition_is_met() graph.AddNode(trueAction); graph.AddNode(falseAction); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[0], condition.InputPorts[0])); - graph.AddConnection(new Connection(condition.OutputPorts[0], trueAction.InputPorts[0])); - graph.AddConnection(new Connection(condition.OutputPorts[1], falseAction.InputPorts[0])); + 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 context = new TestGraphContext(); var runner = new GraphRunner(graph, context); @@ -116,9 +119,9 @@ public void Condition_node_routes_to_false_port_when_condition_is_not_met() graph.AddNode(trueAction); graph.AddNode(falseAction); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[0], condition.InputPorts[0])); - graph.AddConnection(new Connection(condition.OutputPorts[0], trueAction.InputPorts[0])); - graph.AddConnection(new Connection(condition.OutputPorts[1], falseAction.InputPorts[0])); + 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 context = new TestGraphContext(); var runner = new GraphRunner(graph, context); @@ -133,7 +136,7 @@ public void Condition_node_routes_to_false_port_when_condition_is_not_met() public void Action_node_can_read_and_write_graph_variables() { var graph = new Graph(); - graph.GraphVariables.SetVar("counter", 0); + graph.VariableDefinitions.DefineVariable("counter", 0); var incrementNode = new IncrementCounterNode("counter"); var readNode = new ReadVariableNode("counter"); @@ -141,8 +144,8 @@ public void Action_node_can_read_and_write_graph_variables() graph.AddNode(incrementNode); graph.AddNode(readNode); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[0], incrementNode.InputPorts[0])); - graph.AddConnection(new Connection(incrementNode.OutputPorts[0], readNode.InputPorts[0])); + 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 context = new TestGraphContext(); var runner = new GraphRunner(graph, context); @@ -156,8 +159,8 @@ public void Action_node_can_read_and_write_graph_variables() public void Condition_node_can_branch_based_on_graph_variables() { var graph = new Graph(); - graph.GraphVariables.SetVar("threshold", 10); - graph.GraphVariables.SetVar("value", 15); + graph.VariableDefinitions.DefineVariable("threshold", 10); + graph.VariableDefinitions.DefineVariable("value", 15); var condition = new ThresholdConditionNode("value", "threshold"); var aboveAction = new TrackingActionNode(); @@ -167,9 +170,9 @@ public void Condition_node_can_branch_based_on_graph_variables() graph.AddNode(aboveAction); graph.AddNode(belowAction); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[0], condition.InputPorts[0])); - graph.AddConnection(new Connection(condition.OutputPorts[0], aboveAction.InputPorts[0])); - graph.AddConnection(new Connection(condition.OutputPorts[1], belowAction.InputPorts[0])); + 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 context = new TestGraphContext(); var runner = new GraphRunner(graph, context); @@ -190,8 +193,8 @@ public void Output_port_can_connect_to_multiple_input_ports() graph.AddNode(action1); graph.AddNode(action2); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[0], action1.InputPorts[0])); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[0], action2.InputPorts[0])); + 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 context = new TestGraphContext(); var runner = new GraphRunner(graph, context); @@ -206,12 +209,12 @@ public void Output_port_can_connect_to_multiple_input_ports() public void Stopping_graph_resets_variables_to_saved_state() { var graph = new Graph(); - graph.GraphVariables.SetVar("counter", 0); + graph.VariableDefinitions.DefineVariable("counter", 0); var incrementNode = new IncrementCounterNode("counter"); graph.AddNode(incrementNode); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[0], incrementNode.InputPorts[0])); + graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], incrementNode.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); var runner = new GraphRunner(graph, context); @@ -220,12 +223,8 @@ public void Stopping_graph_resets_variables_to_saved_state() context.GraphVariables.TryGetVar("counter", out int valueAfterStart).Should().BeTrue(); valueAfterStart.Should().Be(1); - // StopGraph calls LoadVariableValues and cleans up - verify it doesn't throw. + // StopGraph cleans up node contexts - verify it doesn't throw. runner.StopGraph(); - - // Original graph variables should remain at 0 (they were cloned). - graph.GraphVariables.TryGetVar("counter", out int originalValue).Should().BeTrue(); - originalValue.Should().Be(0); } [Fact] @@ -236,7 +235,7 @@ public void Stopping_graph_removes_all_node_contexts() var actionNode = new TrackingActionNode(); graph.AddNode(actionNode); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[0], actionNode.InputPorts[0])); + graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], actionNode.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); var runner = new GraphRunner(graph, context); @@ -257,7 +256,7 @@ public void Complex_graph_with_condition_and_multiple_actions_executes_correctly var executionOrder = new List(); var graph = new Graph(); - graph.GraphVariables.SetVar("counter", 0); + graph.VariableDefinitions.DefineVariable("counter", 0); var incrementNode = new IncrementCounterNode("counter"); var condition = new ThresholdConditionNode("counter", threshold: 0); @@ -269,10 +268,10 @@ public void Complex_graph_with_condition_and_multiple_actions_executes_correctly graph.AddNode(trackA); graph.AddNode(trackB); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[0], incrementNode.InputPorts[0])); - graph.AddConnection(new Connection(incrementNode.OutputPorts[0], condition.InputPorts[0])); - graph.AddConnection(new Connection(condition.OutputPorts[0], trackA.InputPorts[0])); - graph.AddConnection(new Connection(condition.OutputPorts[1], trackB.InputPorts[0])); + 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 context = new TestGraphContext(); var runner = new GraphRunner(graph, context); @@ -294,7 +293,7 @@ public void Disconnected_node_is_not_executed() graph.AddNode(connectedAction); graph.AddNode(disconnectedAction); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[0], connectedAction.InputPorts[0])); + graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], connectedAction.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); var runner = new GraphRunner(graph, context); @@ -309,11 +308,11 @@ public void Disconnected_node_is_not_executed() public void Each_graph_runner_has_independent_variable_state() { var graph = new Graph(); - graph.GraphVariables.SetVar("counter", 0); + graph.VariableDefinitions.DefineVariable("counter", 0); var incrementNode = new IncrementCounterNode("counter"); graph.AddNode(incrementNode); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[0], incrementNode.InputPorts[0])); + graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], incrementNode.InputPorts[ActionNode.InputPort])); var context1 = new TestGraphContext(); var runner1 = new GraphRunner(graph, context1); @@ -329,10 +328,339 @@ public void Each_graph_runner_has_independent_variable_state() value1.Should().Be(1); value2.Should().Be(1); + } + + [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 TimerStateNode("duration"); + + graph.AddNode(timer); + graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[ActionNode.InputPort])); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.StartGraph(); + + context.IsActive.Should().BeTrue(); + + // Not enough time has passed. + runner.UpdateGraph(1.0); + context.IsActive.Should().BeTrue(); + + // Still not enough. + runner.UpdateGraph(0.5); + context.IsActive.Should().BeTrue(); + + // Now it should deactivate (total: 1.0 + 0.5 + 0.5 = 2.0). + runner.UpdateGraph(0.5); + context.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 TimerStateNode("duration"); + var onDeactivateAction = new TrackingActionNode(); + + graph.AddNode(timer); + graph.AddNode(onDeactivateAction); + + graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[ActionNode.InputPort])); + + // OutputPorts[1] is the OnDeactivate event port on StateNode. + graph.AddConnection(new Connection(timer.OutputPorts[TimerStateNode.OnDeactivatePort], onDeactivateAction.InputPorts[ActionNode.InputPort])); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.StartGraph(); + + onDeactivateAction.ExecutionCount.Should().Be(0); + + runner.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 TimerStateNode("duration"); + var onActivateAction = new TrackingActionNode(); + + graph.AddNode(timer); + graph.AddNode(onActivateAction); + + graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[ActionNode.InputPort])); + + // OutputPorts[0] is the OnActivate event port on StateNode. + graph.AddConnection(new Connection(timer.OutputPorts[TimerStateNode.OnActivatePort], onActivateAction.InputPorts[ActionNode.InputPort])); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.StartGraph(); + + onActivateAction.ExecutionCount.Should().Be(1); + } + + [Fact] + [Trait("Graph", "Timer")] + public void Two_runners_with_same_timer_graph_have_independent_elapsed_time() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 2.0); + + var timer = new TimerStateNode("duration"); + + graph.AddNode(timer); + graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[ActionNode.InputPort])); + + var context1 = new TestGraphContext(); + var runner1 = new GraphRunner(graph, context1); + + var context2 = new TestGraphContext(); + var runner2 = new GraphRunner(graph, context2); + + runner1.StartGraph(); + runner2.StartGraph(); + + // Advance runner1 past duration, but not runner2. + runner1.UpdateGraph(2.0); + runner2.UpdateGraph(1.0); + + context1.IsActive.Should().BeFalse(); + context2.IsActive.Should().BeTrue(); + + // Now advance runner2 past duration. + runner2.UpdateGraph(1.0); + context2.IsActive.Should().BeFalse(); + } + + [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", "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 context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.StartGraph(); - // Original graph variables should remain unchanged. - graph.GraphVariables.TryGetVar("counter", out int originalValue); - originalValue.Should().Be(0); + 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 context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.StartGraph(); + + // counter was incremented to 1, then copied to result + 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 context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.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 context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.StartGraph(); + + // Target should remain unchanged because source doesn't exist + 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 context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.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 context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.StartGraph(); + + readNode.LastReadValue.Should().BeTrue(); + } + + [Fact] + [Trait("Graph", "SetVariable")] + public void Two_runners_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 context1 = new TestGraphContext(); + var runner1 = new GraphRunner(graph, context1); + + var context2 = new TestGraphContext(); + var runner2 = new GraphRunner(graph, context2); + + runner1.StartGraph(); + runner2.StartGraph(); + + context1.GraphVariables.TryGetVar("target", out int value1); + context2.GraphVariables.TryGetVar("target", out int value2); + + // Both should have source incremented from 10 to 11, then copied to target + value1.Should().Be(11); + value2.Should().Be(11); } private sealed class TestGraphContext : IGraphContext @@ -343,6 +671,8 @@ private sealed class TestGraphContext : IGraphContext public bool IsActive => ActiveStateNodeCount > 0; + public IForgeEntity? Owner { get; set; } + public Variables GraphVariables { get; } = new Variables(); public Dictionary InternalNodeActivationStatus { get; } = []; @@ -429,7 +759,7 @@ protected override bool Test(IGraphContext graphContext) { graphContext.GraphVariables.TryGetVar(_variableName, out int value); - int threshold = _fixedThreshold; + var threshold = _fixedThreshold; if (_thresholdVariableName is not null) { graphContext.GraphVariables.TryGetVar(_thresholdVariableName, out threshold); diff --git a/Forge/Statescript/Graph.cs b/Forge/Statescript/Graph.cs index 5780518..a30c69b 100644 --- a/Forge/Statescript/Graph.cs +++ b/Forge/Statescript/Graph.cs @@ -7,8 +7,10 @@ namespace Gamesmiths.Forge.Statescript; /// /// Represents a Statescript graph definition consisting of nodes and connections. This class is immutable after /// construction and can be shared across multiple instances (Flyweight pattern). -/// All mutable runtime state lives in . /// +/// +/// All mutable runtime state lives in . +/// public class Graph { /// @@ -27,10 +29,11 @@ public class Graph public List Connections { get; } /// - /// Gets the default variable definitions for the graph. These are cloned into each - /// when a graph execution starts, providing each runner with independent variable state. + /// 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 Variables GraphVariables { get; } + public GraphVariableDefinitions VariableDefinitions { get; } /// /// Initializes a new instance of the class. @@ -40,7 +43,7 @@ public Graph() EntryNode = new EntryNode(); Nodes = []; Connections = []; - GraphVariables = new Variables(); + VariableDefinitions = new GraphVariableDefinitions(); } /// diff --git a/Forge/Statescript/GraphRunner.cs b/Forge/Statescript/GraphRunner.cs index 7636604..105b4f1 100644 --- a/Forge/Statescript/GraphRunner.cs +++ b/Forge/Statescript/GraphRunner.cs @@ -25,25 +25,25 @@ public class GraphRunner(Graph graph, IGraphContext graphContext) public Graph Graph { get; } = graph; /// - /// Gets the context in which the graph is executed. The context holds all mutable runtime state including - /// variable values, node contexts, and activation status. + /// 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 IGraphContext GraphContext { get; } = graphContext; /// - /// Starts the execution of the graph. This method clones the graph's default variables into the context - /// to ensure that each execution instance has independent state, and then initiates the graph's entry node - /// to begin processing. + /// 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. /// public void StartGraph() { - GraphContext.GraphVariables.LoadFrom(Graph.GraphVariables); + GraphContext.GraphVariables.InitializeFrom(Graph.VariableDefinitions); Graph.EntryNode.StartGraph(GraphContext); } /// - /// Updates all active state nodes in the graph with the given delta time. Call this method in your game loop - /// to drive time-dependent state node logic such as timers, animations, or continuous evaluation. + /// Updates all active state nodes in the graph with the given delta time. 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) diff --git a/Forge/Statescript/GraphVariableDefinitions.cs b/Forge/Statescript/GraphVariableDefinitions.cs new file mode 100644 index 0000000..49aeb47 --- /dev/null +++ b/Forge/Statescript/GraphVariableDefinitions.cs @@ -0,0 +1,94 @@ +// 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 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/IGraphContext.cs b/Forge/Statescript/IGraphContext.cs index 1ceb9e1..624b80c 100644 --- a/Forge/Statescript/IGraphContext.cs +++ b/Forge/Statescript/IGraphContext.cs @@ -1,5 +1,7 @@ // Copyright © Gamesmiths Guild. +using Gamesmiths.Forge.Core; + namespace Gamesmiths.Forge.Statescript; /// @@ -21,8 +23,15 @@ public interface IGraphContext bool IsActive { get; } /// - /// Gets the runtime variables for this graph execution instance. These are cloned from the graph's default - /// variable definitions when the graph starts, ensuring each execution has independent state. + /// Gets the optional owner entity for this graph execution. The owner provides access to entity attributes, tags, + /// and other systems that property resolvers can use to compute derived values. May be if + /// the graph does not require an owner entity. + /// + IForgeEntity? Owner { get; } + + /// + /// 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. /// Variables GraphVariables { get; } diff --git a/Forge/Statescript/Nodes/Action/SetVariableNode.cs b/Forge/Statescript/Nodes/Action/SetVariableNode.cs new file mode 100644 index 0000000..973f32e --- /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(IGraphContext 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 index 9b9c3af..9800cca 100644 --- a/Forge/Statescript/Nodes/ActionNode.cs +++ b/Forge/Statescript/Nodes/ActionNode.cs @@ -10,8 +10,15 @@ namespace Gamesmiths.Forge.Statescript.Nodes; /// public abstract class ActionNode : Node { - private const byte InputPort = 0; - private const byte OutputPort = 0; + /// + /// 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. diff --git a/Forge/Statescript/Nodes/ConditionNode.cs b/Forge/Statescript/Nodes/ConditionNode.cs index ee1f49f..08b0749 100644 --- a/Forge/Statescript/Nodes/ConditionNode.cs +++ b/Forge/Statescript/Nodes/ConditionNode.cs @@ -10,9 +10,20 @@ namespace Gamesmiths.Forge.Statescript.Nodes; /// public abstract class ConditionNode : Node { - private const byte InputPort = 0; - private const byte TruePort = 0; - private const byte FalsePort = 1; + /// + /// Port index for the input port. + /// + public const byte InputPort = 0; + + /// + /// Port index for the true output port. + /// + public const byte TruePort = 1; + + /// + /// Port index for the false output port. + /// + public const byte FalsePort = 2; /// /// Tests the condition and returns true or false. The result determines which output port will emit a message. diff --git a/Forge/Statescript/Nodes/EntryNode.cs b/Forge/Statescript/Nodes/EntryNode.cs index c53d5a7..7cbe34c 100644 --- a/Forge/Statescript/Nodes/EntryNode.cs +++ b/Forge/Statescript/Nodes/EntryNode.cs @@ -10,34 +10,33 @@ namespace Gamesmiths.Forge.Statescript.Nodes; /// public class EntryNode : Node { - private const byte InputPort = 0; + /// + /// Port index for the output port. + /// + public const byte OutputPort = 0; /// - /// Starts the graph execution by saving the current variable values and emitting a message through the output - /// port. + /// 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(IGraphContext graphContext) { - graphContext.GraphVariables.SaveVariableValues(); - OutputPorts[InputPort].EmitMessage(graphContext); + OutputPorts[OutputPort].EmitMessage(graphContext); } /// - /// Stops the graph execution by emitting a disable message through the output port and loading the previous - /// variable values. + /// 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(IGraphContext graphContext) { - ((SubgraphPort)OutputPorts[InputPort]).EmitDisableSubgraphMessage(graphContext); - graphContext.GraphVariables.LoadVariableValues(); + ((SubgraphPort)OutputPorts[OutputPort]).EmitDisableSubgraphMessage(graphContext); } /// protected override void DefinePorts(List inputPorts, List outputPorts) { - outputPorts.Add(CreatePort(InputPort)); + outputPorts.Add(CreatePort(OutputPort)); } /// diff --git a/Forge/Statescript/Nodes/ExitNode.cs b/Forge/Statescript/Nodes/ExitNode.cs index d3b4d40..fad5f34 100644 --- a/Forge/Statescript/Nodes/ExitNode.cs +++ b/Forge/Statescript/Nodes/ExitNode.cs @@ -10,12 +10,15 @@ namespace Gamesmiths.Forge.Statescript.Nodes; /// public class ExitNode : Node { - private const byte InputPortIndex = 0; + /// + /// Port index for the input port. + /// + public const byte InputPort = 0; /// protected override void DefinePorts(List inputPorts, List outputPorts) { - inputPorts.Add(CreatePort(InputPortIndex)); + inputPorts.Add(CreatePort(InputPort)); } /// diff --git a/Forge/Statescript/Nodes/State/TimerNodeContext.cs b/Forge/Statescript/Nodes/State/TimerNodeContext.cs new file mode 100644 index 0000000..6be14da --- /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/State/TimerStateNode.cs b/Forge/Statescript/Nodes/State/TimerStateNode.cs new file mode 100644 index 0000000..deac511 --- /dev/null +++ b/Forge/Statescript/Nodes/State/TimerStateNode.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 TimerStateNode(StringKey durationPropertyName) : StateNode +{ + private readonly StringKey _durationPropertyName = durationPropertyName; + + /// + protected override void OnActivate(IGraphContext graphContext) + { + TimerNodeContext nodeContext = graphContext.GetOrCreateNodeContext(NodeID); + nodeContext.ElapsedTime = 0; + } + + /// + protected override void OnDeactivate(IGraphContext graphContext) + { + } + + /// + protected override void OnUpdate(double deltaTime, IGraphContext 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/StateNode.cs b/Forge/Statescript/Nodes/StateNode.cs index d2bb412..ffa3127 100644 --- a/Forge/Statescript/Nodes/StateNode.cs +++ b/Forge/Statescript/Nodes/StateNode.cs @@ -1,6 +1,7 @@ // Copyright © Gamesmiths Guild. using System.Diagnostics; +using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Statescript.Ports; namespace Gamesmiths.Forge.Statescript.Nodes; @@ -13,12 +14,35 @@ namespace Gamesmiths.Forge.Statescript.Nodes; public abstract class StateNode : Node where T : StateNodeContext, new() { - private const byte InputPort = 0; - private const byte AbortPort = 1; - private const byte OnActivatePort = 0; - private const byte OnDeactivatePort = 1; - private const byte OnAbortPort = 2; - private const byte SubgraphPort = 3; + /// + /// Port index for the input port. + /// + 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; /// /// Called when the node is activated. @@ -52,8 +76,8 @@ internal override void Update(double deltaTime, IGraphContext graphContext) } /// - /// 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. + /// 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. @@ -117,8 +141,8 @@ protected override void EmitMessage(IGraphContext graphContext, params int[] por /// 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[OutputOnDeactivatePortID] (OnDeactivate) will always be called upon node deactivation and - /// should not be used here. + /// 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. @@ -136,8 +160,12 @@ protected void DeactivateNodeAndEmitMessage(IGraphContext graphContext, params i for (var i = 0; i < eventPortIds.Length; i++) { - Debug.Assert(eventPortIds[i] > OnAbortPort, "DeactivateNodeAndEmitMessage should be used only with custom ports."); - Debug.Assert(OutputPorts[eventPortIds[i]] is EventPort, "Only EventPorts can be used for deactivation events."); + 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); } } diff --git a/Forge/Statescript/Properties/AttributeResolver.cs b/Forge/Statescript/Properties/AttributeResolver.cs new file mode 100644 index 0000000..ea3cc0c --- /dev/null +++ b/Forge/Statescript/Properties/AttributeResolver.cs @@ -0,0 +1,39 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Statescript.Properties; + +/// +/// Resolves a property value by reading the current value of a specific attribute from the graph owner's entity. +/// Returns the attribute's as an stored in a +/// . +/// +/// +/// If the graph context has no owner 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(IGraphContext graphContext) + { + if (graphContext.Owner is null) + { + return default; + } + + if (!graphContext.Owner.Attributes.ContainsAttribute(_attributeKey)) + { + return default; + } + + return new Variant128(graphContext.Owner.Attributes[_attributeKey].CurrentValue); + } +} diff --git a/Forge/Statescript/Properties/IPropertyResolver.cs b/Forge/Statescript/Properties/IPropertyResolver.cs new file mode 100644 index 0000000..c58231f --- /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(IGraphContext graphContext); +} diff --git a/Forge/Statescript/Properties/TagResolver.cs b/Forge/Statescript/Properties/TagResolver.cs new file mode 100644 index 0000000..acfa1bf --- /dev/null +++ b/Forge/Statescript/Properties/TagResolver.cs @@ -0,0 +1,33 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Statescript.Properties; + +/// +/// Resolves a property value by checking whether the graph owner entity has a specific tag. Returns +/// stored in a ; if the entity has +/// the tag, otherwise. +/// +/// +/// If the graph context has no owner, 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(IGraphContext graphContext) + { + if (graphContext.Owner is null) + { + return new Variant128(false); + } + + return new Variant128(graphContext.Owner.Tags.CombinedTags.HasTag(_tag)); + } +} diff --git a/Forge/Statescript/Properties/VariantResolver.cs b/Forge/Statescript/Properties/VariantResolver.cs new file mode 100644 index 0000000..be51f65 --- /dev/null +++ b/Forge/Statescript/Properties/VariantResolver.cs @@ -0,0 +1,61 @@ +// 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; + + /// + public Variant128 Resolve(IGraphContext 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 = 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"), + }; + } +} 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 index feffc52..6b4aeaf 100644 --- a/Forge/Statescript/Variables.cs +++ b/Forge/Statescript/Variables.cs @@ -1,99 +1,98 @@ // Copyright © Gamesmiths Guild. -using System.Numerics; using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Statescript.Properties; namespace Gamesmiths.Forge.Statescript; /// -/// Represents a collection of variables used within a Statescript graph. +/// Represents the runtime state of variables and properties during a graph execution. /// -public class Variables : ICloneable +public class Variables { - private Dictionary? _savedVariables; - - private Dictionary _variables; + private readonly Dictionary _propertyResolvers = []; /// - /// Gets or sets the variable with the specified key. + /// 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 key of the variable. - /// The variable associated with the specified key. - public Variant128 this[StringKey key] + /// The graph variable definitions to initialize from. + public void InitializeFrom(GraphVariableDefinitions definitions) { - get => _variables[key]; - set => _variables[key] = value; - } + _propertyResolvers.Clear(); - /// - /// Initializes a new instance of the class. - /// - public Variables() - { - _variables = []; + foreach (PropertyDefinition definition in definitions.Definitions) + { + if (definition.Resolver is VariantResolver variantResolver) + { + _propertyResolvers[definition.Name] = + new VariantResolver(variantResolver.Value, variantResolver.ValueType); + } + else + { + _propertyResolvers[definition.Name] = definition.Resolver; + } + } } /// - /// Saves the current variable values. + /// 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. /// - public void SaveVariableValues() + /// 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, IGraphContext graphContext, out T value) + where T : unmanaged { - _savedVariables = _variables; - } + value = default; - /// - /// Loads the saved variable values. - /// - public void LoadVariableValues() - { - if (_savedVariables is null) + if (!_propertyResolvers.TryGetValue(name, out IPropertyResolver? resolver)) { - return; + return false; } - _variables = _savedVariables; + Variant128 resolved = resolver.Resolve(graphContext); + value = resolved.Get(); + + return true; } /// - /// Sets the variable with the given name to the given value. + /// Tries to get the resolved value of a variable or property as a raw . /// - /// The type of the value to set. Must be supported by Variant128. - /// The name of the variable to set. - /// The value to set the variable to. - /// if the variable was set successfully, otherwise. - /// - /// Thrown if the type T is not supported by Variant128. - public bool SetVar(StringKey name, T value) + /// 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, IGraphContext graphContext, out Variant128 value) { - _variables[name] = value switch + value = default; + + if (!_propertyResolvers.TryGetValue(name, out IPropertyResolver? resolver)) { - 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"), - }; + return false; + } + + value = resolver.Resolve(graphContext); return true; } /// - /// Tries to get the variable with the given name. + /// Tries to get the value of a mutable variable with the given name. /// - /// The type of the variable to get. Must be supported by Variant128. + /// + /// 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, @@ -103,44 +102,67 @@ public bool TryGetVar(StringKey name, out T value) { value = default; - if (!_variables.TryGetValue(name, out Variant128 variant)) + if (!_propertyResolvers.TryGetValue(name, out IPropertyResolver? resolver)) { return false; } - value = variant.Get(); + Variant128 resolved = resolver.Resolve(null!); + value = resolved.Get(); return true; } /// - /// Loads variable definitions and values from another instance, replacing the current - /// variable set. This is typically used to initialize runtime variables from a graph's default variable - /// definitions. + /// 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 source variables to copy from. - public void LoadFrom(Variables source) + /// 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) { - _variables = new Dictionary(source._variables); - _savedVariables = null; + 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; } - /// - public object Clone() + /// + /// 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) { - var copy = new Variables(); - - if (_savedVariables is not null) + if (!_propertyResolvers.TryGetValue(name, out IPropertyResolver? resolver)) { - copy._savedVariables = new Dictionary(_savedVariables); + throw new InvalidOperationException( + $"Cannot set '{name}': no variable or property with this name exists."); } - else + + if (resolver is not VariantResolver variableResolver) { - copy._savedVariables = null; + throw new InvalidOperationException( + $"Cannot set '{name}': it is a read-only property. Only variables can be set at runtime."); } - copy._variables = new Dictionary(_variables); - - return copy; + variableResolver.Value = value; } } From 5f708583d1f28400f4ba349c94593347a02aac7a Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 10 Feb 2026 21:25:26 -0300 Subject: [PATCH 08/19] Fixed ConditionNode port indexes --- Forge/Statescript/Nodes/ConditionNode.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Forge/Statescript/Nodes/ConditionNode.cs b/Forge/Statescript/Nodes/ConditionNode.cs index 08b0749..2fb9eac 100644 --- a/Forge/Statescript/Nodes/ConditionNode.cs +++ b/Forge/Statescript/Nodes/ConditionNode.cs @@ -18,12 +18,12 @@ public abstract class ConditionNode : Node /// /// Port index for the true output port. /// - public const byte TruePort = 1; + public const byte TruePort = 0; /// /// Port index for the false output port. /// - public const byte FalsePort = 2; + public const byte FalsePort = 1; /// /// Tests the condition and returns true or false. The result determines which output port will emit a message. From 62a0a640710a18ecb1e18fee395d026c774a7845 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 14 Feb 2026 18:24:09 -0300 Subject: [PATCH 09/19] Implemented ExitNode and improved Update --- Forge.Tests/Statescript/StatescriptTests.cs | 280 +++++++++++++++++++- Forge/Statescript/Graph.cs | 11 +- Forge/Statescript/GraphRunner.cs | 68 ++++- Forge/Statescript/IGraphContext.cs | 27 +- Forge/Statescript/Nodes/ExitNode.cs | 11 +- Forge/Statescript/Nodes/StateNode.cs | 12 +- 6 files changed, 375 insertions(+), 34 deletions(-) diff --git a/Forge.Tests/Statescript/StatescriptTests.cs b/Forge.Tests/Statescript/StatescriptTests.cs index 7fbca59..771db25 100644 --- a/Forge.Tests/Statescript/StatescriptTests.cs +++ b/Forge.Tests/Statescript/StatescriptTests.cs @@ -232,21 +232,23 @@ public void Stopping_graph_resets_variables_to_saved_state() public void Stopping_graph_removes_all_node_contexts() { var graph = new Graph(); - var actionNode = new TrackingActionNode(); + graph.VariableDefinitions.DefineVariable("duration", 5.0); - graph.AddNode(actionNode); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], actionNode.InputPorts[ActionNode.InputPort])); + var timer = new TimerStateNode("duration"); + graph.AddNode(timer); + graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); var context = new TestGraphContext(); var runner = new GraphRunner(graph, context); runner.StartGraph(); - // ActionNodes register in InternalNodeActivationStatus when they receive messages. context.InternalNodeActivationStatus.Should().NotBeEmpty(); + context.NodeContextCount.Should().BePositive(); runner.StopGraph(); context.NodeContextCount.Should().Be(0); + context.InternalNodeActivationStatus.Should().BeEmpty(); } [Fact] @@ -663,13 +665,271 @@ public void Two_runners_using_set_variable_have_independent_state() value2.Should().Be(11); } + [Fact] + [Trait("Graph", "ExitNode")] + public void Exit_node_stops_graph_execution() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 5.0); + + var timer = new TimerStateNode("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 context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.StartGraph(); + + context.IsActive.Should().BeTrue(); + + // Timer deactivates after 5 seconds, which triggers ExitNode. + runner.UpdateGraph(5.0); + + context.IsActive.Should().BeFalse(); + context.NodeContextCount.Should().Be(0); + context.Runner.Should().BeNull(); + } + + [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 context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.StartGraph(); + + actionNode.ExecutionCount.Should().Be(1); + context.NodeContextCount.Should().Be(0); + context.Runner.Should().BeNull(); + } + + [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 TimerStateNode("shortDuration"); + var longTimer = new TimerStateNode("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])); + + // When the short timer completes, exit the graph (which should also stop the long timer). + graph.AddConnection(new Connection(shortTimer.OutputPorts[StateNode.OnDeactivatePort], exitNode.InputPorts[ExitNode.InputPort])); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.StartGraph(); + + context.ActiveStateNodes.Should().HaveCount(2); + + // Short timer elapses, triggering ExitNode which stops everything. + runner.UpdateGraph(1.0); + + context.IsActive.Should().BeFalse(); + context.ActiveStateNodes.Should().BeEmpty(); + context.NodeContextCount.Should().Be(0); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Runner_reference_is_set_on_start_and_cleared_on_stop() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 5.0); + + var timer = new TimerStateNode("duration"); + graph.AddNode(timer); + graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + + context.Runner.Should().BeNull(); + + runner.StartGraph(); + context.Runner.Should().Be(runner); + + runner.StopGraph(); + context.Runner.Should().BeNull(); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Active_state_nodes_set_tracks_active_nodes() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 2.0); + + var timer = new TimerStateNode("duration"); + graph.AddNode(timer); + graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.StartGraph(); + + context.ActiveStateNodes.Should().ContainSingle().Which.Should().Be(timer); + + runner.UpdateGraph(2.0); + + context.ActiveStateNodes.Should().BeEmpty(); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Action_only_graph_finalizes_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 context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.StartGraph(); + + actionNode.ExecutionCount.Should().Be(1); + context.Runner.Should().BeNull(); + context.HasStarted.Should().BeFalse(); + context.NodeContextCount.Should().Be(0); + context.InternalNodeActivationStatus.Should().BeEmpty(); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Timer_graph_finalizes_when_last_state_node_deactivates() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 2.0); + + var timer = new TimerStateNode("duration"); + graph.AddNode(timer); + graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.StartGraph(); + + context.HasStarted.Should().BeTrue(); + context.Runner.Should().Be(runner); + + runner.UpdateGraph(2.0); + + context.IsActive.Should().BeFalse(); + context.HasStarted.Should().BeFalse(); + context.Runner.Should().BeNull(); + context.NodeContextCount.Should().Be(0); + context.InternalNodeActivationStatus.Should().BeEmpty(); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Multiple_timers_finalize_only_after_all_deactivate() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("shortDuration", 1.0); + graph.VariableDefinitions.DefineVariable("longDuration", 3.0); + + var shortTimer = new TimerStateNode("shortDuration"); + var longTimer = new TimerStateNode("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 context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.StartGraph(); + + context.ActiveStateNodes.Should().HaveCount(2); + context.HasStarted.Should().BeTrue(); + + // Short timer elapses, but long timer still active — graph should NOT finalize. + runner.UpdateGraph(1.0); + context.ActiveStateNodes.Should().ContainSingle(); + context.HasStarted.Should().BeTrue(); + context.Runner.Should().Be(runner); + + // Long timer elapses — now the graph should finalize. + runner.UpdateGraph(2.0); + context.IsActive.Should().BeFalse(); + context.HasStarted.Should().BeFalse(); + context.Runner.Should().BeNull(); + context.NodeContextCount.Should().Be(0); + context.InternalNodeActivationStatus.Should().BeEmpty(); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Update_graph_does_nothing_after_finalization() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 1.0); + + var timer = new TimerStateNode("duration"); + graph.AddNode(timer); + graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); + + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + runner.StartGraph(); + + runner.UpdateGraph(1.0); + context.HasStarted.Should().BeFalse(); + + // Subsequent updates should be no-ops and not throw. + runner.UpdateGraph(1.0); + runner.UpdateGraph(1.0); + context.HasStarted.Should().BeFalse(); + context.Runner.Should().BeNull(); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Empty_graph_finalizes_immediately() + { + var graph = new Graph(); + var context = new TestGraphContext(); + var runner = new GraphRunner(graph, context); + + runner.StartGraph(); + + context.HasStarted.Should().BeFalse(); + context.Runner.Should().BeNull(); + } + private sealed class TestGraphContext : IGraphContext { private readonly Dictionary _nodeContexts = []; - public int ActiveStateNodeCount { get; set; } - - public bool IsActive => ActiveStateNodeCount > 0; + public bool IsActive => ActiveStateNodes.Count > 0; public IForgeEntity? Owner { get; set; } @@ -677,6 +937,12 @@ private sealed class TestGraphContext : IGraphContext public Dictionary InternalNodeActivationStatus { get; } = []; + public HashSet ActiveStateNodes { get; } = []; + + public GraphRunner? Runner { get; set; } + + public bool HasStarted { get; set; } + public int NodeContextCount => _nodeContexts.Count; public T GetOrCreateNodeContext(Guid nodeID) diff --git a/Forge/Statescript/Graph.cs b/Forge/Statescript/Graph.cs index a30c69b..346f385 100644 --- a/Forge/Statescript/Graph.cs +++ b/Forge/Statescript/Graph.cs @@ -5,11 +5,16 @@ namespace Gamesmiths.Forge.Statescript; /// -/// Represents a Statescript graph definition consisting of nodes and connections. This class is immutable after -/// construction and can be shared across multiple instances (Flyweight pattern). +/// 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. /// /// -/// All mutable runtime state lives in . +/// 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 { diff --git a/Forge/Statescript/GraphRunner.cs b/Forge/Statescript/GraphRunner.cs index 105b4f1..3b5c6fd 100644 --- a/Forge/Statescript/GraphRunner.cs +++ b/Forge/Statescript/GraphRunner.cs @@ -6,19 +6,18 @@ namespace Gamesmiths.Forge.Statescript; /// Provides functionality to execute and manage the lifecycle of a graph within a specified context. /// /// -/// The class encapsulates a graph and its associated execution context, allowing for -/// starting, updating, and stopping the graph's execution. It ensures proper initialization and cleanup when running -/// or halting the graph. -/// The graph definition (nodes, connections, default variables) is immutable and shared. All mutable runtime -/// state — including variable values, node contexts, and activation status — lives in the . -/// This allows a single to be executed concurrently by multiple runners, each with its own -/// context (Flyweight pattern). +/// The class pairs a shared, immutable definition with a +/// per-execution that holds all mutable runtime state (variable values, node contexts, +/// activation flags). Multiple runners can share the same instance, each with its own context +/// (Flyweight pattern). /// /// The graph to be executed by this runner. /// The context in which the graph will be executed, providing runtime state for this /// execution instance. public class GraphRunner(Graph graph, IGraphContext graphContext) { + private readonly List _updateBuffer = []; + /// /// Gets the graph that this runner is responsible for executing. /// @@ -37,31 +36,78 @@ public class GraphRunner(Graph graph, IGraphContext graphContext) /// public void StartGraph() { + GraphContext.Runner = this; + GraphContext.HasStarted = true; GraphContext.GraphVariables.InitializeFrom(Graph.VariableDefinitions); 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. Call this method in your game loop to + /// 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) { - foreach (Node node in Graph.Nodes) + if (!GraphContext.HasStarted) + { + return; + } + + _updateBuffer.Clear(); + _updateBuffer.AddRange(GraphContext.ActiveStateNodes); + + for (var i = 0; i < _updateBuffer.Count; i++) { - node.Update(deltaTime, GraphContext); + _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. + /// execution. This method is safe to call re-entrantly (e.g., from an triggered + /// during the disable cascade). /// public void StopGraph() { + if (GraphContext.Runner != this) + { + return; + } + + GraphContext.Runner = null; + GraphContext.HasStarted = false; Graph.EntryNode.StopGraph(GraphContext); + GraphContext.ActiveStateNodes.Clear(); + GraphContext.InternalNodeActivationStatus.Clear(); + GraphContext.RemoveAllNodeContext(); + } + + /// + /// 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.Runner = null; + GraphContext.InternalNodeActivationStatus.Clear(); GraphContext.RemoveAllNodeContext(); } } diff --git a/Forge/Statescript/IGraphContext.cs b/Forge/Statescript/IGraphContext.cs index 624b80c..fa249b5 100644 --- a/Forge/Statescript/IGraphContext.cs +++ b/Forge/Statescript/IGraphContext.cs @@ -10,12 +10,6 @@ namespace Gamesmiths.Forge.Statescript; /// public interface IGraphContext { - /// - /// Gets or sets the count of active state nodes in the graph. This property is used to track how many state nodes - /// are currently active during graph execution. - /// - int ActiveStateNodeCount { get; set; } - /// /// Gets a value indicating whether the graph is currently active. A graph is considered active if it has at least /// one active state node. @@ -41,6 +35,27 @@ public interface IGraphContext /// Dictionary InternalNodeActivationStatus { get; } + /// + /// Gets the set of state nodes that are currently active during this graph execution. Only active state nodes + /// are updated each tick, avoiding unnecessary iteration over inactive nodes. + /// + HashSet ActiveStateNodes { get; } + + /// + /// Gets or sets the currently executing this context. This reference allows nodes + /// (such as ) to trigger graph-level operations like stopping execution. Set + /// automatically by and cleared by . + /// + GraphRunner? Runner { get; set; } + + /// + /// Gets or sets a value indicating whether the graph has been started and is awaiting completion. This flag is + /// set to when the graph starts executing and is cleared when the graph completes or is + /// explicitly stopped. It is used to distinguish between a graph that was never started and one that has finished + /// naturally (i.e., all state nodes have deactivated). + /// + bool HasStarted { get; set; } + /// /// Gets or creates a node context of type T for the specified node ID. If a context for the given node ID already /// exists, it returns the existing context; otherwise, it creates a new instance of T and associates it with the diff --git a/Forge/Statescript/Nodes/ExitNode.cs b/Forge/Statescript/Nodes/ExitNode.cs index fad5f34..a3a2841 100644 --- a/Forge/Statescript/Nodes/ExitNode.cs +++ b/Forge/Statescript/Nodes/ExitNode.cs @@ -5,9 +5,13 @@ namespace Gamesmiths.Forge.Statescript.Nodes; /// -/// Node representing the exit point of a graph. It has a single input port that receives a message to stop the graph -/// execution. +/// 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 { /// @@ -24,7 +28,6 @@ protected override void DefinePorts(List inputPorts, List /// protected override void HandleMessage(InputPort receiverPort, IGraphContext graphContext) { - // TODO: Implement the logic to stop the graph execution when a message is received on the input port. - throw new NotImplementedException(); + graphContext.Runner?.StopGraph(); } } diff --git a/Forge/Statescript/Nodes/StateNode.cs b/Forge/Statescript/Nodes/StateNode.cs index ffa3127..fe454b1 100644 --- a/Forge/Statescript/Nodes/StateNode.cs +++ b/Forge/Statescript/Nodes/StateNode.cs @@ -1,6 +1,5 @@ // Copyright © Gamesmiths Guild. -using System.Diagnostics; using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Statescript.Ports; @@ -17,6 +16,7 @@ public abstract class StateNode : Node /// /// 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; /// @@ -43,6 +43,7 @@ public abstract class StateNode : Node /// 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. @@ -226,15 +227,20 @@ protected sealed override void AfterDisable(IGraphContext graphContext) base.AfterDisable(graphContext); nodeContext.Active = false; - graphContext.ActiveStateNodeCount--; + graphContext.ActiveStateNodes.Remove(this); OnDeactivate(graphContext); + + if (graphContext.ActiveStateNodes.Count == 0) + { + graphContext.Runner?.FinalizeGraph(); + } } private void ActivateNode(IGraphContext graphContext) { StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); nodeContext.Active = true; - graphContext.ActiveStateNodeCount++; + graphContext.ActiveStateNodes.Add(this); OnActivate(graphContext); } From a6fea976341c29852b31d27491f94e11774bdf1e Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 14 Feb 2026 18:46:43 -0300 Subject: [PATCH 10/19] Renamed GraphRunner to GraphProcessor --- Forge.Tests/Statescript/StatescriptTests.cs | 216 +++++++++--------- .../{GraphRunner.cs => GraphProcessor.cs} | 29 ++- Forge/Statescript/IGraphContext.cs | 7 +- Forge/Statescript/Nodes/ExitNode.cs | 4 +- Forge/Statescript/Nodes/StateNode.cs | 2 +- 5 files changed, 135 insertions(+), 123 deletions(-) rename Forge/Statescript/{GraphRunner.cs => GraphProcessor.cs} (75%) diff --git a/Forge.Tests/Statescript/StatescriptTests.cs b/Forge.Tests/Statescript/StatescriptTests.cs index 771db25..7c9dfa6 100644 --- a/Forge.Tests/Statescript/StatescriptTests.cs +++ b/Forge.Tests/Statescript/StatescriptTests.cs @@ -30,9 +30,9 @@ public void Graph_runner_clones_variables_on_start() graph.VariableDefinitions.DefineVariable("health", 100); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); + var processor = new GraphProcessor(graph, context); - runner.StartGraph(); + processor.StartGraph(); context.GraphVariables.TryGetVar("health", out int value).Should().BeTrue(); value.Should().Be(100); @@ -49,8 +49,8 @@ public void Starting_graph_executes_connected_action_node() graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], actionNode.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); actionNode.ExecutionCount.Should().Be(1); } @@ -75,8 +75,8 @@ public void Action_nodes_execute_in_sequence() graph.AddConnection(new Connection(action2.OutputPorts[ActionNode.OutputPort], action3.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); executionOrder.Should().ContainInOrder("A", "B", "C"); } @@ -99,8 +99,8 @@ public void Condition_node_routes_to_true_port_when_condition_is_met() graph.AddConnection(new Connection(condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); trueAction.ExecutionCount.Should().Be(1); falseAction.ExecutionCount.Should().Be(0); @@ -124,8 +124,8 @@ public void Condition_node_routes_to_false_port_when_condition_is_not_met() graph.AddConnection(new Connection(condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); trueAction.ExecutionCount.Should().Be(0); falseAction.ExecutionCount.Should().Be(1); @@ -148,8 +148,8 @@ public void Action_node_can_read_and_write_graph_variables() graph.AddConnection(new Connection(incrementNode.OutputPorts[ActionNode.OutputPort], readNode.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); readNode.LastReadValue.Should().Be(1); } @@ -175,8 +175,8 @@ public void Condition_node_can_branch_based_on_graph_variables() graph.AddConnection(new Connection(condition.OutputPorts[ConditionNode.FalsePort], belowAction.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); aboveAction.ExecutionCount.Should().Be(1, "value (15) is above threshold (10)"); belowAction.ExecutionCount.Should().Be(0); @@ -197,8 +197,8 @@ public void Output_port_can_connect_to_multiple_input_ports() graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], action2.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); action1.ExecutionCount.Should().Be(1); action2.ExecutionCount.Should().Be(1); @@ -217,14 +217,14 @@ public void Stopping_graph_resets_variables_to_saved_state() graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], incrementNode.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); context.GraphVariables.TryGetVar("counter", out int valueAfterStart).Should().BeTrue(); valueAfterStart.Should().Be(1); // StopGraph cleans up node contexts - verify it doesn't throw. - runner.StopGraph(); + processor.StopGraph(); } [Fact] @@ -239,13 +239,13 @@ public void Stopping_graph_removes_all_node_contexts() graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); context.InternalNodeActivationStatus.Should().NotBeEmpty(); context.NodeContextCount.Should().BePositive(); - runner.StopGraph(); + processor.StopGraph(); context.NodeContextCount.Should().Be(0); context.InternalNodeActivationStatus.Should().BeEmpty(); @@ -276,8 +276,8 @@ public void Complex_graph_with_condition_and_multiple_actions_executes_correctly graph.AddConnection(new Connection(condition.OutputPorts[ConditionNode.FalsePort], trackB.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); trackA.ExecutionCount.Should().Be(1); trackB.ExecutionCount.Should().Be(0); @@ -298,8 +298,8 @@ public void Disconnected_node_is_not_executed() graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], connectedAction.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); connectedAction.ExecutionCount.Should().Be(1); disconnectedAction.ExecutionCount.Should().Be(0); @@ -317,13 +317,13 @@ public void Each_graph_runner_has_independent_variable_state() graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], incrementNode.InputPorts[ActionNode.InputPort])); var context1 = new TestGraphContext(); - var runner1 = new GraphRunner(graph, context1); + var processor1 = new GraphProcessor(graph, context1); var context2 = new TestGraphContext(); - var runner2 = new GraphRunner(graph, context2); + var processor2 = new GraphProcessor(graph, context2); - runner1.StartGraph(); - runner2.StartGraph(); + processor1.StartGraph(); + processor2.StartGraph(); context1.GraphVariables.TryGetVar("counter", out int value1); context2.GraphVariables.TryGetVar("counter", out int value2); @@ -345,21 +345,21 @@ public void Timer_node_stays_active_until_duration_elapses() graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); context.IsActive.Should().BeTrue(); // Not enough time has passed. - runner.UpdateGraph(1.0); + processor.UpdateGraph(1.0); context.IsActive.Should().BeTrue(); // Still not enough. - runner.UpdateGraph(0.5); + processor.UpdateGraph(0.5); context.IsActive.Should().BeTrue(); // Now it should deactivate (total: 1.0 + 0.5 + 0.5 = 2.0). - runner.UpdateGraph(0.5); + processor.UpdateGraph(0.5); context.IsActive.Should().BeFalse(); } @@ -382,12 +382,12 @@ public void Timer_node_fires_on_deactivate_event_when_completed() graph.AddConnection(new Connection(timer.OutputPorts[TimerStateNode.OnDeactivatePort], onDeactivateAction.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); onDeactivateAction.ExecutionCount.Should().Be(0); - runner.UpdateGraph(1.0); + processor.UpdateGraph(1.0); onDeactivateAction.ExecutionCount.Should().Be(1); } @@ -411,8 +411,8 @@ public void Timer_node_fires_on_activate_event_on_start() graph.AddConnection(new Connection(timer.OutputPorts[TimerStateNode.OnActivatePort], onActivateAction.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); onActivateAction.ExecutionCount.Should().Be(1); } @@ -430,23 +430,23 @@ public void Two_runners_with_same_timer_graph_have_independent_elapsed_time() graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[ActionNode.InputPort])); var context1 = new TestGraphContext(); - var runner1 = new GraphRunner(graph, context1); + var processor1 = new GraphProcessor(graph, context1); var context2 = new TestGraphContext(); - var runner2 = new GraphRunner(graph, context2); + var processor2 = new GraphProcessor(graph, context2); - runner1.StartGraph(); - runner2.StartGraph(); + processor1.StartGraph(); + processor2.StartGraph(); - // Advance runner1 past duration, but not runner2. - runner1.UpdateGraph(2.0); - runner2.UpdateGraph(1.0); + // Advance processor1 past duration, but not processor2. + processor1.UpdateGraph(2.0); + processor2.UpdateGraph(1.0); context1.IsActive.Should().BeFalse(); context2.IsActive.Should().BeTrue(); - // Now advance runner2 past duration. - runner2.UpdateGraph(1.0); + // Now advance processor2 past duration. + processor2.UpdateGraph(1.0); context2.IsActive.Should().BeFalse(); } @@ -497,8 +497,8 @@ public void Set_variable_node_copies_value_from_source_to_target() graph.AddConnection(new Connection(setNode.OutputPorts[ActionNode.OutputPort], readNode.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); readNode.LastReadValue.Should().Be(42); } @@ -524,8 +524,8 @@ public void Set_variable_node_copies_value_between_different_variables() graph.AddConnection(new Connection(setNode.OutputPorts[ActionNode.OutputPort], readNode.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); // counter was incremented to 1, then copied to result readNode.LastReadValue.Should().Be(1); @@ -552,8 +552,8 @@ public void Set_variable_node_does_not_modify_source() graph.AddConnection(new Connection(readTarget.OutputPorts[ActionNode.OutputPort], readSource.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); readTarget.LastReadValue.Should().Be(99); readSource.LastReadValue.Should().Be(99); @@ -576,8 +576,8 @@ public void Set_variable_node_with_nonexistent_source_does_not_modify_target() graph.AddConnection(new Connection(setNode.OutputPorts[ActionNode.OutputPort], readNode.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); // Target should remain unchanged because source doesn't exist readNode.LastReadValue.Should().Be(77); @@ -601,8 +601,8 @@ public void Set_variable_node_works_with_double_values() graph.AddConnection(new Connection(setNode.OutputPorts[ActionNode.OutputPort], readNode.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); readNode.LastReadValue.Should().Be(3.5); } @@ -625,8 +625,8 @@ public void Set_variable_node_works_with_bool_values() graph.AddConnection(new Connection(setNode.OutputPorts[ActionNode.OutputPort], readNode.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); readNode.LastReadValue.Should().BeTrue(); } @@ -649,13 +649,13 @@ public void Two_runners_using_set_variable_have_independent_state() graph.AddConnection(new Connection(incrementNode.OutputPorts[ActionNode.OutputPort], setNode.InputPorts[ActionNode.InputPort])); var context1 = new TestGraphContext(); - var runner1 = new GraphRunner(graph, context1); + var processor1 = new GraphProcessor(graph, context1); var context2 = new TestGraphContext(); - var runner2 = new GraphRunner(graph, context2); + var processor2 = new GraphProcessor(graph, context2); - runner1.StartGraph(); - runner2.StartGraph(); + processor1.StartGraph(); + processor2.StartGraph(); context1.GraphVariables.TryGetVar("target", out int value1); context2.GraphVariables.TryGetVar("target", out int value2); @@ -682,17 +682,17 @@ public void Exit_node_stops_graph_execution() graph.AddConnection(new Connection(timer.OutputPorts[StateNode.OnDeactivatePort], exitNode.InputPorts[ExitNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); context.IsActive.Should().BeTrue(); // Timer deactivates after 5 seconds, which triggers ExitNode. - runner.UpdateGraph(5.0); + processor.UpdateGraph(5.0); context.IsActive.Should().BeFalse(); context.NodeContextCount.Should().Be(0); - context.Runner.Should().BeNull(); + context.Processor.Should().BeNull(); } [Fact] @@ -710,12 +710,12 @@ public void Exit_node_connected_to_action_stops_graph_after_action() graph.AddConnection(new Connection(actionNode.OutputPorts[ActionNode.OutputPort], exitNode.InputPorts[ExitNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); actionNode.ExecutionCount.Should().Be(1); context.NodeContextCount.Should().Be(0); - context.Runner.Should().BeNull(); + context.Processor.Should().BeNull(); } [Fact] @@ -741,13 +741,13 @@ public void Exit_node_stops_all_active_state_nodes() graph.AddConnection(new Connection(shortTimer.OutputPorts[StateNode.OnDeactivatePort], exitNode.InputPorts[ExitNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); context.ActiveStateNodes.Should().HaveCount(2); // Short timer elapses, triggering ExitNode which stops everything. - runner.UpdateGraph(1.0); + processor.UpdateGraph(1.0); context.IsActive.Should().BeFalse(); context.ActiveStateNodes.Should().BeEmpty(); @@ -766,15 +766,15 @@ public void Runner_reference_is_set_on_start_and_cleared_on_stop() graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); + var processor = new GraphProcessor(graph, context); - context.Runner.Should().BeNull(); + context.Processor.Should().BeNull(); - runner.StartGraph(); - context.Runner.Should().Be(runner); + processor.StartGraph(); + context.Processor.Should().Be(processor); - runner.StopGraph(); - context.Runner.Should().BeNull(); + processor.StopGraph(); + context.Processor.Should().BeNull(); } [Fact] @@ -789,12 +789,12 @@ public void Active_state_nodes_set_tracks_active_nodes() graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); context.ActiveStateNodes.Should().ContainSingle().Which.Should().Be(timer); - runner.UpdateGraph(2.0); + processor.UpdateGraph(2.0); context.ActiveStateNodes.Should().BeEmpty(); } @@ -810,11 +810,11 @@ public void Action_only_graph_finalizes_immediately_after_start() graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], actionNode.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); actionNode.ExecutionCount.Should().Be(1); - context.Runner.Should().BeNull(); + context.Processor.Should().BeNull(); context.HasStarted.Should().BeFalse(); context.NodeContextCount.Should().Be(0); context.InternalNodeActivationStatus.Should().BeEmpty(); @@ -832,17 +832,17 @@ public void Timer_graph_finalizes_when_last_state_node_deactivates() graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); context.HasStarted.Should().BeTrue(); - context.Runner.Should().Be(runner); + context.Processor.Should().Be(processor); - runner.UpdateGraph(2.0); + processor.UpdateGraph(2.0); context.IsActive.Should().BeFalse(); context.HasStarted.Should().BeFalse(); - context.Runner.Should().BeNull(); + context.Processor.Should().BeNull(); context.NodeContextCount.Should().Be(0); context.InternalNodeActivationStatus.Should().BeEmpty(); } @@ -865,23 +865,23 @@ public void Multiple_timers_finalize_only_after_all_deactivate() graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], longTimer.InputPorts[StateNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); context.ActiveStateNodes.Should().HaveCount(2); context.HasStarted.Should().BeTrue(); // Short timer elapses, but long timer still active — graph should NOT finalize. - runner.UpdateGraph(1.0); + processor.UpdateGraph(1.0); context.ActiveStateNodes.Should().ContainSingle(); context.HasStarted.Should().BeTrue(); - context.Runner.Should().Be(runner); + context.Processor.Should().Be(processor); // Long timer elapses — now the graph should finalize. - runner.UpdateGraph(2.0); + processor.UpdateGraph(2.0); context.IsActive.Should().BeFalse(); context.HasStarted.Should().BeFalse(); - context.Runner.Should().BeNull(); + context.Processor.Should().BeNull(); context.NodeContextCount.Should().Be(0); context.InternalNodeActivationStatus.Should().BeEmpty(); } @@ -898,17 +898,17 @@ public void Update_graph_does_nothing_after_finalization() graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); - runner.StartGraph(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); - runner.UpdateGraph(1.0); + processor.UpdateGraph(1.0); context.HasStarted.Should().BeFalse(); // Subsequent updates should be no-ops and not throw. - runner.UpdateGraph(1.0); - runner.UpdateGraph(1.0); + processor.UpdateGraph(1.0); + processor.UpdateGraph(1.0); context.HasStarted.Should().BeFalse(); - context.Runner.Should().BeNull(); + context.Processor.Should().BeNull(); } [Fact] @@ -917,12 +917,12 @@ public void Empty_graph_finalizes_immediately() { var graph = new Graph(); var context = new TestGraphContext(); - var runner = new GraphRunner(graph, context); + var processor = new GraphProcessor(graph, context); - runner.StartGraph(); + processor.StartGraph(); context.HasStarted.Should().BeFalse(); - context.Runner.Should().BeNull(); + context.Processor.Should().BeNull(); } private sealed class TestGraphContext : IGraphContext @@ -939,7 +939,7 @@ private sealed class TestGraphContext : IGraphContext public HashSet ActiveStateNodes { get; } = []; - public GraphRunner? Runner { get; set; } + public GraphProcessor? Processor { get; set; } public bool HasStarted { get; set; } diff --git a/Forge/Statescript/GraphRunner.cs b/Forge/Statescript/GraphProcessor.cs similarity index 75% rename from Forge/Statescript/GraphRunner.cs rename to Forge/Statescript/GraphProcessor.cs index 3b5c6fd..b3075f2 100644 --- a/Forge/Statescript/GraphRunner.cs +++ b/Forge/Statescript/GraphProcessor.cs @@ -6,20 +6,22 @@ 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 +/// The class pairs a shared, immutable definition with a /// per-execution that holds all mutable runtime state (variable values, node contexts, -/// activation flags). Multiple runners can share the same instance, each with its own context +/// 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. /// -/// The graph to be executed by this runner. +/// The graph to be executed by this processor. /// The context in which the graph will be executed, providing runtime state for this /// execution instance. -public class GraphRunner(Graph graph, IGraphContext graphContext) +public class GraphProcessor(Graph graph, IGraphContext graphContext) { private readonly List _updateBuffer = []; /// - /// Gets the graph that this runner is responsible for executing. + /// Gets the graph that this processor is responsible for executing. /// public Graph Graph { get; } = graph; @@ -29,6 +31,13 @@ public class GraphRunner(Graph graph, IGraphContext graphContext) /// public IGraphContext GraphContext { get; } = graphContext; + /// + /// 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; } + /// /// 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 @@ -36,7 +45,7 @@ public class GraphRunner(Graph graph, IGraphContext graphContext) /// public void StartGraph() { - GraphContext.Runner = this; + GraphContext.Processor = this; GraphContext.HasStarted = true; GraphContext.GraphVariables.InitializeFrom(Graph.VariableDefinitions); Graph.EntryNode.StartGraph(GraphContext); @@ -79,17 +88,18 @@ public void UpdateGraph(double deltaTime) /// public void StopGraph() { - if (GraphContext.Runner != this) + if (GraphContext.Processor != this) { return; } - GraphContext.Runner = null; + GraphContext.Processor = null; GraphContext.HasStarted = false; Graph.EntryNode.StopGraph(GraphContext); GraphContext.ActiveStateNodes.Clear(); GraphContext.InternalNodeActivationStatus.Clear(); GraphContext.RemoveAllNodeContext(); + OnGraphCompleted?.Invoke(); } /// @@ -106,8 +116,9 @@ internal void FinalizeGraph() } GraphContext.HasStarted = false; - GraphContext.Runner = null; + GraphContext.Processor = null; GraphContext.InternalNodeActivationStatus.Clear(); GraphContext.RemoveAllNodeContext(); + OnGraphCompleted?.Invoke(); } } diff --git a/Forge/Statescript/IGraphContext.cs b/Forge/Statescript/IGraphContext.cs index fa249b5..3d61f7a 100644 --- a/Forge/Statescript/IGraphContext.cs +++ b/Forge/Statescript/IGraphContext.cs @@ -42,11 +42,12 @@ public interface IGraphContext HashSet ActiveStateNodes { get; } /// - /// Gets or sets the currently executing this context. This reference allows nodes + /// Gets or sets the currently executing this context. This reference allows nodes /// (such as ) to trigger graph-level operations like stopping execution. Set - /// automatically by and cleared by . + /// automatically by and cleared by + /// . /// - GraphRunner? Runner { get; set; } + GraphProcessor? Processor { get; set; } /// /// Gets or sets a value indicating whether the graph has been started and is awaiting completion. This flag is diff --git a/Forge/Statescript/Nodes/ExitNode.cs b/Forge/Statescript/Nodes/ExitNode.cs index a3a2841..04a1364 100644 --- a/Forge/Statescript/Nodes/ExitNode.cs +++ b/Forge/Statescript/Nodes/ExitNode.cs @@ -10,7 +10,7 @@ namespace Gamesmiths.Forge.Statescript.Nodes; /// /// /// 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. +/// has the same effect as calling externally. /// public class ExitNode : Node { @@ -28,6 +28,6 @@ protected override void DefinePorts(List inputPorts, List /// protected override void HandleMessage(InputPort receiverPort, IGraphContext graphContext) { - graphContext.Runner?.StopGraph(); + graphContext.Processor?.StopGraph(); } } diff --git a/Forge/Statescript/Nodes/StateNode.cs b/Forge/Statescript/Nodes/StateNode.cs index fe454b1..ede9942 100644 --- a/Forge/Statescript/Nodes/StateNode.cs +++ b/Forge/Statescript/Nodes/StateNode.cs @@ -232,7 +232,7 @@ protected sealed override void AfterDisable(IGraphContext graphContext) if (graphContext.ActiveStateNodes.Count == 0) { - graphContext.Runner?.FinalizeGraph(); + graphContext.Processor?.FinalizeGraph(); } } From 384be559448128df6da7fa362c6f378306b828e5 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 14 Feb 2026 19:34:03 -0300 Subject: [PATCH 11/19] Added GraphAbilityBehavior implementation --- Forge/Statescript/Graph.cs | 2 +- .../Statescript/GraphAbilityBehavior.Data.cs | 29 ++++++++ Forge/Statescript/GraphAbilityBehavior.cs | 66 +++++++++++++++++++ Forge/Statescript/GraphProcessor.cs | 6 +- 4 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 Forge/Statescript/GraphAbilityBehavior.Data.cs create mode 100644 Forge/Statescript/GraphAbilityBehavior.cs diff --git a/Forge/Statescript/Graph.cs b/Forge/Statescript/Graph.cs index 346f385..1e628ee 100644 --- a/Forge/Statescript/Graph.cs +++ b/Forge/Statescript/Graph.cs @@ -6,7 +6,7 @@ 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). +/// 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. /// /// diff --git a/Forge/Statescript/GraphAbilityBehavior.Data.cs b/Forge/Statescript/GraphAbilityBehavior.Data.cs new file mode 100644 index 0000000..fdf6946 --- /dev/null +++ b/Forge/Statescript/GraphAbilityBehavior.Data.cs @@ -0,0 +1,29 @@ +// 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. +/// The per-instance context that holds mutable runtime state for the graph. +/// 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, IGraphContext graphContext, Action dataBinder) + : GraphAbilityBehavior(graph, graphContext), 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..cbf2d15 --- /dev/null +++ b/Forge/Statescript/GraphAbilityBehavior.cs @@ -0,0 +1,66 @@ +// 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. +/// The per-instance context that holds mutable runtime state for the graph. +public class GraphAbilityBehavior(Graph graph, IGraphContext graphContext) : 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, graphContext); + + /// + 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.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/GraphProcessor.cs b/Forge/Statescript/GraphProcessor.cs index b3075f2..52cafa8 100644 --- a/Forge/Statescript/GraphProcessor.cs +++ b/Forge/Statescript/GraphProcessor.cs @@ -43,11 +43,15 @@ public class GraphProcessor(Graph graph, IGraphContext graphContext) /// variable definitions, ensuring that each execution instance has independent state, and then initiates the /// graph's entry node to begin processing. /// - public void StartGraph() + /// 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.EntryNode.StartGraph(GraphContext); // If no state nodes were activated during the initial message propagation (e.g., action-only graphs), the graph From 08f6fcd1e5d9ab39edaaedf905c333f5586cf9cb Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 15 Feb 2026 00:18:23 -0300 Subject: [PATCH 12/19] Added ExpressionCondition and reorganized tests --- Forge.Tests/Helpers/StatescriptTestHelpers.cs | 143 +++ Forge.Tests/Statescript/ActionNodeTests.cs | 227 ++++ .../Statescript/ExpressionResolverTests.cs | 368 ++++++ .../Statescript/GraphProcessorTests.cs | 694 +++++++++++ .../Statescript/PropertyResolverTests.cs | 376 ++++++ Forge.Tests/Statescript/StateNodeTests.cs | 136 +++ Forge.Tests/Statescript/StatescriptTests.cs | 1062 ----------------- .../Condition/ExpressionConditionNode.cs | 34 + .../Properties/ComparisonOperation.cs | 39 + .../Properties/ComparisonResolver.cs | 120 ++ .../Properties/VariableResolver.cs | 41 + 11 files changed, 2178 insertions(+), 1062 deletions(-) create mode 100644 Forge.Tests/Helpers/StatescriptTestHelpers.cs create mode 100644 Forge.Tests/Statescript/ActionNodeTests.cs create mode 100644 Forge.Tests/Statescript/ExpressionResolverTests.cs create mode 100644 Forge.Tests/Statescript/GraphProcessorTests.cs create mode 100644 Forge.Tests/Statescript/PropertyResolverTests.cs create mode 100644 Forge.Tests/Statescript/StateNodeTests.cs delete mode 100644 Forge.Tests/Statescript/StatescriptTests.cs create mode 100644 Forge/Statescript/Nodes/Condition/ExpressionConditionNode.cs create mode 100644 Forge/Statescript/Properties/ComparisonOperation.cs create mode 100644 Forge/Statescript/Properties/ComparisonResolver.cs create mode 100644 Forge/Statescript/Properties/VariableResolver.cs diff --git a/Forge.Tests/Helpers/StatescriptTestHelpers.cs b/Forge.Tests/Helpers/StatescriptTestHelpers.cs new file mode 100644 index 0000000..bcd9a70 --- /dev/null +++ b/Forge.Tests/Helpers/StatescriptTestHelpers.cs @@ -0,0 +1,143 @@ +// Copyright © Gamesmiths Guild. +#pragma warning disable SA1649, SA1402 // File name should match first type name + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Nodes; + +namespace Gamesmiths.Forge.Tests.Helpers; + +internal sealed class TestGraphContext : IGraphContext +{ + private readonly Dictionary _nodeContexts = []; + + public bool IsActive => ActiveStateNodes.Count > 0; + + public IForgeEntity? Owner { get; set; } + + public Variables GraphVariables { get; } = new Variables(); + + public Dictionary InternalNodeActivationStatus { get; } = []; + + public HashSet ActiveStateNodes { get; } = []; + + public GraphProcessor? Processor { get; set; } + + public bool HasStarted { get; set; } + + public int NodeContextCount => _nodeContexts.Count; + + public 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; + } + + public T GetNodeContext(Guid nodeID) + where T : INodeContext, new() + { + if (_nodeContexts.TryGetValue(nodeID, out INodeContext? context)) + { + return (T)context; + } + + return default!; + } + + public void RemoveAllNodeContext() + { + _nodeContexts.Clear(); + } +} + +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(IGraphContext 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(IGraphContext 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(IGraphContext 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(IGraphContext 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(IGraphContext graphContext) + { + graphContext.GraphVariables.TryGetVar(_variableName, out T value); + LastReadValue = value; + } +} diff --git a/Forge.Tests/Statescript/ActionNodeTests.cs b/Forge.Tests/Statescript/ActionNodeTests.cs new file mode 100644 index 0000000..01889ff --- /dev/null +++ b/Forge.Tests/Statescript/ActionNodeTests.cs @@ -0,0 +1,227 @@ +// 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 context1 = new TestGraphContext(); + var processor1 = new GraphProcessor(graph, context1); + + var context2 = new TestGraphContext(); + var processor2 = new GraphProcessor(graph, context2); + + processor1.StartGraph(); + processor2.StartGraph(); + + context1.GraphVariables.TryGetVar("target", out int value1); + context2.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..7a9275c --- /dev/null +++ b/Forge.Tests/Statescript/ExpressionResolverTests.cs @@ -0,0 +1,368 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Cues; +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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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"), + ComparisonOperation.GreaterThan, + new VariableResolver("threshold"))); + + 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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"), + ComparisonOperation.GreaterThanOrEqual, + new VariableResolver("requiredScore"))); + + 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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"))); + + 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 context = new TestGraphContext + { + Owner = entity, + }; + + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); + + trueAction.ExecutionCount.Should().Be(1); + falseAction.ExecutionCount.Should().Be(0); + } +} diff --git a/Forge.Tests/Statescript/GraphProcessorTests.cs b/Forge.Tests/Statescript/GraphProcessorTests.cs new file mode 100644 index 0000000..8ad55e3 --- /dev/null +++ b/Forge.Tests/Statescript/GraphProcessorTests.cs @@ -0,0 +1,694 @@ +// 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + + processor.StartGraph(); + + context.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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); + + action1.ExecutionCount.Should().Be(1); + action2.ExecutionCount.Should().Be(1); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Stopping_graph_resets_variables_to_saved_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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); + + context.GraphVariables.TryGetVar("counter", out int valueAfterStart).Should().BeTrue(); + valueAfterStart.Should().Be(1); + + // StopGraph cleans up node contexts - verify it doesn't throw. + processor.StopGraph(); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Stopping_graph_removes_all_node_contexts() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 5.0); + + var timer = new TimerStateNode("duration"); + graph.AddNode(timer); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + + var context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); + + context.InternalNodeActivationStatus.Should().NotBeEmpty(); + context.NodeContextCount.Should().BePositive(); + + processor.StopGraph(); + + context.NodeContextCount.Should().Be(0); + context.InternalNodeActivationStatus.Should().BeEmpty(); + } + + [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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 context1 = new TestGraphContext(); + var processor1 = new GraphProcessor(graph, context1); + + var context2 = new TestGraphContext(); + var processor2 = new GraphProcessor(graph, context2); + + processor1.StartGraph(); + processor2.StartGraph(); + + context1.GraphVariables.TryGetVar("counter", out int value1); + context2.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 TimerStateNode("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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); + + context.IsActive.Should().BeTrue(); + + processor.UpdateGraph(5.0); + + context.IsActive.Should().BeFalse(); + context.NodeContextCount.Should().Be(0); + context.Processor.Should().BeNull(); + } + + [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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); + + actionNode.ExecutionCount.Should().Be(1); + context.NodeContextCount.Should().Be(0); + context.Processor.Should().BeNull(); + } + + [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 TimerStateNode("shortDuration"); + var longTimer = new TimerStateNode("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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); + + context.ActiveStateNodes.Should().HaveCount(2); + + processor.UpdateGraph(1.0); + + context.IsActive.Should().BeFalse(); + context.ActiveStateNodes.Should().BeEmpty(); + context.NodeContextCount.Should().Be(0); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Processor_reference_is_set_on_start_and_cleared_on_stop() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 5.0); + + var timer = new TimerStateNode("duration"); + graph.AddNode(timer); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + + var context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + + context.Processor.Should().BeNull(); + + processor.StartGraph(); + context.Processor.Should().Be(processor); + + processor.StopGraph(); + context.Processor.Should().BeNull(); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Active_state_nodes_set_tracks_active_nodes() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 2.0); + + var timer = new TimerStateNode("duration"); + graph.AddNode(timer); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + + var context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); + + context.ActiveStateNodes.Should().ContainSingle().Which.Should().Be(timer); + + processor.UpdateGraph(2.0); + + context.ActiveStateNodes.Should().BeEmpty(); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Action_only_graph_finalizes_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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); + + actionNode.ExecutionCount.Should().Be(1); + context.Processor.Should().BeNull(); + context.HasStarted.Should().BeFalse(); + context.NodeContextCount.Should().Be(0); + context.InternalNodeActivationStatus.Should().BeEmpty(); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Timer_graph_finalizes_when_last_state_node_deactivates() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 2.0); + + var timer = new TimerStateNode("duration"); + graph.AddNode(timer); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + + var context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); + + context.HasStarted.Should().BeTrue(); + context.Processor.Should().Be(processor); + + processor.UpdateGraph(2.0); + + context.IsActive.Should().BeFalse(); + context.HasStarted.Should().BeFalse(); + context.Processor.Should().BeNull(); + context.NodeContextCount.Should().Be(0); + context.InternalNodeActivationStatus.Should().BeEmpty(); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Multiple_timers_finalize_only_after_all_deactivate() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("shortDuration", 1.0); + graph.VariableDefinitions.DefineVariable("longDuration", 3.0); + + var shortTimer = new TimerStateNode("shortDuration"); + var longTimer = new TimerStateNode("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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); + + context.ActiveStateNodes.Should().HaveCount(2); + context.HasStarted.Should().BeTrue(); + + processor.UpdateGraph(1.0); + context.ActiveStateNodes.Should().ContainSingle(); + context.HasStarted.Should().BeTrue(); + context.Processor.Should().Be(processor); + + processor.UpdateGraph(2.0); + context.IsActive.Should().BeFalse(); + context.HasStarted.Should().BeFalse(); + context.Processor.Should().BeNull(); + context.NodeContextCount.Should().Be(0); + context.InternalNodeActivationStatus.Should().BeEmpty(); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Update_graph_does_nothing_after_finalization() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineVariable("duration", 1.0); + + var timer = new TimerStateNode("duration"); + graph.AddNode(timer); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[StateNode.InputPort])); + + var context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); + + processor.UpdateGraph(1.0); + context.HasStarted.Should().BeFalse(); + + // Subsequent updates should be no-ops and not throw. + processor.UpdateGraph(1.0); + processor.UpdateGraph(1.0); + context.HasStarted.Should().BeFalse(); + context.Processor.Should().BeNull(); + } + + [Fact] + [Trait("Graph", "Lifecycle")] + public void Empty_graph_finalizes_immediately() + { + var graph = new Graph(); + var context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + + processor.StartGraph(); + + context.HasStarted.Should().BeFalse(); + context.Processor.Should().BeNull(); + } +} diff --git a/Forge.Tests/Statescript/PropertyResolverTests.cs b/Forge.Tests/Statescript/PropertyResolverTests.cs new file mode 100644 index 0000000..65d034b --- /dev/null +++ b/Forge.Tests/Statescript/PropertyResolverTests.cs @@ -0,0 +1,376 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Cues; +using Gamesmiths.Forge.Statescript; +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"); + + var context = new TestGraphContext { Owner = 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"); + + var context = new TestGraphContext { Owner = entity }; + + Variant128 result = resolver.Resolve(context); + + result.AsInt().Should().Be(0); + } + + [Fact] + [Trait("Resolver", "Attribute")] + public void Attribute_resolver_returns_default_when_owner_is_null() + { + var resolver = new AttributeResolver("TestAttributeSet.Attribute5"); + + var context = new TestGraphContext { Owner = null }; + + 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"); + + var context = new TestGraphContext { Owner = 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); + + var context = new TestGraphContext { Owner = 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); + + var context = new TestGraphContext { Owner = entity }; + + Variant128 result = resolver.Resolve(context); + + result.AsBool().Should().BeFalse(); + } + + [Fact] + [Trait("Resolver", "Tag")] + public void Tag_resolver_returns_false_when_owner_is_null() + { + var tag = Tag.RequestTag(_tagsManager, "enemy.undead.zombie"); + var resolver = new TagResolver(tag); + + var context = new TestGraphContext { Owner = null }; + + 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); + + var context = new TestGraphContext { Owner = 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 TestGraphContext(); + + 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 TestGraphContext(); + 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 TestGraphContext(); + context.GraphVariables.InitializeFrom(graph.VariableDefinitions); + + var resolver = new VariableResolver("speed"); + + 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 TestGraphContext(); + context.GraphVariables.InitializeFrom(graph.VariableDefinitions); + + var resolver = new VariableResolver("nonexistent"); + + 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 TestGraphContext(); + context.GraphVariables.InitializeFrom(graph.VariableDefinitions); + + var resolver = new VariableResolver("counter"); + + 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"); + + 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 TestGraphContext(); + + 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 TestGraphContext(); + + 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 TestGraphContext(); + + 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 TestGraphContext(); + + 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 TestGraphContext(); + + 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 TestGraphContext(); + + 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 TestGraphContext(); + + 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))); + + var context = new TestGraphContext { Owner = entity }; + + resolver.Resolve(context).AsBool().Should().BeTrue("Attribute5 (5) > 3"); + } +} diff --git a/Forge.Tests/Statescript/StateNodeTests.cs b/Forge.Tests/Statescript/StateNodeTests.cs new file mode 100644 index 0000000..a510a34 --- /dev/null +++ b/Forge.Tests/Statescript/StateNodeTests.cs @@ -0,0 +1,136 @@ +// 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 TimerStateNode("duration"); + + graph.AddNode(timer); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[ActionNode.InputPort])); + + var context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); + + context.IsActive.Should().BeTrue(); + + // Not enough time has passed. + processor.UpdateGraph(1.0); + context.IsActive.Should().BeTrue(); + + // Still not enough. + processor.UpdateGraph(0.5); + context.IsActive.Should().BeTrue(); + + // Now it should deactivate (total: 1.0 + 0.5 + 0.5 = 2.0). + processor.UpdateGraph(0.5); + context.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 TimerStateNode("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[TimerStateNode.OnDeactivatePort], + onDeactivateAction.InputPorts[ActionNode.InputPort])); + + var context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 TimerStateNode("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[TimerStateNode.OnActivatePort], + onActivateAction.InputPorts[ActionNode.InputPort])); + + var context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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 TimerStateNode("duration"); + + graph.AddNode(timer); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + timer.InputPorts[ActionNode.InputPort])); + + var context1 = new TestGraphContext(); + var processor1 = new GraphProcessor(graph, context1); + + var context2 = new TestGraphContext(); + var processor2 = new GraphProcessor(graph, context2); + + processor1.StartGraph(); + processor2.StartGraph(); + + processor1.UpdateGraph(2.0); + processor2.UpdateGraph(1.0); + + context1.IsActive.Should().BeFalse(); + context2.IsActive.Should().BeTrue(); + + processor2.UpdateGraph(1.0); + context2.IsActive.Should().BeFalse(); + } +} diff --git a/Forge.Tests/Statescript/StatescriptTests.cs b/Forge.Tests/Statescript/StatescriptTests.cs deleted file mode 100644 index 7c9dfa6..0000000 --- a/Forge.Tests/Statescript/StatescriptTests.cs +++ /dev/null @@ -1,1062 +0,0 @@ -// Copyright © Gamesmiths Guild. - -using FluentAssertions; -using Gamesmiths.Forge.Core; -using Gamesmiths.Forge.Statescript; -using Gamesmiths.Forge.Statescript.Nodes; -using Gamesmiths.Forge.Statescript.Nodes.Action; -using Gamesmiths.Forge.Statescript.Nodes.State; - -namespace Gamesmiths.Forge.Tests.Statescript; - -public class StatescriptTests -{ - [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_runner_clones_variables_on_start() - { - var graph = new Graph(); - graph.VariableDefinitions.DefineVariable("health", 100); - - var context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - - processor.StartGraph(); - - context.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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - 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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - 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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - 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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - 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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - 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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - 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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - processor.StartGraph(); - - action1.ExecutionCount.Should().Be(1); - action2.ExecutionCount.Should().Be(1); - } - - [Fact] - [Trait("Graph", "Lifecycle")] - public void Stopping_graph_resets_variables_to_saved_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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - processor.StartGraph(); - - context.GraphVariables.TryGetVar("counter", out int valueAfterStart).Should().BeTrue(); - valueAfterStart.Should().Be(1); - - // StopGraph cleans up node contexts - verify it doesn't throw. - processor.StopGraph(); - } - - [Fact] - [Trait("Graph", "Lifecycle")] - public void Stopping_graph_removes_all_node_contexts() - { - var graph = new Graph(); - graph.VariableDefinitions.DefineVariable("duration", 5.0); - - var timer = new TimerStateNode("duration"); - graph.AddNode(timer); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); - - var context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - processor.StartGraph(); - - context.InternalNodeActivationStatus.Should().NotBeEmpty(); - context.NodeContextCount.Should().BePositive(); - - processor.StopGraph(); - - context.NodeContextCount.Should().Be(0); - context.InternalNodeActivationStatus.Should().BeEmpty(); - } - - [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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - 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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - processor.StartGraph(); - - connectedAction.ExecutionCount.Should().Be(1); - disconnectedAction.ExecutionCount.Should().Be(0); - } - - [Fact] - [Trait("Graph", "Node")] - public void Each_graph_runner_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 context1 = new TestGraphContext(); - var processor1 = new GraphProcessor(graph, context1); - - var context2 = new TestGraphContext(); - var processor2 = new GraphProcessor(graph, context2); - - processor1.StartGraph(); - processor2.StartGraph(); - - context1.GraphVariables.TryGetVar("counter", out int value1); - context2.GraphVariables.TryGetVar("counter", out int value2); - - value1.Should().Be(1); - value2.Should().Be(1); - } - - [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 TimerStateNode("duration"); - - graph.AddNode(timer); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[ActionNode.InputPort])); - - var context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - processor.StartGraph(); - - context.IsActive.Should().BeTrue(); - - // Not enough time has passed. - processor.UpdateGraph(1.0); - context.IsActive.Should().BeTrue(); - - // Still not enough. - processor.UpdateGraph(0.5); - context.IsActive.Should().BeTrue(); - - // Now it should deactivate (total: 1.0 + 0.5 + 0.5 = 2.0). - processor.UpdateGraph(0.5); - context.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 TimerStateNode("duration"); - var onDeactivateAction = new TrackingActionNode(); - - graph.AddNode(timer); - graph.AddNode(onDeactivateAction); - - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[ActionNode.InputPort])); - - // OutputPorts[1] is the OnDeactivate event port on StateNode. - graph.AddConnection(new Connection(timer.OutputPorts[TimerStateNode.OnDeactivatePort], onDeactivateAction.InputPorts[ActionNode.InputPort])); - - var context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - 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 TimerStateNode("duration"); - var onActivateAction = new TrackingActionNode(); - - graph.AddNode(timer); - graph.AddNode(onActivateAction); - - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[ActionNode.InputPort])); - - // OutputPorts[0] is the OnActivate event port on StateNode. - graph.AddConnection(new Connection(timer.OutputPorts[TimerStateNode.OnActivatePort], onActivateAction.InputPorts[ActionNode.InputPort])); - - var context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - processor.StartGraph(); - - onActivateAction.ExecutionCount.Should().Be(1); - } - - [Fact] - [Trait("Graph", "Timer")] - public void Two_runners_with_same_timer_graph_have_independent_elapsed_time() - { - var graph = new Graph(); - graph.VariableDefinitions.DefineVariable("duration", 2.0); - - var timer = new TimerStateNode("duration"); - - graph.AddNode(timer); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[ActionNode.InputPort])); - - var context1 = new TestGraphContext(); - var processor1 = new GraphProcessor(graph, context1); - - var context2 = new TestGraphContext(); - var processor2 = new GraphProcessor(graph, context2); - - processor1.StartGraph(); - processor2.StartGraph(); - - // Advance processor1 past duration, but not processor2. - processor1.UpdateGraph(2.0); - processor2.UpdateGraph(1.0); - - context1.IsActive.Should().BeFalse(); - context2.IsActive.Should().BeTrue(); - - // Now advance processor2 past duration. - processor2.UpdateGraph(1.0); - context2.IsActive.Should().BeFalse(); - } - - [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", "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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - 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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - processor.StartGraph(); - - // counter was incremented to 1, then copied to result - 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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - 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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - processor.StartGraph(); - - // Target should remain unchanged because source doesn't exist - 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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - 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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - processor.StartGraph(); - - readNode.LastReadValue.Should().BeTrue(); - } - - [Fact] - [Trait("Graph", "SetVariable")] - public void Two_runners_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 context1 = new TestGraphContext(); - var processor1 = new GraphProcessor(graph, context1); - - var context2 = new TestGraphContext(); - var processor2 = new GraphProcessor(graph, context2); - - processor1.StartGraph(); - processor2.StartGraph(); - - context1.GraphVariables.TryGetVar("target", out int value1); - context2.GraphVariables.TryGetVar("target", out int value2); - - // Both should have source incremented from 10 to 11, then copied to target - value1.Should().Be(11); - value2.Should().Be(11); - } - - [Fact] - [Trait("Graph", "ExitNode")] - public void Exit_node_stops_graph_execution() - { - var graph = new Graph(); - graph.VariableDefinitions.DefineVariable("duration", 5.0); - - var timer = new TimerStateNode("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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - processor.StartGraph(); - - context.IsActive.Should().BeTrue(); - - // Timer deactivates after 5 seconds, which triggers ExitNode. - processor.UpdateGraph(5.0); - - context.IsActive.Should().BeFalse(); - context.NodeContextCount.Should().Be(0); - context.Processor.Should().BeNull(); - } - - [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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - processor.StartGraph(); - - actionNode.ExecutionCount.Should().Be(1); - context.NodeContextCount.Should().Be(0); - context.Processor.Should().BeNull(); - } - - [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 TimerStateNode("shortDuration"); - var longTimer = new TimerStateNode("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])); - - // When the short timer completes, exit the graph (which should also stop the long timer). - graph.AddConnection(new Connection(shortTimer.OutputPorts[StateNode.OnDeactivatePort], exitNode.InputPorts[ExitNode.InputPort])); - - var context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - processor.StartGraph(); - - context.ActiveStateNodes.Should().HaveCount(2); - - // Short timer elapses, triggering ExitNode which stops everything. - processor.UpdateGraph(1.0); - - context.IsActive.Should().BeFalse(); - context.ActiveStateNodes.Should().BeEmpty(); - context.NodeContextCount.Should().Be(0); - } - - [Fact] - [Trait("Graph", "Lifecycle")] - public void Runner_reference_is_set_on_start_and_cleared_on_stop() - { - var graph = new Graph(); - graph.VariableDefinitions.DefineVariable("duration", 5.0); - - var timer = new TimerStateNode("duration"); - graph.AddNode(timer); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); - - var context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - - context.Processor.Should().BeNull(); - - processor.StartGraph(); - context.Processor.Should().Be(processor); - - processor.StopGraph(); - context.Processor.Should().BeNull(); - } - - [Fact] - [Trait("Graph", "Lifecycle")] - public void Active_state_nodes_set_tracks_active_nodes() - { - var graph = new Graph(); - graph.VariableDefinitions.DefineVariable("duration", 2.0); - - var timer = new TimerStateNode("duration"); - graph.AddNode(timer); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); - - var context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - processor.StartGraph(); - - context.ActiveStateNodes.Should().ContainSingle().Which.Should().Be(timer); - - processor.UpdateGraph(2.0); - - context.ActiveStateNodes.Should().BeEmpty(); - } - - [Fact] - [Trait("Graph", "Lifecycle")] - public void Action_only_graph_finalizes_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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - processor.StartGraph(); - - actionNode.ExecutionCount.Should().Be(1); - context.Processor.Should().BeNull(); - context.HasStarted.Should().BeFalse(); - context.NodeContextCount.Should().Be(0); - context.InternalNodeActivationStatus.Should().BeEmpty(); - } - - [Fact] - [Trait("Graph", "Lifecycle")] - public void Timer_graph_finalizes_when_last_state_node_deactivates() - { - var graph = new Graph(); - graph.VariableDefinitions.DefineVariable("duration", 2.0); - - var timer = new TimerStateNode("duration"); - graph.AddNode(timer); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); - - var context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - processor.StartGraph(); - - context.HasStarted.Should().BeTrue(); - context.Processor.Should().Be(processor); - - processor.UpdateGraph(2.0); - - context.IsActive.Should().BeFalse(); - context.HasStarted.Should().BeFalse(); - context.Processor.Should().BeNull(); - context.NodeContextCount.Should().Be(0); - context.InternalNodeActivationStatus.Should().BeEmpty(); - } - - [Fact] - [Trait("Graph", "Lifecycle")] - public void Multiple_timers_finalize_only_after_all_deactivate() - { - var graph = new Graph(); - graph.VariableDefinitions.DefineVariable("shortDuration", 1.0); - graph.VariableDefinitions.DefineVariable("longDuration", 3.0); - - var shortTimer = new TimerStateNode("shortDuration"); - var longTimer = new TimerStateNode("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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - processor.StartGraph(); - - context.ActiveStateNodes.Should().HaveCount(2); - context.HasStarted.Should().BeTrue(); - - // Short timer elapses, but long timer still active — graph should NOT finalize. - processor.UpdateGraph(1.0); - context.ActiveStateNodes.Should().ContainSingle(); - context.HasStarted.Should().BeTrue(); - context.Processor.Should().Be(processor); - - // Long timer elapses — now the graph should finalize. - processor.UpdateGraph(2.0); - context.IsActive.Should().BeFalse(); - context.HasStarted.Should().BeFalse(); - context.Processor.Should().BeNull(); - context.NodeContextCount.Should().Be(0); - context.InternalNodeActivationStatus.Should().BeEmpty(); - } - - [Fact] - [Trait("Graph", "Lifecycle")] - public void Update_graph_does_nothing_after_finalization() - { - var graph = new Graph(); - graph.VariableDefinitions.DefineVariable("duration", 1.0); - - var timer = new TimerStateNode("duration"); - graph.AddNode(timer); - graph.AddConnection(new Connection(graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); - - var context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - processor.StartGraph(); - - processor.UpdateGraph(1.0); - context.HasStarted.Should().BeFalse(); - - // Subsequent updates should be no-ops and not throw. - processor.UpdateGraph(1.0); - processor.UpdateGraph(1.0); - context.HasStarted.Should().BeFalse(); - context.Processor.Should().BeNull(); - } - - [Fact] - [Trait("Graph", "Lifecycle")] - public void Empty_graph_finalizes_immediately() - { - var graph = new Graph(); - var context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - - processor.StartGraph(); - - context.HasStarted.Should().BeFalse(); - context.Processor.Should().BeNull(); - } - - private sealed class TestGraphContext : IGraphContext - { - private readonly Dictionary _nodeContexts = []; - - public bool IsActive => ActiveStateNodes.Count > 0; - - public IForgeEntity? Owner { get; set; } - - public Variables GraphVariables { get; } = new Variables(); - - public Dictionary InternalNodeActivationStatus { get; } = []; - - public HashSet ActiveStateNodes { get; } = []; - - public GraphProcessor? Processor { get; set; } - - public bool HasStarted { get; set; } - - public int NodeContextCount => _nodeContexts.Count; - - public 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; - } - - public T GetNodeContext(Guid nodeID) - where T : INodeContext, new() - { - if (_nodeContexts.TryGetValue(nodeID, out INodeContext? context)) - { - return (T)context; - } - - return default!; - } - - public void RemoveAllNodeContext() - { - _nodeContexts.Clear(); - } - } - - private 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(IGraphContext graphContext) - { - ExecutionCount++; - - if (_name is not null) - { - _executionLog?.Add(_name); - } - } - } - - private sealed class FixedConditionNode(bool result) : ConditionNode - { - private readonly bool _result = result; - - protected override bool Test(IGraphContext graphContext) - { - return _result; - } - } - - private 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(IGraphContext 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; - } - } - - private sealed class IncrementCounterNode(string variableName) : ActionNode - { - private readonly string _variableName = variableName; - - protected override void Execute(IGraphContext graphContext) - { - graphContext.GraphVariables.TryGetVar(_variableName, out int currentValue); - graphContext.GraphVariables.SetVar(_variableName, currentValue + 1); - } - } - - private sealed class ReadVariableNode(string variableName) : ActionNode - where T : unmanaged - { - private readonly string _variableName = variableName; - - public T LastReadValue { get; private set; } - - protected override void Execute(IGraphContext graphContext) - { - graphContext.GraphVariables.TryGetVar(_variableName, out T value); - LastReadValue = value; - } - } -} diff --git a/Forge/Statescript/Nodes/Condition/ExpressionConditionNode.cs b/Forge/Statescript/Nodes/Condition/ExpressionConditionNode.cs new file mode 100644 index 0000000..e084429 --- /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(IGraphContext graphContext) + { + if (!graphContext.GraphVariables.TryGet(_conditionPropertyName, graphContext, out bool result)) + { + return false; + } + + return result; + } +} 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..03549c0 --- /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(IGraphContext 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, IGraphContext 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(); + } + + // Fallback: reinterpret as double (works when the resolver stored a double). + return value.AsDouble(); + } +} diff --git a/Forge/Statescript/Properties/VariableResolver.cs b/Forge/Statescript/Properties/VariableResolver.cs new file mode 100644 index 0000000..0c14ddf --- /dev/null +++ b/Forge/Statescript/Properties/VariableResolver.cs @@ -0,0 +1,41 @@ +// 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. +public class VariableResolver(StringKey referencedPropertyName) : IPropertyResolver +{ + private readonly StringKey _referencedPropertyName = referencedPropertyName; + + /// + /// + /// Returns as the default numeric type for comparisons. The actual resolved value depends on + /// the referenced property's type. + /// + public Type ValueType => typeof(double); + + /// + public Variant128 Resolve(IGraphContext graphContext) + { + if (!graphContext.GraphVariables.TryGetVariant(_referencedPropertyName, graphContext, out Variant128 value)) + { + return default; + } + + return value; + } +} From f81428e067d150a10f847e9fc1ea12bfd895b9dc Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 15 Feb 2026 09:00:11 -0300 Subject: [PATCH 13/19] Added shared variables and arrays --- .../Effects/CustomCalculatorsEffectsTests.cs | 3 + Forge.Tests/Helpers/TestEntity.cs | 3 + Forge.Tests/Samples/QuickStartTests.cs | 7 +- .../Statescript/GraphProcessorTests.cs | 72 ++++++++ .../Statescript/PropertyResolverTests.cs | 167 ++++++++++++++++++ Forge/Core/IForgeEntity.cs | 7 + Forge/Statescript/GraphVariableDefinitions.cs | 41 +++++ .../Properties/ArrayVariableResolver.cs | 94 ++++++++++ .../Properties/SharedVariableResolver.cs | 45 +++++ .../Statescript/Properties/VariantResolver.cs | 35 ++-- Forge/Statescript/Variables.cs | 136 +++++++++++++- 11 files changed, 590 insertions(+), 20 deletions(-) create mode 100644 Forge/Statescript/Properties/ArrayVariableResolver.cs create mode 100644 Forge/Statescript/Properties/SharedVariableResolver.cs diff --git a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs index 12bbd23..53fdb6f 100644 --- a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs +++ b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs @@ -12,6 +12,7 @@ using Gamesmiths.Forge.Tags; using Gamesmiths.Forge.Tests.Core; using Gamesmiths.Forge.Tests.Helpers; +using Gamesmiths.Forge.Statescript; namespace Gamesmiths.Forge.Tests.Effects; @@ -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/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/GraphProcessorTests.cs b/Forge.Tests/Statescript/GraphProcessorTests.cs index 8ad55e3..885cbf0 100644 --- a/Forge.Tests/Statescript/GraphProcessorTests.cs +++ b/Forge.Tests/Statescript/GraphProcessorTests.cs @@ -691,4 +691,76 @@ public void Empty_graph_finalizes_immediately() context.HasStarted.Should().BeFalse(); context.Processor.Should().BeNull(); } + + [Fact] + [Trait("Graph", "ArrayVariables")] + public void Array_variable_is_initialized_from_definition() + { + var graph = new Graph(); + graph.VariableDefinitions.DefineArrayVariable("targets", 10, 20, 30); + + var context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); + + context.GraphVariables.GetArrayLength("targets").Should().Be(3); + context.GraphVariables.TryGetArrayElement("targets", 0, out int v0).Should().BeTrue(); + context.GraphVariables.TryGetArrayElement("targets", 1, out int v1).Should().BeTrue(); + context.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 context1 = new TestGraphContext(); + var processor1 = new GraphProcessor(graph, context1); + processor1.StartGraph(); + + var context2 = new TestGraphContext(); + var processor2 = new GraphProcessor(graph, context2); + processor2.StartGraph(); + + context1.GraphVariables.SetArrayElement("ids", 0, 99); + + context1.GraphVariables.TryGetArrayElement("ids", 0, out int val1); + context2.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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); + + context.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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + processor.StartGraph(); + + context.GraphVariables.TryGetArrayElement("data", 5, out double _).Should().BeFalse(); + context.GraphVariables.TryGetArrayElement("data", -1, out double _).Should().BeFalse(); + } } diff --git a/Forge.Tests/Statescript/PropertyResolverTests.cs b/Forge.Tests/Statescript/PropertyResolverTests.cs index 65d034b..67ea3a0 100644 --- a/Forge.Tests/Statescript/PropertyResolverTests.cs +++ b/Forge.Tests/Statescript/PropertyResolverTests.cs @@ -373,4 +373,171 @@ public void Comparison_resolver_supports_nested_resolvers() resolver.Resolve(context).AsBool().Should().BeTrue("Attribute5 (5) > 3"); } + + [Fact] + [Trait("Resolver", "SharedVariable")] + public void Shared_variable_resolver_reads_value_from_owner_shared_variables() + { + var entity = new TestEntity(_tagsManager, _cuesManager); + entity.SharedVariables.DefineVariable("abilityLock", true); + + var resolver = new SharedVariableResolver("abilityLock"); + + var context = new TestGraphContext { Owner = entity }; + + resolver.Resolve(context).AsBool().Should().BeTrue(); + } + + [Fact] + [Trait("Resolver", "SharedVariable")] + public void Shared_variable_resolver_returns_default_when_owner_is_null() + { + var resolver = new SharedVariableResolver("abilityLock"); + + var context = new TestGraphContext { Owner = null }; + + 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"); + + var context = new TestGraphContext { Owner = entity }; + + 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"); + + 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"); + + var context1 = new TestGraphContext { Owner = entity }; + var context2 = new TestGraphContext { Owner = entity }; + + 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 TestGraphContext(); + + 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 TestGraphContext(); + + 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)); + } } 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/GraphVariableDefinitions.cs b/Forge/Statescript/GraphVariableDefinitions.cs index 49aeb47..d4c6496 100644 --- a/Forge/Statescript/GraphVariableDefinitions.cs +++ b/Forge/Statescript/GraphVariableDefinitions.cs @@ -60,6 +60,47 @@ public void DefineVariable(StringKey name, T initialValue) 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. /// diff --git a/Forge/Statescript/Properties/ArrayVariableResolver.cs b/Forge/Statescript/Properties/ArrayVariableResolver.cs new file mode 100644 index 0000000..0f20582 --- /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(IGraphContext 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/SharedVariableResolver.cs b/Forge/Statescript/Properties/SharedVariableResolver.cs new file mode 100644 index 0000000..eb38ee7 --- /dev/null +++ b/Forge/Statescript/Properties/SharedVariableResolver.cs @@ -0,0 +1,45 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Statescript.Properties; + +/// +/// Resolves a property value by reading a named variable from the graph owner entity's +/// . Shared variables are accessible by all graph instances running on the +/// same entity, enabling cross-ability communication (e.g., an "ability lock" flag shared by all abilities on a hero). +/// +/// +/// If the graph context has no owner or the owner's 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 entity-level shared bag, allowing one ability's graph to read values written by another. +/// +/// The name of the shared variable to read from the owner entity. +public class SharedVariableResolver(StringKey variableName) : IPropertyResolver +{ + private readonly StringKey _variableName = variableName; + + /// + /// + /// Returns as the default numeric type. The actual resolved value depends on the shared + /// variable's stored type. + /// + public Type ValueType => typeof(double); + + /// + public Variant128 Resolve(IGraphContext graphContext) + { + if (graphContext.Owner is null) + { + return default; + } + + if (!graphContext.Owner.SharedVariables.TryGetVariant(_variableName, graphContext, out Variant128 value)) + { + return default; + } + + return value; + } +} diff --git a/Forge/Statescript/Properties/VariantResolver.cs b/Forge/Statescript/Properties/VariantResolver.cs index be51f65..663d6cd 100644 --- a/Forge/Statescript/Properties/VariantResolver.cs +++ b/Forge/Statescript/Properties/VariantResolver.cs @@ -20,22 +20,17 @@ public class VariantResolver(Variant128 initialValue, Type valueType) : IPropert /// public Type ValueType { get; } = valueType; - /// - public Variant128 Resolve(IGraphContext graphContext) - { - return Value; - } - /// - /// Sets the value from a typed input, converting it to a . + /// Creates a from a typed value. /// - /// The type of the value to set. Must be supported by . - /// The value to set. + /// 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 void Set(T value) + public static Variant128 CreateVariant(T value) { - Value = value switch + return value switch { bool @bool => new Variant128(@bool), byte @byte => new Variant128(@byte), @@ -58,4 +53,22 @@ public void Set(T value) _ => throw new ArgumentException($"{typeof(T)} is not supported by Variant128"), }; } + + /// + public Variant128 Resolve(IGraphContext 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/Variables.cs b/Forge/Statescript/Variables.cs index 6b4aeaf..17c2981 100644 --- a/Forge/Statescript/Variables.cs +++ b/Forge/Statescript/Variables.cs @@ -25,14 +25,27 @@ public void InitializeFrom(GraphVariableDefinitions definitions) foreach (PropertyDefinition definition in definitions.Definitions) { - if (definition.Resolver is VariantResolver variantResolver) + switch (definition.Resolver) { - _propertyResolvers[definition.Name] = - new VariantResolver(variantResolver.Value, variantResolver.ValueType); - } - else - { - _propertyResolvers[definition.Name] = 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; } } } @@ -165,4 +178,113 @@ public void SetVariant(StringKey name, Variant128 value) 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; + } } From c4617777511b4dd7b32dcea783511ef059e69403 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 15 Feb 2026 14:01:46 -0300 Subject: [PATCH 14/19] Added graph loop validation --- .../Effects/CustomCalculatorsEffectsTests.cs | 2 +- .../Statescript/GraphLoopDetectionTests.cs | 768 ++++++++++++++++++ Forge/Statescript/Graph.cs | 229 ++++++ Forge/Statescript/GraphProcessor.cs | 1 + Forge/Statescript/Node.cs | 43 + Forge/Statescript/Nodes/ActionNode.cs | 8 + Forge/Statescript/Nodes/ConditionNode.cs | 9 + Forge/Statescript/Nodes/EntryNode.cs | 6 + Forge/Statescript/Nodes/ExitNode.cs | 6 + Forge/Statescript/Nodes/StateNode.cs | 45 +- Forge/Statescript/Ports/InputPort.cs | 11 +- Forge/Statescript/Ports/OutputPort.cs | 58 +- Forge/Statescript/Ports/SubgraphPort.cs | 6 +- 13 files changed, 1170 insertions(+), 22 deletions(-) create mode 100644 Forge.Tests/Statescript/GraphLoopDetectionTests.cs diff --git a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs index 53fdb6f..4c31928 100644 --- a/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs +++ b/Forge.Tests/Effects/CustomCalculatorsEffectsTests.cs @@ -9,10 +9,10 @@ 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; -using Gamesmiths.Forge.Statescript; namespace Gamesmiths.Forge.Tests.Effects; diff --git a/Forge.Tests/Statescript/GraphLoopDetectionTests.cs b/Forge.Tests/Statescript/GraphLoopDetectionTests.cs new file mode 100644 index 0000000..33cada1 --- /dev/null +++ b/Forge.Tests/Statescript/GraphLoopDetectionTests.cs @@ -0,0 +1,768 @@ +// 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 TimerStateNode("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 TimerStateNode("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 TimerStateNode("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 TimerStateNode("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 TimerStateNode("short"); + var longTimer = new TimerStateNode("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 TimerStateNode("d1"); + var timer2 = new TimerStateNode("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 TimerStateNode("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 TimerStateNode("d1"); + var timer2 = new TimerStateNode("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 TimerStateNode("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 TimerStateNode("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 TimerStateNode("d1"); + var timer2 = new TimerStateNode("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 TimerStateNode("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 TimerStateNode("d1"); + var timer2 = new TimerStateNode("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 TimerStateNode("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 context = new TestGraphContext(); + var processor = new GraphProcessor(graph, context); + 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/Statescript/Graph.cs b/Forge/Statescript/Graph.cs index 1e628ee..138432a 100644 --- a/Forge/Statescript/Graph.cs +++ b/Forge/Statescript/Graph.cs @@ -1,6 +1,8 @@ // Copyright © Gamesmiths Guild. +using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Statescript.Nodes; +using Gamesmiths.Forge.Statescript.Ports; namespace Gamesmiths.Forge.Statescript; @@ -18,6 +20,11 @@ namespace Gamesmiths.Forge.Statescript; /// 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. /// @@ -69,5 +76,227 @@ 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/GraphProcessor.cs b/Forge/Statescript/GraphProcessor.cs index 52cafa8..1ac373b 100644 --- a/Forge/Statescript/GraphProcessor.cs +++ b/Forge/Statescript/GraphProcessor.cs @@ -52,6 +52,7 @@ public void StartGraph(Action? variableOverrides = null) 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 diff --git a/Forge/Statescript/Node.cs b/Forge/Statescript/Node.cs index 1362745..e3322a6 100644 --- a/Forge/Statescript/Node.cs +++ b/Forge/Statescript/Node.cs @@ -34,6 +34,12 @@ public abstract class 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. /// @@ -55,6 +61,7 @@ protected Node() InputPorts = [.. inputPorts]; OutputPorts = [.. outputPorts]; + SubgraphPorts = [.. OutputPorts.OfType()]; } internal void OnMessageReceived( @@ -88,6 +95,42 @@ internal void OnSubgraphDisabledMessageReceived(IGraphContext 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., ). diff --git a/Forge/Statescript/Nodes/ActionNode.cs b/Forge/Statescript/Nodes/ActionNode.cs index 9800cca..bcbe326 100644 --- a/Forge/Statescript/Nodes/ActionNode.cs +++ b/Forge/Statescript/Nodes/ActionNode.cs @@ -26,6 +26,14 @@ public abstract class ActionNode : Node /// The current graph context. protected abstract void Execute(IGraphContext 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) { diff --git a/Forge/Statescript/Nodes/ConditionNode.cs b/Forge/Statescript/Nodes/ConditionNode.cs index 2fb9eac..6b911af 100644 --- a/Forge/Statescript/Nodes/ConditionNode.cs +++ b/Forge/Statescript/Nodes/ConditionNode.cs @@ -32,6 +32,15 @@ public abstract class ConditionNode : Node /// if the condition is met; otherwise, . protected abstract bool Test(IGraphContext 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) { diff --git a/Forge/Statescript/Nodes/EntryNode.cs b/Forge/Statescript/Nodes/EntryNode.cs index 7cbe34c..0268757 100644 --- a/Forge/Statescript/Nodes/EntryNode.cs +++ b/Forge/Statescript/Nodes/EntryNode.cs @@ -33,6 +33,12 @@ public void StopGraph(IGraphContext graphContext) ((SubgraphPort)OutputPorts[OutputPort]).EmitDisableSubgraphMessage(graphContext); } + /// + internal override IEnumerable GetReachableOutputPorts(byte inputPortIndex) + { + yield return OutputPort; + } + /// protected override void DefinePorts(List inputPorts, List outputPorts) { diff --git a/Forge/Statescript/Nodes/ExitNode.cs b/Forge/Statescript/Nodes/ExitNode.cs index 04a1364..9c45999 100644 --- a/Forge/Statescript/Nodes/ExitNode.cs +++ b/Forge/Statescript/Nodes/ExitNode.cs @@ -19,6 +19,12 @@ public class ExitNode : Node /// public const byte InputPort = 0; + /// + internal override IEnumerable GetReachableOutputPorts(byte inputPortIndex) + { + return []; + } + /// protected override void DefinePorts(List inputPorts, List outputPorts) { diff --git a/Forge/Statescript/Nodes/StateNode.cs b/Forge/Statescript/Nodes/StateNode.cs index ede9942..cf72ffc 100644 --- a/Forge/Statescript/Nodes/StateNode.cs +++ b/Forge/Statescript/Nodes/StateNode.cs @@ -76,6 +76,43 @@ internal override void Update(double deltaTime, IGraphContext graphContext) 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. @@ -179,12 +216,9 @@ protected void DeactivateNode(IGraphContext graphContext) { BeforeDisable(graphContext); - foreach (OutputPort outputPort in OutputPorts) + foreach (SubgraphPort subgraphPort in SubgraphPorts) { - if (outputPort is SubgraphPort subgraphPort) - { - subgraphPort.EmitDisableSubgraphMessage(graphContext); - } + subgraphPort.EmitDisableSubgraphMessage(graphContext); } AfterDisable(graphContext); @@ -207,7 +241,6 @@ protected sealed override void BeforeDisable(IGraphContext graphContext) base.BeforeDisable(graphContext); OutputPorts[OnDeactivatePort].EmitMessage(graphContext); - ((SubgraphPort)OutputPorts[SubgraphPort]).EmitDisableSubgraphMessage(graphContext); } /// diff --git a/Forge/Statescript/Ports/InputPort.cs b/Forge/Statescript/Ports/InputPort.cs index 1483633..680f260 100644 --- a/Forge/Statescript/Ports/InputPort.cs +++ b/Forge/Statescript/Ports/InputPort.cs @@ -7,7 +7,10 @@ namespace Gamesmiths.Forge.Statescript.Ports; /// public class InputPort : Port { - private Node? _ownerNode; + /// + /// Gets the node that owns this input port. + /// + internal Node? OwnerNode { get; private set; } /// /// Initializes a new instance of the class. @@ -23,7 +26,7 @@ public InputPort() /// The owner node to set. public void SetOwnerNode(Node ownerNode) { - _ownerNode = ownerNode; + OwnerNode = ownerNode; } /// @@ -32,7 +35,7 @@ public void SetOwnerNode(Node ownerNode) /// The graph context for the message. public void ReceiveMessage(IGraphContext graphContext) { - _ownerNode?.OnMessageReceived(this, graphContext); + OwnerNode?.OnMessageReceived(this, graphContext); } /// @@ -41,6 +44,6 @@ public void ReceiveMessage(IGraphContext graphContext) /// The graph context for the message. public void ReceiveDisableSubgraphMessage(IGraphContext graphContext) { - _ownerNode?.OnSubgraphDisabledMessageReceived(graphContext); + OwnerNode?.OnSubgraphDisabledMessageReceived(graphContext); } } diff --git a/Forge/Statescript/Ports/OutputPort.cs b/Forge/Statescript/Ports/OutputPort.cs index ed1ef8a..d1a9706 100644 --- a/Forge/Statescript/Ports/OutputPort.cs +++ b/Forge/Statescript/Ports/OutputPort.cs @@ -18,17 +18,24 @@ public class OutputPort : Port public event Action? OnEmitMessageDisableSubgraphMessage; /// - /// Gets the list of input ports connected to this output port. + /// Gets the number of input ports connected to this output port. /// - /// TODO: Convert to array. - protected List ConnectedPorts { get; } + 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() { - ConnectedPorts = []; + PendingConnectedPorts = []; } /// @@ -37,14 +44,45 @@ protected OutputPort() /// The input port to connect. public void Connect(InputPort inputPort) { - ConnectedPorts.Add(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(IGraphContext graphContext) { - foreach (InputPort inputPort in ConnectedPorts) + InputPort[] ports = FinalizedConnectedPorts!; + + for (var i = 0; i < ports.Length; i++) { - inputPort.ReceiveMessage(graphContext); + ports[i].ReceiveMessage(graphContext); } OnEmitMessage?.Invoke(PortID); @@ -52,9 +90,11 @@ internal void EmitMessage(IGraphContext graphContext) internal void InternalEmitDisableSubgraphMessage(IGraphContext graphContext) { - foreach (InputPort inputPort in ConnectedPorts) + InputPort[] ports = FinalizedConnectedPorts!; + + for (var i = 0; i < ports.Length; i++) { - inputPort.ReceiveDisableSubgraphMessage(graphContext); + ports[i].ReceiveDisableSubgraphMessage(graphContext); } OnEmitMessageDisableSubgraphMessage?.Invoke(PortID); diff --git a/Forge/Statescript/Ports/SubgraphPort.cs b/Forge/Statescript/Ports/SubgraphPort.cs index 63b3eda..519758e 100644 --- a/Forge/Statescript/Ports/SubgraphPort.cs +++ b/Forge/Statescript/Ports/SubgraphPort.cs @@ -21,9 +21,11 @@ public SubgraphPort() /// The graph context for the message. public void EmitDisableSubgraphMessage(IGraphContext graphContext) { - foreach (InputPort inputPort in ConnectedPorts) + InputPort[] ports = FinalizedConnectedPorts!; + + for (var i = 0; i < ports.Length; i++) { - inputPort.ReceiveDisableSubgraphMessage(graphContext); + ports[i].ReceiveDisableSubgraphMessage(graphContext); } } } From bd41671704b190f1a729e4691f5526a31abf6831 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 15 Feb 2026 17:34:21 -0300 Subject: [PATCH 15/19] Simplified TimerNode name --- .../Statescript/GraphLoopDetectionTests.cs | 38 +++++++++---------- .../Statescript/GraphProcessorTests.cs | 20 +++++----- Forge.Tests/Statescript/StateNodeTests.cs | 12 +++--- .../State/{TimerStateNode.cs => TimerNode.cs} | 2 +- .../Nodes/State/TimerNodeContext.cs | 2 +- 5 files changed, 37 insertions(+), 37 deletions(-) rename Forge/Statescript/Nodes/State/{TimerStateNode.cs => TimerNode.cs} (95%) diff --git a/Forge.Tests/Statescript/GraphLoopDetectionTests.cs b/Forge.Tests/Statescript/GraphLoopDetectionTests.cs index 33cada1..f878609 100644 --- a/Forge.Tests/Statescript/GraphLoopDetectionTests.cs +++ b/Forge.Tests/Statescript/GraphLoopDetectionTests.cs @@ -218,7 +218,7 @@ 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 TimerStateNode("duration"); + var timer = new TimerNode("duration"); graph.AddNode(timer); graph.AddConnection(new Connection( @@ -238,7 +238,7 @@ public void State_on_deactivate_through_action_looping_back_to_own_input_is_reje { var graph = new Graph(); graph.VariableDefinitions.DefineVariable("duration", 5.0); - var timer = new TimerStateNode("duration"); + var timer = new TimerNode("duration"); var action = new TrackingActionNode(); graph.AddNode(timer); graph.AddNode(action); @@ -263,7 +263,7 @@ public void State_on_deactivate_to_exit_node_is_allowed() { var graph = new Graph(); graph.VariableDefinitions.DefineVariable("duration", 5.0); - var timer = new TimerStateNode("duration"); + var timer = new TimerNode("duration"); var exitNode = new ExitNode(); graph.AddNode(timer); graph.AddNode(exitNode); @@ -287,7 +287,7 @@ public void State_on_activate_to_action_is_allowed() { var graph = new Graph(); graph.VariableDefinitions.DefineVariable("duration", 5.0); - var timer = new TimerStateNode("duration"); + var timer = new TimerNode("duration"); var action = new TrackingActionNode(); graph.AddNode(timer); graph.AddNode(action); @@ -312,8 +312,8 @@ 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 TimerStateNode("short"); - var longTimer = new TimerStateNode("long"); + var shortTimer = new TimerNode("short"); + var longTimer = new TimerNode("long"); graph.AddNode(shortTimer); graph.AddNode(longTimer); @@ -340,8 +340,8 @@ 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 TimerStateNode("d1"); - var timer2 = new TimerStateNode("d2"); + var timer1 = new TimerNode("d1"); + var timer2 = new TimerNode("d2"); graph.AddNode(timer1); graph.AddNode(timer2); @@ -364,7 +364,7 @@ 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 TimerStateNode("duration"); + var timer = new TimerNode("duration"); graph.AddNode(timer); graph.AddConnection(new Connection( @@ -385,8 +385,8 @@ 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 TimerStateNode("d1"); - var timer2 = new TimerStateNode("d2"); + var timer1 = new TimerNode("d1"); + var timer2 = new TimerNode("d2"); graph.AddNode(timer1); graph.AddNode(timer2); @@ -416,7 +416,7 @@ public void Abort_on_deactivate_cycle_through_action_is_rejected() { var graph = new Graph(); graph.VariableDefinitions.DefineVariable("duration", 5.0); - var timer = new TimerStateNode("duration"); + var timer = new TimerNode("duration"); var action = new TrackingActionNode(); graph.AddNode(timer); graph.AddNode(action); @@ -444,7 +444,7 @@ 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 TimerStateNode("duration"); + var timer = new TimerNode("duration"); graph.AddNode(timer); graph.AddConnection(new Connection( @@ -470,8 +470,8 @@ public void Disable_cascade_through_state_on_deactivate_looping_back_is_rejected var graph = new Graph(); graph.VariableDefinitions.DefineVariable("d1", 5.0); graph.VariableDefinitions.DefineVariable("d2", 5.0); - var timer1 = new TimerStateNode("d1"); - var timer2 = new TimerStateNode("d2"); + var timer1 = new TimerNode("d1"); + var timer2 = new TimerNode("d2"); var action1 = new TrackingActionNode(); var action2 = new TrackingActionNode(); graph.AddNode(timer1); @@ -507,7 +507,7 @@ public void Disable_cascade_through_action_chain_without_state_is_allowed() // 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 TimerStateNode("duration"); + var timer = new TimerNode("duration"); var action1 = new TrackingActionNode(); var action2 = new TrackingActionNode(); graph.AddNode(timer); @@ -541,8 +541,8 @@ public void Nested_state_nodes_on_deactivate_chain_loop_is_rejected() var graph = new Graph(); graph.VariableDefinitions.DefineVariable("d1", 5.0); graph.VariableDefinitions.DefineVariable("d2", 3.0); - var timer1 = new TimerStateNode("d1"); - var timer2 = new TimerStateNode("d2"); + var timer1 = new TimerNode("d1"); + var timer2 = new TimerNode("d2"); graph.AddNode(timer1); graph.AddNode(timer2); @@ -654,7 +654,7 @@ public void State_on_activate_through_condition_and_action_looping_back_is_rejec { var graph = new Graph(); graph.VariableDefinitions.DefineVariable("duration", 5.0); - var timer = new TimerStateNode("duration"); + var timer = new TimerNode("duration"); var condition = new FixedConditionNode(result: true); var action = new TrackingActionNode(); graph.AddNode(timer); diff --git a/Forge.Tests/Statescript/GraphProcessorTests.cs b/Forge.Tests/Statescript/GraphProcessorTests.cs index 885cbf0..5b9a0ba 100644 --- a/Forge.Tests/Statescript/GraphProcessorTests.cs +++ b/Forge.Tests/Statescript/GraphProcessorTests.cs @@ -269,7 +269,7 @@ public void Stopping_graph_removes_all_node_contexts() var graph = new Graph(); graph.VariableDefinitions.DefineVariable("duration", 5.0); - var timer = new TimerStateNode("duration"); + var timer = new TimerNode("duration"); graph.AddNode(timer); graph.AddConnection(new Connection( graph.EntryNode.OutputPorts[EntryNode.OutputPort], @@ -417,7 +417,7 @@ public void Exit_node_stops_graph_execution() var graph = new Graph(); graph.VariableDefinitions.DefineVariable("duration", 5.0); - var timer = new TimerStateNode("duration"); + var timer = new TimerNode("duration"); var exitNode = new ExitNode(); graph.AddNode(timer); @@ -478,8 +478,8 @@ public void Exit_node_stops_all_active_state_nodes() graph.VariableDefinitions.DefineVariable("shortDuration", 1.0); graph.VariableDefinitions.DefineVariable("longDuration", 10.0); - var shortTimer = new TimerStateNode("shortDuration"); - var longTimer = new TimerStateNode("longDuration"); + var shortTimer = new TimerNode("shortDuration"); + var longTimer = new TimerNode("longDuration"); var exitNode = new ExitNode(); graph.AddNode(shortTimer); @@ -516,7 +516,7 @@ public void Processor_reference_is_set_on_start_and_cleared_on_stop() var graph = new Graph(); graph.VariableDefinitions.DefineVariable("duration", 5.0); - var timer = new TimerStateNode("duration"); + var timer = new TimerNode("duration"); graph.AddNode(timer); graph.AddConnection(new Connection( graph.EntryNode.OutputPorts[EntryNode.OutputPort], @@ -541,7 +541,7 @@ public void Active_state_nodes_set_tracks_active_nodes() var graph = new Graph(); graph.VariableDefinitions.DefineVariable("duration", 2.0); - var timer = new TimerStateNode("duration"); + var timer = new TimerNode("duration"); graph.AddNode(timer); graph.AddConnection(new Connection( graph.EntryNode.OutputPorts[EntryNode.OutputPort], @@ -588,7 +588,7 @@ public void Timer_graph_finalizes_when_last_state_node_deactivates() var graph = new Graph(); graph.VariableDefinitions.DefineVariable("duration", 2.0); - var timer = new TimerStateNode("duration"); + var timer = new TimerNode("duration"); graph.AddNode(timer); graph.AddConnection(new Connection( graph.EntryNode.OutputPorts[EntryNode.OutputPort], @@ -618,8 +618,8 @@ public void Multiple_timers_finalize_only_after_all_deactivate() graph.VariableDefinitions.DefineVariable("shortDuration", 1.0); graph.VariableDefinitions.DefineVariable("longDuration", 3.0); - var shortTimer = new TimerStateNode("shortDuration"); - var longTimer = new TimerStateNode("longDuration"); + var shortTimer = new TimerNode("shortDuration"); + var longTimer = new TimerNode("longDuration"); graph.AddNode(shortTimer); graph.AddNode(longTimer); @@ -658,7 +658,7 @@ public void Update_graph_does_nothing_after_finalization() var graph = new Graph(); graph.VariableDefinitions.DefineVariable("duration", 1.0); - var timer = new TimerStateNode("duration"); + var timer = new TimerNode("duration"); graph.AddNode(timer); graph.AddConnection(new Connection( graph.EntryNode.OutputPorts[EntryNode.OutputPort], diff --git a/Forge.Tests/Statescript/StateNodeTests.cs b/Forge.Tests/Statescript/StateNodeTests.cs index a510a34..ce587c8 100644 --- a/Forge.Tests/Statescript/StateNodeTests.cs +++ b/Forge.Tests/Statescript/StateNodeTests.cs @@ -17,7 +17,7 @@ public void Timer_node_stays_active_until_duration_elapses() var graph = new Graph(); graph.VariableDefinitions.DefineVariable("duration", 2.0); - var timer = new TimerStateNode("duration"); + var timer = new TimerNode("duration"); graph.AddNode(timer); graph.AddConnection(new Connection( @@ -50,7 +50,7 @@ public void Timer_node_fires_on_deactivate_event_when_completed() var graph = new Graph(); graph.VariableDefinitions.DefineVariable("duration", 1.0); - var timer = new TimerStateNode("duration"); + var timer = new TimerNode("duration"); var onDeactivateAction = new TrackingActionNode(); graph.AddNode(timer); @@ -60,7 +60,7 @@ public void Timer_node_fires_on_deactivate_event_when_completed() graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[ActionNode.InputPort])); graph.AddConnection(new Connection( - timer.OutputPorts[TimerStateNode.OnDeactivatePort], + timer.OutputPorts[TimerNode.OnDeactivatePort], onDeactivateAction.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); @@ -81,7 +81,7 @@ public void Timer_node_fires_on_activate_event_on_start() var graph = new Graph(); graph.VariableDefinitions.DefineVariable("duration", 5.0); - var timer = new TimerStateNode("duration"); + var timer = new TimerNode("duration"); var onActivateAction = new TrackingActionNode(); graph.AddNode(timer); @@ -91,7 +91,7 @@ public void Timer_node_fires_on_activate_event_on_start() graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[ActionNode.InputPort])); graph.AddConnection(new Connection( - timer.OutputPorts[TimerStateNode.OnActivatePort], + timer.OutputPorts[TimerNode.OnActivatePort], onActivateAction.InputPorts[ActionNode.InputPort])); var context = new TestGraphContext(); @@ -108,7 +108,7 @@ 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 TimerStateNode("duration"); + var timer = new TimerNode("duration"); graph.AddNode(timer); graph.AddConnection(new Connection( diff --git a/Forge/Statescript/Nodes/State/TimerStateNode.cs b/Forge/Statescript/Nodes/State/TimerNode.cs similarity index 95% rename from Forge/Statescript/Nodes/State/TimerStateNode.cs rename to Forge/Statescript/Nodes/State/TimerNode.cs index deac511..f0e02f0 100644 --- a/Forge/Statescript/Nodes/State/TimerStateNode.cs +++ b/Forge/Statescript/Nodes/State/TimerNode.cs @@ -17,7 +17,7 @@ namespace Gamesmiths.Forge.Statescript.Nodes.State; /// /// The name of the graph variable or property that provides the timer duration in /// seconds. -public class TimerStateNode(StringKey durationPropertyName) : StateNode +public class TimerNode(StringKey durationPropertyName) : StateNode { private readonly StringKey _durationPropertyName = durationPropertyName; diff --git a/Forge/Statescript/Nodes/State/TimerNodeContext.cs b/Forge/Statescript/Nodes/State/TimerNodeContext.cs index 6be14da..e2e4f0b 100644 --- a/Forge/Statescript/Nodes/State/TimerNodeContext.cs +++ b/Forge/Statescript/Nodes/State/TimerNodeContext.cs @@ -3,7 +3,7 @@ namespace Gamesmiths.Forge.Statescript.Nodes.State; /// -/// The context for a . Tracks elapsed time since activation so the node can determine when +/// 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 From 1e92e55c56a9dc40be92812743543feb32ce9647 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 15 Feb 2026 17:42:34 -0300 Subject: [PATCH 16/19] Fixed quick-start.md capture source --- docs/quick-start.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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; From 3de5a462f8432c7b461ba1cedf85ff972b9a15a8 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 15 Feb 2026 20:40:31 -0300 Subject: [PATCH 17/19] Changed GraphContext into a concrete class --- Forge.Tests/Helpers/StatescriptTestHelpers.cs | 61 +----- Forge.Tests/Statescript/ActionNodeTests.cs | 16 +- .../Statescript/ExpressionResolverTests.cs | 28 +-- .../Statescript/GraphLoopDetectionTests.cs | 2 +- .../Statescript/GraphProcessorTests.cs | 201 +++++++----------- .../Statescript/PropertyResolverTests.cs | 74 +++---- Forge.Tests/Statescript/StateNodeTests.cs | 10 +- .../Statescript/GraphAbilityBehavior.Data.cs | 2 +- Forge/Statescript/GraphAbilityBehavior.cs | 2 +- Forge/Statescript/GraphContext.cs | 90 ++++++++ Forge/Statescript/GraphProcessor.cs | 6 +- Forge/Statescript/IGraphContext.cs | 87 -------- Forge/Statescript/Node.cs | 14 +- .../Nodes/Action/SetVariableNode.cs | 2 +- Forge/Statescript/Nodes/ActionNode.cs | 4 +- .../Condition/ExpressionConditionNode.cs | 2 +- Forge/Statescript/Nodes/ConditionNode.cs | 4 +- Forge/Statescript/Nodes/EntryNode.cs | 8 +- Forge/Statescript/Nodes/ExitNode.cs | 2 +- Forge/Statescript/Nodes/State/TimerNode.cs | 8 +- Forge/Statescript/Nodes/StateNode.cs | 43 ++-- Forge/Statescript/Ports/InputPort.cs | 4 +- Forge/Statescript/Ports/OutputPort.cs | 8 +- Forge/Statescript/Ports/SubgraphPort.cs | 2 +- .../Properties/ArrayVariableResolver.cs | 2 +- .../Properties/AttributeResolver.cs | 2 +- .../Properties/ComparisonResolver.cs | 8 +- .../Properties/IPropertyResolver.cs | 2 +- .../Properties/SharedVariableResolver.cs | 11 +- Forge/Statescript/Properties/TagResolver.cs | 2 +- .../Properties/VariableResolver.cs | 11 +- .../Statescript/Properties/VariantResolver.cs | 2 +- Forge/Statescript/Variables.cs | 4 +- 33 files changed, 318 insertions(+), 406 deletions(-) create mode 100644 Forge/Statescript/GraphContext.cs delete mode 100644 Forge/Statescript/IGraphContext.cs diff --git a/Forge.Tests/Helpers/StatescriptTestHelpers.cs b/Forge.Tests/Helpers/StatescriptTestHelpers.cs index bcd9a70..c3054e2 100644 --- a/Forge.Tests/Helpers/StatescriptTestHelpers.cs +++ b/Forge.Tests/Helpers/StatescriptTestHelpers.cs @@ -1,62 +1,11 @@ // Copyright © Gamesmiths Guild. #pragma warning disable SA1649, SA1402 // File name should match first type name -using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Statescript; using Gamesmiths.Forge.Statescript.Nodes; namespace Gamesmiths.Forge.Tests.Helpers; -internal sealed class TestGraphContext : IGraphContext -{ - private readonly Dictionary _nodeContexts = []; - - public bool IsActive => ActiveStateNodes.Count > 0; - - public IForgeEntity? Owner { get; set; } - - public Variables GraphVariables { get; } = new Variables(); - - public Dictionary InternalNodeActivationStatus { get; } = []; - - public HashSet ActiveStateNodes { get; } = []; - - public GraphProcessor? Processor { get; set; } - - public bool HasStarted { get; set; } - - public int NodeContextCount => _nodeContexts.Count; - - public 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; - } - - public T GetNodeContext(Guid nodeID) - where T : INodeContext, new() - { - if (_nodeContexts.TryGetValue(nodeID, out INodeContext? context)) - { - return (T)context; - } - - return default!; - } - - public void RemoveAllNodeContext() - { - _nodeContexts.Clear(); - } -} - internal sealed class TrackingActionNode(string? name = null, List? executionLog = null) : ActionNode { private readonly string? _name = name; @@ -64,7 +13,7 @@ internal sealed class TrackingActionNode(string? name = null, List? exec public int ExecutionCount { get; private set; } - protected override void Execute(IGraphContext graphContext) + protected override void Execute(GraphContext graphContext) { ExecutionCount++; @@ -79,7 +28,7 @@ internal sealed class FixedConditionNode(bool result) : ConditionNode { private readonly bool _result = result; - protected override bool Test(IGraphContext graphContext) + protected override bool Test(GraphContext graphContext) { return _result; } @@ -103,7 +52,7 @@ public ThresholdConditionNode(string variableName, int threshold) _fixedThreshold = threshold; } - protected override bool Test(IGraphContext graphContext) + protected override bool Test(GraphContext graphContext) { graphContext.GraphVariables.TryGetVar(_variableName, out int value); @@ -121,7 +70,7 @@ internal sealed class IncrementCounterNode(string variableName) : ActionNode { private readonly string _variableName = variableName; - protected override void Execute(IGraphContext graphContext) + protected override void Execute(GraphContext graphContext) { graphContext.GraphVariables.TryGetVar(_variableName, out int currentValue); graphContext.GraphVariables.SetVar(_variableName, currentValue + 1); @@ -135,7 +84,7 @@ internal sealed class ReadVariableNode(string variableName) : ActionNode public T LastReadValue { get; private set; } - protected override void Execute(IGraphContext graphContext) + protected override void Execute(GraphContext graphContext) { graphContext.GraphVariables.TryGetVar(_variableName, out T value); LastReadValue = value; diff --git a/Forge.Tests/Statescript/ActionNodeTests.cs b/Forge.Tests/Statescript/ActionNodeTests.cs index 01889ff..4cbf248 100644 --- a/Forge.Tests/Statescript/ActionNodeTests.cs +++ b/Forge.Tests/Statescript/ActionNodeTests.cs @@ -31,7 +31,7 @@ public void Set_variable_node_copies_value_from_source_to_target() setNode.OutputPorts[ActionNode.OutputPort], readNode.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -64,7 +64,7 @@ public void Set_variable_node_copies_value_between_different_variables() setNode.OutputPorts[ActionNode.OutputPort], readNode.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -97,7 +97,7 @@ public void Set_variable_node_does_not_modify_source() readTarget.OutputPorts[ActionNode.OutputPort], readSource.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -125,7 +125,7 @@ public void Set_variable_node_with_nonexistent_source_does_not_modify_target() setNode.OutputPorts[ActionNode.OutputPort], readNode.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -153,7 +153,7 @@ public void Set_variable_node_works_with_double_values() setNode.OutputPorts[ActionNode.OutputPort], readNode.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -181,7 +181,7 @@ public void Set_variable_node_works_with_bool_values() setNode.OutputPorts[ActionNode.OutputPort], readNode.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -209,10 +209,10 @@ public void Two_processors_using_set_variable_have_independent_state() incrementNode.OutputPorts[ActionNode.OutputPort], setNode.InputPorts[ActionNode.InputPort])); - var context1 = new TestGraphContext(); + var context1 = new GraphContext(); var processor1 = new GraphProcessor(graph, context1); - var context2 = new TestGraphContext(); + var context2 = new GraphContext(); var processor2 = new GraphProcessor(graph, context2); processor1.StartGraph(); diff --git a/Forge.Tests/Statescript/ExpressionResolverTests.cs b/Forge.Tests/Statescript/ExpressionResolverTests.cs index 7a9275c..a943a16 100644 --- a/Forge.Tests/Statescript/ExpressionResolverTests.cs +++ b/Forge.Tests/Statescript/ExpressionResolverTests.cs @@ -46,7 +46,7 @@ public void Comparison_resolver_greater_than_returns_true_when_left_exceeds_righ condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -84,7 +84,7 @@ public void Comparison_resolver_greater_than_returns_false_when_left_is_less() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -122,7 +122,7 @@ public void Comparison_resolver_equal_returns_true_for_matching_values() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -141,9 +141,9 @@ public void Comparison_resolver_compares_two_graph_variables() graph.VariableDefinitions.DefineProperty( "isHealthAboveThreshold", new ComparisonResolver( - new VariableResolver("health"), + new VariableResolver("health", typeof(double)), ComparisonOperation.GreaterThan, - new VariableResolver("threshold"))); + new VariableResolver("threshold", typeof(double)))); var condition = new ExpressionConditionNode("isHealthAboveThreshold"); var trueAction = new TrackingActionNode(); @@ -163,7 +163,7 @@ public void Comparison_resolver_compares_two_graph_variables() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -201,7 +201,7 @@ public void Comparison_resolver_less_than_or_equal_at_boundary() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -239,7 +239,7 @@ public void Comparison_resolver_not_equal_returns_true_for_different_values() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -271,7 +271,7 @@ public void Expression_condition_node_returns_false_for_missing_property() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -290,9 +290,9 @@ public void Comparison_resolver_works_with_int_operands() graph.VariableDefinitions.DefineProperty( "hasEnoughScore", new ComparisonResolver( - new VariableResolver("score"), + new VariableResolver("score", typeof(int)), ComparisonOperation.GreaterThanOrEqual, - new VariableResolver("requiredScore"))); + new VariableResolver("requiredScore", typeof(int)))); var condition = new ExpressionConditionNode("hasEnoughScore"); var trueAction = new TrackingActionNode(); @@ -312,7 +312,7 @@ public void Comparison_resolver_works_with_int_operands() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -334,7 +334,7 @@ public void Comparison_resolver_works_with_attribute_resolver() new ComparisonResolver( new AttributeResolver("TestAttributeSet.Attribute5"), ComparisonOperation.GreaterThanOrEqual, - new VariableResolver("required"))); + new VariableResolver("required", typeof(int)))); var condition = new ExpressionConditionNode("hasEnoughAttribute"); var trueAction = new TrackingActionNode(); @@ -354,7 +354,7 @@ public void Comparison_resolver_works_with_attribute_resolver() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext + var context = new GraphContext { Owner = entity, }; diff --git a/Forge.Tests/Statescript/GraphLoopDetectionTests.cs b/Forge.Tests/Statescript/GraphLoopDetectionTests.cs index f878609..84a140a 100644 --- a/Forge.Tests/Statescript/GraphLoopDetectionTests.cs +++ b/Forge.Tests/Statescript/GraphLoopDetectionTests.cs @@ -737,7 +737,7 @@ public void Rejected_connection_is_disconnected_from_port() } // The graph should still work normally without the loop. - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); diff --git a/Forge.Tests/Statescript/GraphProcessorTests.cs b/Forge.Tests/Statescript/GraphProcessorTests.cs index 5b9a0ba..c19e0ce 100644 --- a/Forge.Tests/Statescript/GraphProcessorTests.cs +++ b/Forge.Tests/Statescript/GraphProcessorTests.cs @@ -28,7 +28,7 @@ public void Graph_processor_initializes_variables_on_start() var graph = new Graph(); graph.VariableDefinitions.DefineVariable("health", 100); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -49,7 +49,7 @@ public void Starting_graph_executes_connected_action_node() graph.EntryNode.OutputPorts[EntryNode.OutputPort], actionNode.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -81,7 +81,7 @@ public void Action_nodes_execute_in_sequence() action2.OutputPorts[ActionNode.OutputPort], action3.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -111,7 +111,7 @@ public void Condition_node_routes_to_true_port_when_condition_is_met() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -142,7 +142,7 @@ public void Condition_node_routes_to_false_port_when_condition_is_not_met() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -170,7 +170,7 @@ public void Action_node_can_read_and_write_graph_variables() incrementNode.OutputPorts[ActionNode.OutputPort], readNode.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -203,7 +203,7 @@ public void Condition_node_can_branch_based_on_graph_variables() condition.OutputPorts[ConditionNode.FalsePort], belowAction.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -229,7 +229,7 @@ public void Output_port_can_connect_to_multiple_input_ports() graph.EntryNode.OutputPorts[EntryNode.OutputPort], action2.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -239,7 +239,7 @@ public void Output_port_can_connect_to_multiple_input_ports() [Fact] [Trait("Graph", "Lifecycle")] - public void Stopping_graph_resets_variables_to_saved_state() + public void Stopping_graph_does_not_throw() { var graph = new Graph(); graph.VariableDefinitions.DefineVariable("counter", 0); @@ -251,20 +251,21 @@ public void Stopping_graph_resets_variables_to_saved_state() graph.EntryNode.OutputPorts[EntryNode.OutputPort], incrementNode.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); context.GraphVariables.TryGetVar("counter", out int valueAfterStart).Should().BeTrue(); valueAfterStart.Should().Be(1); - // StopGraph cleans up node contexts - verify it doesn't throw. - processor.StopGraph(); + // 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_removes_all_node_contexts() + public void Stopping_graph_fires_on_graph_completed() { var graph = new Graph(); graph.VariableDefinitions.DefineVariable("duration", 5.0); @@ -275,17 +276,19 @@ public void Stopping_graph_removes_all_node_contexts() graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); + var completed = false; + processor.OnGraphCompleted = () => completed = true; processor.StartGraph(); - context.InternalNodeActivationStatus.Should().NotBeEmpty(); - context.NodeContextCount.Should().BePositive(); + context.IsActive.Should().BeTrue(); + completed.Should().BeFalse(); processor.StopGraph(); - context.NodeContextCount.Should().Be(0); - context.InternalNodeActivationStatus.Should().BeEmpty(); + context.IsActive.Should().BeFalse(); + completed.Should().BeTrue(); } [Fact] @@ -320,7 +323,7 @@ public void Complex_graph_with_condition_and_multiple_actions_executes_correctly condition.OutputPorts[ConditionNode.FalsePort], trackB.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -344,7 +347,7 @@ public void Disconnected_node_is_not_executed() graph.EntryNode.OutputPorts[EntryNode.OutputPort], connectedAction.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -365,10 +368,10 @@ public void Each_graph_processor_has_independent_variable_state() graph.EntryNode.OutputPorts[EntryNode.OutputPort], incrementNode.InputPorts[ActionNode.InputPort])); - var context1 = new TestGraphContext(); + var context1 = new GraphContext(); var processor1 = new GraphProcessor(graph, context1); - var context2 = new TestGraphContext(); + var context2 = new GraphContext(); var processor2 = new GraphProcessor(graph, context2); processor1.StartGraph(); @@ -430,17 +433,19 @@ public void Exit_node_stops_graph_execution() timer.OutputPorts[StateNode.OnDeactivatePort], exitNode.InputPorts[ExitNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); + var completed = false; + processor.OnGraphCompleted = () => completed = true; processor.StartGraph(); context.IsActive.Should().BeTrue(); + completed.Should().BeFalse(); processor.UpdateGraph(5.0); context.IsActive.Should().BeFalse(); - context.NodeContextCount.Should().Be(0); - context.Processor.Should().BeNull(); + completed.Should().BeTrue(); } [Fact] @@ -461,13 +466,14 @@ public void Exit_node_connected_to_action_stops_graph_after_action() actionNode.OutputPorts[ActionNode.OutputPort], exitNode.InputPorts[ExitNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); + var completed = false; + processor.OnGraphCompleted = () => completed = true; processor.StartGraph(); actionNode.ExecutionCount.Should().Be(1); - context.NodeContextCount.Should().Be(0); - context.Processor.Should().BeNull(); + completed.Should().BeTrue(); } [Fact] @@ -496,71 +502,23 @@ public void Exit_node_stops_all_active_state_nodes() shortTimer.OutputPorts[StateNode.OnDeactivatePort], exitNode.InputPorts[ExitNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); + var completed = false; + processor.OnGraphCompleted = () => completed = true; processor.StartGraph(); - context.ActiveStateNodes.Should().HaveCount(2); + context.IsActive.Should().BeTrue(); processor.UpdateGraph(1.0); context.IsActive.Should().BeFalse(); - context.ActiveStateNodes.Should().BeEmpty(); - context.NodeContextCount.Should().Be(0); - } - - [Fact] - [Trait("Graph", "Lifecycle")] - public void Processor_reference_is_set_on_start_and_cleared_on_stop() - { - 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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - - context.Processor.Should().BeNull(); - - processor.StartGraph(); - context.Processor.Should().Be(processor); - - processor.StopGraph(); - context.Processor.Should().BeNull(); - } - - [Fact] - [Trait("Graph", "Lifecycle")] - public void Active_state_nodes_set_tracks_active_nodes() - { - 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 context = new TestGraphContext(); - var processor = new GraphProcessor(graph, context); - processor.StartGraph(); - - context.ActiveStateNodes.Should().ContainSingle().Which.Should().Be(timer); - - processor.UpdateGraph(2.0); - - context.ActiveStateNodes.Should().BeEmpty(); + completed.Should().BeTrue(); } [Fact] [Trait("Graph", "Lifecycle")] - public void Action_only_graph_finalizes_immediately_after_start() + public void Action_only_graph_completes_immediately_after_start() { var graph = new Graph(); var actionNode = new TrackingActionNode(); @@ -570,20 +528,20 @@ public void Action_only_graph_finalizes_immediately_after_start() graph.EntryNode.OutputPorts[EntryNode.OutputPort], actionNode.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); + var completed = false; + processor.OnGraphCompleted = () => completed = true; processor.StartGraph(); actionNode.ExecutionCount.Should().Be(1); - context.Processor.Should().BeNull(); - context.HasStarted.Should().BeFalse(); - context.NodeContextCount.Should().Be(0); - context.InternalNodeActivationStatus.Should().BeEmpty(); + context.IsActive.Should().BeFalse(); + completed.Should().BeTrue(); } [Fact] [Trait("Graph", "Lifecycle")] - public void Timer_graph_finalizes_when_last_state_node_deactivates() + public void Timer_graph_completes_when_last_state_node_deactivates() { var graph = new Graph(); graph.VariableDefinitions.DefineVariable("duration", 2.0); @@ -594,25 +552,24 @@ public void Timer_graph_finalizes_when_last_state_node_deactivates() graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); + var completed = false; + processor.OnGraphCompleted = () => completed = true; processor.StartGraph(); - context.HasStarted.Should().BeTrue(); - context.Processor.Should().Be(processor); + context.IsActive.Should().BeTrue(); + completed.Should().BeFalse(); processor.UpdateGraph(2.0); context.IsActive.Should().BeFalse(); - context.HasStarted.Should().BeFalse(); - context.Processor.Should().BeNull(); - context.NodeContextCount.Should().Be(0); - context.InternalNodeActivationStatus.Should().BeEmpty(); + completed.Should().BeTrue(); } [Fact] [Trait("Graph", "Lifecycle")] - public void Multiple_timers_finalize_only_after_all_deactivate() + public void Multiple_timers_complete_only_after_all_deactivate() { var graph = new Graph(); graph.VariableDefinitions.DefineVariable("shortDuration", 1.0); @@ -631,29 +588,27 @@ public void Multiple_timers_finalize_only_after_all_deactivate() graph.EntryNode.OutputPorts[EntryNode.OutputPort], longTimer.InputPorts[StateNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); + var completed = false; + processor.OnGraphCompleted = () => completed = true; processor.StartGraph(); - context.ActiveStateNodes.Should().HaveCount(2); - context.HasStarted.Should().BeTrue(); + context.IsActive.Should().BeTrue(); + completed.Should().BeFalse(); processor.UpdateGraph(1.0); - context.ActiveStateNodes.Should().ContainSingle(); - context.HasStarted.Should().BeTrue(); - context.Processor.Should().Be(processor); + context.IsActive.Should().BeTrue(); + completed.Should().BeFalse(); processor.UpdateGraph(2.0); context.IsActive.Should().BeFalse(); - context.HasStarted.Should().BeFalse(); - context.Processor.Should().BeNull(); - context.NodeContextCount.Should().Be(0); - context.InternalNodeActivationStatus.Should().BeEmpty(); + completed.Should().BeTrue(); } [Fact] [Trait("Graph", "Lifecycle")] - public void Update_graph_does_nothing_after_finalization() + public void Update_graph_does_nothing_after_completion() { var graph = new Graph(); graph.VariableDefinitions.DefineVariable("duration", 1.0); @@ -664,32 +619,36 @@ public void Update_graph_does_nothing_after_finalization() graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); + var completedCount = 0; + processor.OnGraphCompleted = () => completedCount++; processor.StartGraph(); processor.UpdateGraph(1.0); - context.HasStarted.Should().BeFalse(); + completedCount.Should().Be(1); - // Subsequent updates should be no-ops and not throw. + // Subsequent updates should be no-ops and not throw or fire the callback again. processor.UpdateGraph(1.0); processor.UpdateGraph(1.0); - context.HasStarted.Should().BeFalse(); - context.Processor.Should().BeNull(); + completedCount.Should().Be(1); + context.IsActive.Should().BeFalse(); } [Fact] [Trait("Graph", "Lifecycle")] - public void Empty_graph_finalizes_immediately() + public void Empty_graph_completes_immediately() { var graph = new Graph(); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); + var completed = false; + processor.OnGraphCompleted = () => completed = true; processor.StartGraph(); - context.HasStarted.Should().BeFalse(); - context.Processor.Should().BeNull(); + context.IsActive.Should().BeFalse(); + completed.Should().BeTrue(); } [Fact] @@ -699,7 +658,7 @@ public void Array_variable_is_initialized_from_definition() var graph = new Graph(); graph.VariableDefinitions.DefineArrayVariable("targets", 10, 20, 30); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -719,11 +678,11 @@ public void Array_variable_has_independent_state_per_processor() var graph = new Graph(); graph.VariableDefinitions.DefineArrayVariable("ids", 1, 2, 3); - var context1 = new TestGraphContext(); + var context1 = new GraphContext(); var processor1 = new GraphProcessor(graph, context1); processor1.StartGraph(); - var context2 = new TestGraphContext(); + var context2 = new GraphContext(); var processor2 = new GraphProcessor(graph, context2); processor2.StartGraph(); @@ -742,7 +701,7 @@ public void Array_variable_returns_negative_length_for_nonexistent_variable() { var graph = new Graph(); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -756,7 +715,7 @@ 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 context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); diff --git a/Forge.Tests/Statescript/PropertyResolverTests.cs b/Forge.Tests/Statescript/PropertyResolverTests.cs index 67ea3a0..551eb96 100644 --- a/Forge.Tests/Statescript/PropertyResolverTests.cs +++ b/Forge.Tests/Statescript/PropertyResolverTests.cs @@ -21,7 +21,7 @@ public void Attribute_resolver_returns_current_value_of_existing_attribute() var entity = new TestEntity(_tagsManager, _cuesManager); var resolver = new AttributeResolver("TestAttributeSet.Attribute5"); - var context = new TestGraphContext { Owner = entity }; + var context = new GraphContext { Owner = entity }; Variant128 result = resolver.Resolve(context); @@ -35,7 +35,7 @@ public void Attribute_resolver_returns_default_for_missing_attribute() var entity = new TestEntity(_tagsManager, _cuesManager); var resolver = new AttributeResolver("TestAttributeSet.NonExistent"); - var context = new TestGraphContext { Owner = entity }; + var context = new GraphContext { Owner = entity }; Variant128 result = resolver.Resolve(context); @@ -48,7 +48,7 @@ public void Attribute_resolver_returns_default_when_owner_is_null() { var resolver = new AttributeResolver("TestAttributeSet.Attribute5"); - var context = new TestGraphContext { Owner = null }; + var context = new GraphContext { Owner = null }; Variant128 result = resolver.Resolve(context); @@ -72,7 +72,7 @@ public void Attribute_resolver_reads_different_attributes() var resolver1 = new AttributeResolver("TestAttributeSet.Attribute1"); var resolver90 = new AttributeResolver("TestAttributeSet.Attribute90"); - var context = new TestGraphContext { Owner = entity }; + var context = new GraphContext { Owner = entity }; resolver1.Resolve(context).AsInt().Should().Be(1); resolver90.Resolve(context).AsInt().Should().Be(90); @@ -86,7 +86,7 @@ public void Tag_resolver_returns_true_when_entity_has_tag() var tag = Tag.RequestTag(_tagsManager, "enemy.undead.zombie"); var resolver = new TagResolver(tag); - var context = new TestGraphContext { Owner = entity }; + var context = new GraphContext { Owner = entity }; Variant128 result = resolver.Resolve(context); @@ -101,7 +101,7 @@ public void Tag_resolver_returns_false_when_entity_does_not_have_tag() var tag = Tag.RequestTag(_tagsManager, "enemy.beast.wolf"); var resolver = new TagResolver(tag); - var context = new TestGraphContext { Owner = entity }; + var context = new GraphContext { Owner = entity }; Variant128 result = resolver.Resolve(context); @@ -115,7 +115,7 @@ public void Tag_resolver_returns_false_when_owner_is_null() var tag = Tag.RequestTag(_tagsManager, "enemy.undead.zombie"); var resolver = new TagResolver(tag); - var context = new TestGraphContext { Owner = null }; + var context = new GraphContext { Owner = null }; Variant128 result = resolver.Resolve(context); @@ -140,7 +140,7 @@ public void Tag_resolver_matches_parent_tag() var parentTag = Tag.RequestTag(_tagsManager, "enemy.undead"); var resolver = new TagResolver(parentTag); - var context = new TestGraphContext { Owner = entity }; + var context = new GraphContext { Owner = entity }; Variant128 result = resolver.Resolve(context); @@ -153,7 +153,7 @@ public void Variant_resolver_returns_stored_value() { var resolver = new VariantResolver(new Variant128(42.0), typeof(double)); - var context = new TestGraphContext(); + var context = new GraphContext(); Variant128 result = resolver.Resolve(context); @@ -168,7 +168,7 @@ public void Variant_resolver_value_can_be_updated() resolver.Set(25); - var context = new TestGraphContext(); + var context = new GraphContext(); Variant128 result = resolver.Resolve(context); result.AsInt().Should().Be(25); @@ -194,10 +194,10 @@ public void Variable_resolver_reads_value_from_graph_variables() var graph = new Graph(); graph.VariableDefinitions.DefineVariable("speed", 7.5); - var context = new TestGraphContext(); + var context = new GraphContext(); context.GraphVariables.InitializeFrom(graph.VariableDefinitions); - var resolver = new VariableResolver("speed"); + var resolver = new VariableResolver("speed", typeof(double)); Variant128 result = resolver.Resolve(context); @@ -210,10 +210,10 @@ public void Variable_resolver_returns_default_for_missing_variable() { var graph = new Graph(); - var context = new TestGraphContext(); + var context = new GraphContext(); context.GraphVariables.InitializeFrom(graph.VariableDefinitions); - var resolver = new VariableResolver("nonexistent"); + var resolver = new VariableResolver("nonexistent", typeof(double)); Variant128 result = resolver.Resolve(context); @@ -227,10 +227,10 @@ public void Variable_resolver_reflects_runtime_variable_changes() var graph = new Graph(); graph.VariableDefinitions.DefineVariable("counter", 0); - var context = new TestGraphContext(); + var context = new GraphContext(); context.GraphVariables.InitializeFrom(graph.VariableDefinitions); - var resolver = new VariableResolver("counter"); + var resolver = new VariableResolver("counter", typeof(int)); resolver.Resolve(context).AsInt().Should().Be(0); @@ -243,7 +243,7 @@ public void Variable_resolver_reflects_runtime_variable_changes() [Trait("Resolver", "Variable")] public void Variable_resolver_value_type_is_double() { - var resolver = new VariableResolver("anything"); + var resolver = new VariableResolver("anything", typeof(double)); resolver.ValueType.Should().Be(typeof(double)); } @@ -269,7 +269,7 @@ public void Comparison_resolver_equal_returns_true_for_same_values() ComparisonOperation.Equal, new VariantResolver(new Variant128(5.0), typeof(double))); - var context = new TestGraphContext(); + var context = new GraphContext(); resolver.Resolve(context).AsBool().Should().BeTrue(); } @@ -283,7 +283,7 @@ public void Comparison_resolver_equal_returns_false_for_different_values() ComparisonOperation.Equal, new VariantResolver(new Variant128(10.0), typeof(double))); - var context = new TestGraphContext(); + var context = new GraphContext(); resolver.Resolve(context).AsBool().Should().BeFalse(); } @@ -297,7 +297,7 @@ public void Comparison_resolver_not_equal_returns_true_for_different_values() ComparisonOperation.NotEqual, new VariantResolver(new Variant128(2.0), typeof(double))); - var context = new TestGraphContext(); + var context = new GraphContext(); resolver.Resolve(context).AsBool().Should().BeTrue(); } @@ -311,7 +311,7 @@ public void Comparison_resolver_less_than_returns_true_when_left_is_smaller() ComparisonOperation.LessThan, new VariantResolver(new Variant128(10.0), typeof(double))); - var context = new TestGraphContext(); + var context = new GraphContext(); resolver.Resolve(context).AsBool().Should().BeTrue(); } @@ -325,7 +325,7 @@ public void Comparison_resolver_less_than_returns_false_at_boundary() ComparisonOperation.LessThan, new VariantResolver(new Variant128(10.0), typeof(double))); - var context = new TestGraphContext(); + var context = new GraphContext(); resolver.Resolve(context).AsBool().Should().BeFalse(); } @@ -339,7 +339,7 @@ public void Comparison_resolver_greater_than_returns_true_when_left_is_larger() ComparisonOperation.GreaterThan, new VariantResolver(new Variant128(10.0), typeof(double))); - var context = new TestGraphContext(); + var context = new GraphContext(); resolver.Resolve(context).AsBool().Should().BeTrue(); } @@ -353,7 +353,7 @@ public void Comparison_resolver_greater_than_or_equal_returns_true_at_boundary() ComparisonOperation.GreaterThanOrEqual, new VariantResolver(new Variant128(10.0), typeof(double))); - var context = new TestGraphContext(); + var context = new GraphContext(); resolver.Resolve(context).AsBool().Should().BeTrue(); } @@ -369,7 +369,7 @@ public void Comparison_resolver_supports_nested_resolvers() ComparisonOperation.GreaterThan, new VariantResolver(new Variant128(3.0), typeof(double))); - var context = new TestGraphContext { Owner = entity }; + var context = new GraphContext { Owner = entity }; resolver.Resolve(context).AsBool().Should().BeTrue("Attribute5 (5) > 3"); } @@ -381,9 +381,9 @@ public void Shared_variable_resolver_reads_value_from_owner_shared_variables() var entity = new TestEntity(_tagsManager, _cuesManager); entity.SharedVariables.DefineVariable("abilityLock", true); - var resolver = new SharedVariableResolver("abilityLock"); + var resolver = new SharedVariableResolver("abilityLock", typeof(bool)); - var context = new TestGraphContext { Owner = entity }; + var context = new GraphContext { Owner = entity }; resolver.Resolve(context).AsBool().Should().BeTrue(); } @@ -392,9 +392,9 @@ public void Shared_variable_resolver_reads_value_from_owner_shared_variables() [Trait("Resolver", "SharedVariable")] public void Shared_variable_resolver_returns_default_when_owner_is_null() { - var resolver = new SharedVariableResolver("abilityLock"); + var resolver = new SharedVariableResolver("abilityLock", typeof(double)); - var context = new TestGraphContext { Owner = null }; + var context = new GraphContext { Owner = null }; resolver.Resolve(context).AsDouble().Should().Be(0); } @@ -404,9 +404,9 @@ public void Shared_variable_resolver_returns_default_when_owner_is_null() public void Shared_variable_resolver_returns_default_for_missing_variable() { var entity = new TestEntity(_tagsManager, _cuesManager); - var resolver = new SharedVariableResolver("nonexistent"); + var resolver = new SharedVariableResolver("nonexistent", typeof(double)); - var context = new TestGraphContext { Owner = entity }; + var context = new GraphContext { Owner = entity }; resolver.Resolve(context).AsDouble().Should().Be(0); } @@ -415,7 +415,7 @@ public void Shared_variable_resolver_returns_default_for_missing_variable() [Trait("Resolver", "SharedVariable")] public void Shared_variable_resolver_value_type_is_double() { - var resolver = new SharedVariableResolver("anything"); + var resolver = new SharedVariableResolver("anything", typeof(double)); resolver.ValueType.Should().Be(typeof(double)); } @@ -427,10 +427,10 @@ 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"); + var resolver = new SharedVariableResolver("sharedCounter", typeof(int)); - var context1 = new TestGraphContext { Owner = entity }; - var context2 = new TestGraphContext { Owner = entity }; + var context1 = new GraphContext { Owner = entity }; + var context2 = new GraphContext { Owner = entity }; resolver.Resolve(context1).AsInt().Should().Be(0); @@ -448,7 +448,7 @@ public void Array_resolver_returns_first_element_on_resolve() [new Variant128(10), new Variant128(20), new Variant128(30)], typeof(int)); - var context = new TestGraphContext(); + var context = new GraphContext(); resolver.Resolve(context).AsInt().Should().Be(10); } @@ -459,7 +459,7 @@ public void Array_resolver_returns_default_when_empty() { var resolver = new ArrayVariableResolver([], typeof(int)); - var context = new TestGraphContext(); + var context = new GraphContext(); resolver.Resolve(context).AsInt().Should().Be(0); } diff --git a/Forge.Tests/Statescript/StateNodeTests.cs b/Forge.Tests/Statescript/StateNodeTests.cs index ce587c8..62e5a39 100644 --- a/Forge.Tests/Statescript/StateNodeTests.cs +++ b/Forge.Tests/Statescript/StateNodeTests.cs @@ -24,7 +24,7 @@ public void Timer_node_stays_active_until_duration_elapses() graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -63,7 +63,7 @@ public void Timer_node_fires_on_deactivate_event_when_completed() timer.OutputPorts[TimerNode.OnDeactivatePort], onDeactivateAction.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -94,7 +94,7 @@ public void Timer_node_fires_on_activate_event_on_start() timer.OutputPorts[TimerNode.OnActivatePort], onActivateAction.InputPorts[ActionNode.InputPort])); - var context = new TestGraphContext(); + var context = new GraphContext(); var processor = new GraphProcessor(graph, context); processor.StartGraph(); @@ -115,10 +115,10 @@ public void Two_processors_with_same_timer_graph_have_independent_elapsed_time() graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[ActionNode.InputPort])); - var context1 = new TestGraphContext(); + var context1 = new GraphContext(); var processor1 = new GraphProcessor(graph, context1); - var context2 = new TestGraphContext(); + var context2 = new GraphContext(); var processor2 = new GraphProcessor(graph, context2); processor1.StartGraph(); diff --git a/Forge/Statescript/GraphAbilityBehavior.Data.cs b/Forge/Statescript/GraphAbilityBehavior.Data.cs index fdf6946..cfdf9c6 100644 --- a/Forge/Statescript/GraphAbilityBehavior.Data.cs +++ b/Forge/Statescript/GraphAbilityBehavior.Data.cs @@ -16,7 +16,7 @@ namespace Gamesmiths.Forge.Statescript; /// 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, IGraphContext graphContext, Action dataBinder) +public class GraphAbilityBehavior(Graph graph, GraphContext graphContext, Action dataBinder) : GraphAbilityBehavior(graph, graphContext), IAbilityBehavior { private readonly Action _dataBinder = dataBinder; diff --git a/Forge/Statescript/GraphAbilityBehavior.cs b/Forge/Statescript/GraphAbilityBehavior.cs index cbf2d15..cb0eab1 100644 --- a/Forge/Statescript/GraphAbilityBehavior.cs +++ b/Forge/Statescript/GraphAbilityBehavior.cs @@ -17,7 +17,7 @@ namespace Gamesmiths.Forge.Statescript; /// /// The graph definition to execute when the ability activates. /// The per-instance context that holds mutable runtime state for the graph. -public class GraphAbilityBehavior(Graph graph, IGraphContext graphContext) : IAbilityBehavior +public class GraphAbilityBehavior(Graph graph, GraphContext graphContext) : IAbilityBehavior { /// /// Gets the that drives this behavior. Callers must invoke diff --git a/Forge/Statescript/GraphContext.cs b/Forge/Statescript/GraphContext.cs new file mode 100644 index 0000000..5dd71e8 --- /dev/null +++ b/Forge/Statescript/GraphContext.cs @@ -0,0 +1,90 @@ +// Copyright © Gamesmiths Guild. + +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 owner entity for this graph execution. The owner provides access to entity attributes, + /// tags, and other systems that property resolvers can use to compute derived values. May be + /// if the graph does not require an owner entity. + /// + public IForgeEntity? Owner { 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; + + /// + /// 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 index 1ac373b..4a67478 100644 --- a/Forge/Statescript/GraphProcessor.cs +++ b/Forge/Statescript/GraphProcessor.cs @@ -7,7 +7,7 @@ namespace Gamesmiths.Forge.Statescript; /// /// /// The class pairs a shared, immutable definition with a -/// per-execution that holds all mutable runtime state (variable values, node contexts, +/// 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, @@ -16,7 +16,7 @@ namespace Gamesmiths.Forge.Statescript; /// The graph to be executed by this processor. /// The context in which the graph will be executed, providing runtime state for this /// execution instance. -public class GraphProcessor(Graph graph, IGraphContext graphContext) +public class GraphProcessor(Graph graph, GraphContext graphContext) { private readonly List _updateBuffer = []; @@ -29,7 +29,7 @@ public class GraphProcessor(Graph graph, IGraphContext graphContext) /// 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 IGraphContext GraphContext { get; } = graphContext; + public GraphContext GraphContext { get; } = graphContext; /// /// Gets or sets an optional callback that is invoked when the graph completes naturally (i.e., all state nodes diff --git a/Forge/Statescript/IGraphContext.cs b/Forge/Statescript/IGraphContext.cs deleted file mode 100644 index 3d61f7a..0000000 --- a/Forge/Statescript/IGraphContext.cs +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright © Gamesmiths Guild. - -using Gamesmiths.Forge.Core; - -namespace Gamesmiths.Forge.Statescript; - -/// -/// Interface representing the context of a graph during execution, providing access to necessary information and -/// services for graph processing. -/// -public interface IGraphContext -{ - /// - /// Gets a value indicating whether the graph is currently active. A graph is considered active if it has at least - /// one active state node. - /// - bool IsActive { get; } - - /// - /// Gets the optional owner entity for this graph execution. The owner provides access to entity attributes, tags, - /// and other systems that property resolvers can use to compute derived values. May be if - /// the graph does not require an owner entity. - /// - IForgeEntity? Owner { get; } - - /// - /// 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. - /// - Variables GraphVariables { get; } - - /// - /// Gets a dictionary mapping node IDs to their activation status. This allows tracking which nodes in the graph are - /// currently active or inactive during execution. - /// - Dictionary InternalNodeActivationStatus { get; } - - /// - /// Gets the set of state nodes that are currently active during this graph execution. Only active state nodes - /// are updated each tick, avoiding unnecessary iteration over inactive nodes. - /// - HashSet ActiveStateNodes { get; } - - /// - /// Gets or sets the currently executing this context. This reference allows nodes - /// (such as ) to trigger graph-level operations like stopping execution. Set - /// automatically by and cleared by - /// . - /// - GraphProcessor? Processor { get; set; } - - /// - /// Gets or sets a value indicating whether the graph has been started and is awaiting completion. This flag is - /// set to when the graph starts executing and is cleared when the graph completes or is - /// explicitly stopped. It is used to distinguish between a graph that was never started and one that has finished - /// naturally (i.e., all state nodes have deactivated). - /// - bool HasStarted { get; set; } - - /// - /// Gets or creates a node context of type T for the specified node ID. If a context for the given node ID already - /// exists, it returns the existing context; otherwise, it creates a new instance of T and associates it with the - /// node ID. - /// - /// The type of the node context to get or create. Must implement INodeContext and have a - /// parameterless constructor. - /// The unique identifier of the node for which to get or create the context. - /// The node context associated with the specified node ID. - T GetOrCreateNodeContext(Guid nodeID) - where T : INodeContext, new(); - - /// - /// Gets the node context of type T for the specified node ID. If no context exists for the given node ID, it - /// returns null. - /// - /// The type of the node context to get. Must implement INodeContext. - /// The unique identifier of the node for which to get the context. - /// The node context associated with the specified node ID, or null if no context exists. - T GetNodeContext(Guid nodeID) - where T : INodeContext, new(); - - /// - /// Removes all node contexts from the graph context. This method is typically called when resetting the graph or - /// when the graph is no longer needed. - /// - void RemoveAllNodeContext(); -} diff --git a/Forge/Statescript/Node.cs b/Forge/Statescript/Node.cs index e3322a6..ce43882 100644 --- a/Forge/Statescript/Node.cs +++ b/Forge/Statescript/Node.cs @@ -66,14 +66,14 @@ protected Node() internal void OnMessageReceived( InputPort receiverPort, - IGraphContext graphContext) + GraphContext graphContext) { graphContext.InternalNodeActivationStatus[NodeID] = true; HandleMessage(receiverPort, graphContext); } - internal void OnSubgraphDisabledMessageReceived(IGraphContext graphContext) + internal void OnSubgraphDisabledMessageReceived(GraphContext graphContext) { if (!graphContext.InternalNodeActivationStatus.TryAdd(NodeID, false)) { @@ -137,7 +137,7 @@ internal virtual IEnumerable GetMessagePortsOnDisable() /// /// The time elapsed since the last update, in seconds. /// The graph context. - internal virtual void Update(double deltaTime, IGraphContext graphContext) + internal virtual void Update(double deltaTime, GraphContext graphContext) { } @@ -161,7 +161,7 @@ protected static T CreatePort(byte index) /// /// The graph context. /// The IDs of the output ports to emit the message from. - protected virtual void EmitMessage(IGraphContext graphContext, params int[] portIds) + protected virtual void EmitMessage(GraphContext graphContext, params int[] portIds) { foreach (var portId in portIds) { @@ -174,7 +174,7 @@ protected virtual void EmitMessage(IGraphContext graphContext, params int[] port /// /// The input port that received the message. /// The graph context. - protected virtual void HandleMessage(InputPort receiverPort, IGraphContext graphContext) + protected virtual void HandleMessage(InputPort receiverPort, GraphContext graphContext) { } @@ -182,7 +182,7 @@ protected virtual void HandleMessage(InputPort receiverPort, IGraphContext graph /// Called before the node is disabled. /// /// The graph context. - protected virtual void BeforeDisable(IGraphContext graphContext) + protected virtual void BeforeDisable(GraphContext graphContext) { } @@ -190,7 +190,7 @@ protected virtual void BeforeDisable(IGraphContext graphContext) /// Called after the node is disabled. /// /// The graph context. - protected virtual void AfterDisable(IGraphContext graphContext) + protected virtual void AfterDisable(GraphContext graphContext) { } } diff --git a/Forge/Statescript/Nodes/Action/SetVariableNode.cs b/Forge/Statescript/Nodes/Action/SetVariableNode.cs index 973f32e..1969a4f 100644 --- a/Forge/Statescript/Nodes/Action/SetVariableNode.cs +++ b/Forge/Statescript/Nodes/Action/SetVariableNode.cs @@ -24,7 +24,7 @@ public class SetVariableNode(StringKey sourcePropertyName, StringKey targetVaria private readonly StringKey _targetVariableName = targetVariableName; /// - protected override void Execute(IGraphContext graphContext) + protected override void Execute(GraphContext graphContext) { if (!graphContext.GraphVariables.TryGetVariant(_sourcePropertyName, graphContext, out Variant128 value)) { diff --git a/Forge/Statescript/Nodes/ActionNode.cs b/Forge/Statescript/Nodes/ActionNode.cs index bcbe326..12b045e 100644 --- a/Forge/Statescript/Nodes/ActionNode.cs +++ b/Forge/Statescript/Nodes/ActionNode.cs @@ -24,7 +24,7 @@ public abstract class ActionNode : Node /// 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(IGraphContext graphContext); + protected abstract void Execute(GraphContext graphContext); /// #pragma warning disable SA1202 // Elements should be ordered by access @@ -42,7 +42,7 @@ protected override void DefinePorts(List inputPorts, List } /// - protected override void HandleMessage(InputPort receiverPort, IGraphContext graphContext) + 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 index e084429..1fecf55 100644 --- a/Forge/Statescript/Nodes/Condition/ExpressionConditionNode.cs +++ b/Forge/Statescript/Nodes/Condition/ExpressionConditionNode.cs @@ -22,7 +22,7 @@ public class ExpressionConditionNode(StringKey conditionPropertyName) : Conditio private readonly StringKey _conditionPropertyName = conditionPropertyName; /// - protected override bool Test(IGraphContext graphContext) + protected override bool Test(GraphContext graphContext) { if (!graphContext.GraphVariables.TryGet(_conditionPropertyName, graphContext, out bool result)) { diff --git a/Forge/Statescript/Nodes/ConditionNode.cs b/Forge/Statescript/Nodes/ConditionNode.cs index 6b911af..4dabde9 100644 --- a/Forge/Statescript/Nodes/ConditionNode.cs +++ b/Forge/Statescript/Nodes/ConditionNode.cs @@ -30,7 +30,7 @@ public abstract class ConditionNode : Node /// /// The current graph context. /// if the condition is met; otherwise, . - protected abstract bool Test(IGraphContext graphContext); + protected abstract bool Test(GraphContext graphContext); /// #pragma warning disable SA1202 // Elements should be ordered by access @@ -50,7 +50,7 @@ protected override void DefinePorts(List inputPorts, List } /// - protected sealed override void HandleMessage(InputPort receiverPort, IGraphContext graphContext) + protected sealed override void HandleMessage(InputPort receiverPort, GraphContext graphContext) { if (Test(graphContext)) { diff --git a/Forge/Statescript/Nodes/EntryNode.cs b/Forge/Statescript/Nodes/EntryNode.cs index 0268757..f25a834 100644 --- a/Forge/Statescript/Nodes/EntryNode.cs +++ b/Forge/Statescript/Nodes/EntryNode.cs @@ -19,7 +19,7 @@ public class EntryNode : Node /// 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(IGraphContext graphContext) + public void StartGraph(GraphContext graphContext) { OutputPorts[OutputPort].EmitMessage(graphContext); } @@ -28,7 +28,7 @@ public void StartGraph(IGraphContext 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(IGraphContext graphContext) + public void StopGraph(GraphContext graphContext) { ((SubgraphPort)OutputPorts[OutputPort]).EmitDisableSubgraphMessage(graphContext); } @@ -46,8 +46,8 @@ protected override void DefinePorts(List inputPorts, List } /// - protected override void HandleMessage(InputPort receiverPort, IGraphContext graphContext) + protected override void HandleMessage(InputPort receiverPort, GraphContext graphContext) { - throw new NotImplementedException(); + throw new InvalidOperationException("EntryNode does not accept incoming messages."); } } diff --git a/Forge/Statescript/Nodes/ExitNode.cs b/Forge/Statescript/Nodes/ExitNode.cs index 9c45999..7eefa8a 100644 --- a/Forge/Statescript/Nodes/ExitNode.cs +++ b/Forge/Statescript/Nodes/ExitNode.cs @@ -32,7 +32,7 @@ protected override void DefinePorts(List inputPorts, List } /// - protected override void HandleMessage(InputPort receiverPort, IGraphContext graphContext) + 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 index f0e02f0..315f317 100644 --- a/Forge/Statescript/Nodes/State/TimerNode.cs +++ b/Forge/Statescript/Nodes/State/TimerNode.cs @@ -22,19 +22,19 @@ public class TimerNode(StringKey durationPropertyName) : StateNode - protected override void OnActivate(IGraphContext graphContext) + protected override void OnActivate(GraphContext graphContext) { - TimerNodeContext nodeContext = graphContext.GetOrCreateNodeContext(NodeID); + TimerNodeContext nodeContext = graphContext.GetNodeContext(NodeID); nodeContext.ElapsedTime = 0; } /// - protected override void OnDeactivate(IGraphContext graphContext) + protected override void OnDeactivate(GraphContext graphContext) { } /// - protected override void OnUpdate(double deltaTime, IGraphContext graphContext) + protected override void OnUpdate(double deltaTime, GraphContext graphContext) { TimerNodeContext nodeContext = graphContext.GetNodeContext(NodeID); diff --git a/Forge/Statescript/Nodes/StateNode.cs b/Forge/Statescript/Nodes/StateNode.cs index cf72ffc..07cdf19 100644 --- a/Forge/Statescript/Nodes/StateNode.cs +++ b/Forge/Statescript/Nodes/StateNode.cs @@ -49,13 +49,13 @@ public abstract class StateNode : Node /// Called when the node is activated. /// /// The graph's context. - protected abstract void OnActivate(IGraphContext graphContext); + protected abstract void OnActivate(GraphContext graphContext); /// /// Called when the node is deactivated. /// /// The graph's context. - protected abstract void OnDeactivate(IGraphContext graphContext); + 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. @@ -63,12 +63,17 @@ public abstract class StateNode : Node /// 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, IGraphContext graphContext) + 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 != true) + if (!nodeContext.Active) { return; } @@ -119,7 +124,7 @@ internal override IEnumerable GetMessagePortsOnDisable() /// /// The time elapsed since the last update, in seconds. /// The graph's context. - protected virtual void OnUpdate(double deltaTime, IGraphContext graphContext) + protected virtual void OnUpdate(double deltaTime, GraphContext graphContext) { } @@ -135,7 +140,7 @@ protected override void DefinePorts(List inputPorts, List } /// - protected sealed override void HandleMessage(InputPort receiverPort, IGraphContext graphContext) + protected sealed override void HandleMessage(InputPort receiverPort, GraphContext graphContext) { if (receiverPort.Index == InputPort) { @@ -158,7 +163,7 @@ protected sealed override void HandleMessage(InputPort receiverPort, IGraphConte } /// - protected override void EmitMessage(IGraphContext graphContext, params int[] portIds) + protected override void EmitMessage(GraphContext graphContext, params int[] portIds) { StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); @@ -184,7 +189,7 @@ protected override void EmitMessage(IGraphContext graphContext, params int[] por /// /// The graph's context. /// ID of ports you want to Emit a message to. - protected void DeactivateNodeAndEmitMessage(IGraphContext graphContext, params int[] eventPortIds) + protected void DeactivateNodeAndEmitMessage(GraphContext graphContext, params int[] eventPortIds) { StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); @@ -212,7 +217,7 @@ protected void DeactivateNodeAndEmitMessage(IGraphContext graphContext, params i /// Deactivates the node without emitting any custom messages. /// /// The graph's context. - protected void DeactivateNode(IGraphContext graphContext) + protected void DeactivateNode(GraphContext graphContext) { BeforeDisable(graphContext); @@ -225,14 +230,15 @@ protected void DeactivateNode(IGraphContext graphContext) } /// - protected sealed override void BeforeDisable(IGraphContext graphContext) + protected sealed override void BeforeDisable(GraphContext graphContext) { - StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); - if (nodeContext is null) + if (!graphContext.HasNodeContext(NodeID)) { return; } + StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); + if (!nodeContext.Active) { return; @@ -244,14 +250,15 @@ protected sealed override void BeforeDisable(IGraphContext graphContext) } /// - protected sealed override void AfterDisable(IGraphContext graphContext) + protected sealed override void AfterDisable(GraphContext graphContext) { - StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); - if (nodeContext is null) + if (!graphContext.HasNodeContext(NodeID)) { return; } + StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); + if (!nodeContext.Active) { return; @@ -269,7 +276,7 @@ protected sealed override void AfterDisable(IGraphContext graphContext) } } - private void ActivateNode(IGraphContext graphContext) + private void ActivateNode(GraphContext graphContext) { StateNodeContext nodeContext = graphContext.GetNodeContext(NodeID); nodeContext.Active = true; @@ -277,7 +284,7 @@ private void ActivateNode(IGraphContext graphContext) OnActivate(graphContext); } - private void HandleDeferredEmitMessages(IGraphContext graphContext, StateNodeContext nodeContext) + private void HandleDeferredEmitMessages(GraphContext graphContext, StateNodeContext nodeContext) { if (nodeContext.DeferredEmitMessageData.Count > 0) { @@ -290,7 +297,7 @@ private void HandleDeferredEmitMessages(IGraphContext graphContext, StateNodeCon } } - private void HandleDeferredDeactivationMessages(IGraphContext graphContext, StateNodeContext nodeContext) + private void HandleDeferredDeactivationMessages(GraphContext graphContext, StateNodeContext nodeContext) { if (nodeContext.DeferredDeactivationEventPortIds is not null) { diff --git a/Forge/Statescript/Ports/InputPort.cs b/Forge/Statescript/Ports/InputPort.cs index 680f260..6e8a8a2 100644 --- a/Forge/Statescript/Ports/InputPort.cs +++ b/Forge/Statescript/Ports/InputPort.cs @@ -33,7 +33,7 @@ public void SetOwnerNode(Node ownerNode) /// Receives a message and notifies the owner node. /// /// The graph context for the message. - public void ReceiveMessage(IGraphContext graphContext) + public void ReceiveMessage(GraphContext graphContext) { OwnerNode?.OnMessageReceived(this, graphContext); } @@ -42,7 +42,7 @@ public void ReceiveMessage(IGraphContext graphContext) /// Receives a disable subgraph message and notifies the owner node. /// /// The graph context for the message. - public void ReceiveDisableSubgraphMessage(IGraphContext graphContext) + public void ReceiveDisableSubgraphMessage(GraphContext graphContext) { OwnerNode?.OnSubgraphDisabledMessageReceived(graphContext); } diff --git a/Forge/Statescript/Ports/OutputPort.cs b/Forge/Statescript/Ports/OutputPort.cs index d1a9706..cebf82a 100644 --- a/Forge/Statescript/Ports/OutputPort.cs +++ b/Forge/Statescript/Ports/OutputPort.cs @@ -15,7 +15,7 @@ public class OutputPort : Port /// /// Event triggered when a disable subgraph message is emitted from this output port. /// - public event Action? OnEmitMessageDisableSubgraphMessage; + public event Action? OnEmitDisableSubgraphMessage; /// /// Gets the number of input ports connected to this output port. @@ -76,7 +76,7 @@ internal void FinalizeConnections() FinalizedConnectedPorts = [.. PendingConnectedPorts]; } - internal void EmitMessage(IGraphContext graphContext) + internal void EmitMessage(GraphContext graphContext) { InputPort[] ports = FinalizedConnectedPorts!; @@ -88,7 +88,7 @@ internal void EmitMessage(IGraphContext graphContext) OnEmitMessage?.Invoke(PortID); } - internal void InternalEmitDisableSubgraphMessage(IGraphContext graphContext) + internal void InternalEmitDisableSubgraphMessage(GraphContext graphContext) { InputPort[] ports = FinalizedConnectedPorts!; @@ -97,6 +97,6 @@ internal void InternalEmitDisableSubgraphMessage(IGraphContext graphContext) ports[i].ReceiveDisableSubgraphMessage(graphContext); } - OnEmitMessageDisableSubgraphMessage?.Invoke(PortID); + OnEmitDisableSubgraphMessage?.Invoke(PortID); } } diff --git a/Forge/Statescript/Ports/SubgraphPort.cs b/Forge/Statescript/Ports/SubgraphPort.cs index 519758e..3f09d6c 100644 --- a/Forge/Statescript/Ports/SubgraphPort.cs +++ b/Forge/Statescript/Ports/SubgraphPort.cs @@ -19,7 +19,7 @@ public SubgraphPort() /// Emits a disable subgraph message to all connected input ports. /// /// The graph context for the message. - public void EmitDisableSubgraphMessage(IGraphContext graphContext) + public void EmitDisableSubgraphMessage(GraphContext graphContext) { InputPort[] ports = FinalizedConnectedPorts!; diff --git a/Forge/Statescript/Properties/ArrayVariableResolver.cs b/Forge/Statescript/Properties/ArrayVariableResolver.cs index 0f20582..1400bde 100644 --- a/Forge/Statescript/Properties/ArrayVariableResolver.cs +++ b/Forge/Statescript/Properties/ArrayVariableResolver.cs @@ -38,7 +38,7 @@ public class ArrayVariableResolver(Variant128[] initialValues, Type elementType) /// /// Returns the first element of the array, or a default if the array is empty. /// - public Variant128 Resolve(IGraphContext graphContext) + public Variant128 Resolve(GraphContext graphContext) { return _values.Count > 0 ? _values[0] : default; } diff --git a/Forge/Statescript/Properties/AttributeResolver.cs b/Forge/Statescript/Properties/AttributeResolver.cs index ea3cc0c..6e1cc2f 100644 --- a/Forge/Statescript/Properties/AttributeResolver.cs +++ b/Forge/Statescript/Properties/AttributeResolver.cs @@ -22,7 +22,7 @@ public class AttributeResolver(StringKey attributeKey) : IPropertyResolver public Type ValueType => typeof(int); /// - public Variant128 Resolve(IGraphContext graphContext) + public Variant128 Resolve(GraphContext graphContext) { if (graphContext.Owner is null) { diff --git a/Forge/Statescript/Properties/ComparisonResolver.cs b/Forge/Statescript/Properties/ComparisonResolver.cs index 03549c0..344114b 100644 --- a/Forge/Statescript/Properties/ComparisonResolver.cs +++ b/Forge/Statescript/Properties/ComparisonResolver.cs @@ -32,7 +32,7 @@ public class ComparisonResolver( public Type ValueType => typeof(bool); /// - public Variant128 Resolve(IGraphContext graphContext) + public Variant128 Resolve(GraphContext graphContext) { var leftValue = ResolveAsDouble(_left, graphContext); var rightValue = ResolveAsDouble(_right, graphContext); @@ -53,7 +53,7 @@ public Variant128 Resolve(IGraphContext graphContext) return new Variant128(result); } - private static double ResolveAsDouble(IPropertyResolver resolver, IGraphContext graphContext) + private static double ResolveAsDouble(IPropertyResolver resolver, GraphContext graphContext) { Variant128 value = resolver.Resolve(graphContext); @@ -114,7 +114,7 @@ private static double ResolveAsDouble(IPropertyResolver resolver, IGraphContext return (double)value.AsDecimal(); } - // Fallback: reinterpret as double (works when the resolver stored a double). - return value.AsDouble(); + 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 index c58231f..0770364 100644 --- a/Forge/Statescript/Properties/IPropertyResolver.cs +++ b/Forge/Statescript/Properties/IPropertyResolver.cs @@ -18,5 +18,5 @@ public interface IPropertyResolver /// /// The graph context providing the runtime state and owner entity. /// The resolved value as a . - Variant128 Resolve(IGraphContext graphContext); + Variant128 Resolve(GraphContext graphContext); } diff --git a/Forge/Statescript/Properties/SharedVariableResolver.cs b/Forge/Statescript/Properties/SharedVariableResolver.cs index eb38ee7..8438446 100644 --- a/Forge/Statescript/Properties/SharedVariableResolver.cs +++ b/Forge/Statescript/Properties/SharedVariableResolver.cs @@ -16,19 +16,16 @@ namespace Gamesmiths.Forge.Statescript.Properties; /// the entity-level shared bag, allowing one ability's graph to read values written by another. /// /// The name of the shared variable to read from the owner entity. -public class SharedVariableResolver(StringKey variableName) : IPropertyResolver +/// The type of the value this resolver produces. +public class SharedVariableResolver(StringKey variableName, Type valueType) : IPropertyResolver { private readonly StringKey _variableName = variableName; /// - /// - /// Returns as the default numeric type. The actual resolved value depends on the shared - /// variable's stored type. - /// - public Type ValueType => typeof(double); + public Type ValueType { get; } = valueType; /// - public Variant128 Resolve(IGraphContext graphContext) + public Variant128 Resolve(GraphContext graphContext) { if (graphContext.Owner is null) { diff --git a/Forge/Statescript/Properties/TagResolver.cs b/Forge/Statescript/Properties/TagResolver.cs index acfa1bf..34cd565 100644 --- a/Forge/Statescript/Properties/TagResolver.cs +++ b/Forge/Statescript/Properties/TagResolver.cs @@ -21,7 +21,7 @@ public class TagResolver(Tag tag) : IPropertyResolver public Type ValueType => typeof(bool); /// - public Variant128 Resolve(IGraphContext graphContext) + public Variant128 Resolve(GraphContext graphContext) { if (graphContext.Owner is null) { diff --git a/Forge/Statescript/Properties/VariableResolver.cs b/Forge/Statescript/Properties/VariableResolver.cs index 0c14ddf..bcaec73 100644 --- a/Forge/Statescript/Properties/VariableResolver.cs +++ b/Forge/Statescript/Properties/VariableResolver.cs @@ -17,19 +17,16 @@ namespace Gamesmiths.Forge.Statescript.Properties; /// (zero). /// /// The name of the graph variable or property to resolve at runtime. -public class VariableResolver(StringKey referencedPropertyName) : IPropertyResolver +/// The type of the value this resolver produces. +public class VariableResolver(StringKey referencedPropertyName, Type valueType) : IPropertyResolver { private readonly StringKey _referencedPropertyName = referencedPropertyName; /// - /// - /// Returns as the default numeric type for comparisons. The actual resolved value depends on - /// the referenced property's type. - /// - public Type ValueType => typeof(double); + public Type ValueType { get; } = valueType; /// - public Variant128 Resolve(IGraphContext graphContext) + public Variant128 Resolve(GraphContext graphContext) { if (!graphContext.GraphVariables.TryGetVariant(_referencedPropertyName, graphContext, out Variant128 value)) { diff --git a/Forge/Statescript/Properties/VariantResolver.cs b/Forge/Statescript/Properties/VariantResolver.cs index 663d6cd..e5c6d08 100644 --- a/Forge/Statescript/Properties/VariantResolver.cs +++ b/Forge/Statescript/Properties/VariantResolver.cs @@ -55,7 +55,7 @@ public static Variant128 CreateVariant(T value) } /// - public Variant128 Resolve(IGraphContext graphContext) + public Variant128 Resolve(GraphContext graphContext) { return Value; } diff --git a/Forge/Statescript/Variables.cs b/Forge/Statescript/Variables.cs index 17c2981..4a9b898 100644 --- a/Forge/Statescript/Variables.cs +++ b/Forge/Statescript/Variables.cs @@ -61,7 +61,7 @@ public void InitializeFrom(GraphVariableDefinitions definitions) /// The resolved value if the entry was found. /// if the entry was found and resolved successfully, /// otherwise. - public bool TryGet(StringKey name, IGraphContext graphContext, out T value) + public bool TryGet(StringKey name, GraphContext graphContext, out T value) where T : unmanaged { value = default; @@ -85,7 +85,7 @@ public bool TryGet(StringKey name, IGraphContext graphContext, out T value) /// The resolved value if the entry was found. /// if the entry was found and resolved successfully, /// otherwise. - public bool TryGetVariant(StringKey name, IGraphContext graphContext, out Variant128 value) + public bool TryGetVariant(StringKey name, GraphContext graphContext, out Variant128 value) { value = default; From 1e97e63ffc08c21485cf67c65b9b43c9f2bc8f53 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 15 Feb 2026 22:16:02 -0300 Subject: [PATCH 18/19] Simplified GraphProcessor and GraphAbilityBehavior constructors --- Forge.Tests/Helpers/StatescriptTestHelpers.cs | 24 ++ Forge.Tests/Statescript/ActionNodeTests.cs | 29 +- .../Statescript/ExpressionResolverTests.cs | 31 +- .../Statescript/GraphAbilityBehaviorTests.cs | 370 ++++++++++++++++++ .../Statescript/GraphLoopDetectionTests.cs | 3 +- .../Statescript/GraphProcessorTests.cs | 138 +++---- Forge.Tests/Statescript/StateNodeTests.cs | 30 +- .../Statescript/GraphAbilityBehavior.Data.cs | 5 +- Forge/Statescript/GraphAbilityBehavior.cs | 7 +- Forge/Statescript/GraphContext.cs | 33 ++ Forge/Statescript/GraphProcessor.cs | 23 +- 11 files changed, 537 insertions(+), 156 deletions(-) create mode 100644 Forge.Tests/Statescript/GraphAbilityBehaviorTests.cs diff --git a/Forge.Tests/Helpers/StatescriptTestHelpers.cs b/Forge.Tests/Helpers/StatescriptTestHelpers.cs index c3054e2..ab36417 100644 --- a/Forge.Tests/Helpers/StatescriptTestHelpers.cs +++ b/Forge.Tests/Helpers/StatescriptTestHelpers.cs @@ -90,3 +90,27 @@ protected override void Execute(GraphContext graphContext) 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; + } +} diff --git a/Forge.Tests/Statescript/ActionNodeTests.cs b/Forge.Tests/Statescript/ActionNodeTests.cs index 4cbf248..ed9acea 100644 --- a/Forge.Tests/Statescript/ActionNodeTests.cs +++ b/Forge.Tests/Statescript/ActionNodeTests.cs @@ -31,8 +31,7 @@ public void Set_variable_node_copies_value_from_source_to_target() setNode.OutputPorts[ActionNode.OutputPort], readNode.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); readNode.LastReadValue.Should().Be(42); @@ -64,8 +63,7 @@ public void Set_variable_node_copies_value_between_different_variables() setNode.OutputPorts[ActionNode.OutputPort], readNode.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); readNode.LastReadValue.Should().Be(1); @@ -97,8 +95,7 @@ public void Set_variable_node_does_not_modify_source() readTarget.OutputPorts[ActionNode.OutputPort], readSource.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); readTarget.LastReadValue.Should().Be(99); @@ -125,8 +122,7 @@ public void Set_variable_node_with_nonexistent_source_does_not_modify_target() setNode.OutputPorts[ActionNode.OutputPort], readNode.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); readNode.LastReadValue.Should().Be(77); @@ -153,8 +149,7 @@ public void Set_variable_node_works_with_double_values() setNode.OutputPorts[ActionNode.OutputPort], readNode.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); readNode.LastReadValue.Should().Be(3.5); @@ -181,8 +176,7 @@ public void Set_variable_node_works_with_bool_values() setNode.OutputPorts[ActionNode.OutputPort], readNode.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); readNode.LastReadValue.Should().BeTrue(); @@ -209,17 +203,14 @@ public void Two_processors_using_set_variable_have_independent_state() incrementNode.OutputPorts[ActionNode.OutputPort], setNode.InputPorts[ActionNode.InputPort])); - var context1 = new GraphContext(); - var processor1 = new GraphProcessor(graph, context1); - - var context2 = new GraphContext(); - var processor2 = new GraphProcessor(graph, context2); + var processor1 = new GraphProcessor(graph); + var processor2 = new GraphProcessor(graph); processor1.StartGraph(); processor2.StartGraph(); - context1.GraphVariables.TryGetVar("target", out int value1); - context2.GraphVariables.TryGetVar("target", out int value2); + 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 index a943a16..cb80380 100644 --- a/Forge.Tests/Statescript/ExpressionResolverTests.cs +++ b/Forge.Tests/Statescript/ExpressionResolverTests.cs @@ -46,8 +46,7 @@ public void Comparison_resolver_greater_than_returns_true_when_left_exceeds_righ condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); trueAction.ExecutionCount.Should().Be(1); @@ -84,8 +83,7 @@ public void Comparison_resolver_greater_than_returns_false_when_left_is_less() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); trueAction.ExecutionCount.Should().Be(0); @@ -122,8 +120,7 @@ public void Comparison_resolver_equal_returns_true_for_matching_values() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); trueAction.ExecutionCount.Should().Be(1); @@ -163,8 +160,7 @@ public void Comparison_resolver_compares_two_graph_variables() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); trueAction.ExecutionCount.Should().Be(0, "health (25) is NOT above threshold (50)"); @@ -201,8 +197,7 @@ public void Comparison_resolver_less_than_or_equal_at_boundary() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); trueAction.ExecutionCount.Should().Be(1, "10 <= 10 is true"); @@ -239,8 +234,7 @@ public void Comparison_resolver_not_equal_returns_true_for_different_values() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); trueAction.ExecutionCount.Should().Be(1); @@ -271,8 +265,7 @@ public void Expression_condition_node_returns_false_for_missing_property() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); trueAction.ExecutionCount.Should().Be(0); @@ -312,8 +305,7 @@ public void Comparison_resolver_works_with_int_operands() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); trueAction.ExecutionCount.Should().Be(1, "score (100) >= requiredScore (50)"); @@ -354,12 +346,7 @@ public void Comparison_resolver_works_with_attribute_resolver() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new GraphContext - { - Owner = entity, - }; - - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph, entity); processor.StartGraph(); trueAction.ExecutionCount.Should().Be(1); diff --git a/Forge.Tests/Statescript/GraphAbilityBehaviorTests.cs b/Forge.Tests/Statescript/GraphAbilityBehaviorTests.cs new file mode 100644 index 0000000..7e865b6 --- /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", "Owner")] + public void Owner_is_set_from_ability_context() + { + 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("OwnerGraph", behaviorFactory: () => behavior); + AbilityHandle? handle = Grant(entity, abilityData); + handle.Should().NotBeNull(); + + handle!.Activate(out _).Should().BeTrue(); + + behavior.Processor.GraphContext.Owner.Should().Be(entity); + } + + [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 index 84a140a..e872032 100644 --- a/Forge.Tests/Statescript/GraphLoopDetectionTests.cs +++ b/Forge.Tests/Statescript/GraphLoopDetectionTests.cs @@ -737,8 +737,7 @@ public void Rejected_connection_is_disconnected_from_port() } // The graph should still work normally without the loop. - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); action1.ExecutionCount.Should().Be(1); diff --git a/Forge.Tests/Statescript/GraphProcessorTests.cs b/Forge.Tests/Statescript/GraphProcessorTests.cs index c19e0ce..07a9ff6 100644 --- a/Forge.Tests/Statescript/GraphProcessorTests.cs +++ b/Forge.Tests/Statescript/GraphProcessorTests.cs @@ -28,12 +28,11 @@ public void Graph_processor_initializes_variables_on_start() var graph = new Graph(); graph.VariableDefinitions.DefineVariable("health", 100); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); - context.GraphVariables.TryGetVar("health", out int value).Should().BeTrue(); + processor.GraphContext.GraphVariables.TryGetVar("health", out int value).Should().BeTrue(); value.Should().Be(100); } @@ -49,8 +48,7 @@ public void Starting_graph_executes_connected_action_node() graph.EntryNode.OutputPorts[EntryNode.OutputPort], actionNode.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); actionNode.ExecutionCount.Should().Be(1); @@ -81,8 +79,7 @@ public void Action_nodes_execute_in_sequence() action2.OutputPorts[ActionNode.OutputPort], action3.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); executionOrder.Should().ContainInOrder("A", "B", "C"); @@ -111,8 +108,7 @@ public void Condition_node_routes_to_true_port_when_condition_is_met() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); trueAction.ExecutionCount.Should().Be(1); @@ -142,8 +138,7 @@ public void Condition_node_routes_to_false_port_when_condition_is_not_met() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); trueAction.ExecutionCount.Should().Be(0); @@ -170,8 +165,7 @@ public void Action_node_can_read_and_write_graph_variables() incrementNode.OutputPorts[ActionNode.OutputPort], readNode.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); readNode.LastReadValue.Should().Be(1); @@ -203,8 +197,7 @@ public void Condition_node_can_branch_based_on_graph_variables() condition.OutputPorts[ConditionNode.FalsePort], belowAction.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); aboveAction.ExecutionCount.Should().Be(1, "value (15) is above threshold (10)"); @@ -229,8 +222,7 @@ public void Output_port_can_connect_to_multiple_input_ports() graph.EntryNode.OutputPorts[EntryNode.OutputPort], action2.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); action1.ExecutionCount.Should().Be(1); @@ -251,11 +243,10 @@ public void Stopping_graph_does_not_throw() graph.EntryNode.OutputPorts[EntryNode.OutputPort], incrementNode.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); - context.GraphVariables.TryGetVar("counter", out int valueAfterStart).Should().BeTrue(); + processor.GraphContext.GraphVariables.TryGetVar("counter", out int valueAfterStart).Should().BeTrue(); valueAfterStart.Should().Be(1); // StopGraph cleans up node contexts; verify it doesn't throw. @@ -276,18 +267,17 @@ public void Stopping_graph_fires_on_graph_completed() graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); var completed = false; processor.OnGraphCompleted = () => completed = true; processor.StartGraph(); - context.IsActive.Should().BeTrue(); + processor.GraphContext.IsActive.Should().BeTrue(); completed.Should().BeFalse(); processor.StopGraph(); - context.IsActive.Should().BeFalse(); + processor.GraphContext.IsActive.Should().BeFalse(); completed.Should().BeTrue(); } @@ -323,8 +313,7 @@ public void Complex_graph_with_condition_and_multiple_actions_executes_correctly condition.OutputPorts[ConditionNode.FalsePort], trackB.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); trackA.ExecutionCount.Should().Be(1); @@ -347,8 +336,7 @@ public void Disconnected_node_is_not_executed() graph.EntryNode.OutputPorts[EntryNode.OutputPort], connectedAction.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); connectedAction.ExecutionCount.Should().Be(1); @@ -368,17 +356,14 @@ public void Each_graph_processor_has_independent_variable_state() graph.EntryNode.OutputPorts[EntryNode.OutputPort], incrementNode.InputPorts[ActionNode.InputPort])); - var context1 = new GraphContext(); - var processor1 = new GraphProcessor(graph, context1); - - var context2 = new GraphContext(); - var processor2 = new GraphProcessor(graph, context2); + var processor1 = new GraphProcessor(graph); + var processor2 = new GraphProcessor(graph); processor1.StartGraph(); processor2.StartGraph(); - context1.GraphVariables.TryGetVar("counter", out int value1); - context2.GraphVariables.TryGetVar("counter", out int value2); + processor1.GraphContext.GraphVariables.TryGetVar("counter", out int value1); + processor2.GraphContext.GraphVariables.TryGetVar("counter", out int value2); value1.Should().Be(1); value2.Should().Be(1); @@ -433,18 +418,17 @@ public void Exit_node_stops_graph_execution() timer.OutputPorts[StateNode.OnDeactivatePort], exitNode.InputPorts[ExitNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); var completed = false; processor.OnGraphCompleted = () => completed = true; processor.StartGraph(); - context.IsActive.Should().BeTrue(); + processor.GraphContext.IsActive.Should().BeTrue(); completed.Should().BeFalse(); processor.UpdateGraph(5.0); - context.IsActive.Should().BeFalse(); + processor.GraphContext.IsActive.Should().BeFalse(); completed.Should().BeTrue(); } @@ -466,8 +450,7 @@ public void Exit_node_connected_to_action_stops_graph_after_action() actionNode.OutputPorts[ActionNode.OutputPort], exitNode.InputPorts[ExitNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); var completed = false; processor.OnGraphCompleted = () => completed = true; processor.StartGraph(); @@ -502,17 +485,16 @@ public void Exit_node_stops_all_active_state_nodes() shortTimer.OutputPorts[StateNode.OnDeactivatePort], exitNode.InputPorts[ExitNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); var completed = false; processor.OnGraphCompleted = () => completed = true; processor.StartGraph(); - context.IsActive.Should().BeTrue(); + processor.GraphContext.IsActive.Should().BeTrue(); processor.UpdateGraph(1.0); - context.IsActive.Should().BeFalse(); + processor.GraphContext.IsActive.Should().BeFalse(); completed.Should().BeTrue(); } @@ -528,14 +510,13 @@ public void Action_only_graph_completes_immediately_after_start() graph.EntryNode.OutputPorts[EntryNode.OutputPort], actionNode.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); var completed = false; processor.OnGraphCompleted = () => completed = true; processor.StartGraph(); actionNode.ExecutionCount.Should().Be(1); - context.IsActive.Should().BeFalse(); + processor.GraphContext.IsActive.Should().BeFalse(); completed.Should().BeTrue(); } @@ -552,18 +533,17 @@ public void Timer_graph_completes_when_last_state_node_deactivates() graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); var completed = false; processor.OnGraphCompleted = () => completed = true; processor.StartGraph(); - context.IsActive.Should().BeTrue(); + processor.GraphContext.IsActive.Should().BeTrue(); completed.Should().BeFalse(); processor.UpdateGraph(2.0); - context.IsActive.Should().BeFalse(); + processor.GraphContext.IsActive.Should().BeFalse(); completed.Should().BeTrue(); } @@ -588,21 +568,20 @@ public void Multiple_timers_complete_only_after_all_deactivate() graph.EntryNode.OutputPorts[EntryNode.OutputPort], longTimer.InputPorts[StateNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); var completed = false; processor.OnGraphCompleted = () => completed = true; processor.StartGraph(); - context.IsActive.Should().BeTrue(); + processor.GraphContext.IsActive.Should().BeTrue(); completed.Should().BeFalse(); processor.UpdateGraph(1.0); - context.IsActive.Should().BeTrue(); + processor.GraphContext.IsActive.Should().BeTrue(); completed.Should().BeFalse(); processor.UpdateGraph(2.0); - context.IsActive.Should().BeFalse(); + processor.GraphContext.IsActive.Should().BeFalse(); completed.Should().BeTrue(); } @@ -619,8 +598,7 @@ public void Update_graph_does_nothing_after_completion() graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[StateNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); var completedCount = 0; processor.OnGraphCompleted = () => completedCount++; processor.StartGraph(); @@ -632,7 +610,7 @@ public void Update_graph_does_nothing_after_completion() processor.UpdateGraph(1.0); processor.UpdateGraph(1.0); completedCount.Should().Be(1); - context.IsActive.Should().BeFalse(); + processor.GraphContext.IsActive.Should().BeFalse(); } [Fact] @@ -640,14 +618,13 @@ public void Update_graph_does_nothing_after_completion() public void Empty_graph_completes_immediately() { var graph = new Graph(); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); var completed = false; processor.OnGraphCompleted = () => completed = true; processor.StartGraph(); - context.IsActive.Should().BeFalse(); + processor.GraphContext.IsActive.Should().BeFalse(); completed.Should().BeTrue(); } @@ -658,14 +635,13 @@ public void Array_variable_is_initialized_from_definition() var graph = new Graph(); graph.VariableDefinitions.DefineArrayVariable("targets", 10, 20, 30); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); - context.GraphVariables.GetArrayLength("targets").Should().Be(3); - context.GraphVariables.TryGetArrayElement("targets", 0, out int v0).Should().BeTrue(); - context.GraphVariables.TryGetArrayElement("targets", 1, out int v1).Should().BeTrue(); - context.GraphVariables.TryGetArrayElement("targets", 2, out int v2).Should().BeTrue(); + 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); @@ -678,18 +654,16 @@ public void Array_variable_has_independent_state_per_processor() var graph = new Graph(); graph.VariableDefinitions.DefineArrayVariable("ids", 1, 2, 3); - var context1 = new GraphContext(); - var processor1 = new GraphProcessor(graph, context1); + var processor1 = new GraphProcessor(graph); processor1.StartGraph(); - var context2 = new GraphContext(); - var processor2 = new GraphProcessor(graph, context2); + var processor2 = new GraphProcessor(graph); processor2.StartGraph(); - context1.GraphVariables.SetArrayElement("ids", 0, 99); + processor1.GraphContext.GraphVariables.SetArrayElement("ids", 0, 99); - context1.GraphVariables.TryGetArrayElement("ids", 0, out int val1); - context2.GraphVariables.TryGetArrayElement("ids", 0, out int val2); + 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); @@ -701,11 +675,10 @@ public void Array_variable_returns_negative_length_for_nonexistent_variable() { var graph = new Graph(); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); - context.GraphVariables.GetArrayLength("nonexistent").Should().Be(-1); + processor.GraphContext.GraphVariables.GetArrayLength("nonexistent").Should().Be(-1); } [Fact] @@ -715,11 +688,10 @@ 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 context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); - context.GraphVariables.TryGetArrayElement("data", 5, out double _).Should().BeFalse(); - context.GraphVariables.TryGetArrayElement("data", -1, out double _).Should().BeFalse(); + 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/StateNodeTests.cs b/Forge.Tests/Statescript/StateNodeTests.cs index 62e5a39..7e76d9d 100644 --- a/Forge.Tests/Statescript/StateNodeTests.cs +++ b/Forge.Tests/Statescript/StateNodeTests.cs @@ -24,23 +24,22 @@ public void Timer_node_stays_active_until_duration_elapses() graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); - context.IsActive.Should().BeTrue(); + processor.GraphContext.IsActive.Should().BeTrue(); // Not enough time has passed. processor.UpdateGraph(1.0); - context.IsActive.Should().BeTrue(); + processor.GraphContext.IsActive.Should().BeTrue(); // Still not enough. processor.UpdateGraph(0.5); - context.IsActive.Should().BeTrue(); + processor.GraphContext.IsActive.Should().BeTrue(); // Now it should deactivate (total: 1.0 + 0.5 + 0.5 = 2.0). processor.UpdateGraph(0.5); - context.IsActive.Should().BeFalse(); + processor.GraphContext.IsActive.Should().BeFalse(); } [Fact] @@ -63,8 +62,7 @@ public void Timer_node_fires_on_deactivate_event_when_completed() timer.OutputPorts[TimerNode.OnDeactivatePort], onDeactivateAction.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); onDeactivateAction.ExecutionCount.Should().Be(0); @@ -94,8 +92,7 @@ public void Timer_node_fires_on_activate_event_on_start() timer.OutputPorts[TimerNode.OnActivatePort], onActivateAction.InputPorts[ActionNode.InputPort])); - var context = new GraphContext(); - var processor = new GraphProcessor(graph, context); + var processor = new GraphProcessor(graph); processor.StartGraph(); onActivateAction.ExecutionCount.Should().Be(1); @@ -115,11 +112,8 @@ public void Two_processors_with_same_timer_graph_have_independent_elapsed_time() graph.EntryNode.OutputPorts[EntryNode.OutputPort], timer.InputPorts[ActionNode.InputPort])); - var context1 = new GraphContext(); - var processor1 = new GraphProcessor(graph, context1); - - var context2 = new GraphContext(); - var processor2 = new GraphProcessor(graph, context2); + var processor1 = new GraphProcessor(graph); + var processor2 = new GraphProcessor(graph); processor1.StartGraph(); processor2.StartGraph(); @@ -127,10 +121,10 @@ public void Two_processors_with_same_timer_graph_have_independent_elapsed_time() processor1.UpdateGraph(2.0); processor2.UpdateGraph(1.0); - context1.IsActive.Should().BeFalse(); - context2.IsActive.Should().BeTrue(); + processor1.GraphContext.IsActive.Should().BeFalse(); + processor2.GraphContext.IsActive.Should().BeTrue(); processor2.UpdateGraph(1.0); - context2.IsActive.Should().BeFalse(); + processor2.GraphContext.IsActive.Should().BeFalse(); } } diff --git a/Forge/Statescript/GraphAbilityBehavior.Data.cs b/Forge/Statescript/GraphAbilityBehavior.Data.cs index cfdf9c6..11a7b8a 100644 --- a/Forge/Statescript/GraphAbilityBehavior.Data.cs +++ b/Forge/Statescript/GraphAbilityBehavior.Data.cs @@ -12,12 +12,11 @@ namespace Gamesmiths.Forge.Statescript; /// /// The type of the activation data expected from the ability system. /// The graph definition to execute when the ability activates. -/// The per-instance context that holds mutable runtime state for the graph. /// 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, GraphContext graphContext, Action dataBinder) - : GraphAbilityBehavior(graph, graphContext), IAbilityBehavior +public class GraphAbilityBehavior(Graph graph, Action dataBinder) + : GraphAbilityBehavior(graph), IAbilityBehavior { private readonly Action _dataBinder = dataBinder; diff --git a/Forge/Statescript/GraphAbilityBehavior.cs b/Forge/Statescript/GraphAbilityBehavior.cs index cb0eab1..6fa89e7 100644 --- a/Forge/Statescript/GraphAbilityBehavior.cs +++ b/Forge/Statescript/GraphAbilityBehavior.cs @@ -16,14 +16,13 @@ namespace Gamesmiths.Forge.Statescript; /// alongside . /// /// The graph definition to execute when the ability activates. -/// The per-instance context that holds mutable runtime state for the graph. -public class GraphAbilityBehavior(Graph graph, GraphContext graphContext) : IAbilityBehavior +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, graphContext); + public GraphProcessor Processor { get; } = new GraphProcessor(graph); /// public void OnStarted(AbilityBehaviorContext context) @@ -46,6 +45,8 @@ public void OnEnded(AbilityBehaviorContext context) /// the graph's entry node fires. protected void StartGraph(AbilityBehaviorContext context, Action? variableOverrides = null) { + Processor.GraphContext.Owner = context.Owner; + Processor.GraphContext.ActivationContext = context; Processor.OnGraphCompleted = context.InstanceHandle.End; Processor.StartGraph(variableOverrides); } diff --git a/Forge/Statescript/GraphContext.cs b/Forge/Statescript/GraphContext.cs index 5dd71e8..9da02d9 100644 --- a/Forge/Statescript/GraphContext.cs +++ b/Forge/Statescript/GraphContext.cs @@ -1,5 +1,6 @@ // Copyright © Gamesmiths Guild. +using System.Diagnostics.CodeAnalysis; using Gamesmiths.Forge.Core; namespace Gamesmiths.Forge.Statescript; @@ -26,6 +27,15 @@ public sealed class GraphContext /// public IForgeEntity? Owner { 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. @@ -42,6 +52,29 @@ public sealed class GraphContext 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. diff --git a/Forge/Statescript/GraphProcessor.cs b/Forge/Statescript/GraphProcessor.cs index 4a67478..4808126 100644 --- a/Forge/Statescript/GraphProcessor.cs +++ b/Forge/Statescript/GraphProcessor.cs @@ -1,5 +1,7 @@ // Copyright © Gamesmiths Guild. +using Gamesmiths.Forge.Core; + namespace Gamesmiths.Forge.Statescript; /// @@ -13,23 +15,20 @@ namespace Gamesmiths.Forge.Statescript; /// A is reusable: after a graph completes naturally or is explicitly stopped, /// it can be started again with a fresh execution cycle. /// -/// The graph to be executed by this processor. -/// The context in which the graph will be executed, providing runtime state for this -/// execution instance. -public class GraphProcessor(Graph graph, GraphContext graphContext) +public class GraphProcessor { private readonly List _updateBuffer = []; /// /// Gets the graph that this processor is responsible for executing. /// - public Graph Graph { get; } = graph; + 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; } = graphContext; + public GraphContext GraphContext { get; } /// /// Gets or sets an optional callback that is invoked when the graph completes naturally (i.e., all state nodes @@ -38,6 +37,18 @@ public class GraphProcessor(Graph graph, GraphContext graphContext) /// public Action? OnGraphCompleted { get; set; } + /// + /// Initializes a new instance of the class. + /// + /// The graph to be executed by this processor. + /// An optional owner entity for this graph execution. The owner provides access to entity + /// attributes, tags, and other systems that property resolvers can use to compute derived values. + public GraphProcessor(Graph graph, IForgeEntity? owner = null) + { + Graph = graph; + GraphContext = new GraphContext { Owner = owner }; + } + /// /// 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 From 2955247ae176447f9bd2482d027763bd47d4d9db Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 15 Feb 2026 23:11:51 -0300 Subject: [PATCH 19/19] Refactored GraphContext Owner into SharedVariables --- Forge.Tests/Helpers/StatescriptTestHelpers.cs | 10 +++ .../Statescript/ExpressionResolverTests.cs | 30 ++++++- .../Statescript/GraphAbilityBehaviorTests.cs | 8 +- .../Statescript/PropertyResolverTests.cs | 89 +++++++++++++++---- Forge/Statescript/GraphAbilityBehavior.cs | 2 +- Forge/Statescript/GraphContext.cs | 9 +- Forge/Statescript/GraphProcessor.cs | 10 +-- .../Properties/AttributeResolver.cs | 16 ++-- .../Properties/SharedVariableResolver.cs | 17 ++-- Forge/Statescript/Properties/TagResolver.cs | 16 ++-- 10 files changed, 152 insertions(+), 55 deletions(-) diff --git a/Forge.Tests/Helpers/StatescriptTestHelpers.cs b/Forge.Tests/Helpers/StatescriptTestHelpers.cs index ab36417..dc45c82 100644 --- a/Forge.Tests/Helpers/StatescriptTestHelpers.cs +++ b/Forge.Tests/Helpers/StatescriptTestHelpers.cs @@ -114,3 +114,13 @@ protected override void Execute(GraphContext graphContext) 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/Statescript/ExpressionResolverTests.cs b/Forge.Tests/Statescript/ExpressionResolverTests.cs index cb80380..2b29780 100644 --- a/Forge.Tests/Statescript/ExpressionResolverTests.cs +++ b/Forge.Tests/Statescript/ExpressionResolverTests.cs @@ -1,7 +1,13 @@ // 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; @@ -346,8 +352,28 @@ public void Comparison_resolver_works_with_attribute_resolver() condition.OutputPorts[ConditionNode.FalsePort], falseAction.InputPorts[ActionNode.InputPort])); - var processor = new GraphProcessor(graph, entity); - processor.StartGraph(); + 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 index 7e865b6..1003ba6 100644 --- a/Forge.Tests/Statescript/GraphAbilityBehaviorTests.cs +++ b/Forge.Tests/Statescript/GraphAbilityBehaviorTests.cs @@ -135,8 +135,8 @@ public void Graph_variables_are_initialized_from_definitions() } [Fact] - [Trait("GraphBehavior", "Owner")] - public void Owner_is_set_from_ability_context() + [Trait("GraphBehavior", "SharedVariables")] + public void Shared_variables_are_set_from_ability_context_owner() { var graph = new Graph(); var actionNode = new TrackingActionNode(); @@ -149,13 +149,13 @@ public void Owner_is_set_from_ability_context() var entity = new TestEntity(_tagsManager, _cuesManager); var behavior = new GraphAbilityBehavior(graph); - AbilityData abilityData = CreateAbilityData("OwnerGraph", behaviorFactory: () => behavior); + AbilityData abilityData = CreateAbilityData("SharedVarsGraph", behaviorFactory: () => behavior); AbilityHandle? handle = Grant(entity, abilityData); handle.Should().NotBeNull(); handle!.Activate(out _).Should().BeTrue(); - behavior.Processor.GraphContext.Owner.Should().Be(entity); + behavior.Processor.GraphContext.SharedVariables.Should().BeSameAs(entity.SharedVariables); } [Fact] diff --git a/Forge.Tests/Statescript/PropertyResolverTests.cs b/Forge.Tests/Statescript/PropertyResolverTests.cs index 551eb96..f8126f1 100644 --- a/Forge.Tests/Statescript/PropertyResolverTests.cs +++ b/Forge.Tests/Statescript/PropertyResolverTests.cs @@ -1,8 +1,15 @@ // 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; @@ -21,7 +28,7 @@ public void Attribute_resolver_returns_current_value_of_existing_attribute() var entity = new TestEntity(_tagsManager, _cuesManager); var resolver = new AttributeResolver("TestAttributeSet.Attribute5"); - var context = new GraphContext { Owner = entity }; + GraphContext context = CreateAbilityGraphContext(entity); Variant128 result = resolver.Resolve(context); @@ -35,7 +42,7 @@ public void Attribute_resolver_returns_default_for_missing_attribute() var entity = new TestEntity(_tagsManager, _cuesManager); var resolver = new AttributeResolver("TestAttributeSet.NonExistent"); - var context = new GraphContext { Owner = entity }; + GraphContext context = CreateAbilityGraphContext(entity); Variant128 result = resolver.Resolve(context); @@ -44,11 +51,11 @@ public void Attribute_resolver_returns_default_for_missing_attribute() [Fact] [Trait("Resolver", "Attribute")] - public void Attribute_resolver_returns_default_when_owner_is_null() + public void Attribute_resolver_returns_default_when_no_activation_context() { var resolver = new AttributeResolver("TestAttributeSet.Attribute5"); - var context = new GraphContext { Owner = null }; + var context = new GraphContext(); Variant128 result = resolver.Resolve(context); @@ -72,7 +79,7 @@ public void Attribute_resolver_reads_different_attributes() var resolver1 = new AttributeResolver("TestAttributeSet.Attribute1"); var resolver90 = new AttributeResolver("TestAttributeSet.Attribute90"); - var context = new GraphContext { Owner = entity }; + GraphContext context = CreateAbilityGraphContext(entity); resolver1.Resolve(context).AsInt().Should().Be(1); resolver90.Resolve(context).AsInt().Should().Be(90); @@ -86,7 +93,7 @@ public void Tag_resolver_returns_true_when_entity_has_tag() var tag = Tag.RequestTag(_tagsManager, "enemy.undead.zombie"); var resolver = new TagResolver(tag); - var context = new GraphContext { Owner = entity }; + GraphContext context = CreateAbilityGraphContext(entity); Variant128 result = resolver.Resolve(context); @@ -101,7 +108,7 @@ public void Tag_resolver_returns_false_when_entity_does_not_have_tag() var tag = Tag.RequestTag(_tagsManager, "enemy.beast.wolf"); var resolver = new TagResolver(tag); - var context = new GraphContext { Owner = entity }; + GraphContext context = CreateAbilityGraphContext(entity); Variant128 result = resolver.Resolve(context); @@ -110,12 +117,12 @@ public void Tag_resolver_returns_false_when_entity_does_not_have_tag() [Fact] [Trait("Resolver", "Tag")] - public void Tag_resolver_returns_false_when_owner_is_null() + 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 { Owner = null }; + var context = new GraphContext(); Variant128 result = resolver.Resolve(context); @@ -140,7 +147,7 @@ public void Tag_resolver_matches_parent_tag() var parentTag = Tag.RequestTag(_tagsManager, "enemy.undead"); var resolver = new TagResolver(parentTag); - var context = new GraphContext { Owner = entity }; + GraphContext context = CreateAbilityGraphContext(entity); Variant128 result = resolver.Resolve(context); @@ -369,32 +376,32 @@ public void Comparison_resolver_supports_nested_resolvers() ComparisonOperation.GreaterThan, new VariantResolver(new Variant128(3.0), typeof(double))); - var context = new GraphContext { Owner = entity }; + 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_owner_shared_variables() + 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 { Owner = entity }; + 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_owner_is_null() + public void Shared_variable_resolver_returns_default_when_shared_variables_is_null() { var resolver = new SharedVariableResolver("abilityLock", typeof(double)); - var context = new GraphContext { Owner = null }; + var context = new GraphContext(); resolver.Resolve(context).AsDouble().Should().Be(0); } @@ -406,7 +413,7 @@ 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 { Owner = entity }; + var context = new GraphContext { SharedVariables = entity.SharedVariables }; resolver.Resolve(context).AsDouble().Should().Be(0); } @@ -429,8 +436,8 @@ public void Shared_variable_resolver_reflects_changes_across_graph_contexts() var resolver = new SharedVariableResolver("sharedCounter", typeof(int)); - var context1 = new GraphContext { Owner = entity }; - var context2 = new GraphContext { Owner = entity }; + var context1 = new GraphContext { SharedVariables = entity.SharedVariables }; + var context2 = new GraphContext { SharedVariables = entity.SharedVariables }; resolver.Resolve(context1).AsInt().Should().Be(0); @@ -540,4 +547,50 @@ public void Array_resolver_reports_correct_value_type() 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/Statescript/GraphAbilityBehavior.cs b/Forge/Statescript/GraphAbilityBehavior.cs index 6fa89e7..d9c87eb 100644 --- a/Forge/Statescript/GraphAbilityBehavior.cs +++ b/Forge/Statescript/GraphAbilityBehavior.cs @@ -45,7 +45,7 @@ public void OnEnded(AbilityBehaviorContext context) /// the graph's entry node fires. protected void StartGraph(AbilityBehaviorContext context, Action? variableOverrides = null) { - Processor.GraphContext.Owner = context.Owner; + Processor.GraphContext.SharedVariables = context.Owner.SharedVariables; Processor.GraphContext.ActivationContext = context; Processor.OnGraphCompleted = context.InstanceHandle.End; Processor.StartGraph(variableOverrides); diff --git a/Forge/Statescript/GraphContext.cs b/Forge/Statescript/GraphContext.cs index 9da02d9..2e6e061 100644 --- a/Forge/Statescript/GraphContext.cs +++ b/Forge/Statescript/GraphContext.cs @@ -21,11 +21,12 @@ public sealed class GraphContext public bool IsActive => ActiveStateNodes.Count > 0; /// - /// Gets or sets the optional owner entity for this graph execution. The owner provides access to entity attributes, - /// tags, and other systems that property resolvers can use to compute derived values. May be - /// if the graph does not require an owner entity. + /// 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 IForgeEntity? Owner { get; set; } + public Variables? SharedVariables { get; set; } /// /// Gets or sets optional activation context data for this graph execution. This provides a generic extensibility diff --git a/Forge/Statescript/GraphProcessor.cs b/Forge/Statescript/GraphProcessor.cs index 4808126..481ad45 100644 --- a/Forge/Statescript/GraphProcessor.cs +++ b/Forge/Statescript/GraphProcessor.cs @@ -1,7 +1,5 @@ // Copyright © Gamesmiths Guild. -using Gamesmiths.Forge.Core; - namespace Gamesmiths.Forge.Statescript; /// @@ -41,12 +39,12 @@ public class GraphProcessor /// Initializes a new instance of the class. /// /// The graph to be executed by this processor. - /// An optional owner entity for this graph execution. The owner provides access to entity - /// attributes, tags, and other systems that property resolvers can use to compute derived values. - public GraphProcessor(Graph graph, IForgeEntity? owner = null) + /// 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 { Owner = owner }; + GraphContext = new GraphContext { SharedVariables = sharedVariables }; } /// diff --git a/Forge/Statescript/Properties/AttributeResolver.cs b/Forge/Statescript/Properties/AttributeResolver.cs index 6e1cc2f..d27dcbc 100644 --- a/Forge/Statescript/Properties/AttributeResolver.cs +++ b/Forge/Statescript/Properties/AttributeResolver.cs @@ -1,17 +1,21 @@ // 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 graph owner's entity. +/// 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 /// . /// /// -/// If the graph context has no owner or the owner does not have the specified attribute, the resolver returns a default -/// (zero). +/// 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 @@ -24,16 +28,16 @@ public class AttributeResolver(StringKey attributeKey) : IPropertyResolver /// public Variant128 Resolve(GraphContext graphContext) { - if (graphContext.Owner is null) + if (!graphContext.TryGetActivationContext(out AbilityBehaviorContext? abilityContext)) { return default; } - if (!graphContext.Owner.Attributes.ContainsAttribute(_attributeKey)) + if (!abilityContext.Owner.Attributes.ContainsAttribute(_attributeKey)) { return default; } - return new Variant128(graphContext.Owner.Attributes[_attributeKey].CurrentValue); + return new Variant128(abilityContext.Owner.Attributes[_attributeKey].CurrentValue); } } diff --git a/Forge/Statescript/Properties/SharedVariableResolver.cs b/Forge/Statescript/Properties/SharedVariableResolver.cs index 8438446..1a12392 100644 --- a/Forge/Statescript/Properties/SharedVariableResolver.cs +++ b/Forge/Statescript/Properties/SharedVariableResolver.cs @@ -5,17 +5,18 @@ namespace Gamesmiths.Forge.Statescript.Properties; /// -/// Resolves a property value by reading a named variable from the graph owner entity's -/// . Shared variables are accessible by all graph instances running on the -/// same entity, enabling cross-ability communication (e.g., an "ability lock" flag shared by all abilities on a hero). +/// 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 owner or the owner's shared variables do not contain the specified name, the +/// 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 entity-level shared bag, allowing one ability's graph to read values written by another. +/// the shared bag, allowing one ability's graph to read values written by another. /// -/// The name of the shared variable to read from the owner entity. +/// The name of the shared variable to read. /// The type of the value this resolver produces. public class SharedVariableResolver(StringKey variableName, Type valueType) : IPropertyResolver { @@ -27,12 +28,12 @@ public class SharedVariableResolver(StringKey variableName, Type valueType) : IP /// public Variant128 Resolve(GraphContext graphContext) { - if (graphContext.Owner is null) + if (graphContext.SharedVariables is null) { return default; } - if (!graphContext.Owner.SharedVariables.TryGetVariant(_variableName, graphContext, out Variant128 value)) + if (!graphContext.SharedVariables.TryGetVariant(_variableName, graphContext, out Variant128 value)) { return default; } diff --git a/Forge/Statescript/Properties/TagResolver.cs b/Forge/Statescript/Properties/TagResolver.cs index 34cd565..2a81e1c 100644 --- a/Forge/Statescript/Properties/TagResolver.cs +++ b/Forge/Statescript/Properties/TagResolver.cs @@ -1,16 +1,20 @@ // Copyright © Gamesmiths Guild. +using Gamesmiths.Forge.Abilities; using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Statescript.Properties; /// -/// Resolves a property value by checking whether the graph owner entity has a specific tag. Returns -/// stored in a ; if the entity has -/// the tag, otherwise. +/// 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. /// /// -/// If the graph context has no owner, the resolver always returns . +/// 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 @@ -23,11 +27,11 @@ public class TagResolver(Tag tag) : IPropertyResolver /// public Variant128 Resolve(GraphContext graphContext) { - if (graphContext.Owner is null) + if (!graphContext.TryGetActivationContext(out AbilityBehaviorContext? abilityContext)) { return new Variant128(false); } - return new Variant128(graphContext.Owner.Tags.CombinedTags.HasTag(_tag)); + return new Variant128(abilityContext.Owner.Tags.CombinedTags.HasTag(_tag)); } }