From 89282b7023810e0f84ff0cf9616b9e174d0dbc4f Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 15 Jun 2026 23:09:05 -0300 Subject: [PATCH 1/7] Changed generic event raises catch for non generic handlers --- Forge.Tests/Events/EventTests.cs | 8 +++--- Forge/Events/EventManager.cs | 43 ++++++++++++++++++++++++-------- docs/events.md | 3 ++- 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/Forge.Tests/Events/EventTests.cs b/Forge.Tests/Events/EventTests.cs index 9d54d6a..abb9750 100644 --- a/Forge.Tests/Events/EventTests.cs +++ b/Forge.Tests/Events/EventTests.cs @@ -439,21 +439,21 @@ public void Single_handler_called_once_even_with_multiple_matching_tags() [Fact] [Trait("Isolation", null)] - public void Generic_raise_does_not_trigger_non_generic_handlers() + public void Generic_raise_also_triggers_non_generic_handlers_with_boxed_payload() { var events = new EventManager(); var eventTag = Tag.RequestTag(_tagsManager, "simple.tag"); - bool nonGenericCalled = false; + object? capturedPayload = null; bool genericCalled = false; - events.Subscribe(eventTag, _ => nonGenericCalled = true); + events.Subscribe(eventTag, data => capturedPayload = data.Payload); events.Subscribe(eventTag, _ => genericCalled = true); // Raise generic event events.Raise(new EventData { EventTags = eventTag.GetSingleTagContainer()!, Payload = 42 }); - nonGenericCalled.Should().BeFalse("non-generic handler should not be called by generic raise"); genericCalled.Should().BeTrue(); + capturedPayload.Should().Be(42, "non-generic subscriptions are catch-all and receive the boxed payload"); } [Fact] diff --git a/Forge/Events/EventManager.cs b/Forge/Events/EventManager.cs index 75deb0e..c473308 100644 --- a/Forge/Events/EventManager.cs +++ b/Forge/Events/EventManager.cs @@ -7,7 +7,9 @@ namespace Gamesmiths.Forge.Events; /// /// Per-entity event bus that supports both non-generic and generic (typed) event subscriptions. /// Subscriptions are ordered by priority (higher priority invoked first). -/// Generic handlers are invoked without boxing. Generic raises do NOT forward to non-generic handlers. +/// Generic handlers are invoked without boxing. Non-generic subscriptions are catch-all: a generic raise also reaches +/// them with the payload boxed into . A non-generic raise does NOT reach generic +/// (typed) handlers. /// public sealed class EventManager { @@ -20,16 +22,7 @@ public sealed class EventManager /// The event data to raise. public void Raise(in EventData data) { - for (int i = 0; i < _nonGeneric.Count; i++) - { - NonGenericSubscription sub = _nonGeneric[i]; - if (!data.EventTags.HasTag(sub.EventTag)) - { - continue; - } - - sub.Handler.Invoke(data); - } + InvokeNonGenericHandlers(data); } /// @@ -53,6 +46,20 @@ public void Raise(in EventData data) ((Action>)sub.Handler).Invoke(data); } } + + // Non-generic subscriptions are catch-all: a generic raise also reaches them with the payload boxed. Only build + // the boxed event when such subscribers exist, so typed-only buses pay no boxing cost. + if (_nonGeneric.Count > 0) + { + InvokeNonGenericHandlers(new EventData + { + EventTags = data.EventTags, + Source = data.Source, + Target = data.Target, + EventMagnitude = data.EventMagnitude, + Payload = data.Payload, + }); + } } /// @@ -137,6 +144,20 @@ public bool Unsubscribe(EventSubscriptionToken token) return removed; } + private void InvokeNonGenericHandlers(in EventData data) + { + for (int i = 0; i < _nonGeneric.Count; i++) + { + NonGenericSubscription sub = _nonGeneric[i]; + if (!data.EventTags.HasTag(sub.EventTag)) + { + continue; + } + + sub.Handler.Invoke(data); + } + } + private readonly record struct NonGenericSubscription( EventSubscriptionToken Token, Tag EventTag, diff --git a/docs/events.md b/docs/events.md index fb1c8e3..ccc2673 100644 --- a/docs/events.md +++ b/docs/events.md @@ -9,7 +9,7 @@ For a practical guide on using events with abilities, see the [Abilities documen - Events carry tags for filtering `EventTags` plus optional source, target, magnitude, and payload data. - Handlers subscribe by tag and run in priority order (higher priority first). - Generic events avoid boxing by using typed payloads. -- Generic raises do **not** forward to non-generic handlers. +- Non-generic subscriptions are **catch-all**: a generic raise also reaches them with the payload boxed into `EventData.Payload`. A non-generic raise does **not** reach generic (typed) handlers. ## Event Data @@ -81,6 +81,7 @@ public sealed class EventManager - Subscriptions are sorted by `priority` (higher first). - A handler is invoked when `data.EventTags.HasTag(eventTag)` is true. - Generic subscriptions are stored per `TPayload` type and are only invoked for matching generic raises. +- Non-generic subscriptions also receive generic raises, with the typed payload boxed into `EventData.Payload` (only boxed when at least one non-generic subscriber exists). ## Usage Examples From 97703053d1a97b7781021d88f856cda55e1348b8 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 20 Jun 2026 11:39:17 -0300 Subject: [PATCH 2/7] Added RaiseEvent and EventListener nodes --- Forge/Statescript/EventOutputBinding.cs | 12 + Forge/Statescript/EventPayloadInput.cs | 14 + Forge/Statescript/EventPayloadInputs.cs | 57 ++++ Forge/Statescript/EventPayloadOutput.cs | 14 + Forge/Statescript/EventPayloadOutputs.cs | 86 ++++++ Forge/Statescript/EventPayloadProvider.cs | 100 +++++++ Forge/Statescript/IEventPayloadProvider.cs | 91 ++++++ .../Nodes/Action/RaiseEventNode.cs | 200 ++++++++++++++ .../Nodes/State/EventListenerNode.cs | 260 ++++++++++++++++++ .../Nodes/State/EventListenerNodeContext.cs | 28 ++ .../Properties/EventPayloadOutputResolver.cs | 23 ++ .../Properties/EventPayloadRaiser.cs | 45 +++ .../Properties/EventPayloadResolver.cs | 28 ++ .../Properties/EventPayloadWriter.cs | 52 ++++ 14 files changed, 1010 insertions(+) create mode 100644 Forge/Statescript/EventOutputBinding.cs create mode 100644 Forge/Statescript/EventPayloadInput.cs create mode 100644 Forge/Statescript/EventPayloadInputs.cs create mode 100644 Forge/Statescript/EventPayloadOutput.cs create mode 100644 Forge/Statescript/EventPayloadOutputs.cs create mode 100644 Forge/Statescript/EventPayloadProvider.cs create mode 100644 Forge/Statescript/IEventPayloadProvider.cs create mode 100644 Forge/Statescript/Nodes/Action/RaiseEventNode.cs create mode 100644 Forge/Statescript/Nodes/State/EventListenerNode.cs create mode 100644 Forge/Statescript/Nodes/State/EventListenerNodeContext.cs create mode 100644 Forge/Statescript/Properties/EventPayloadOutputResolver.cs create mode 100644 Forge/Statescript/Properties/EventPayloadRaiser.cs create mode 100644 Forge/Statescript/Properties/EventPayloadResolver.cs create mode 100644 Forge/Statescript/Properties/EventPayloadWriter.cs diff --git a/Forge/Statescript/EventOutputBinding.cs b/Forge/Statescript/EventOutputBinding.cs new file mode 100644 index 0000000..84fe21a --- /dev/null +++ b/Forge/Statescript/EventOutputBinding.cs @@ -0,0 +1,12 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Binds a declared event-payload output to the graph variable the event-listener node writes it to. +/// +/// The target variable name. +/// The scope (graph or shared) the variable lives in. +public readonly record struct EventOutputBinding(StringKey VariableName, VariableScope Scope); diff --git a/Forge/Statescript/EventPayloadInput.cs b/Forge/Statescript/EventPayloadInput.cs new file mode 100644 index 0000000..95a0438 --- /dev/null +++ b/Forge/Statescript/EventPayloadInput.cs @@ -0,0 +1,14 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Declares an authored input that an exposes to the graph editor when building an +/// event payload. Each input is rendered as a nested resolver on the provider's Payload section of the +/// raise-event node, and the resolved value is handed to the provider through . +/// +/// The input name, used both as the editor label and as the key to read the resolved value with +/// . +/// The expected value type. The editor lists resolvers compatible with this type; values must +/// be supported by (numbers, vectors, planes, quaternions, and so on). +public sealed record EventPayloadInput(string Name, Type ValueType); diff --git a/Forge/Statescript/EventPayloadInputs.cs b/Forge/Statescript/EventPayloadInputs.cs new file mode 100644 index 0000000..df1382d --- /dev/null +++ b/Forge/Statescript/EventPayloadInputs.cs @@ -0,0 +1,57 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Statescript.Properties; + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Provides the resolved values for the inputs an declared through +/// . Values are resolved lazily against the current +/// when read, so an input that the provider never reads is never evaluated. +/// +public sealed class EventPayloadInputs +{ + private readonly GraphContext _graphContext; + private readonly IReadOnlyDictionary _resolvers; + + internal EventPayloadInputs(GraphContext graphContext, IReadOnlyDictionary resolvers) + { + _graphContext = graphContext; + _resolvers = resolvers; + } + + /// + /// Reads a declared input as the raw authored in the editor. + /// + /// The declared input name. + /// The resolved value, or when no resolver is bound for the name. + public Variant128 GetVariant(string name) + { + return _resolvers.TryGetValue(name, out IPropertyResolver? resolver) + ? resolver.Resolve(_graphContext) + : default; + } + + /// + /// Reads a declared input as a specific value type. + /// + /// The value type to interpret the resolved value as. Must be supported by + /// . + /// The declared input name. + /// The resolved value, or when no resolver is bound for the name. + public T Get(string name) + where T : unmanaged + { + return GetVariant(name).Get(); + } + + /// + /// Gets a value indicating whether a resolver is bound for the given input name. + /// + /// The declared input name. + /// when a resolver is bound for the name. + public bool Has(string name) + { + return _resolvers.ContainsKey(name); + } +} diff --git a/Forge/Statescript/EventPayloadOutput.cs b/Forge/Statescript/EventPayloadOutput.cs new file mode 100644 index 0000000..4b8a0a3 --- /dev/null +++ b/Forge/Statescript/EventPayloadOutput.cs @@ -0,0 +1,14 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Declares an output that an exposes to the graph editor when decomposing a +/// received event payload. Each output is rendered as a graph-variable binding on the event-listener node, and the +/// provider writes its value through when an event fires. +/// +/// The output name, used both as the editor label and as the key the provider writes with +/// . +/// The value type written to the bound graph variable. Unmanaged types are written through the +/// scalar lane; reference types through the object lane. +public sealed record EventPayloadOutput(string Name, Type ValueType); diff --git a/Forge/Statescript/EventPayloadOutputs.cs b/Forge/Statescript/EventPayloadOutputs.cs new file mode 100644 index 0000000..702d436 --- /dev/null +++ b/Forge/Statescript/EventPayloadOutputs.cs @@ -0,0 +1,86 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Writes the values an extracts from a received event payload to the graph +/// variables bound to the provider's declared . Output names with no binding +/// are skipped. +/// +public sealed class EventPayloadOutputs +{ + private readonly GraphContext _graphContext; + private readonly IReadOnlyDictionary _bindings; + + internal EventPayloadOutputs(GraphContext graphContext, IReadOnlyDictionary bindings) + { + _graphContext = graphContext; + _bindings = bindings; + } + + /// + /// Writes an unmanaged value to the variable bound to the named output. + /// + /// The value type. Must be supported by . + /// The declared output name. + /// The value to write. + public void Set(string name, T value) + where T : unmanaged + { + Variables? variables = ResolveBinding(name, out StringKey variableName); + + if (variables is null) + { + return; + } + + // Floating-point graph variables are double-backed, so widen single-precision values before storing them. + if (typeof(T) == typeof(float)) + { + variables.SetVar(variableName, (double)(float)(object)value); + } + else + { + variables.SetVar(variableName, value); + } + } + + /// + /// Writes a reference value to the variable bound to the named output. + /// + /// The reference value type. + /// The declared output name. + /// The value to write. + public void SetObject(string name, T value) + { + Variables? variables = ResolveBinding(name, out StringKey variableName); + variables?.SetObject(variableName, value); + } + + /// + /// Gets a value indicating whether a binding exists for the given output name. + /// + /// The declared output name. + /// when a graph variable is bound for the name. + public bool Has(string name) + { + return _bindings.ContainsKey(name); + } + + private Variables? ResolveBinding(string name, out StringKey variableName) + { + variableName = default; + + if (!_bindings.TryGetValue(name, out EventOutputBinding binding)) + { + return null; + } + + variableName = binding.VariableName; + return binding.Scope == VariableScope.Shared + ? _graphContext.SharedVariables + : _graphContext.GraphVariables; + } +} diff --git a/Forge/Statescript/EventPayloadProvider.cs b/Forge/Statescript/EventPayloadProvider.cs new file mode 100644 index 0000000..d7c32d1 --- /dev/null +++ b/Forge/Statescript/EventPayloadProvider.cs @@ -0,0 +1,100 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Statescript.Properties; +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Base class for typed event-payload providers. Override to build the payload from the +/// current graph state, and to extract its values into the listener's bound graph variables. +/// The base seals the boxing to and from used by the non-generic event path. +/// +/// The payload type produced and consumed by this provider. +/// +/// Override and to expose authored resolvers and output bindings in the +/// editor; read declared inputs from and write declared outputs to +/// . +/// +public abstract class EventPayloadProvider : IEventPayloadProvider +{ + /// + /// Builds the payload for the current graph execution. Read whatever graph state the payload is derived from + /// (graph/shared variables, attributes, activation data, and so on) from , and read + /// declared from . + /// + /// The graph execution context. + /// The resolved values for the provider's declared . + /// The payload to attach to the raised event. + public abstract TPayload CreatePayload(GraphContext graphContext, EventPayloadInputs inputs); + + /// + /// Writes the values of a received payload to the listener's bound graph variables through + /// . + /// + /// The payload carried by the received event. + /// The writer bound to the listener node's output variables. + public abstract void WriteOutputs(TPayload payload, EventPayloadOutputs outputs); + + /// + public virtual IReadOnlyList Inputs => []; + + /// + public virtual IReadOnlyList Outputs => []; + + /// + object IEventPayloadProvider.CreatePayload(GraphContext graphContext, EventPayloadInputs inputs) + { + return CreatePayload(graphContext, inputs)!; + } + + /// + void IEventPayloadProvider.WriteOutputs(object payload, EventPayloadOutputs outputs) + { + WriteOutputs((TPayload)payload, outputs); + } + + /// + EventSubscriptionToken IEventPayloadProvider.Subscribe( + EventManager manager, + Tag eventTag, + GraphContext graphContext, + IReadOnlyDictionary outputBindings, + Action onReceived) + { + var outputs = new EventPayloadOutputs(graphContext, outputBindings); + + // Subscribe through the typed path so the payload is never boxed; decompose it directly into the bound + // variables. + return manager.Subscribe(eventTag, data => + { + WriteOutputs(data.Payload, outputs); + onReceived(data.Source, data.Target, data.EventMagnitude); + }); + } + + /// + void IEventPayloadProvider.Raise( + EventManager manager, + TagContainer eventTags, + IForgeEntity? source, + IForgeEntity? target, + float magnitude, + GraphContext graphContext, + IReadOnlyDictionary inputResolvers) + { + TPayload payload = CreatePayload(graphContext, new EventPayloadInputs(graphContext, inputResolvers)); + + // Raise through the typed path so the payload is never boxed and typed listeners receive it. + manager.Raise(new EventData + { + EventTags = eventTags, + Source = source, + Target = target, + EventMagnitude = magnitude, + Payload = payload, + }); + } +} diff --git a/Forge/Statescript/IEventPayloadProvider.cs b/Forge/Statescript/IEventPayloadProvider.cs new file mode 100644 index 0000000..c35568c --- /dev/null +++ b/Forge/Statescript/IEventPayloadProvider.cs @@ -0,0 +1,91 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Statescript.Properties; +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Statescript; + +/// +/// Builds and decomposes the custom Payload of an event for the event nodes. The same provider serves both +/// directions: builds the payload object the raise-event node attaches to an event, and +/// extracts values from a received payload so the event-listener node can write them to +/// graph variables. +/// +/// +/// The recommended way is to derive from , which supplies the default +/// (empty) input/output lists and the boxing so implementations work with the typed payload directly. +/// Declared render as nested resolvers on the raise-event node; declared +/// render as graph-variable bindings on the event-listener node. +/// +public interface IEventPayloadProvider +{ + /// + /// Gets the authored inputs this provider exposes to the raise-event node. Defaults to an empty list for providers + /// that build their payload entirely from the graph state. + /// + IReadOnlyList Inputs { get; } + + /// + /// Gets the outputs this provider exposes to the event-listener node. Defaults to an empty list for providers whose + /// payload is not decomposed into graph variables. + /// + IReadOnlyList Outputs { get; } + + /// + /// Builds the payload for an event raised by the graph. + /// + /// The graph execution context. + /// The resolved values for the provider's declared . + /// The payload object attached to the raised event. + object CreatePayload(GraphContext graphContext, EventPayloadInputs inputs); + + /// + /// Writes the values of a received payload to the graph variables bound to the declared . + /// + /// The payload carried by the received event. + /// The writer bound to the listener node's output variables. + void WriteOutputs(object payload, EventPayloadOutputs outputs); + + /// + /// Subscribes to on through the typed (non-boxing) event + /// path. + /// Each received event's payload is decomposed into the graph variables described by + /// , and is invoked with the event's source, target, + /// and magnitude. Use this instead of a non-generic subscription to avoid boxing value-type payloads. + /// + /// The event bus to subscribe to. + /// The tag to subscribe to. + /// The graph execution context whose variables receive the decomposed payload. + /// The output-name to graph-variable bindings authored on the listener node. + /// Invoked for each received event with its source, target, and magnitude. + /// The subscription token to release on deactivation. + EventSubscriptionToken Subscribe( + EventManager manager, + Tag eventTag, + GraphContext graphContext, + IReadOnlyDictionary outputBindings, + Action onReceived); + + /// + /// Builds the payload from the current graph state and raises a typed (non-boxing) event on + /// . Use this instead of attaching a boxed payload to a non-generic raise so that typed + /// (Subscribe<TPayload>) listeners receive the event. + /// + /// The event bus to raise on. + /// The event's tag container. + /// The optional event source. + /// The event target. + /// The event magnitude. + /// The graph execution context the payload is built from. + /// The resolvers for the provider's declared inputs, keyed by input name. + void Raise( + EventManager manager, + TagContainer eventTags, + IForgeEntity? source, + IForgeEntity? target, + float magnitude, + GraphContext graphContext, + IReadOnlyDictionary inputResolvers); +} diff --git a/Forge/Statescript/Nodes/Action/RaiseEventNode.cs b/Forge/Statescript/Nodes/Action/RaiseEventNode.cs new file mode 100644 index 0000000..7461149 --- /dev/null +++ b/Forge/Statescript/Nodes/Action/RaiseEventNode.cs @@ -0,0 +1,200 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Statescript.Properties; +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Statescript.Nodes.Action; + +/// +/// Raises an event () on one or more target entities' event buses, then +/// continues execution. +/// +/// +/// The event-tag input accepts either a single or an array of tags; all selected tags are +/// combined into the event's container. +/// The target input accepts either a single or an array of entities; the same event is +/// raised on each target's bus. +/// Source, magnitude, and payload are optional. When a payload provider is bound, the node builds the provider's +/// typed payload and raises a typed (non-boxing) event +/// () so that typed +/// (Subscribe<TPayload>) listeners receive it; otherwise it raises a non-generic event with no payload. +/// +/// +public class RaiseEventNode : ActionNode +{ + /// + /// Input property index for the event tag(s). + /// + public const byte EventTagInput = 0; + + /// + /// Input property index for the event target(s). + /// + public const byte TargetInput = 1; + + /// + /// Input property index for the optional event source entity. + /// + public const byte SourceInput = 2; + + /// + /// Input property index for the optional event magnitude. + /// + public const byte MagnitudeInput = 3; + + /// + /// Input property index for the optional event payload. + /// + public const byte PayloadInput = 4; + + /// + public override string Description => "Raises an event on target entities."; + + /// + protected override void DefineParameters(List inputProperties, List outputVariables) + { + inputProperties.Add(new InputProperty("Event Tags", typeof(Tag))); + inputProperties.Add(new InputProperty("Target", typeof(IForgeEntity))); + inputProperties.Add(new InputProperty("Source", typeof(IForgeEntity))); + inputProperties.Add(new InputProperty("Magnitude", typeof(float))); + inputProperties.Add(new InputProperty("Payload", typeof(EventPayloadRaiser))); + } + + /// + protected override void Execute(GraphContext graphContext) + { + TagContainer? eventTags = ResolveEventTags(graphContext); + if (eventTags is null || !ResolveTargets(graphContext, out IReadOnlyList targets)) + { + return; + } + + IForgeEntity? source = ResolveSource(graphContext); + float magnitude = ResolveMagnitude(graphContext); + EventPayloadRaiser? payloadRaiser = ResolvePayloadRaiser(graphContext); + + for (int i = 0; i < targets.Count; i++) + { + IForgeEntity target = targets[i]; + + if (payloadRaiser is not null) + { + // Build and raise the provider's typed payload with no boxing so typed listeners receive it. + payloadRaiser.Raise(target.Events, eventTags, source, target, magnitude, graphContext); + } + else + { + target.Events.Raise(new EventData + { + EventTags = eventTags, + Source = source, + Target = target, + EventMagnitude = magnitude, + }); + } + } + } + + private TagContainer? ResolveEventTags(GraphContext graphContext) + { + StringKey inputName = InputProperties[EventTagInput].BoundName; + + if (inputName == StringKey.Empty) + { + return null; + } + + var tags = new List(); + + if (graphContext.TryResolveObjectArray(inputName, typeof(Tag), out object?[]? resolvedArray)) + { + for (int i = 0; i < resolvedArray.Length; i++) + { + if (resolvedArray[i] is Tag tag) + { + tags.Add(tag); + } + } + } + else if (graphContext.TryResolveObject(inputName, typeof(Tag), out object? resolved) + && resolved is Tag singleTag) + { + tags.Add(singleTag); + } + + if (tags.Count == 0) + { + return null; + } + + return tags.Count == 1 + ? new TagContainer(tags[0]) + : new TagContainer(tags[0].TagsManager!, [.. tags]); + } + + private bool ResolveTargets(GraphContext graphContext, out IReadOnlyList targets) + { + StringKey inputName = InputProperties[TargetInput].BoundName; + + if (graphContext.TryResolveObjectArray(inputName, typeof(IForgeEntity), out object?[]? resolvedArray)) + { + var resolvedTargets = new List(resolvedArray.Length); + + for (int i = 0; i < resolvedArray.Length; i++) + { + if (resolvedArray[i] is IForgeEntity entity) + { + resolvedTargets.Add(entity); + } + } + + targets = resolvedTargets; + return resolvedTargets.Count > 0; + } + + if (graphContext.TryResolveObject(inputName, typeof(IForgeEntity), out object? resolved) + && resolved is IForgeEntity singleTarget) + { + targets = [singleTarget]; + return true; + } + + targets = []; + return false; + } + + private IForgeEntity? ResolveSource(GraphContext graphContext) + { + StringKey inputName = InputProperties[SourceInput].BoundName; + + return inputName != StringKey.Empty + && graphContext.TryResolveObject(inputName, typeof(IForgeEntity), out object? resolved) + ? resolved as IForgeEntity + : null; + } + + private float ResolveMagnitude(GraphContext graphContext) + { + StringKey inputName = InputProperties[MagnitudeInput].BoundName; + float magnitude = 0f; + + if (inputName != StringKey.Empty) + { + graphContext.TryResolve(inputName, out magnitude); + } + + return magnitude; + } + + private EventPayloadRaiser? ResolvePayloadRaiser(GraphContext graphContext) + { + StringKey inputName = InputProperties[PayloadInput].BoundName; + + return inputName != StringKey.Empty + && graphContext.TryResolveObject(inputName, typeof(EventPayloadRaiser), out object? resolved) + ? resolved as EventPayloadRaiser + : null; + } +} diff --git a/Forge/Statescript/Nodes/State/EventListenerNode.cs b/Forge/Statescript/Nodes/State/EventListenerNode.cs new file mode 100644 index 0000000..dca1a9c --- /dev/null +++ b/Forge/Statescript/Nodes/State/EventListenerNode.cs @@ -0,0 +1,260 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Statescript.Ports; +using Gamesmiths.Forge.Statescript.Properties; +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Statescript.Nodes.State; + +/// +/// Subscribes to one or more event tags on a chosen entity's while active and emits the +/// OnEvent port every time a matching event is raised. +/// +/// +/// The event-tag input accepts a single or an array of tags; the node subscribes to each. The +/// listen-on input selects the entity whose bus is observed. +/// On each matching event the node writes the optional built-in outputs (Source, Target, Event Magnitude) and, +/// when a payload provider is bound, decomposes into the provider's output variables, +/// then emits OnEvent. The node has no timer: it stays active until deactivated externally, unsubscribing on +/// deactivation. +/// When a payload provider is bound, the node subscribes through the provider's typed payload type +/// (), so generic raises are +/// received with no boxing and the typed payload is decomposed directly. When no provider is bound, the node subscribes +/// non-generically and is a catch-all: it also receives generic raises with the payload boxed into +/// . Either way the handler emits synchronously from the raise call. +/// +public class EventListenerNode : StateNode +{ + /// + /// Input property index for the event tag(s) to listen for. + /// + public const byte EventTagInput = 0; + + /// + /// Input property index for the entity whose event bus is observed. + /// + public const byte ListenOnInput = 1; + + /// + /// Input property index for the optional payload writer (decomposes received payloads into output variables). + /// + public const byte PayloadOutputInput = 2; + + /// + /// Output variable index for the event source entity. + /// + public const byte SourceOutput = 0; + + /// + /// Output variable index for the event target entity. + /// + public const byte TargetOutput = 1; + + /// + /// Output variable index for the event magnitude. + /// + public const byte MagnitudeOutput = 2; + + /// + /// Output port index for the per-event signal. + /// + public const byte OnEventPort = 4; + + /// + public override string Description => + "Listens for events while active and emits OnEvent each time a matching event fires."; + + /// + protected override void DefinePorts(List inputPorts, List outputPorts) + { + base.DefinePorts(inputPorts, outputPorts); + outputPorts.Add(CreatePort(OnEventPort, "OnEvent")); + } + + /// + protected override void DefineParameters(List inputProperties, List outputVariables) + { + inputProperties.Add(new InputProperty("Event Tags", typeof(Tag))); + inputProperties.Add(new InputProperty("Listen On", typeof(IForgeEntity))); + inputProperties.Add(new InputProperty("Payload", typeof(EventPayloadWriter))); + outputVariables.Add(new OutputVariable("Source", typeof(IForgeEntity))); + outputVariables.Add(new OutputVariable("Target", typeof(IForgeEntity))); + outputVariables.Add(new OutputVariable("Magnitude", typeof(float))); + } + + /// + protected override void OnActivate(GraphContext graphContext) + { + EventListenerNodeContext nodeContext = graphContext.GetNodeContext(NodeID); + nodeContext.Tokens.Clear(); + nodeContext.SubscribedManager = null; + nodeContext.PayloadWriter = null; + + EventManager? manager = ResolveListenManager(graphContext); + if (manager is null) + { + return; + } + + nodeContext.SubscribedManager = manager; + EventPayloadWriter? payloadWriter = ResolvePayloadWriter(graphContext); + nodeContext.PayloadWriter = payloadWriter; + + List tags = ResolveEventTags(graphContext); + for (int i = 0; i < tags.Count; i++) + { + Tag tag = tags[i]; + + // With a payload provider, subscribe through its typed (non-boxing) path; otherwise subscribe + // non-generically (a catch-all that also receives generic raises with the payload boxed). + EventSubscriptionToken token; + if (payloadWriter is not null) + { + token = payloadWriter.Subscribe( + manager, + tag, + graphContext, + (source, target, magnitude) => OnTypedEventReceived(graphContext, source, target, magnitude)); + } + else + { + token = manager.Subscribe(tag, data => OnEventReceived(data, graphContext)); + } + + nodeContext.Tokens.Add(token); + } + } + + /// + protected override void OnDeactivate(GraphContext graphContext) + { + EventListenerNodeContext nodeContext = graphContext.GetNodeContext(NodeID); + + if (nodeContext.SubscribedManager is not null) + { + for (int i = 0; i < nodeContext.Tokens.Count; i++) + { + nodeContext.SubscribedManager.Unsubscribe(nodeContext.Tokens[i]); + } + } + + nodeContext.Tokens.Clear(); + nodeContext.SubscribedManager = null; + nodeContext.PayloadWriter = null; + } + + private static void WriteEntityOutput(GraphContext graphContext, OutputVariable output, IForgeEntity? value) + { + if (output.BoundName == StringKey.Empty) + { + return; + } + + Variables? variables = output.Scope == VariableScope.Shared + ? graphContext.SharedVariables + : graphContext.GraphVariables; + variables?.SetObject(output.BoundName, value); + } + + private static void WriteMagnitudeOutput(GraphContext graphContext, OutputVariable output, float value) + { + if (output.BoundName == StringKey.Empty) + { + return; + } + + Variables? variables = output.Scope == VariableScope.Shared + ? graphContext.SharedVariables + : graphContext.GraphVariables; + + // Floating-point graph variables are double-backed, so widen the float magnitude before storing it. + variables?.SetVar(output.BoundName, (double)value); + } + + private void OnTypedEventReceived( + GraphContext graphContext, + IForgeEntity? source, + IForgeEntity? target, + float magnitude) + { + // The provider already decomposed the typed payload into its bound variables before this callback. + WriteBuiltInOutputsAndEmit(graphContext, source, target, magnitude); + } + + private void OnEventReceived(EventData data, GraphContext graphContext) + { + // Non-generic (provider-less) path: there is no provider to decompose the payload, so only the built-in fields + // are written. + WriteBuiltInOutputsAndEmit(graphContext, data.Source, data.Target, data.EventMagnitude); + } + + private void WriteBuiltInOutputsAndEmit( + GraphContext graphContext, + IForgeEntity? source, + IForgeEntity? target, + float magnitude) + { + if (!graphContext.HasNodeContext(NodeID)) + { + return; + } + + if (!graphContext.GetNodeContext(NodeID).Active) + { + return; + } + + WriteEntityOutput(graphContext, OutputVariables[SourceOutput], source); + WriteEntityOutput(graphContext, OutputVariables[TargetOutput], target); + WriteMagnitudeOutput(graphContext, OutputVariables[MagnitudeOutput], magnitude); + + OutputPorts[OnEventPort].EmitMessage(graphContext); + } + + private EventManager? ResolveListenManager(GraphContext graphContext) + { + StringKey inputName = InputProperties[ListenOnInput].BoundName; + + return inputName != StringKey.Empty + && graphContext.TryResolveObject(inputName, typeof(IForgeEntity), out object? resolved) + && resolved is IForgeEntity entity + ? entity.Events + : null; + } + + private List ResolveEventTags(GraphContext graphContext) + { + StringKey inputName = InputProperties[EventTagInput].BoundName; + var tags = new List(); + + if (graphContext.TryResolveObjectArray(inputName, typeof(Tag), out object?[]? resolvedArray)) + { + for (int i = 0; i < resolvedArray.Length; i++) + { + if (resolvedArray[i] is Tag tag) + { + tags.Add(tag); + } + } + } + else if (graphContext.TryResolveObject(inputName, typeof(Tag), out object? resolved) + && resolved is Tag singleTag) + { + tags.Add(singleTag); + } + + return tags; + } + + private EventPayloadWriter? ResolvePayloadWriter(GraphContext graphContext) + { + StringKey inputName = InputProperties[PayloadOutputInput].BoundName; + + return inputName != StringKey.Empty + && graphContext.TryResolveObject(inputName, typeof(EventPayloadWriter), out object? resolved) + ? resolved as EventPayloadWriter + : null; + } +} diff --git a/Forge/Statescript/Nodes/State/EventListenerNodeContext.cs b/Forge/Statescript/Nodes/State/EventListenerNodeContext.cs new file mode 100644 index 0000000..9f648f1 --- /dev/null +++ b/Forge/Statescript/Nodes/State/EventListenerNodeContext.cs @@ -0,0 +1,28 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Statescript.Properties; + +namespace Gamesmiths.Forge.Statescript.Nodes.State; + +/// +/// Runtime context for . +/// +public class EventListenerNodeContext : StateNodeContext +{ + /// + /// Gets the subscription tokens to release on deactivation. + /// + public List Tokens { get; } = []; + + /// + /// Gets the event bus this node is subscribed to while active. + /// + public EventManager? SubscribedManager { get; internal set; } + + /// + /// Gets the payload writer used to decompose received payloads into output variables, or + /// when no provider is bound. + /// + public EventPayloadWriter? PayloadWriter { get; internal set; } +} diff --git a/Forge/Statescript/Properties/EventPayloadOutputResolver.cs b/Forge/Statescript/Properties/EventPayloadOutputResolver.cs new file mode 100644 index 0000000..b0498be --- /dev/null +++ b/Forge/Statescript/Properties/EventPayloadOutputResolver.cs @@ -0,0 +1,23 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Statescript.Properties; + +/// +/// Object resolver that produces an for the event-listener node's optional payload +/// output. The listener resolves it once on activation and uses it to decompose each received payload into the bound +/// graph variables. +/// +/// The provider that decomposes the payload. +/// The output-name to graph-variable bindings authored on the listener node. +public class EventPayloadOutputResolver( + IEventPayloadProvider provider, + IReadOnlyDictionary bindings) : ObjectResolver +{ + private readonly EventPayloadWriter _writer = new(provider, bindings); + + /// + public override EventPayloadWriter Resolve(GraphContext graphContext) + { + return _writer; + } +} diff --git a/Forge/Statescript/Properties/EventPayloadRaiser.cs b/Forge/Statescript/Properties/EventPayloadRaiser.cs new file mode 100644 index 0000000..5ff2857 --- /dev/null +++ b/Forge/Statescript/Properties/EventPayloadRaiser.cs @@ -0,0 +1,45 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Statescript.Properties; + +/// +/// Builds and raises a typed event through an with no boxing. Created by +/// and used by RaiseEventNode to raise the provider's typed payload through +/// . +/// +/// The provider that builds the payload. +/// The resolvers for the provider's declared inputs, keyed by input name. May be +/// when the provider declares no inputs. +public sealed class EventPayloadRaiser( + IEventPayloadProvider provider, + IReadOnlyDictionary? inputResolvers) +{ + private static readonly Dictionary _noInputs = []; + + private readonly IEventPayloadProvider _provider = provider; + private readonly IReadOnlyDictionary _inputResolvers = inputResolvers ?? _noInputs; + + /// + /// Builds the payload from the current graph state and raises a typed event on . + /// + /// The event bus to raise on. + /// The event's tag container. + /// The optional event source. + /// The event target. + /// The event magnitude. + /// The graph execution context the payload is built from. + public void Raise( + EventManager manager, + TagContainer eventTags, + IForgeEntity? source, + IForgeEntity? target, + float magnitude, + GraphContext graphContext) + { + _provider.Raise(manager, eventTags, source, target, magnitude, graphContext, _inputResolvers); + } +} diff --git a/Forge/Statescript/Properties/EventPayloadResolver.cs b/Forge/Statescript/Properties/EventPayloadResolver.cs new file mode 100644 index 0000000..7c05374 --- /dev/null +++ b/Forge/Statescript/Properties/EventPayloadResolver.cs @@ -0,0 +1,28 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Statescript.Properties; + +/// +/// Object resolver that produces an for the optional payload input of +/// RaiseEventNode. The node uses the raiser to build and raise a typed (non-boxing) event through the bound +/// . +/// +/// +/// When the provider declares authored inputs, the matching resolve them on demand as +/// the payload is built. +/// +/// The provider that builds the payload from the graph state. +/// The resolvers for the provider's declared inputs, keyed by input name. May be +/// when the provider declares no inputs. +public class EventPayloadResolver( + IEventPayloadProvider provider, + IReadOnlyDictionary? inputResolvers = null) : ObjectResolver +{ + private readonly EventPayloadRaiser _raiser = new(provider, inputResolvers); + + /// + public override EventPayloadRaiser Resolve(GraphContext graphContext) + { + return _raiser; + } +} diff --git a/Forge/Statescript/Properties/EventPayloadWriter.cs b/Forge/Statescript/Properties/EventPayloadWriter.cs new file mode 100644 index 0000000..dccd830 --- /dev/null +++ b/Forge/Statescript/Properties/EventPayloadWriter.cs @@ -0,0 +1,52 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Tags; + +namespace Gamesmiths.Forge.Statescript.Properties; + +/// +/// Decomposes a received event payload into the listener node's bound graph variables by delegating to an +/// . Created by and used by +/// EventListenerNode, either to subscribe through the provider's typed (non-boxing) path or to write a payload +/// received from a non-generic raise. +/// +/// The provider that decomposes the payload. +/// The output-name to graph-variable bindings authored on the listener node. +public sealed class EventPayloadWriter( + IEventPayloadProvider provider, + IReadOnlyDictionary bindings) +{ + private readonly IEventPayloadProvider _provider = provider; + private readonly IReadOnlyDictionary _bindings = bindings; + + /// + /// Subscribes to through the provider's typed (non-boxing) event path, decomposing each + /// received payload into the bound graph variables and invoking with the event's + /// source, target, and magnitude. + /// + /// The event bus to subscribe to. + /// The tag to subscribe to. + /// The graph execution context whose variables receive the decomposed payload. + /// Invoked for each received event with its source, target, and magnitude. + /// The subscription token to release on deactivation. + public EventSubscriptionToken Subscribe( + EventManager manager, + Tag eventTag, + GraphContext graphContext, + Action onReceived) + { + return _provider.Subscribe(manager, eventTag, graphContext, _bindings, onReceived); + } + + /// + /// Writes the values of to the bound graph variables. + /// + /// The payload carried by the received event. + /// The graph execution context whose variables receive the values. + public void Write(object payload, GraphContext graphContext) + { + _provider.WriteOutputs(payload, new EventPayloadOutputs(graphContext, _bindings)); + } +} From 1cde2aeae106410f210bac46011b327ed2b04727 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 20 Jun 2026 21:11:52 -0300 Subject: [PATCH 3/7] Added event node and resolver tests. --- Forge.Tests/Helpers/StatescriptTestHelpers.cs | 20 ++ Forge.Tests/Helpers/TestEventPayload.cs | 9 + .../Helpers/TestEventPayloadProvider.cs | 36 ++++ .../Nodes/Action/RaiseEventNodeTests.cs | 154 +++++++++++++ .../Nodes/State/EventListenerNodeTests.cs | 203 ++++++++++++++++++ .../EventPayloadOutputResolverTests.cs | 92 ++++++++ .../Resolvers/EventPayloadResolverTests.cs | 65 ++++++ 7 files changed, 579 insertions(+) create mode 100644 Forge.Tests/Helpers/TestEventPayload.cs create mode 100644 Forge.Tests/Helpers/TestEventPayloadProvider.cs create mode 100644 Forge.Tests/Statescript/Nodes/Action/RaiseEventNodeTests.cs create mode 100644 Forge.Tests/Statescript/Nodes/State/EventListenerNodeTests.cs create mode 100644 Forge.Tests/Statescript/Resolvers/EventPayloadOutputResolverTests.cs create mode 100644 Forge.Tests/Statescript/Resolvers/EventPayloadResolverTests.cs diff --git a/Forge.Tests/Helpers/StatescriptTestHelpers.cs b/Forge.Tests/Helpers/StatescriptTestHelpers.cs index a837ea6..62acf6d 100644 --- a/Forge.Tests/Helpers/StatescriptTestHelpers.cs +++ b/Forge.Tests/Helpers/StatescriptTestHelpers.cs @@ -95,6 +95,26 @@ public static CueNode CreateCueNode( node.BindInput(CueNode.TargetInput, targetPropertyName); return node; } + + public static RaiseEventNode CreateRaiseEventNode( + StringKey eventTagPropertyName, + StringKey targetPropertyName) + { + var node = new RaiseEventNode(); + node.BindInput(RaiseEventNode.EventTagInput, eventTagPropertyName); + node.BindInput(RaiseEventNode.TargetInput, targetPropertyName); + return node; + } + + public static EventListenerNode CreateEventListenerNode( + StringKey eventTagPropertyName, + StringKey listenOnPropertyName) + { + var node = new EventListenerNode(); + node.BindInput(EventListenerNode.EventTagInput, eventTagPropertyName); + node.BindInput(EventListenerNode.ListenOnInput, listenOnPropertyName); + return node; + } } /// diff --git a/Forge.Tests/Helpers/TestEventPayload.cs b/Forge.Tests/Helpers/TestEventPayload.cs new file mode 100644 index 0000000..67eb676 --- /dev/null +++ b/Forge.Tests/Helpers/TestEventPayload.cs @@ -0,0 +1,9 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Tests.Helpers; + +/// +/// A test payload carried by an event in the event-node tests. +/// +/// An arbitrary value the provider builds from an input and writes back to an output. +internal sealed record TestEventPayload(int Amount); diff --git a/Forge.Tests/Helpers/TestEventPayloadProvider.cs b/Forge.Tests/Helpers/TestEventPayloadProvider.cs new file mode 100644 index 0000000..769da44 --- /dev/null +++ b/Forge.Tests/Helpers/TestEventPayloadProvider.cs @@ -0,0 +1,36 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Statescript; + +namespace Gamesmiths.Forge.Tests.Helpers; + +/// +/// A bidirectional test event-payload provider: it builds a from a declared +/// Amount input and writes the payload's amount back to a declared Amount output. Used by both the +/// raise-event and event-listener node tests. +/// +internal sealed class TestEventPayloadProvider : EventPayloadProvider +{ + /// + /// The name of the declared input and output this provider uses. + /// + public const string AmountKey = "Amount"; + + /// + public override IReadOnlyList Inputs => [new EventPayloadInput(AmountKey, typeof(int))]; + + /// + public override IReadOnlyList Outputs => [new EventPayloadOutput(AmountKey, typeof(int))]; + + /// + public override TestEventPayload CreatePayload(GraphContext graphContext, EventPayloadInputs inputs) + { + return new TestEventPayload(inputs.Get(AmountKey)); + } + + /// + public override void WriteOutputs(TestEventPayload payload, EventPayloadOutputs outputs) + { + outputs.Set(AmountKey, payload.Amount); + } +} diff --git a/Forge.Tests/Statescript/Nodes/Action/RaiseEventNodeTests.cs b/Forge.Tests/Statescript/Nodes/Action/RaiseEventNodeTests.cs new file mode 100644 index 0000000..1b285d4 --- /dev/null +++ b/Forge.Tests/Statescript/Nodes/Action/RaiseEventNodeTests.cs @@ -0,0 +1,154 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Cues; +using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Nodes; +using Gamesmiths.Forge.Statescript.Nodes.Action; +using Gamesmiths.Forge.Statescript.Properties; +using Gamesmiths.Forge.Tags; +using Gamesmiths.Forge.Tests.Helpers; + +using static Gamesmiths.Forge.Tests.Helpers.NodeBindings; + +namespace Gamesmiths.Forge.Tests.Statescript.Nodes.Action; + +public class RaiseEventNodeTests(TagsAndCuesFixture tagsAndCuesFixture) : IClassFixture +{ + private readonly TagsManager _tagsManager = tagsAndCuesFixture.TagsManager; + + [Fact] + [Trait("Graph", "RaiseEvent")] + public void Raise_event_node_raises_event_with_resolved_fields() + { + var cuesManager = new CuesManager(); + var target = new TestEntity(_tagsManager, cuesManager); + var source = new TestEntity(_tagsManager, cuesManager); + var eventTag = Tag.RequestTag(_tagsManager, "test.cue1"); + + EventData? captured = null; + target.Events.Subscribe(eventTag, data => captured = data); + + var graph = new Graph(); + graph.VariableDefinitions.DefineObjectVariable("eventTag", eventTag); + graph.VariableDefinitions.DefineObjectVariable("target", target); + graph.VariableDefinitions.DefineObjectVariable("source", source); + graph.VariableDefinitions.DefineVariable("magnitude", 25f); + + RaiseEventNode node = CreateRaiseEventNode("eventTag", "target"); + node.BindInput(RaiseEventNode.SourceInput, "source"); + node.BindInput(RaiseEventNode.MagnitudeInput, "magnitude"); + graph.AddNode(node); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + node.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + captured.Should().NotBeNull(); + captured!.Value.EventTags.HasTag(eventTag).Should().BeTrue(); + captured.Value.Source.Should().BeSameAs(source); + captured.Value.Target.Should().BeSameAs(target); + captured.Value.EventMagnitude.Should().Be(25f); + captured.Value.Payload.Should().BeNull(); + } + + [Fact] + [Trait("Graph", "RaiseEvent")] + public void Raise_event_node_combines_multiple_tags_into_one_event() + { + var cuesManager = new CuesManager(); + var target = new TestEntity(_tagsManager, cuesManager); + var firstTag = Tag.RequestTag(_tagsManager, "test.cue1"); + var secondTag = Tag.RequestTag(_tagsManager, "test.cue2"); + + EventData? captured = null; + target.Events.Subscribe(firstTag, data => captured = data); + + var graph = new Graph(); + graph.VariableDefinitions.DefineObjectArrayVariable("eventTag", firstTag, secondTag); + graph.VariableDefinitions.DefineObjectVariable("target", target); + + RaiseEventNode node = CreateRaiseEventNode("eventTag", "target"); + graph.AddNode(node); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + node.InputPorts[ActionNode.InputPort])); + + new GraphProcessor(graph).StartGraph(); + + captured.Should().NotBeNull(); + captured!.Value.EventTags.HasTag(firstTag).Should().BeTrue(); + captured.Value.EventTags.HasTag(secondTag).Should().BeTrue(); + } + + [Fact] + [Trait("Graph", "RaiseEvent")] + public void Raise_event_node_raises_on_every_target() + { + var cuesManager = new CuesManager(); + var firstTarget = new TestEntity(_tagsManager, cuesManager); + var secondTarget = new TestEntity(_tagsManager, cuesManager); + var eventTag = Tag.RequestTag(_tagsManager, "test.cue1"); + + bool firstFired = false; + bool secondFired = false; + firstTarget.Events.Subscribe(eventTag, _ => firstFired = true); + secondTarget.Events.Subscribe(eventTag, _ => secondFired = true); + + var graph = new Graph(); + graph.VariableDefinitions.DefineObjectVariable("eventTag", eventTag); + graph.VariableDefinitions.DefineObjectArrayVariable("target", firstTarget, secondTarget); + + RaiseEventNode node = CreateRaiseEventNode("eventTag", "target"); + graph.AddNode(node); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + node.InputPorts[ActionNode.InputPort])); + + new GraphProcessor(graph).StartGraph(); + + firstFired.Should().BeTrue(); + secondFired.Should().BeTrue(); + } + + [Fact] + [Trait("Graph", "RaiseEvent")] + public void Raise_event_node_raises_a_typed_event_with_the_provider_payload() + { + var cuesManager = new CuesManager(); + var target = new TestEntity(_tagsManager, cuesManager); + var eventTag = Tag.RequestTag(_tagsManager, "test.cue1"); + + // A typed subscriber receives the event only if the node raises through the typed path (with no boxing). + TestEventPayload? captured = null; + target.Events.Subscribe(eventTag, data => captured = data.Payload); + + var graph = new Graph(); + graph.VariableDefinitions.DefineObjectVariable("eventTag", eventTag); + graph.VariableDefinitions.DefineObjectVariable("target", target); + graph.VariableDefinitions.DefineObjectProperty( + "payload", + new EventPayloadResolver( + new TestEventPayloadProvider(), + new Dictionary + { + [TestEventPayloadProvider.AmountKey] = new VariantResolver(new Variant128(42), typeof(int)), + })); + + RaiseEventNode node = CreateRaiseEventNode("eventTag", "target"); + node.BindInput(RaiseEventNode.PayloadInput, "payload"); + graph.AddNode(node); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + node.InputPorts[ActionNode.InputPort])); + + new GraphProcessor(graph).StartGraph(); + + captured.Should().NotBeNull(); + captured!.Amount.Should().Be(42); + } +} diff --git a/Forge.Tests/Statescript/Nodes/State/EventListenerNodeTests.cs b/Forge.Tests/Statescript/Nodes/State/EventListenerNodeTests.cs new file mode 100644 index 0000000..e21d54e --- /dev/null +++ b/Forge.Tests/Statescript/Nodes/State/EventListenerNodeTests.cs @@ -0,0 +1,203 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Cues; +using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Nodes; +using Gamesmiths.Forge.Statescript.Nodes.State; +using Gamesmiths.Forge.Statescript.Properties; +using Gamesmiths.Forge.Tags; +using Gamesmiths.Forge.Tests.Helpers; + +using static Gamesmiths.Forge.Tests.Helpers.NodeBindings; + +namespace Gamesmiths.Forge.Tests.Statescript.Nodes.State; + +public class EventListenerNodeTests(TagsAndCuesFixture tagsAndCuesFixture) : IClassFixture +{ + private readonly TagsManager _tagsManager = tagsAndCuesFixture.TagsManager; + + [Fact] + [Trait("Graph", "EventListener")] + public void Listener_emits_on_event_and_writes_built_in_outputs_when_a_matching_event_fires() + { + var cuesManager = new CuesManager(); + var entity = new TestEntity(_tagsManager, cuesManager); + var source = new TestEntity(_tagsManager, cuesManager); + var eventTag = Tag.RequestTag(_tagsManager, "test.cue1"); + var tracking = new TrackingActionNode(); + + var graph = new Graph(); + graph.VariableDefinitions.DefineObjectVariable("eventTag", eventTag); + graph.VariableDefinitions.DefineObjectVariable("listenOn", entity); + graph.VariableDefinitions.DefineObjectVariable("sourceOut", null!); + graph.VariableDefinitions.DefineVariable("magOut", 0.0); + + EventListenerNode listener = CreateEventListenerNode("eventTag", "listenOn"); + listener.BindOutput(EventListenerNode.SourceOutput, "sourceOut", VariableScope.Graph); + listener.BindOutput(EventListenerNode.MagnitudeOutput, "magOut", VariableScope.Graph); + graph.AddNode(listener); + graph.AddNode(tracking); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + listener.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + listener.OutputPorts[EventListenerNode.OnEventPort], + tracking.InputPorts[ActionNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + tracking.ExecutionCount.Should().Be(0); + + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Source = source, + Target = entity, + EventMagnitude = 5f, + }); + + tracking.ExecutionCount.Should().Be(1); + processor.GraphContext.GraphVariables.TryGetObject("sourceOut", out IForgeEntity? capturedSource) + .Should().BeTrue(); + capturedSource.Should().BeSameAs(source); + processor.GraphContext.GraphVariables.TryGetVar("magOut", out double magnitude).Should().BeTrue(); + magnitude.Should().Be(5.0); + } + + [Fact] + [Trait("Graph", "EventListener")] + public void Listener_writes_payload_outputs_through_the_provider() + { + var cuesManager = new CuesManager(); + var entity = new TestEntity(_tagsManager, cuesManager); + var eventTag = Tag.RequestTag(_tagsManager, "test.cue1"); + + var graph = new Graph(); + graph.VariableDefinitions.DefineObjectVariable("eventTag", eventTag); + graph.VariableDefinitions.DefineObjectVariable("listenOn", entity); + graph.VariableDefinitions.DefineVariable("amountOut", 0); + graph.VariableDefinitions.DefineObjectProperty( + "payloadOut", + new EventPayloadOutputResolver( + new TestEventPayloadProvider(), + new Dictionary + { + [TestEventPayloadProvider.AmountKey] = new EventOutputBinding("amountOut", VariableScope.Graph), + })); + + EventListenerNode listener = CreateEventListenerNode("eventTag", "listenOn"); + listener.BindInput(EventListenerNode.PayloadOutputInput, "payloadOut"); + graph.AddNode(listener); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + listener.InputPorts[StateNode.InputPort])); + + var processor = new GraphProcessor(graph); + processor.StartGraph(); + + // The provider is typed, so the listener subscribes through the typed path; raise a typed event so the payload + // is delivered without boxing. + entity.Events.Raise(new EventData + { + EventTags = eventTag.GetSingleTagContainer()!, + Target = entity, + Payload = new TestEventPayload(7), + }); + + processor.GraphContext.GraphVariables.TryGetVar("amountOut", out int amount).Should().BeTrue(); + amount.Should().Be(7); + } + + [Fact] + [Trait("Graph", "EventListener")] + public void Listener_stops_receiving_events_after_deactivation() + { + ListenerGraph listenerGraph = BuildListenerGraph(); + + listenerGraph.Processor.StartGraph(); + listenerGraph.Entity.Events.Raise(new EventData + { + EventTags = listenerGraph.EventTag.GetSingleTagContainer()!, + Target = listenerGraph.Entity, + }); + listenerGraph.Tracking.ExecutionCount.Should().Be(1); + + listenerGraph.Processor.StopGraph(); + listenerGraph.Entity.Events.Raise(new EventData + { + EventTags = listenerGraph.EventTag.GetSingleTagContainer()!, + Target = listenerGraph.Entity, + }); + + listenerGraph.Tracking.ExecutionCount.Should().Be(1, "the listener unsubscribes on deactivation"); + } + + [Fact] + [Trait("Graph", "EventListener")] + public void Listener_ignores_events_with_non_matching_tags() + { + ListenerGraph listenerGraph = BuildListenerGraph(); + var otherTag = Tag.RequestTag(_tagsManager, "test.cue2"); + + listenerGraph.Processor.StartGraph(); + listenerGraph.Entity.Events.Raise(new EventData + { + EventTags = otherTag.GetSingleTagContainer()!, + Target = listenerGraph.Entity, + }); + + listenerGraph.Tracking.ExecutionCount.Should().Be(0); + } + + [Fact] + [Trait("Graph", "EventListener")] + public void Listener_receives_generic_events_as_a_catch_all_subscriber() + { + ListenerGraph listenerGraph = BuildListenerGraph(); + + listenerGraph.Processor.StartGraph(); + + // Generic (typed) raise, like DamageExecution's Raise; the non-generic listener still catches it. + listenerGraph.Entity.Events.Raise(new EventData + { + EventTags = listenerGraph.EventTag.GetSingleTagContainer()!, + Target = listenerGraph.Entity, + Payload = 7, + }); + + listenerGraph.Tracking.ExecutionCount.Should().Be(1); + } + + private ListenerGraph BuildListenerGraph() + { + var cuesManager = new CuesManager(); + var entity = new TestEntity(_tagsManager, cuesManager); + var eventTag = Tag.RequestTag(_tagsManager, "test.cue1"); + var tracking = new TrackingActionNode(); + + var graph = new Graph(); + graph.VariableDefinitions.DefineObjectVariable("eventTag", eventTag); + graph.VariableDefinitions.DefineObjectVariable("listenOn", entity); + + EventListenerNode listener = CreateEventListenerNode("eventTag", "listenOn"); + graph.AddNode(listener); + graph.AddNode(tracking); + graph.AddConnection(new Connection( + graph.EntryNode.OutputPorts[EntryNode.OutputPort], + listener.InputPorts[StateNode.InputPort])); + graph.AddConnection(new Connection( + listener.OutputPorts[EventListenerNode.OnEventPort], + tracking.InputPorts[ActionNode.InputPort])); + + return new ListenerGraph(new GraphProcessor(graph), entity, eventTag, tracking); + } + + private readonly record struct ListenerGraph( + GraphProcessor Processor, + TestEntity Entity, + Tag EventTag, + TrackingActionNode Tracking); +} diff --git a/Forge.Tests/Statescript/Resolvers/EventPayloadOutputResolverTests.cs b/Forge.Tests/Statescript/Resolvers/EventPayloadOutputResolverTests.cs new file mode 100644 index 0000000..77641ba --- /dev/null +++ b/Forge.Tests/Statescript/Resolvers/EventPayloadOutputResolverTests.cs @@ -0,0 +1,92 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Properties; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Statescript.Resolvers; + +public class EventPayloadOutputResolverTests +{ + [Fact] + [Trait("Resolver", "EventPayloadOutput")] + public void Writer_writes_payload_fields_to_bound_variables() + { + var context = new GraphContext(); + context.GraphVariables.DefineVariable("amountVar", 0); + var resolver = new EventPayloadOutputResolver( + new TestEventPayloadProvider(), + new Dictionary + { + [TestEventPayloadProvider.AmountKey] = new EventOutputBinding("amountVar", VariableScope.Graph), + }); + + EventPayloadWriter writer = resolver.Resolve(context); + writer.Write(new TestEventPayload(7), context); + + context.GraphVariables.TryGetVar("amountVar", out int amount).Should().BeTrue(); + amount.Should().Be(7); + } + + [Fact] + [Trait("Resolver", "EventPayloadOutput")] + public void Writer_skips_outputs_with_no_binding() + { + var context = new GraphContext(); + context.GraphVariables.DefineVariable("amountVar", 99); + var resolver = new EventPayloadOutputResolver( + new TestEventPayloadProvider(), + new Dictionary()); + + resolver.Resolve(context).Write(new TestEventPayload(7), context); + + context.GraphVariables.TryGetVar("amountVar", out int amount).Should().BeTrue(); + amount.Should().Be(99); + } + + [Fact] + [Trait("Resolver", "EventPayloadOutput")] + public void Resolver_value_type_is_the_payload_writer() + { + var resolver = new EventPayloadOutputResolver( + new TestEventPayloadProvider(), + new Dictionary()); + + resolver.ValueType.Should().Be(typeof(EventPayloadWriter)); + } + + [Fact] + [Trait("Resolver", "EventPayloadOutput")] + public void Writer_widens_float_outputs_to_double() + { + var context = new GraphContext(); + context.GraphVariables.DefineVariable("floatVar", 0.0); + var resolver = new EventPayloadOutputResolver( + new FloatPayloadProvider(), + new Dictionary + { + ["Value"] = new EventOutputBinding("floatVar", VariableScope.Graph), + }); + + resolver.Resolve(context).Write(11.0f, context); + + context.GraphVariables.TryGetVar("floatVar", out double value).Should().BeTrue(); + value.Should().Be(11.0); + } + + private sealed class FloatPayloadProvider : EventPayloadProvider + { + public override IReadOnlyList Outputs => [new EventPayloadOutput("Value", typeof(float))]; + + public override float CreatePayload(GraphContext graphContext, EventPayloadInputs inputs) + { + return 0f; + } + + public override void WriteOutputs(float payload, EventPayloadOutputs outputs) + { + outputs.Set("Value", payload); + } + } +} diff --git a/Forge.Tests/Statescript/Resolvers/EventPayloadResolverTests.cs b/Forge.Tests/Statescript/Resolvers/EventPayloadResolverTests.cs new file mode 100644 index 0000000..b31166c --- /dev/null +++ b/Forge.Tests/Statescript/Resolvers/EventPayloadResolverTests.cs @@ -0,0 +1,65 @@ +// Copyright © Gamesmiths Guild. + +using FluentAssertions; +using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Properties; +using Gamesmiths.Forge.Tags; +using Gamesmiths.Forge.Tests.Helpers; + +namespace Gamesmiths.Forge.Tests.Statescript.Resolvers; + +public class EventPayloadResolverTests(TagsAndCuesFixture tagsAndCuesFixture) : IClassFixture +{ + private readonly TagsManager _tagsManager = tagsAndCuesFixture.TagsManager; + + [Fact] + [Trait("Resolver", "EventPayload")] + public void Resolver_value_type_is_the_payload_raiser() + { + var resolver = new EventPayloadResolver(new TestEventPayloadProvider()); + + resolver.ValueType.Should().Be(typeof(EventPayloadRaiser)); + } + + [Fact] + [Trait("Resolver", "EventPayload")] + public void Raiser_raises_a_typed_event_with_the_payload_built_from_declared_inputs() + { + var context = new GraphContext(); + EventPayloadRaiser raiser = new EventPayloadResolver( + new TestEventPayloadProvider(), + new Dictionary + { + [TestEventPayloadProvider.AmountKey] = new VariantResolver(new Variant128(42), typeof(int)), + }).Resolve(context); + + var manager = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "test.cue1"); + TestEventPayload? captured = null; + manager.Subscribe(eventTag, data => captured = data.Payload); + + raiser.Raise(manager, eventTag.GetSingleTagContainer()!, null, null, 0f, context); + + captured.Should().NotBeNull(); + captured!.Amount.Should().Be(42); + } + + [Fact] + [Trait("Resolver", "EventPayload")] + public void Raiser_uses_default_input_value_when_no_resolver_is_bound() + { + var context = new GraphContext(); + EventPayloadRaiser raiser = new EventPayloadResolver(new TestEventPayloadProvider()).Resolve(context); + + var manager = new EventManager(); + var eventTag = Tag.RequestTag(_tagsManager, "test.cue1"); + TestEventPayload? captured = null; + manager.Subscribe(eventTag, data => captured = data.Payload); + + raiser.Raise(manager, eventTag.GetSingleTagContainer()!, null, null, 0f, context); + + captured.Should().NotBeNull(); + captured!.Amount.Should().Be(0); + } +} From ae0a27d02b4d80bb88fa4991dbcbc80849351342 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 20 Jun 2026 21:56:48 -0300 Subject: [PATCH 4/7] Added EventPayloadResolver docs --- docs/statescript/resolvers/README.md | 11 +++ .../resolvers/event-payload-resolver.md | 87 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 docs/statescript/resolvers/event-payload-resolver.md diff --git a/docs/statescript/resolvers/README.md b/docs/statescript/resolvers/README.md index fe616d4..e609328 100644 --- a/docs/statescript/resolvers/README.md +++ b/docs/statescript/resolvers/README.md @@ -48,6 +48,17 @@ These resolvers author the optional inputs of the cue nodes (`ExecuteCueNode`, ` --- +## Event Resolvers + +These resolvers author the optional payload of the event nodes (`RaiseEventNode`, `EventListenerNode`) via an `IEventPayloadProvider`. + +| Resolver | Output Type | Description | +|----------|-------------|-------------| +| [EventPayloadOutputResolver](event-payload-resolver.md#listener-side-eventpayloadoutputresolver) | `EventPayloadWriter` | Decomposes a received event payload into graph variables for `EventListenerNode` via an `IEventPayloadProvider`. | +| [EventPayloadResolver](event-payload-resolver.md#raise-side-eventpayloadresolver) | `EventPayloadRaiser` | Builds and raises a typed event payload for `RaiseEventNode` via an `IEventPayloadProvider`. | + +--- + ## Entity Resolvers Entity resolvers are typed object-backed resolvers used by APIs such as `AttributeResolver` and `TagQueryResolver`. They do diff --git a/docs/statescript/resolvers/event-payload-resolver.md b/docs/statescript/resolvers/event-payload-resolver.md new file mode 100644 index 0000000..f248ebd --- /dev/null +++ b/docs/statescript/resolvers/event-payload-resolver.md @@ -0,0 +1,87 @@ +# Event Payload Resolvers + +> **Types:** `Gamesmiths.Forge.Statescript.Properties.EventPayloadResolver` (raise side) and +> `Gamesmiths.Forge.Statescript.Properties.EventPayloadOutputResolver` (listener side) +> **Output Types:** `EventPayloadRaiser` (raise side) / `EventPayloadWriter` (listener side) + +Authors the custom typed payload of an event for the event nodes. A single `IEventPayloadProvider` serves both directions: + +- On [RaiseEventNode](../nodes/action/raise-event-node.md), an `EventPayloadResolver` produces an `EventPayloadRaiser` that calls the provider's `CreatePayload` to build the payload and raises a typed (non-boxing) `EventData`. +- On [EventListenerNode](../nodes/state/event-listener-node.md), an `EventPayloadOutputResolver` produces an `EventPayloadWriter` that calls the provider's `WriteOutputs` to decompose a received payload into bound graph variables. + +Both directions go through the typed `Raise` / `Subscribe` path, so the payload is never boxed. (A provider-less listener instead uses the non-generic catch-all and receives generic raises with the payload boxed into `EventData.Payload`.) + +This is the event-side analog of [EffectContextDataResolver](effect-context-data-resolver.md) and [CueCustomParametersResolver](cue-custom-parameters-resolver.md), extended with the decompose-to-outputs direction the listener needs. + +## Defining a provider + +Derive from `EventPayloadProvider` and override `CreatePayload` (build) and `WriteOutputs` (decompose). Declare `Inputs` to author values on the raise node, and `Outputs` to bind graph variables on the listener node. + +```csharp +public sealed record HitEventPayload(int Damage, bool IsCritical); + +public sealed class HitEventPayloadProvider : EventPayloadProvider +{ + public override IReadOnlyList Inputs => + [new EventPayloadInput("Damage", typeof(int)), new EventPayloadInput("IsCritical", typeof(bool))]; + + public override IReadOnlyList Outputs => + [new EventPayloadOutput("Damage", typeof(int)), new EventPayloadOutput("IsCritical", typeof(bool))]; + + public override HitEventPayload CreatePayload(GraphContext graphContext, EventPayloadInputs inputs) + { + return new HitEventPayload(inputs.Get("Damage"), inputs.Get("IsCritical")); + } + + public override void WriteOutputs(HitEventPayload payload, EventPayloadOutputs outputs) + { + outputs.Set("Damage", payload.Damage); + outputs.Set("IsCritical", payload.IsCritical); + } +} +``` + +`EventPayloadInputs.Get` reads a declared input (`default` when unbound); `EventPayloadOutputs.Set` writes an unmanaged value, and `SetObject` writes a reference value, to the variable bound to the named output (skipped when the output has no binding). Declared input/output value types must be supported by `Variant128`; a provider that needs object-lane values can read them directly from `graphContext` (build) or write them with `SetObject` (decompose). + +## Raise side: `EventPayloadResolver` + +> **Output Type:** `EventPayloadRaiser`, bind to the `Payload` input of [RaiseEventNode](../nodes/action/raise-event-node.md). + +`EventPayloadResolver.Resolve` returns an `EventPayloadRaiser`; for each target the node calls `raiser.Raise(...)`, which builds the payload via `provider.CreatePayload(graphContext, inputs)` and raises a typed `EventData` (`EventManager.Raise`), no boxing. + +```csharp +graph.VariableDefinitions.DefineObjectProperty( + "hitPayload", + new EventPayloadResolver(new HitEventPayloadProvider())); + +var raiseEvent = new RaiseEventNode(); +raiseEvent.BindInput(RaiseEventNode.PayloadInput, "hitPayload"); +``` + +## Listener side: `EventPayloadOutputResolver` + +> **Output Type:** `EventPayloadWriter`, bind to the `Payload` input of [EventListenerNode](../nodes/state/event-listener-node.md). + +`EventPayloadOutputResolver.Resolve` returns an `EventPayloadWriter`; the listener subscribes through the provider's typed `Subscribe` path, and the writer invokes `provider.WriteOutputs` against the authored output-to-variable bindings on each matching event. + +```csharp +graph.VariableDefinitions.DefineObjectProperty( + "hitPayloadOut", + new EventPayloadOutputResolver( + new HitEventPayloadProvider(), + new Dictionary + { + ["Damage"] = new EventOutputBinding("lastDamage", VariableScope.Graph), + })); + +var listener = new EventListenerNode(); +listener.BindInput(EventListenerNode.PayloadOutputInput, "hitPayloadOut"); +``` + +## See Also + +- [Resolvers Overview](README.md) +- [EffectContextDataResolver](effect-context-data-resolver.md) +- [CueCustomParametersResolver](cue-custom-parameters-resolver.md) +- [RaiseEventNode](../nodes/action/raise-event-node.md) +- [EventListenerNode](../nodes/state/event-listener-node.md) From c1766553839b461c859eeeab7f1a76f82f1fef2e Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 20 Jun 2026 22:04:45 -0300 Subject: [PATCH 5/7] Added event node docs --- docs/statescript/nodes/action/README.md | 1 + .../nodes/action/raise-event-node.md | 69 ++++++++++++++++++ docs/statescript/nodes/state/README.md | 1 + .../nodes/state/event-listener-node.md | 72 +++++++++++++++++++ 4 files changed, 143 insertions(+) create mode 100644 docs/statescript/nodes/action/raise-event-node.md create mode 100644 docs/statescript/nodes/state/event-listener-node.md diff --git a/docs/statescript/nodes/action/README.md b/docs/statescript/nodes/action/README.md index 575df74..c56493a 100644 --- a/docs/statescript/nodes/action/README.md +++ b/docs/statescript/nodes/action/README.md @@ -55,5 +55,6 @@ public class ApplyEffectActionNode : ActionNode |------|-------------| | [ApplyEffectNode](apply-effect-node.md) | Applies one or more effects to one or more targets. | | [ExecuteCueNode](execute-cue-node.md) | Executes one or more one-shot cues on one or more targets. | +| [RaiseEventNode](raise-event-node.md) | Raises an event on one or more target entities' event buses. | | [SetVariableNode](set-variable-node.md) | Copies a value from an input property to a graph or shared variable. | | [UpdateCueNode](update-cue-node.md) | Updates one or more active cues on one or more targets. | diff --git a/docs/statescript/nodes/action/raise-event-node.md b/docs/statescript/nodes/action/raise-event-node.md new file mode 100644 index 0000000..1c6ebf2 --- /dev/null +++ b/docs/statescript/nodes/action/raise-event-node.md @@ -0,0 +1,69 @@ +# RaiseEventNode + +> **Type:** Action Node +> **Class:** `Gamesmiths.Forge.Statescript.Nodes.Action.RaiseEventNode` + +Raises an event (`EventManager.Raise`) on one or more target entities' event buses, then continues execution. Events do not persist, so the raise side is fire-and-forget; use [EventListenerNode](../state/event-listener-node.md) to react to them. + +## Ports + +**Input Ports:** + +| Index | Name | Description | +|-------|------|-------------| +| 0 | Input | Triggers the action. | + +**Output Ports:** + +| Index | Name | Type | Description | +|-------|------|------|-------------| +| 0 | Output | Event | Emits after the event is raised. | + +## Parameters + +**Input Properties:** + +| Index | Label | Type | Description | +|-------|-------|------|-------------| +| 0 | Event Tags | `Tag` or `Tag[]` | The tag(s) combined into the event's `EventData.EventTags` container. | +| 1 | Target | `IForgeEntity` or `IForgeEntity[]` | The entity or entities whose `Events` bus the event is raised on. | +| 2 | Source | `IForgeEntity` | Optional. The source entity carried in `EventData.Source`. | +| 3 | Magnitude | `float` | Optional. The `EventData.EventMagnitude`. | +| 4 | Payload | `EventPayloadRaiser` | Optional. A typed payload built and raised by an `IEventPayloadProvider` (see [EventPayloadResolver](../../resolvers/event-payload-resolver.md)). | + +This node has no output variables. + +## Behavior + +1. Resolves the **Event Tags** input as a single `Tag` or an array of tags and combines them into one `TagContainer`. +2. Resolves the **Target** input as a single `IForgeEntity` or an array of entities. +3. Resolves the optional **Source**, **Magnitude**, and **Payload** inputs once and shares them across all targets. +4. For each target, raises one event through that target's `IForgeEntity.Events` bus. When a payload provider is bound, it raises a typed `EventData` (`EventManager.Raise`, no boxing); otherwise a non-generic `EventData` with no payload. A single raise carries all selected tags, so a subscriber to any matching tag is notified once. + +When a payload provider is bound the node raises through the typed `Raise` path, so typed (`Subscribe`) listeners (including an [EventListenerNode](../state/event-listener-node.md) with the same provider) receive the payload with no boxing. Non-generic subscribers still receive it (with the payload boxed) via the event manager's catch-all forwarding. + +## Usage + +```csharp +graph.VariableDefinitions.DefineObjectVariable("hitEvent", Tag.RequestTag(tagsManager, "event.hit")); +graph.VariableDefinitions.DefineObjectVariable("target", target); + +var raiseEvent = new RaiseEventNode(); +raiseEvent.BindInput(RaiseEventNode.EventTagInput, "hitEvent"); +raiseEvent.BindInput(RaiseEventNode.TargetInput, "target"); +``` + +To attach a payload, bind the optional payload input to an `EventPayloadResolver`: + +```csharp +graph.VariableDefinitions.DefineObjectProperty( + "hitPayload", + new EventPayloadResolver(new HitEventPayloadProvider())); +raiseEvent.BindInput(RaiseEventNode.PayloadInput, "hitPayload"); +``` + +## See Also + +- [Action Nodes Overview](README.md) +- [EventListenerNode](../state/event-listener-node.md) +- [EventPayloadResolver](../../resolvers/event-payload-resolver.md) diff --git a/docs/statescript/nodes/state/README.md b/docs/statescript/nodes/state/README.md index c560bb7..81f1d67 100644 --- a/docs/statescript/nodes/state/README.md +++ b/docs/statescript/nodes/state/README.md @@ -96,4 +96,5 @@ That label becomes the canonical port name surfaced by editor integrations such |------|-------------| | [CueNode](cue-node.md) | Applies cues on activation and removes them on deactivation, with an optional interrupted flag. | | [EffectNode](effect-node.md) | Applies effects on activation, emits OnEffectEnd on natural completion, and removes still-active instances on deactivation. | +| [EventListenerNode](event-listener-node.md) | Listens for events while active and emits OnEvent each time a matching event fires. | | [TimerNode](timer-node.md) | Remains active for a configured duration and emits OnTimerEnd when it finishes naturally. | diff --git a/docs/statescript/nodes/state/event-listener-node.md b/docs/statescript/nodes/state/event-listener-node.md new file mode 100644 index 0000000..38b4ae8 --- /dev/null +++ b/docs/statescript/nodes/state/event-listener-node.md @@ -0,0 +1,72 @@ +# EventListenerNode + +> **Type:** State Node +> **Class:** `Gamesmiths.Forge.Statescript.Nodes.State.EventListenerNode` +> **Context:** `EventListenerNodeContext` + +Subscribes to one or more event tags on a chosen entity's event bus while active and emits the **OnEvent** port every time a matching event is raised. It writes the event's data to graph variables and unsubscribes on deactivation. + +## Ports + +**Input Ports:** + +| Index | Name | Description | +|-------|------|-------------| +| 0 | Input | Activates the state node (subscribes). | +| 1 | Abort | Forcefully deactivates and fires OnAbort (unsubscribes). | + +**Output Ports:** + +| Index | Name | Type | Description | +|-------|------|------|-------------| +| 0 | OnActivate | Event | Emits when the node activates. | +| 1 | OnDeactivate | Event | Emits when the node deactivates (any reason). | +| 2 | OnAbort | Event | Emits only when aborted via the Abort port. | +| 3 | Subgraph | Subgraph | Remains active while the node is active. | +| 4 | OnEvent | Event | Emits each time a subscribed event is raised. | + +## Parameters + +**Input Properties:** + +| Index | Label | Type | Description | +|-------|-------|------|-------------| +| 0 | Event Tags | `Tag` or `Tag[]` | The tag(s) to subscribe to. The node subscribes to each. | +| 1 | Listen On | `IForgeEntity` | The entity whose `Events` bus is observed. | +| 2 | Payload | `EventPayloadWriter` | Optional. Decomposes the received `EventData.Payload` into graph variables via an `IEventPayloadProvider` (see [EventPayloadResolver](../../resolvers/event-payload-resolver.md)). | + +**Output Variables:** + +| Index | Label | Type | Description | +|-------|-------|------|-------------| +| 0 | Source | `IForgeEntity` | The received `EventData.Source`. | +| 1 | Target | `IForgeEntity` | The received `EventData.Target`. | +| 2 | Magnitude | `float` | The received `EventData.EventMagnitude`. | + +## Behavior + +1. On activation, resolves the **Listen On** entity and subscribes a handler to each resolved **Event Tag** on that entity's `IForgeEntity.Events` bus, storing the subscription tokens in `EventListenerNodeContext`. When a payload provider is bound, the subscription uses the provider's typed payload type (`EventManager.Subscribe`), so generic raises are received with **no boxing** and the typed payload is decomposed directly. When no provider is bound, the subscription is non-generic and acts as a catch-all that also receives generic raises with the payload boxed into `EventData.Payload`. +2. Each time a matching event is raised, the handler writes the bound built-in outputs (Source, Target, Magnitude), decomposes the payload into the provider's bound graph variables (when a payload provider is bound and a payload is present), and emits the **OnEvent** port. +3. The node has **no timer**: it stays active until deactivated externally (typically as a subgraph of another state node). On deactivation it unsubscribes every stored token. + +> The handler emits synchronously from the `EventManager.Raise` call. Keep downstream graphs of **OnEvent** lightweight, and be aware that deactivating the listener from within its own `OnEvent` reaction re-enters the event manager's dispatch. + +## Usage + +```csharp +graph.VariableDefinitions.DefineObjectVariable("hitEvent", Tag.RequestTag(tagsManager, "event.hit")); +graph.VariableDefinitions.DefineObjectVariable("self", owner); + +var listener = new EventListenerNode(); +listener.BindInput(EventListenerNode.EventTagInput, "hitEvent"); +listener.BindInput(EventListenerNode.ListenOnInput, "self"); +listener.BindOutput(EventListenerNode.MagnitudeOutput, "lastHitMagnitude", VariableScope.Graph); +``` + +Place the node as a subgraph of another state node so it lives for that state's duration, and route the **OnEvent** port into the reaction you want each time the event fires. + +## See Also + +- [State Nodes Overview](README.md) +- [RaiseEventNode](../action/raise-event-node.md) +- [EventPayloadResolver](../../resolvers/event-payload-resolver.md) From f3d0c981a62ddda4b7073eaa9a89e07afd56af10 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 20 Jun 2026 23:40:38 -0300 Subject: [PATCH 6/7] Moved providers to their own namespace --- Forge.Tests/Helpers/TestCueCustomParametersProvider.cs | 1 + Forge.Tests/Helpers/TestEventPayloadProvider.cs | 1 + Forge.Tests/Statescript/Nodes/Action/ApplyEffectNodeTests.cs | 1 + Forge.Tests/Statescript/Nodes/State/EffectNodeTests.cs | 1 + Forge.Tests/Statescript/Nodes/State/EventListenerNodeTests.cs | 1 + .../Statescript/Resolvers/CueCustomParametersResolverTests.cs | 1 + .../Statescript/Resolvers/EffectContextDataResolverTests.cs | 1 + .../Statescript/Resolvers/EventPayloadOutputResolverTests.cs | 1 + Forge/Statescript/Nodes/Action/ApplyEffectNode.cs | 4 ++-- Forge/Statescript/Nodes/State/EffectNode.cs | 4 ++-- Forge/Statescript/Properties/CueCustomParametersResolver.cs | 1 + Forge/Statescript/Properties/EffectContextDataResolver.cs | 1 + Forge/Statescript/Properties/EventPayloadOutputResolver.cs | 2 ++ Forge/Statescript/Properties/EventPayloadRaiser.cs | 1 + Forge/Statescript/Properties/EventPayloadResolver.cs | 2 ++ Forge/Statescript/Properties/EventPayloadWriter.cs | 1 + Forge/Statescript/{ => Providers}/CueCustomParameterInput.cs | 2 +- Forge/Statescript/{ => Providers}/CueCustomParameterInputs.cs | 2 +- .../{ => Providers}/CueCustomParametersProvider.cs | 2 +- Forge/Statescript/{ => Providers}/EffectContextDataInput.cs | 2 +- Forge/Statescript/{ => Providers}/EffectContextDataInputs.cs | 2 +- .../Statescript/{ => Providers}/EffectContextDataProvider.cs | 2 +- Forge/Statescript/{ => Providers}/EventOutputBinding.cs | 2 +- Forge/Statescript/{ => Providers}/EventPayloadInput.cs | 2 +- Forge/Statescript/{ => Providers}/EventPayloadInputs.cs | 2 +- Forge/Statescript/{ => Providers}/EventPayloadOutput.cs | 2 +- Forge/Statescript/{ => Providers}/EventPayloadOutputs.cs | 2 +- Forge/Statescript/{ => Providers}/EventPayloadProvider.cs | 2 +- .../{ => Providers}/ICueCustomParametersProvider.cs | 2 +- .../Statescript/{ => Providers}/IEffectContextDataProvider.cs | 2 +- Forge/Statescript/{ => Providers}/IEventPayloadProvider.cs | 2 +- 31 files changed, 35 insertions(+), 19 deletions(-) rename Forge/Statescript/{ => Providers}/CueCustomParameterInput.cs (94%) rename Forge/Statescript/{ => Providers}/CueCustomParameterInputs.cs (97%) rename Forge/Statescript/{ => Providers}/CueCustomParametersProvider.cs (96%) rename Forge/Statescript/{ => Providers}/EffectContextDataInput.cs (94%) rename Forge/Statescript/{ => Providers}/EffectContextDataInputs.cs (97%) rename Forge/Statescript/{ => Providers}/EffectContextDataProvider.cs (97%) rename Forge/Statescript/{ => Providers}/EventOutputBinding.cs (89%) rename Forge/Statescript/{ => Providers}/EventPayloadInput.cs (94%) rename Forge/Statescript/{ => Providers}/EventPayloadInputs.cs (97%) rename Forge/Statescript/{ => Providers}/EventPayloadOutput.cs (94%) rename Forge/Statescript/{ => Providers}/EventPayloadOutputs.cs (98%) rename Forge/Statescript/{ => Providers}/EventPayloadProvider.cs (98%) rename Forge/Statescript/{ => Providers}/ICueCustomParametersProvider.cs (97%) rename Forge/Statescript/{ => Providers}/IEffectContextDataProvider.cs (97%) rename Forge/Statescript/{ => Providers}/IEventPayloadProvider.cs (98%) diff --git a/Forge.Tests/Helpers/TestCueCustomParametersProvider.cs b/Forge.Tests/Helpers/TestCueCustomParametersProvider.cs index 4c81aa0..d05c662 100644 --- a/Forge.Tests/Helpers/TestCueCustomParametersProvider.cs +++ b/Forge.Tests/Helpers/TestCueCustomParametersProvider.cs @@ -2,6 +2,7 @@ using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Providers; namespace Gamesmiths.Forge.Tests.Helpers; diff --git a/Forge.Tests/Helpers/TestEventPayloadProvider.cs b/Forge.Tests/Helpers/TestEventPayloadProvider.cs index 769da44..5df7a41 100644 --- a/Forge.Tests/Helpers/TestEventPayloadProvider.cs +++ b/Forge.Tests/Helpers/TestEventPayloadProvider.cs @@ -1,6 +1,7 @@ // Copyright © Gamesmiths Guild. using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Providers; namespace Gamesmiths.Forge.Tests.Helpers; diff --git a/Forge.Tests/Statescript/Nodes/Action/ApplyEffectNodeTests.cs b/Forge.Tests/Statescript/Nodes/Action/ApplyEffectNodeTests.cs index 061b8d2..35fed2b 100644 --- a/Forge.Tests/Statescript/Nodes/Action/ApplyEffectNodeTests.cs +++ b/Forge.Tests/Statescript/Nodes/Action/ApplyEffectNodeTests.cs @@ -12,6 +12,7 @@ using Gamesmiths.Forge.Statescript.Nodes; using Gamesmiths.Forge.Statescript.Nodes.Action; using Gamesmiths.Forge.Statescript.Properties; +using Gamesmiths.Forge.Statescript.Providers; using Gamesmiths.Forge.Tags; using Gamesmiths.Forge.Tests.Helpers; diff --git a/Forge.Tests/Statescript/Nodes/State/EffectNodeTests.cs b/Forge.Tests/Statescript/Nodes/State/EffectNodeTests.cs index 3a5c775..44991e3 100644 --- a/Forge.Tests/Statescript/Nodes/State/EffectNodeTests.cs +++ b/Forge.Tests/Statescript/Nodes/State/EffectNodeTests.cs @@ -12,6 +12,7 @@ using Gamesmiths.Forge.Statescript.Nodes; using Gamesmiths.Forge.Statescript.Nodes.State; using Gamesmiths.Forge.Statescript.Properties; +using Gamesmiths.Forge.Statescript.Providers; using Gamesmiths.Forge.Tags; using Gamesmiths.Forge.Tests.Helpers; diff --git a/Forge.Tests/Statescript/Nodes/State/EventListenerNodeTests.cs b/Forge.Tests/Statescript/Nodes/State/EventListenerNodeTests.cs index e21d54e..1bedd0d 100644 --- a/Forge.Tests/Statescript/Nodes/State/EventListenerNodeTests.cs +++ b/Forge.Tests/Statescript/Nodes/State/EventListenerNodeTests.cs @@ -8,6 +8,7 @@ using Gamesmiths.Forge.Statescript.Nodes; using Gamesmiths.Forge.Statescript.Nodes.State; using Gamesmiths.Forge.Statescript.Properties; +using Gamesmiths.Forge.Statescript.Providers; using Gamesmiths.Forge.Tags; using Gamesmiths.Forge.Tests.Helpers; diff --git a/Forge.Tests/Statescript/Resolvers/CueCustomParametersResolverTests.cs b/Forge.Tests/Statescript/Resolvers/CueCustomParametersResolverTests.cs index 9042966..7da812c 100644 --- a/Forge.Tests/Statescript/Resolvers/CueCustomParametersResolverTests.cs +++ b/Forge.Tests/Statescript/Resolvers/CueCustomParametersResolverTests.cs @@ -4,6 +4,7 @@ using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Statescript; using Gamesmiths.Forge.Statescript.Properties; +using Gamesmiths.Forge.Statescript.Providers; namespace Gamesmiths.Forge.Tests.Statescript.Resolvers; diff --git a/Forge.Tests/Statescript/Resolvers/EffectContextDataResolverTests.cs b/Forge.Tests/Statescript/Resolvers/EffectContextDataResolverTests.cs index 4c5dc11..92e27b2 100644 --- a/Forge.Tests/Statescript/Resolvers/EffectContextDataResolverTests.cs +++ b/Forge.Tests/Statescript/Resolvers/EffectContextDataResolverTests.cs @@ -5,6 +5,7 @@ using Gamesmiths.Forge.Effects; using Gamesmiths.Forge.Statescript; using Gamesmiths.Forge.Statescript.Properties; +using Gamesmiths.Forge.Statescript.Providers; namespace Gamesmiths.Forge.Tests.Statescript.Resolvers; diff --git a/Forge.Tests/Statescript/Resolvers/EventPayloadOutputResolverTests.cs b/Forge.Tests/Statescript/Resolvers/EventPayloadOutputResolverTests.cs index 77641ba..3379249 100644 --- a/Forge.Tests/Statescript/Resolvers/EventPayloadOutputResolverTests.cs +++ b/Forge.Tests/Statescript/Resolvers/EventPayloadOutputResolverTests.cs @@ -3,6 +3,7 @@ using FluentAssertions; using Gamesmiths.Forge.Statescript; using Gamesmiths.Forge.Statescript.Properties; +using Gamesmiths.Forge.Statescript.Providers; using Gamesmiths.Forge.Tests.Helpers; namespace Gamesmiths.Forge.Tests.Statescript.Resolvers; diff --git a/Forge/Statescript/Nodes/Action/ApplyEffectNode.cs b/Forge/Statescript/Nodes/Action/ApplyEffectNode.cs index b310be5..92cb3db 100644 --- a/Forge/Statescript/Nodes/Action/ApplyEffectNode.cs +++ b/Forge/Statescript/Nodes/Action/ApplyEffectNode.cs @@ -16,8 +16,8 @@ namespace Gamesmiths.Forge.Statescript.Nodes.Action; /// for example by storing it in a variable and re-reading it. Level and ownership are configured on the resolved effect /// (see EffectFromDataResolver) rather than on the node. /// The optional context-data input supplies a custom (typically built by -/// an ) that is passed through the effect pipeline for every application. When -/// the input is unbound, effects are applied without context data. +/// an ) that is passed through the effect pipeline for every +/// application. When the input is unbound, effects are applied without context data. /// public class ApplyEffectNode : ActionNode { diff --git a/Forge/Statescript/Nodes/State/EffectNode.cs b/Forge/Statescript/Nodes/State/EffectNode.cs index 630e2b8..76c6632 100644 --- a/Forge/Statescript/Nodes/State/EffectNode.cs +++ b/Forge/Statescript/Nodes/State/EffectNode.cs @@ -19,8 +19,8 @@ namespace Gamesmiths.Forge.Statescript.Nodes.State; /// Level and ownership are configured on the resolved effect (see EffectFromDataResolver) rather than on the /// node. /// The optional context-data input supplies a custom (typically built by -/// an ) that is passed through the effect pipeline for every application. When -/// the input is unbound, effects are applied without context data. +/// an ) that is passed through the effect pipeline for every +/// application. When the input is unbound, effects are applied without context data. /// public class EffectNode : StateNode { diff --git a/Forge/Statescript/Properties/CueCustomParametersResolver.cs b/Forge/Statescript/Properties/CueCustomParametersResolver.cs index 2b80e60..d948241 100644 --- a/Forge/Statescript/Properties/CueCustomParametersResolver.cs +++ b/Forge/Statescript/Properties/CueCustomParametersResolver.cs @@ -1,6 +1,7 @@ // Copyright © Gamesmiths Guild. using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Statescript.Providers; namespace Gamesmiths.Forge.Statescript.Properties; diff --git a/Forge/Statescript/Properties/EffectContextDataResolver.cs b/Forge/Statescript/Properties/EffectContextDataResolver.cs index 4bdb3e4..017ce1a 100644 --- a/Forge/Statescript/Properties/EffectContextDataResolver.cs +++ b/Forge/Statescript/Properties/EffectContextDataResolver.cs @@ -1,6 +1,7 @@ // Copyright © Gamesmiths Guild. using Gamesmiths.Forge.Effects; +using Gamesmiths.Forge.Statescript.Providers; namespace Gamesmiths.Forge.Statescript.Properties; diff --git a/Forge/Statescript/Properties/EventPayloadOutputResolver.cs b/Forge/Statescript/Properties/EventPayloadOutputResolver.cs index b0498be..7f776e5 100644 --- a/Forge/Statescript/Properties/EventPayloadOutputResolver.cs +++ b/Forge/Statescript/Properties/EventPayloadOutputResolver.cs @@ -1,5 +1,7 @@ // Copyright © Gamesmiths Guild. +using Gamesmiths.Forge.Statescript.Providers; + namespace Gamesmiths.Forge.Statescript.Properties; /// diff --git a/Forge/Statescript/Properties/EventPayloadRaiser.cs b/Forge/Statescript/Properties/EventPayloadRaiser.cs index 5ff2857..e4d4e73 100644 --- a/Forge/Statescript/Properties/EventPayloadRaiser.cs +++ b/Forge/Statescript/Properties/EventPayloadRaiser.cs @@ -2,6 +2,7 @@ using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Statescript.Providers; using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Statescript.Properties; diff --git a/Forge/Statescript/Properties/EventPayloadResolver.cs b/Forge/Statescript/Properties/EventPayloadResolver.cs index 7c05374..6bd62ce 100644 --- a/Forge/Statescript/Properties/EventPayloadResolver.cs +++ b/Forge/Statescript/Properties/EventPayloadResolver.cs @@ -1,5 +1,7 @@ // Copyright © Gamesmiths Guild. +using Gamesmiths.Forge.Statescript.Providers; + namespace Gamesmiths.Forge.Statescript.Properties; /// diff --git a/Forge/Statescript/Properties/EventPayloadWriter.cs b/Forge/Statescript/Properties/EventPayloadWriter.cs index dccd830..c7411cc 100644 --- a/Forge/Statescript/Properties/EventPayloadWriter.cs +++ b/Forge/Statescript/Properties/EventPayloadWriter.cs @@ -2,6 +2,7 @@ using Gamesmiths.Forge.Core; using Gamesmiths.Forge.Events; +using Gamesmiths.Forge.Statescript.Providers; using Gamesmiths.Forge.Tags; namespace Gamesmiths.Forge.Statescript.Properties; diff --git a/Forge/Statescript/CueCustomParameterInput.cs b/Forge/Statescript/Providers/CueCustomParameterInput.cs similarity index 94% rename from Forge/Statescript/CueCustomParameterInput.cs rename to Forge/Statescript/Providers/CueCustomParameterInput.cs index 07cff4c..4234363 100644 --- a/Forge/Statescript/CueCustomParameterInput.cs +++ b/Forge/Statescript/Providers/CueCustomParameterInput.cs @@ -1,6 +1,6 @@ // Copyright © Gamesmiths Guild. -namespace Gamesmiths.Forge.Statescript; +namespace Gamesmiths.Forge.Statescript.Providers; /// /// Declares an authored input that an exposes to the graph editor. Each diff --git a/Forge/Statescript/CueCustomParameterInputs.cs b/Forge/Statescript/Providers/CueCustomParameterInputs.cs similarity index 97% rename from Forge/Statescript/CueCustomParameterInputs.cs rename to Forge/Statescript/Providers/CueCustomParameterInputs.cs index ad20834..1ca1501 100644 --- a/Forge/Statescript/CueCustomParameterInputs.cs +++ b/Forge/Statescript/Providers/CueCustomParameterInputs.cs @@ -2,7 +2,7 @@ using Gamesmiths.Forge.Statescript.Properties; -namespace Gamesmiths.Forge.Statescript; +namespace Gamesmiths.Forge.Statescript.Providers; /// /// Provides the resolved values for the inputs an declared through diff --git a/Forge/Statescript/CueCustomParametersProvider.cs b/Forge/Statescript/Providers/CueCustomParametersProvider.cs similarity index 96% rename from Forge/Statescript/CueCustomParametersProvider.cs rename to Forge/Statescript/Providers/CueCustomParametersProvider.cs index fa3f423..b48fb04 100644 --- a/Forge/Statescript/CueCustomParametersProvider.cs +++ b/Forge/Statescript/Providers/CueCustomParametersProvider.cs @@ -2,7 +2,7 @@ using Gamesmiths.Forge.Core; -namespace Gamesmiths.Forge.Statescript; +namespace Gamesmiths.Forge.Statescript.Providers; /// /// Base class for cue custom-parameter providers. Override to build the parameter diff --git a/Forge/Statescript/EffectContextDataInput.cs b/Forge/Statescript/Providers/EffectContextDataInput.cs similarity index 94% rename from Forge/Statescript/EffectContextDataInput.cs rename to Forge/Statescript/Providers/EffectContextDataInput.cs index 035e80a..7ef2f8f 100644 --- a/Forge/Statescript/EffectContextDataInput.cs +++ b/Forge/Statescript/Providers/EffectContextDataInput.cs @@ -1,6 +1,6 @@ // Copyright © Gamesmiths Guild. -namespace Gamesmiths.Forge.Statescript; +namespace Gamesmiths.Forge.Statescript.Providers; /// /// Declares an authored input that an exposes to the graph editor. Each input diff --git a/Forge/Statescript/EffectContextDataInputs.cs b/Forge/Statescript/Providers/EffectContextDataInputs.cs similarity index 97% rename from Forge/Statescript/EffectContextDataInputs.cs rename to Forge/Statescript/Providers/EffectContextDataInputs.cs index c19276f..ee598ca 100644 --- a/Forge/Statescript/EffectContextDataInputs.cs +++ b/Forge/Statescript/Providers/EffectContextDataInputs.cs @@ -2,7 +2,7 @@ using Gamesmiths.Forge.Statescript.Properties; -namespace Gamesmiths.Forge.Statescript; +namespace Gamesmiths.Forge.Statescript.Providers; /// /// Provides the resolved values for the inputs an declared through diff --git a/Forge/Statescript/EffectContextDataProvider.cs b/Forge/Statescript/Providers/EffectContextDataProvider.cs similarity index 97% rename from Forge/Statescript/EffectContextDataProvider.cs rename to Forge/Statescript/Providers/EffectContextDataProvider.cs index c48e3d1..0099505 100644 --- a/Forge/Statescript/EffectContextDataProvider.cs +++ b/Forge/Statescript/Providers/EffectContextDataProvider.cs @@ -2,7 +2,7 @@ using Gamesmiths.Forge.Effects; -namespace Gamesmiths.Forge.Statescript; +namespace Gamesmiths.Forge.Statescript.Providers; /// /// Base class for strongly-typed effect context-data providers. Override to build a diff --git a/Forge/Statescript/EventOutputBinding.cs b/Forge/Statescript/Providers/EventOutputBinding.cs similarity index 89% rename from Forge/Statescript/EventOutputBinding.cs rename to Forge/Statescript/Providers/EventOutputBinding.cs index 84fe21a..2937727 100644 --- a/Forge/Statescript/EventOutputBinding.cs +++ b/Forge/Statescript/Providers/EventOutputBinding.cs @@ -2,7 +2,7 @@ using Gamesmiths.Forge.Core; -namespace Gamesmiths.Forge.Statescript; +namespace Gamesmiths.Forge.Statescript.Providers; /// /// Binds a declared event-payload output to the graph variable the event-listener node writes it to. diff --git a/Forge/Statescript/EventPayloadInput.cs b/Forge/Statescript/Providers/EventPayloadInput.cs similarity index 94% rename from Forge/Statescript/EventPayloadInput.cs rename to Forge/Statescript/Providers/EventPayloadInput.cs index 95a0438..faef37f 100644 --- a/Forge/Statescript/EventPayloadInput.cs +++ b/Forge/Statescript/Providers/EventPayloadInput.cs @@ -1,6 +1,6 @@ // Copyright © Gamesmiths Guild. -namespace Gamesmiths.Forge.Statescript; +namespace Gamesmiths.Forge.Statescript.Providers; /// /// Declares an authored input that an exposes to the graph editor when building an diff --git a/Forge/Statescript/EventPayloadInputs.cs b/Forge/Statescript/Providers/EventPayloadInputs.cs similarity index 97% rename from Forge/Statescript/EventPayloadInputs.cs rename to Forge/Statescript/Providers/EventPayloadInputs.cs index df1382d..bb8f996 100644 --- a/Forge/Statescript/EventPayloadInputs.cs +++ b/Forge/Statescript/Providers/EventPayloadInputs.cs @@ -2,7 +2,7 @@ using Gamesmiths.Forge.Statescript.Properties; -namespace Gamesmiths.Forge.Statescript; +namespace Gamesmiths.Forge.Statescript.Providers; /// /// Provides the resolved values for the inputs an declared through diff --git a/Forge/Statescript/EventPayloadOutput.cs b/Forge/Statescript/Providers/EventPayloadOutput.cs similarity index 94% rename from Forge/Statescript/EventPayloadOutput.cs rename to Forge/Statescript/Providers/EventPayloadOutput.cs index 4b8a0a3..711f16a 100644 --- a/Forge/Statescript/EventPayloadOutput.cs +++ b/Forge/Statescript/Providers/EventPayloadOutput.cs @@ -1,6 +1,6 @@ // Copyright © Gamesmiths Guild. -namespace Gamesmiths.Forge.Statescript; +namespace Gamesmiths.Forge.Statescript.Providers; /// /// Declares an output that an exposes to the graph editor when decomposing a diff --git a/Forge/Statescript/EventPayloadOutputs.cs b/Forge/Statescript/Providers/EventPayloadOutputs.cs similarity index 98% rename from Forge/Statescript/EventPayloadOutputs.cs rename to Forge/Statescript/Providers/EventPayloadOutputs.cs index 702d436..1b791d2 100644 --- a/Forge/Statescript/EventPayloadOutputs.cs +++ b/Forge/Statescript/Providers/EventPayloadOutputs.cs @@ -2,7 +2,7 @@ using Gamesmiths.Forge.Core; -namespace Gamesmiths.Forge.Statescript; +namespace Gamesmiths.Forge.Statescript.Providers; /// /// Writes the values an extracts from a received event payload to the graph diff --git a/Forge/Statescript/EventPayloadProvider.cs b/Forge/Statescript/Providers/EventPayloadProvider.cs similarity index 98% rename from Forge/Statescript/EventPayloadProvider.cs rename to Forge/Statescript/Providers/EventPayloadProvider.cs index d7c32d1..7f5b7a7 100644 --- a/Forge/Statescript/EventPayloadProvider.cs +++ b/Forge/Statescript/Providers/EventPayloadProvider.cs @@ -5,7 +5,7 @@ using Gamesmiths.Forge.Statescript.Properties; using Gamesmiths.Forge.Tags; -namespace Gamesmiths.Forge.Statescript; +namespace Gamesmiths.Forge.Statescript.Providers; /// /// Base class for typed event-payload providers. Override to build the payload from the diff --git a/Forge/Statescript/ICueCustomParametersProvider.cs b/Forge/Statescript/Providers/ICueCustomParametersProvider.cs similarity index 97% rename from Forge/Statescript/ICueCustomParametersProvider.cs rename to Forge/Statescript/Providers/ICueCustomParametersProvider.cs index a9e99f4..90e30fb 100644 --- a/Forge/Statescript/ICueCustomParametersProvider.cs +++ b/Forge/Statescript/Providers/ICueCustomParametersProvider.cs @@ -2,7 +2,7 @@ using Gamesmiths.Forge.Core; -namespace Gamesmiths.Forge.Statescript; +namespace Gamesmiths.Forge.Statescript.Providers; /// /// Builds the custom parameter bag () passed when a graph fires a cue. diff --git a/Forge/Statescript/IEffectContextDataProvider.cs b/Forge/Statescript/Providers/IEffectContextDataProvider.cs similarity index 97% rename from Forge/Statescript/IEffectContextDataProvider.cs rename to Forge/Statescript/Providers/IEffectContextDataProvider.cs index c56c857..a75a265 100644 --- a/Forge/Statescript/IEffectContextDataProvider.cs +++ b/Forge/Statescript/Providers/IEffectContextDataProvider.cs @@ -2,7 +2,7 @@ using Gamesmiths.Forge.Effects; -namespace Gamesmiths.Forge.Statescript; +namespace Gamesmiths.Forge.Statescript.Providers; /// /// Produces an to pass as custom context data when a graph applies an effect. diff --git a/Forge/Statescript/IEventPayloadProvider.cs b/Forge/Statescript/Providers/IEventPayloadProvider.cs similarity index 98% rename from Forge/Statescript/IEventPayloadProvider.cs rename to Forge/Statescript/Providers/IEventPayloadProvider.cs index c35568c..0a92eb8 100644 --- a/Forge/Statescript/IEventPayloadProvider.cs +++ b/Forge/Statescript/Providers/IEventPayloadProvider.cs @@ -5,7 +5,7 @@ using Gamesmiths.Forge.Statescript.Properties; using Gamesmiths.Forge.Tags; -namespace Gamesmiths.Forge.Statescript; +namespace Gamesmiths.Forge.Statescript.Providers; /// /// Builds and decomposes the custom Payload of an event for the event nodes. The same provider serves both From aee400e4f9827a54a566d8d4dcbb8375419cc494 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 21 Jun 2026 14:11:55 -0300 Subject: [PATCH 7/7] Fixed PR comments --- .../Nodes/Action/RaiseEventNode.cs | 28 ++++++++++++++++--- .../Properties/EventPayloadWriter.cs | 8 +++--- .../Providers/EventPayloadOutputs.cs | 15 ++++++++++ 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/Forge/Statescript/Nodes/Action/RaiseEventNode.cs b/Forge/Statescript/Nodes/Action/RaiseEventNode.cs index 7461149..d66319b 100644 --- a/Forge/Statescript/Nodes/Action/RaiseEventNode.cs +++ b/Forge/Statescript/Nodes/Action/RaiseEventNode.cs @@ -124,14 +124,34 @@ protected override void Execute(GraphContext graphContext) tags.Add(singleTag); } - if (tags.Count == 0) + // Keep only valid tags that share a single manager: an unregistered tag has no manager (and would throw when + // building the container), and a TagContainer cannot mix managers. + TagsManager? manager = null; + var validTags = new List(); + + foreach (Tag tag in tags) + { + if (!tag.IsValid || tag.TagsManager is null) + { + continue; + } + + manager ??= tag.TagsManager; + + if (tag.TagsManager == manager) + { + validTags.Add(tag); + } + } + + if (manager is null || validTags.Count == 0) { return null; } - return tags.Count == 1 - ? new TagContainer(tags[0]) - : new TagContainer(tags[0].TagsManager!, [.. tags]); + return validTags.Count == 1 + ? new TagContainer(validTags[0]) + : new TagContainer(manager, [.. validTags]); } private bool ResolveTargets(GraphContext graphContext, out IReadOnlyList targets) diff --git a/Forge/Statescript/Properties/EventPayloadWriter.cs b/Forge/Statescript/Properties/EventPayloadWriter.cs index c7411cc..6f7fd4e 100644 --- a/Forge/Statescript/Properties/EventPayloadWriter.cs +++ b/Forge/Statescript/Properties/EventPayloadWriter.cs @@ -8,10 +8,10 @@ namespace Gamesmiths.Forge.Statescript.Properties; /// -/// Decomposes a received event payload into the listener node's bound graph variables by delegating to an -/// . Created by and used by -/// EventListenerNode, either to subscribe through the provider's typed (non-boxing) path or to write a payload -/// received from a non-generic raise. +/// Wraps an and its output-to-variable bindings for the event-listener node. +/// Created by : Subscribe sets up the provider's typed (non-boxing) +/// subscription that EventListenerNode uses while active; Write decomposes an already-boxed payload +/// directly into the bound graph variables. /// /// The provider that decomposes the payload. /// The output-name to graph-variable bindings authored on the listener node. diff --git a/Forge/Statescript/Providers/EventPayloadOutputs.cs b/Forge/Statescript/Providers/EventPayloadOutputs.cs index 1b791d2..3c02a38 100644 --- a/Forge/Statescript/Providers/EventPayloadOutputs.cs +++ b/Forge/Statescript/Providers/EventPayloadOutputs.cs @@ -47,6 +47,21 @@ public void Set(string name, T value) } } + /// + /// Writes a single-precision float to the variable bound to the named output, widening it to the double-backed + /// graph variable. Providers writing a bind to this overload, which avoids the boxing the + /// generic incurs when it special-cases . + /// + /// The declared output name. + /// The value to write. + public void Set(string name, float value) + { + Variables? variables = ResolveBinding(name, out StringKey variableName); + + // Floating-point graph variables are double-backed, so widen single-precision values before storing them. + variables?.SetVar(variableName, (double)value); + } + /// /// Writes a reference value to the variable bound to the named output. ///