diff --git a/src/OECS/QueryDescriptor.cs b/src/OECS/QueryDescriptor.cs index 489beb1..4caad07 100644 --- a/src/OECS/QueryDescriptor.cs +++ b/src/OECS/QueryDescriptor.cs @@ -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(); } diff --git a/src/OECS/SparseSet.cs b/src/OECS/SparseSet.cs index ae27835..5a514e6 100644 --- a/src/OECS/SparseSet.cs +++ b/src/OECS/SparseSet.cs @@ -110,10 +110,45 @@ internal class SparseSet : 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]; } + /// + /// Tries to get the component value for the given entity. + /// Returns true and copies the value to if present. + /// + 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}."); + } + /// /// Returns true if the entity has this component. /// diff --git a/src/OECS/World.cs b/src/OECS/World.cs index a4fb1d0..760ef2d 100644 --- a/src/OECS/World.cs +++ b/src/OECS/World.cs @@ -190,6 +190,16 @@ public class World : IDisposable return ref _components.Get(entity); } + /// + /// Tries to get the component of type for the + /// given entity. Returns true and copies the value to + /// if the component exists; otherwise returns false. + /// + public bool TryGetComponent(Entity entity, out T value) where T : struct + { + return _components.TryGet(entity, out value); + } + /// /// Returns true if the entity has a component of type . /// @@ -354,6 +364,7 @@ public class World : IDisposable ForEachAction action) where T1 : struct { + ValidateQueryTypes(query, typeof(T1)); QueryExecutor.ForEach(_components, query, action); } @@ -366,6 +377,7 @@ public class World : IDisposable ForEachAction 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 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 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 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 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."); } + /// + /// 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. + /// + 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()."); + } + // ── Cleanup ─────────────────────────────────────────────────────── public void Dispose() diff --git a/tests/OECS.Tests/CoreTests.cs b/tests/OECS.Tests/CoreTests.cs index 3292c64..b1033dd 100644 --- a/tests/OECS.Tests/CoreTests.cs +++ b/tests/OECS.Tests/CoreTests.cs @@ -196,7 +196,48 @@ public class ComponentTests var entity = world.CreateEntity(); var act = () => world.GetComponent(entity); - act.Should().Throw(); + act.Should().Throw() + .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(entity); + act.Should().Throw() + .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(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(entity, out var pos); + + found.Should().BeFalse(); + pos.Should().Be(default(Position)); } [Fact] diff --git a/tests/OECS.Tests/EdgeCaseTests.cs b/tests/OECS.Tests/EdgeCaseTests.cs index fe1119a..aee1a72 100644 --- a/tests/OECS.Tests/EdgeCaseTests.cs +++ b/tests/OECS.Tests/EdgeCaseTests.cs @@ -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().With().Build(); + + var act = () => + { + world.ForEach(query, (Entity e, ref Position pos) => { }); + }; + act.Should().Throw() + .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().With().With().Build(); + + var act = () => + { + world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => { }); + }; + act.Should().Throw() + .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().Build(); + + var act = () => + { + world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => { }); + }; + act.Should().Throw() + .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().With().Build(); + + var act = () => + { + world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => { }); + }; + act.Should().NotThrow(); + } + // ── Helpers ────────────────────────────────────────────────────── private sealed class ChangeCollector : IObserver