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:
hypercross 2026-07-18 20:07:43 +08:00
parent 25789e7c9d
commit 6f9efc050b
5 changed files with 204 additions and 3 deletions

View File

@ -39,9 +39,9 @@ public class QueryDescriptor
public override int GetHashCode()
{
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);
foreach (var t in Without.OrderBy(t => t.FullName))
foreach (var t in Without.OrderBy(t => t.GUID.ToString()))
hash.Add(t);
return hash.ToHashCode();
}

View File

@ -110,10 +110,45 @@ internal class SparseSet<T> : ISparseSet where T : struct
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public ref T Get(Entity entity)
{
if (entity.Id >= (uint)_sparse.Length)
ThrowEntityNotPresent(entity);
int index = _sparse[entity.Id];
if (index == -1)
ThrowEntityNotPresent(entity);
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>
/// Returns true if the entity has this component.
/// </summary>

View File

@ -190,6 +190,16 @@ public class World : IDisposable
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>
/// Returns true if the entity has a component of type <typeparamref name="T"/>.
/// </summary>
@ -354,6 +364,7 @@ public class World : IDisposable
ForEachAction<T1> action)
where T1 : struct
{
ValidateQueryTypes(query, typeof(T1));
QueryExecutor.ForEach(_components, query, action);
}
@ -366,6 +377,7 @@ public class World : IDisposable
ForEachAction<T1, T2> action)
where T1 : struct where T2 : struct
{
ValidateQueryTypes(query, typeof(T1), typeof(T2));
QueryExecutor.ForEach(_components, query, action);
}
@ -378,6 +390,7 @@ public class World : IDisposable
ForEachAction<T1, T2, T3> action)
where T1 : struct where T2 : struct where T3 : struct
{
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3));
QueryExecutor.ForEach(_components, query, action);
}
@ -390,6 +403,7 @@ public class World : IDisposable
ForEachAction<T1, T2, T3, T4> action)
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);
}
@ -402,6 +416,7 @@ public class World : IDisposable
ForEachAction<T1, T2, T3, T4, T5> action)
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);
}
@ -414,6 +429,7 @@ public class World : IDisposable
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
{
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3), typeof(T4), typeof(T5), typeof(T6));
QueryExecutor.ForEach(_components, query, action);
}
@ -433,6 +449,39 @@ public class World : IDisposable
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 ───────────────────────────────────────────────────────
public void Dispose()

View File

@ -196,7 +196,48 @@ public class ComponentTests
var entity = world.CreateEntity();
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]

View File

@ -456,6 +456,82 @@ public class EdgeCaseTests
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 ──────────────────────────────────────────────────────
private sealed class ChangeCollector : IObserver<EntityChange>