feat: add component validation and TryGetComponent
- Implement `TryGetComponent<T>` in `World` and `SparseSet<T>` - Add validation to ensure `ForEach` type parameters match the `QueryDescriptor` - Improve error handling in `SparseSet<T>.Get` to throw `InvalidOperationException` - Use `GUID` instead of `FullName` for `QueryDescriptor` hash stability
This commit is contained in:
parent
25789e7c9d
commit
6f9efc050b
|
|
@ -39,9 +39,9 @@ public class QueryDescriptor
|
||||||
public override int GetHashCode()
|
public override int GetHashCode()
|
||||||
{
|
{
|
||||||
var hash = new HashCode();
|
var hash = new HashCode();
|
||||||
foreach (var t in With.OrderBy(t => t.FullName))
|
foreach (var t in With.OrderBy(t => t.GUID.ToString()))
|
||||||
hash.Add(t);
|
hash.Add(t);
|
||||||
foreach (var t in Without.OrderBy(t => t.FullName))
|
foreach (var t in Without.OrderBy(t => t.GUID.ToString()))
|
||||||
hash.Add(t);
|
hash.Add(t);
|
||||||
return hash.ToHashCode();
|
return hash.ToHashCode();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,10 +110,45 @@ internal class SparseSet<T> : ISparseSet where T : struct
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public ref T Get(Entity entity)
|
public ref T Get(Entity entity)
|
||||||
{
|
{
|
||||||
|
if (entity.Id >= (uint)_sparse.Length)
|
||||||
|
ThrowEntityNotPresent(entity);
|
||||||
|
|
||||||
int index = _sparse[entity.Id];
|
int index = _sparse[entity.Id];
|
||||||
|
if (index == -1)
|
||||||
|
ThrowEntityNotPresent(entity);
|
||||||
|
|
||||||
return ref _dense[index];
|
return ref _dense[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to get the component value for the given entity.
|
||||||
|
/// Returns true and copies the value to <paramref name="value"/> if present.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryGet(Entity entity, out T value)
|
||||||
|
{
|
||||||
|
if (entity.Id >= (uint)_sparse.Length)
|
||||||
|
{
|
||||||
|
value = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int index = _sparse[entity.Id];
|
||||||
|
if (index == -1)
|
||||||
|
{
|
||||||
|
value = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = _dense[index];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ThrowEntityNotPresent(Entity entity)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Entity {entity} does not have component of type {typeof(T).Name}.");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns true if the entity has this component.
|
/// Returns true if the entity has this component.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,16 @@ public class World : IDisposable
|
||||||
return ref _components.Get<T>(entity);
|
return ref _components.Get<T>(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to get the component of type <typeparamref name="T"/> for the
|
||||||
|
/// given entity. Returns true and copies the value to
|
||||||
|
/// <paramref name="value"/> if the component exists; otherwise returns false.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryGetComponent<T>(Entity entity, out T value) where T : struct
|
||||||
|
{
|
||||||
|
return _components.TryGet<T>(entity, out value);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns true if the entity has a component of type <typeparamref name="T"/>.
|
/// Returns true if the entity has a component of type <typeparamref name="T"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -354,6 +364,7 @@ public class World : IDisposable
|
||||||
ForEachAction<T1> action)
|
ForEachAction<T1> action)
|
||||||
where T1 : struct
|
where T1 : struct
|
||||||
{
|
{
|
||||||
|
ValidateQueryTypes(query, typeof(T1));
|
||||||
QueryExecutor.ForEach(_components, query, action);
|
QueryExecutor.ForEach(_components, query, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -366,6 +377,7 @@ public class World : IDisposable
|
||||||
ForEachAction<T1, T2> action)
|
ForEachAction<T1, T2> action)
|
||||||
where T1 : struct where T2 : struct
|
where T1 : struct where T2 : struct
|
||||||
{
|
{
|
||||||
|
ValidateQueryTypes(query, typeof(T1), typeof(T2));
|
||||||
QueryExecutor.ForEach(_components, query, action);
|
QueryExecutor.ForEach(_components, query, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -378,6 +390,7 @@ public class World : IDisposable
|
||||||
ForEachAction<T1, T2, T3> action)
|
ForEachAction<T1, T2, T3> action)
|
||||||
where T1 : struct where T2 : struct where T3 : struct
|
where T1 : struct where T2 : struct where T3 : struct
|
||||||
{
|
{
|
||||||
|
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3));
|
||||||
QueryExecutor.ForEach(_components, query, action);
|
QueryExecutor.ForEach(_components, query, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -390,6 +403,7 @@ public class World : IDisposable
|
||||||
ForEachAction<T1, T2, T3, T4> action)
|
ForEachAction<T1, T2, T3, T4> action)
|
||||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct
|
where T1 : struct where T2 : struct where T3 : struct where T4 : struct
|
||||||
{
|
{
|
||||||
|
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3), typeof(T4));
|
||||||
QueryExecutor.ForEach(_components, query, action);
|
QueryExecutor.ForEach(_components, query, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -402,6 +416,7 @@ public class World : IDisposable
|
||||||
ForEachAction<T1, T2, T3, T4, T5> action)
|
ForEachAction<T1, T2, T3, T4, T5> action)
|
||||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct
|
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct
|
||||||
{
|
{
|
||||||
|
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3), typeof(T4), typeof(T5));
|
||||||
QueryExecutor.ForEach(_components, query, action);
|
QueryExecutor.ForEach(_components, query, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -414,6 +429,7 @@ public class World : IDisposable
|
||||||
ForEachAction<T1, T2, T3, T4, T5, T6> action)
|
ForEachAction<T1, T2, T3, T4, T5, T6> action)
|
||||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct
|
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct
|
||||||
{
|
{
|
||||||
|
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3), typeof(T4), typeof(T5), typeof(T6));
|
||||||
QueryExecutor.ForEach(_components, query, action);
|
QueryExecutor.ForEach(_components, query, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -433,6 +449,39 @@ public class World : IDisposable
|
||||||
throw new InvalidOperationException($"Entity {entity} is not alive.");
|
throw new InvalidOperationException($"Entity {entity} is not alive.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates that the ForEach type parameters match the query's
|
||||||
|
/// With set. When the With set is empty (only Without filters are
|
||||||
|
/// specified), any ForEach type parameters are accepted.
|
||||||
|
/// </summary>
|
||||||
|
private static void ValidateQueryTypes(QueryDescriptor query, params Type[] forEachTypes)
|
||||||
|
{
|
||||||
|
// When With is empty, the query is using only Without filters.
|
||||||
|
// The ForEach type parameters are the sole source of truth.
|
||||||
|
if (query.With.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (query.With.Count != forEachTypes.Length)
|
||||||
|
ThrowMismatch(query, forEachTypes);
|
||||||
|
|
||||||
|
foreach (var t in forEachTypes)
|
||||||
|
{
|
||||||
|
if (!query.With.Contains(t))
|
||||||
|
ThrowMismatch(query, forEachTypes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ThrowMismatch(QueryDescriptor query, Type[] forEachTypes)
|
||||||
|
{
|
||||||
|
var queryTypes = string.Join(", ", query.With.Select(t => t.Name));
|
||||||
|
var forEachTypeNames = string.Join(", ", forEachTypes.Select(t => t.Name));
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"ForEach type parameters [{forEachTypeNames}] do not match " +
|
||||||
|
$"the query's With types [{queryTypes}]. " +
|
||||||
|
$"Ensure the ForEach type parameters exactly match the types " +
|
||||||
|
$"passed to QueryBuilder.With<T>().");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Cleanup ───────────────────────────────────────────────────────
|
// ── Cleanup ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,48 @@ public class ComponentTests
|
||||||
var entity = world.CreateEntity();
|
var entity = world.CreateEntity();
|
||||||
|
|
||||||
var act = () => world.GetComponent<Position>(entity);
|
var act = () => world.GetComponent<Position>(entity);
|
||||||
act.Should().Throw<IndexOutOfRangeException>();
|
act.Should().Throw<InvalidOperationException>()
|
||||||
|
.WithMessage("*does not have*Position*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetComponent_Throws_WhenEntityIdOutOfRange()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
// Create an entity with a very large ID by exhausting the allocator.
|
||||||
|
// This is hard to do without internals, so we test via a destroyed
|
||||||
|
// entity that has a valid ID but no component.
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
|
||||||
|
var act = () => world.GetComponent<Position>(entity);
|
||||||
|
act.Should().Throw<InvalidOperationException>()
|
||||||
|
.WithMessage("*does not have*Position*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetComponent_ReturnsTrue_WhenPresent()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||||
|
|
||||||
|
bool found = world.TryGetComponent<Position>(entity, out var pos);
|
||||||
|
|
||||||
|
found.Should().BeTrue();
|
||||||
|
pos.X.Should().Be(1);
|
||||||
|
pos.Y.Should().Be(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetComponent_ReturnsFalse_WhenNotPresent()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
|
||||||
|
bool found = world.TryGetComponent<Position>(entity, out var pos);
|
||||||
|
|
||||||
|
found.Should().BeFalse();
|
||||||
|
pos.Should().Be(default(Position));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -456,6 +456,82 @@ public class EdgeCaseTests
|
||||||
act.Should().NotThrow();
|
act.Should().NotThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── QueryDescriptor.With enforced during ForEach ─────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForEach_Throws_WhenTypeParamsDivergeFromQueryWith()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||||
|
world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
|
||||||
|
|
||||||
|
// Build a query requiring Position + Velocity, but iterate only Position.
|
||||||
|
var query = world.Query().With<Position>().With<Velocity>().Build();
|
||||||
|
|
||||||
|
var act = () =>
|
||||||
|
{
|
||||||
|
world.ForEach(query, (Entity e, ref Position pos) => { });
|
||||||
|
};
|
||||||
|
act.Should().Throw<InvalidOperationException>()
|
||||||
|
.WithMessage("*ForEach*type*Position*Velocity*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForEach_Throws_WhenTypeParamsAreSubsetOfQueryWith()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||||
|
world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
|
||||||
|
world.AddComponent(entity, new Health { Value = 100 });
|
||||||
|
|
||||||
|
// Build a query requiring 3 components, but iterate only 2.
|
||||||
|
var query = world.Query().With<Position>().With<Velocity>().With<Health>().Build();
|
||||||
|
|
||||||
|
var act = () =>
|
||||||
|
{
|
||||||
|
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => { });
|
||||||
|
};
|
||||||
|
act.Should().Throw<InvalidOperationException>()
|
||||||
|
.WithMessage("*ForEach*type*Health*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForEach_Throws_WhenTypeParamsAreSupersetOfQueryWith()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||||
|
|
||||||
|
// Build a query requiring only Position, but iterate Position + Velocity.
|
||||||
|
var query = world.Query().With<Position>().Build();
|
||||||
|
|
||||||
|
var act = () =>
|
||||||
|
{
|
||||||
|
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => { });
|
||||||
|
};
|
||||||
|
act.Should().Throw<InvalidOperationException>()
|
||||||
|
.WithMessage("*ForEach*type*Velocity*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForEach_DoesNotThrow_WhenTypeParamsMatchQueryWith()
|
||||||
|
{
|
||||||
|
var world = new World();
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||||
|
world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
|
||||||
|
|
||||||
|
var query = world.Query().With<Position>().With<Velocity>().Build();
|
||||||
|
|
||||||
|
var act = () =>
|
||||||
|
{
|
||||||
|
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => { });
|
||||||
|
};
|
||||||
|
act.Should().NotThrow();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private sealed class ChangeCollector : IObserver<EntityChange>
|
private sealed class ChangeCollector : IObserver<EntityChange>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue