Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Forge.Tests/Helpers/StatescriptTestHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,19 @@ protected override void Execute(GraphContext graphContext)
}
}

internal sealed class ResolveReferenceArrayResolverNode<T>(IReferenceArrayResolver<T> resolver) : ActionNode
where T : class
{
private readonly IReferenceArrayResolver<T> _resolver = resolver;

public T?[]? LastResolvedArray { get; private set; }

protected override void Execute(GraphContext graphContext)
{
LastResolvedArray = _resolver.ResolveArray(graphContext);
}
}

internal sealed class FixedRandom(int nextInt = 0, float nextSingle = 0.0f, double nextDouble = 0.0) : IRandom
{
public int NextInt()
Expand Down
88 changes: 88 additions & 0 deletions Forge.Tests/Statescript/Resolvers/ArrayCompositionResolverTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright © Gamesmiths Guild.

using FluentAssertions;
using Gamesmiths.Forge.Statescript;
using Gamesmiths.Forge.Statescript.Nodes;
using Gamesmiths.Forge.Statescript.Properties;
using Gamesmiths.Forge.Tests.Helpers;

namespace Gamesmiths.Forge.Tests.Statescript.Resolvers;

public class ArrayCompositionResolverTests
{
[Fact]
[Trait("Resolver", "Array")]
public void Array_resolver_resolves_nested_value_resolvers_in_order()
{
var resolver = new ArrayResolver(
new PiResolver(typeof(float)),
new EResolver(typeof(float)),
new VariantResolver(new Variant128(1.5f), typeof(float)));

Variant128[] result = resolver.ResolveArray(new GraphContext());

result.Should().HaveCount(3);
result[0].AsFloat().Should().Be(MathF.PI);
result[1].AsFloat().Should().Be(MathF.E);
result[2].AsFloat().Should().Be(1.5f);
}

[Fact]
[Trait("Resolver", "Array")]
public void Array_resolver_infers_element_type_from_nested_resolvers()
{
var resolver = new ArrayResolver(new PiResolver(), new EResolver());

resolver.ElementType.Should().Be(typeof(double));
}

[Fact]
[Trait("Resolver", "Array")]
public void Array_resolver_allows_empty_arrays_with_explicit_type()
{
var resolver = new ArrayResolver(typeof(int));

resolver.ElementType.Should().Be(typeof(int));
resolver.ResolveArray(new GraphContext()).Should().BeEmpty();
}

[Fact]
[Trait("Resolver", "Array")]
public void Array_resolver_throws_for_mismatched_nested_resolver_types()
{
#pragma warning disable CA1806
Action act = () => new ArrayResolver(
typeof(float),
new PiResolver(typeof(float)),
new EResolver());
#pragma warning restore CA1806

act.Should().Throw<ArgumentException>();
}

[Fact]
[Trait("Resolver", "Array")]
public void Graph_context_resolves_array_property_from_nested_array_resolver()
{
var graph = new Graph();
var readArray = new ReadArrayPropertyNode();

graph.VariableDefinitions.DefineArrayProperty(
"constants",
new ArrayResolver(new PiResolver(), new EResolver()));
readArray.BindInput(ReadArrayPropertyNode.InputArray, "constants");
graph.AddNode(readArray);
graph.AddConnection(new Connection(
graph.EntryNode.OutputPorts[EntryNode.OutputPort],
readArray.InputPorts[ActionNode.InputPort]));

var processor = new GraphProcessor(graph);

processor.StartGraph();

readArray.LastReadArray.Should().NotBeNull();
readArray.LastReadArray.Should().HaveCount(2);
readArray.LastReadArray![0].AsDouble().Should().Be(Math.PI);
readArray.LastReadArray[1].AsDouble().Should().Be(Math.E);
}
}
98 changes: 98 additions & 0 deletions Forge.Tests/Statescript/Resolvers/EntityArrayResolverTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright © Gamesmiths Guild.

using FluentAssertions;
using Gamesmiths.Forge.Core;
using Gamesmiths.Forge.Cues;
using Gamesmiths.Forge.Statescript;
using Gamesmiths.Forge.Statescript.Properties;
using Gamesmiths.Forge.Tags;
using Gamesmiths.Forge.Tests.Helpers;

using static Gamesmiths.Forge.Tests.Helpers.ResolverTestContextFactory;

namespace Gamesmiths.Forge.Tests.Statescript.Resolvers;

public class EntityArrayResolverTests(TagsAndCuesFixture tagsAndCuesFixture) : IClassFixture<TagsAndCuesFixture>
{
private readonly TagsManager _tagsManager = tagsAndCuesFixture.TagsManager;
private readonly CuesManager _cuesManager = tagsAndCuesFixture.CuesManager;

[Fact]
[Trait("Resolver", "EntityArray")]
public void Entity_array_resolver_reads_nested_entity_resolvers()
{
var owner = new TestEntity(_tagsManager, _cuesManager);
var target = new TestEntity(_tagsManager, _cuesManager);
var source = new TestEntity(_tagsManager, _cuesManager);
var node = new ResolveReferenceArrayResolverNode<IForgeEntity>(new EntityArrayResolver(
new OwnerEntityResolver(),
new TargetEntityResolver(),
new SourceEntityResolver()));

ExecuteAbilityGraph(owner, node, target, source);

node.LastResolvedArray.Should().NotBeNull();
node.LastResolvedArray.Should().HaveCount(3);
node.LastResolvedArray![0].Should().BeSameAs(owner);
node.LastResolvedArray[1].Should().BeSameAs(target);
node.LastResolvedArray[2].Should().BeSameAs(source);
}

[Fact]
[Trait("Resolver", "EntityArray")]
public void Entity_array_resolver_returns_null_entries_without_activation_context()
{
var resolver = new EntityArrayResolver(
new OwnerEntityResolver(),
new TargetEntityResolver(),
new SourceEntityResolver());

IForgeEntity?[] result = resolver.ResolveArray(new GraphContext());

result.Should().BeEquivalentTo(new IForgeEntity?[] { null, null, null });
}

[Fact]
[Trait("Resolver", "EntityArray")]
public void Entity_array_resolver_throws_for_null_resolver_array()
{
IEntityResolver[]? resolvers = null;

#pragma warning disable CA1806
Action act = () => new EntityArrayResolver(resolvers!);
#pragma warning restore CA1806

act.Should().Throw<ArgumentNullException>();
}

[Fact]
[Trait("Resolver", "EntityArray")]
public void Entity_array_resolver_throws_for_null_nested_resolver()
{
IEntityResolver[] resolvers =
[
new OwnerEntityResolver(),
null!,
];

#pragma warning disable CA1806
Action act = () => new EntityArrayResolver(resolvers);
#pragma warning restore CA1806

act.Should().Throw<ArgumentException>();
}

[Fact]
[Trait("Resolver", "EntityArray")]
public void Graph_variable_definitions_validate_entity_array_resolver_output_type()
{
var graph = new Graph();

graph.VariableDefinitions.DefineReferenceArrayProperty(
"entities",
new EntityArrayResolver(new OwnerEntityResolver()));

graph.VariableDefinitions.ValidatePropertyType("entities", typeof(IForgeEntity[])).Should().BeTrue();
graph.VariableDefinitions.ValidatePropertyType("entities", typeof(string[])).Should().BeFalse();
}
}
128 changes: 128 additions & 0 deletions Forge/Statescript/Properties/ArrayResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright © Gamesmiths Guild.

namespace Gamesmiths.Forge.Statescript.Properties;

/// <summary>
/// Resolves an array by evaluating a nested resolver for each element in order.
/// </summary>
/// <remarks>
/// Use this when the array contents should be composed from other resolvers rather than read from a variable. All
/// nested resolvers must produce the same value type, either inferred from the first resolver or supplied explicitly.
/// </remarks>
public class ArrayResolver : IArrayPropertyResolver
{
private readonly IPropertyResolver[] _elementResolvers;

/// <inheritdoc/>
public Type ElementType { get; }

/// <summary>
/// Initializes a new instance of the <see cref="ArrayResolver"/> class, inferring the element type from the first
/// nested resolver.
/// </summary>
/// <param name="elementResolvers">The nested resolvers that produce the array elements.</param>
public ArrayResolver(params IPropertyResolver[] elementResolvers)
: this(ResolveElementType(elementResolvers), elementResolvers)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="ArrayResolver"/> class with an explicit element type.
/// </summary>
/// <remarks>
/// This overload may be used to construct an empty array because the element type is supplied explicitly. The
/// inferred-type overload requires at least one nested resolver so it can determine <see cref="ElementType"/>.
/// </remarks>
/// <param name="elementType">The type of each array element.</param>
/// <param name="elementResolvers">The nested resolvers that produce the array elements.</param>
public ArrayResolver(Type elementType, params IPropertyResolver[] elementResolvers)
{
#if NET8_0_OR_GREATER
ArgumentNullException.ThrowIfNull(elementType);
ArgumentNullException.ThrowIfNull(elementResolvers);
#else
if (elementType is null)
{
throw new ArgumentNullException(nameof(elementType));
}

if (elementResolvers is null)
{
throw new ArgumentNullException(nameof(elementResolvers));
}
#endif

ValidateResolverTypes(elementType, elementResolvers);
ElementType = elementType;
_elementResolvers = elementResolvers;
}
Comment thread
lextatic marked this conversation as resolved.

/// <inheritdoc/>
public Variant128[] ResolveArray(GraphContext graphContext)
{
var values = new Variant128[_elementResolvers.Length];

for (int i = 0; i < _elementResolvers.Length; i++)
{
values[i] = _elementResolvers[i].Resolve(graphContext);
}

return values;
}

private static Type ResolveElementType(IPropertyResolver[] elementResolvers)
{
#if NET8_0_OR_GREATER
ArgumentNullException.ThrowIfNull(elementResolvers);
#else
if (elementResolvers is null)
{
throw new ArgumentNullException(nameof(elementResolvers));
}
#endif

if (elementResolvers.Length == 0)
{
throw new ArgumentException(
"ArrayResolver requires at least one element resolver when no explicit element type is provided.",
nameof(elementResolvers));
}

if (elementResolvers[0] is null)
{
throw new ArgumentException(
"ArrayResolver does not allow null element resolvers.",
nameof(elementResolvers));
}

Type elementType = elementResolvers[0].ValueType;
ValidateResolverTypes(elementType, elementResolvers, 1);
return elementType;
}

private static void ValidateResolverTypes(
Type elementType,
IPropertyResolver[] elementResolvers,
int startIndex = 0)
{
for (int i = startIndex; i < elementResolvers.Length; i++)
{
IPropertyResolver? resolver = elementResolvers[i];

if (resolver is null)
{
throw new ArgumentException(
"ArrayResolver does not allow null element resolvers.",
nameof(elementResolvers));
}

if (resolver.ValueType != elementType)
{
throw new ArgumentException(
$"ArrayResolver element resolver at index {i} produces '{resolver.ValueType}', " +
$"which does not match the configured element type '{elementType}'.",
nameof(elementResolvers));
}
}
}
}
14 changes: 14 additions & 0 deletions Forge/Statescript/Properties/EntityArrayResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright © Gamesmiths Guild.

using Gamesmiths.Forge.Core;

namespace Gamesmiths.Forge.Statescript.Properties;

/// <summary>
/// Resolves an array of <see cref="IForgeEntity"/> references by evaluating a nested entity resolver for each element.
/// </summary>
/// <param name="elementResolvers">The nested entity resolvers that produce the array elements.</param>
public class EntityArrayResolver(params IEntityResolver[] elementResolvers)
: ReferenceArrayCompositeResolver<IForgeEntity>(elementResolvers)
{
}
4 changes: 1 addition & 3 deletions Forge/Statescript/Properties/IEntityResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,4 @@ namespace Gamesmiths.Forge.Statescript.Properties;
/// <summary>
/// Resolves an <see cref="IForgeEntity"/> reference at runtime.
/// </summary>
public interface IEntityResolver : IReferenceResolver<IForgeEntity>
{
}
public interface IEntityResolver : IReferenceResolver<IForgeEntity>;
Loading
Loading