From e4afaafd02cac702ffa3906d09e9b1859febeefe Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 18 Jul 2026 19:33:05 +0800 Subject: [PATCH] feat: add query system and system execution logic Introduces a complete query system for iterating over entities with specific component combinations, including support for "with" and "without" filters. Also adds: - `ISystem` and `ITickedSystem` interfaces for defining logic. - `SystemGroup` for managing and running groups of systems. - `QueryBuilder` for a fluent API to construct queries. - `QueryExecutor` for high-performance iteration using ref parameters. - `Tick` and `TickType` to support timed and logical updates. --- src/OECS/ForEachAction.cs | 39 +++ src/OECS/ISystem.cs | 18 ++ src/OECS/ITickedSystem.cs | 13 + src/OECS/QueryBuilder.cs | 39 +++ src/OECS/QueryDescriptor.cs | 30 +++ src/OECS/QueryExecutor.cs | 242 +++++++++++++++++++ src/OECS/SparseSet.cs | 1 + src/OECS/SystemGroup.cs | 73 ++++++ src/OECS/Tick.cs | 50 ++++ src/OECS/World.cs | 79 ++++++ tests/OECS.Tests/QueryAndSystemTests.cs | 306 ++++++++++++++++++++++++ 11 files changed, 890 insertions(+) create mode 100644 src/OECS/ForEachAction.cs create mode 100644 src/OECS/ISystem.cs create mode 100644 src/OECS/ITickedSystem.cs create mode 100644 src/OECS/QueryBuilder.cs create mode 100644 src/OECS/QueryDescriptor.cs create mode 100644 src/OECS/QueryExecutor.cs create mode 100644 src/OECS/SystemGroup.cs create mode 100644 src/OECS/Tick.cs create mode 100644 tests/OECS.Tests/QueryAndSystemTests.cs diff --git a/src/OECS/ForEachAction.cs b/src/OECS/ForEachAction.cs new file mode 100644 index 0000000..2182adb --- /dev/null +++ b/src/OECS/ForEachAction.cs @@ -0,0 +1,39 @@ +namespace OECS; + +// Custom delegate types for query iteration with ref parameters. +// The built-in Action<...> delegates do not support ref parameters. + +/// +/// Callback for iterating entities with one component type. +/// +public delegate void ForEachAction(Entity entity, ref T1 c1) where T1 : struct; + +/// +/// Callback for iterating entities with two component types. +/// +public delegate void ForEachAction(Entity entity, ref T1 c1, ref T2 c2) + where T1 : struct where T2 : struct; + +/// +/// Callback for iterating entities with three component types. +/// +public delegate void ForEachAction(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3) + where T1 : struct where T2 : struct where T3 : struct; + +/// +/// Callback for iterating entities with four component types. +/// +public delegate void ForEachAction(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3, ref T4 c4) + where T1 : struct where T2 : struct where T3 : struct where T4 : struct; + +/// +/// Callback for iterating entities with five component types. +/// +public delegate void ForEachAction(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3, ref T4 c4, ref T5 c5) + where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct; + +/// +/// Callback for iterating entities with six component types. +/// +public delegate void ForEachAction(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3, ref T4 c4, ref T5 c5, ref T6 c6) + where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct; \ No newline at end of file diff --git a/src/OECS/ISystem.cs b/src/OECS/ISystem.cs new file mode 100644 index 0000000..0aab965 --- /dev/null +++ b/src/OECS/ISystem.cs @@ -0,0 +1,18 @@ +namespace OECS; + +/// +/// A system that runs on every tick. +/// Systems declare a query and receive the world in their method. +/// +public interface ISystem +{ + /// + /// The query that defines which entities this system operates on. + /// + QueryDescriptor Query { get; } + + /// + /// Executes the system logic for this tick. + /// + void Run(World world); +} \ No newline at end of file diff --git a/src/OECS/ITickedSystem.cs b/src/OECS/ITickedSystem.cs new file mode 100644 index 0000000..7d1addc --- /dev/null +++ b/src/OECS/ITickedSystem.cs @@ -0,0 +1,13 @@ +namespace OECS; + +/// +/// A system that receives tick data (delta time or logical tick) in addition +/// to the world reference. +/// +public interface ITickedSystem : ISystem +{ + /// + /// Executes the system logic with tick information. + /// + void Run(World world, Tick tick); +} \ No newline at end of file diff --git a/src/OECS/QueryBuilder.cs b/src/OECS/QueryBuilder.cs new file mode 100644 index 0000000..cde58e3 --- /dev/null +++ b/src/OECS/QueryBuilder.cs @@ -0,0 +1,39 @@ +namespace OECS; + +/// +/// Fluent builder for constructing instances. +/// Returned by . +/// +public class QueryBuilder +{ + private readonly HashSet _with = new(); + private readonly HashSet _without = new(); + + /// + /// Requires entities to have a component of type . + /// + public QueryBuilder With() where T : struct + { + _with.Add(typeof(T)); + return this; + } + + /// + /// Excludes entities that have a component of type . + /// + public QueryBuilder Without() where T : struct + { + _without.Add(typeof(T)); + return this; + } + + /// + /// Builds the query descriptor. + /// + public QueryDescriptor Build() + { + return new QueryDescriptor( + new HashSet(_with), + new HashSet(_without)); + } +} \ No newline at end of file diff --git a/src/OECS/QueryDescriptor.cs b/src/OECS/QueryDescriptor.cs new file mode 100644 index 0000000..5e98fff --- /dev/null +++ b/src/OECS/QueryDescriptor.cs @@ -0,0 +1,30 @@ +namespace OECS; + +/// +/// Describes a query over the ECS world. +/// Composed of a set of required component types ("with") and +/// a set of excluded component types ("without"). +/// +public class QueryDescriptor +{ + /// + /// Component types that must be present on matching entities. + /// + public IReadOnlySet With { get; } + + /// + /// Component types that must NOT be present on matching entities. + /// + public IReadOnlySet Without { get; } + + internal QueryDescriptor(HashSet with, HashSet without) + { + With = with; + Without = without; + } + + /// + /// Returns true if this query has no "with" components (matches nothing). + /// + internal bool IsEmpty => With.Count == 0; +} \ No newline at end of file diff --git a/src/OECS/QueryExecutor.cs b/src/OECS/QueryExecutor.cs new file mode 100644 index 0000000..b6188e3 --- /dev/null +++ b/src/OECS/QueryExecutor.cs @@ -0,0 +1,242 @@ +using System.Runtime.CompilerServices; + +namespace OECS; + +/// +/// Provides query execution logic for . +/// Iterates one sparse set as the driver and probes the remaining sets +/// for membership. The "without" filter is checked after all "with" probes. +/// +internal static class QueryExecutor +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool PassesWithoutFilter(ComponentStore store, Entity entity, IReadOnlySet withoutTypes) + { + foreach (var type in withoutTypes) + { + var set = store.GetSet(type); + if (set != null && set.Contains(entity)) + return false; + } + return true; + } + + // ── ForEach overloads ────────────────────────────────────────────── + + public static void ForEach( + ComponentStore store, + QueryDescriptor query, + ForEachAction action) + where T1 : struct + { + var set = store.GetSet(typeof(T1)) as SparseSet; + if (set == null) return; + + var dense = set.Dense; + var denseEntities = set.DenseEntities; + var count = set.Count; + + for (int i = 0; i < count; i++) + { + var entity = denseEntities[i]; + if (!PassesWithoutFilter(store, entity, query.Without)) + continue; + action(entity, ref dense[i]); + } + } + + public static void ForEach( + ComponentStore store, + QueryDescriptor query, + ForEachAction action) + where T1 : struct where T2 : struct + { + var set1 = store.GetSet(typeof(T1)) as SparseSet; + if (set1 == null) return; + var set2 = store.GetSet(typeof(T2)) as SparseSet; + if (set2 == null) return; + + if (set1.Count <= set2.Count) + { + IterateTwo(set1, set2, store, query.Without, action); + } + else + { + IterateTwoSwapped(set2, set1, store, query.Without, action); + } + } + + private static void IterateTwo( + SparseSet driveSet, + SparseSet otherSet, + ComponentStore store, + IReadOnlySet withoutTypes, + ForEachAction action) + where TDriver : struct where TOther : struct + { + var dense = driveSet.Dense; + var denseEntities = driveSet.DenseEntities; + var count = driveSet.Count; + + for (int i = 0; i < count; i++) + { + var entity = denseEntities[i]; + if (!otherSet.Contains(entity)) continue; + if (!PassesWithoutFilter(store, entity, withoutTypes)) + continue; + action(entity, ref dense[i], ref otherSet.Get(entity)); + } + } + + private static void IterateTwoSwapped( + SparseSet driveSet, + SparseSet otherSet, + ComponentStore store, + IReadOnlySet withoutTypes, + ForEachAction action) + where TDriver : struct where TOther : struct + { + var dense = driveSet.Dense; + var denseEntities = driveSet.DenseEntities; + var count = driveSet.Count; + + for (int i = 0; i < count; i++) + { + var entity = denseEntities[i]; + if (!otherSet.Contains(entity)) continue; + if (!PassesWithoutFilter(store, entity, withoutTypes)) + continue; + action(entity, ref otherSet.Get(entity), ref dense[i]); + } + } + + public static void ForEach( + ComponentStore store, + QueryDescriptor query, + ForEachAction action) + where T1 : struct where T2 : struct where T3 : struct + { + var set1 = store.GetSet(typeof(T1)) as SparseSet; + if (set1 == null) return; + var set2 = store.GetSet(typeof(T2)) as SparseSet; + if (set2 == null) return; + var set3 = store.GetSet(typeof(T3)) as SparseSet; + if (set3 == null) return; + + var dense = set1.Dense; + var denseEntities = set1.DenseEntities; + var count = set1.Count; + + for (int i = 0; i < count; i++) + { + var entity = denseEntities[i]; + if (!set2.Contains(entity)) continue; + if (!set3.Contains(entity)) continue; + if (!PassesWithoutFilter(store, entity, query.Without)) + continue; + action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity)); + } + } + + public static void ForEach( + ComponentStore store, + QueryDescriptor query, + ForEachAction action) + where T1 : struct where T2 : struct where T3 : struct where T4 : struct + { + var set1 = store.GetSet(typeof(T1)) as SparseSet; + if (set1 == null) return; + var set2 = store.GetSet(typeof(T2)) as SparseSet; + if (set2 == null) return; + var set3 = store.GetSet(typeof(T3)) as SparseSet; + if (set3 == null) return; + var set4 = store.GetSet(typeof(T4)) as SparseSet; + if (set4 == null) return; + + var dense = set1.Dense; + var denseEntities = set1.DenseEntities; + var count = set1.Count; + + for (int i = 0; i < count; i++) + { + var entity = denseEntities[i]; + if (!set2.Contains(entity)) continue; + if (!set3.Contains(entity)) continue; + if (!set4.Contains(entity)) continue; + if (!PassesWithoutFilter(store, entity, query.Without)) + continue; + action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity)); + } + } + + public static void ForEach( + ComponentStore store, + QueryDescriptor query, + ForEachAction action) + where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct + { + var set1 = store.GetSet(typeof(T1)) as SparseSet; + if (set1 == null) return; + var set2 = store.GetSet(typeof(T2)) as SparseSet; + if (set2 == null) return; + var set3 = store.GetSet(typeof(T3)) as SparseSet; + if (set3 == null) return; + var set4 = store.GetSet(typeof(T4)) as SparseSet; + if (set4 == null) return; + var set5 = store.GetSet(typeof(T5)) as SparseSet; + if (set5 == null) return; + + var dense = set1.Dense; + var denseEntities = set1.DenseEntities; + var count = set1.Count; + + for (int i = 0; i < count; i++) + { + var entity = denseEntities[i]; + if (!set2.Contains(entity)) continue; + if (!set3.Contains(entity)) continue; + if (!set4.Contains(entity)) continue; + if (!set5.Contains(entity)) continue; + if (!PassesWithoutFilter(store, entity, query.Without)) + continue; + action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity)); + } + } + + public static void ForEach( + ComponentStore store, + QueryDescriptor query, + ForEachAction action) + where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct + { + var set1 = store.GetSet(typeof(T1)) as SparseSet; + if (set1 == null) return; + var set2 = store.GetSet(typeof(T2)) as SparseSet; + if (set2 == null) return; + var set3 = store.GetSet(typeof(T3)) as SparseSet; + if (set3 == null) return; + var set4 = store.GetSet(typeof(T4)) as SparseSet; + if (set4 == null) return; + var set5 = store.GetSet(typeof(T5)) as SparseSet; + if (set5 == null) return; + var set6 = store.GetSet(typeof(T6)) as SparseSet; + if (set6 == null) return; + + var dense = set1.Dense; + var denseEntities = set1.DenseEntities; + var count = set1.Count; + + for (int i = 0; i < count; i++) + { + var entity = denseEntities[i]; + if (!set2.Contains(entity)) continue; + if (!set3.Contains(entity)) continue; + if (!set4.Contains(entity)) continue; + if (!set5.Contains(entity)) continue; + if (!set6.Contains(entity)) continue; + if (!PassesWithoutFilter(store, entity, query.Without)) + continue; + action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity), ref set6.Get(entity)); + } + } +} \ No newline at end of file diff --git a/src/OECS/SparseSet.cs b/src/OECS/SparseSet.cs index 70b429f..c351091 100644 --- a/src/OECS/SparseSet.cs +++ b/src/OECS/SparseSet.cs @@ -9,6 +9,7 @@ namespace OECS; internal interface ISparseSet { void Remove(Entity entity); + bool Contains(Entity entity); int Count { get; } } diff --git a/src/OECS/SystemGroup.cs b/src/OECS/SystemGroup.cs new file mode 100644 index 0000000..0e4043b --- /dev/null +++ b/src/OECS/SystemGroup.cs @@ -0,0 +1,73 @@ +namespace OECS; + +/// +/// Manages a group of systems, running them in registration order +/// against a specific . +/// +public class SystemGroup +{ + private readonly World _world; + private readonly List _systems = new(); + + /// + /// Creates a system group bound to the given world. + /// + public SystemGroup(World world) + { + _world = world ?? throw new ArgumentNullException(nameof(world)); + } + + /// + /// The number of systems in this group. + /// + public int Count => _systems.Count; + + /// + /// Adds a system to the group. Systems execute in the order they are added. + /// + public void Add(ISystem system) + { + _systems.Add(system); + } + + /// + /// Removes a system from the group. + /// + public void Remove(ISystem system) + { + _systems.Remove(system); + } + + /// + /// Runs all systems with a timed tick. + /// Systems that implement receive the tick data; + /// plain implementations receive only the world. + /// + public void RunTimed(float deltaTime) + { + RunAll(Tick.Timed(deltaTime)); + } + + /// + /// Runs all systems with a logical tick. + /// + public void RunLogical() + { + RunAll(Tick.Logical()); + } + + private void RunAll(Tick tick) + { + foreach (var system in _systems) + { + if (system is ITickedSystem ticked) + { + ticked.Run(_world, tick); + } + else + { + system.Run(_world); + } + } + } +} \ No newline at end of file diff --git a/src/OECS/Tick.cs b/src/OECS/Tick.cs new file mode 100644 index 0000000..ab74ccb --- /dev/null +++ b/src/OECS/Tick.cs @@ -0,0 +1,50 @@ +namespace OECS; + +/// +/// Describes the kind of tick being processed. +/// +public enum TickType +{ + /// + /// A timed tick, carrying a delta time (e.g., frame-based update). + /// + Timed, + + /// + /// A logical tick, carrying no delta time (e.g., fixed-step simulation). + /// + Logical +} + +/// +/// Data passed to systems during a tick. +/// +public readonly struct Tick +{ + /// + /// The kind of tick. + /// + public TickType Type { get; } + + /// + /// The elapsed time since the last tick, in seconds. + /// Always 0 for logical ticks. + /// + public float DeltaTime { get; } + + private Tick(TickType type, float deltaTime) + { + Type = type; + DeltaTime = deltaTime; + } + + /// + /// Creates a timed tick with the given delta time. + /// + public static Tick Timed(float deltaTime) => new(TickType.Timed, deltaTime); + + /// + /// Creates a logical tick (no delta time). + /// + public static Tick Logical() => new(TickType.Logical, 0f); +} \ No newline at end of file diff --git a/src/OECS/World.cs b/src/OECS/World.cs index c76af7d..7e62c55 100644 --- a/src/OECS/World.cs +++ b/src/OECS/World.cs @@ -89,6 +89,85 @@ public class World : IDisposable return _components.Has(entity); } + // ── Queries ────────────────────────────────────────────────────── + + /// + /// Returns a new for constructing queries. + /// + public QueryBuilder Query() => new(); + + /// + /// Iterates all entities matching the query, providing ref access to + /// one component type. + /// + public void ForEach( + QueryDescriptor query, + ForEachAction action) + where T1 : struct + { + QueryExecutor.ForEach(_components, query, action); + } + + /// + /// Iterates all entities matching the query, providing ref access to + /// two component types. + /// + public void ForEach( + QueryDescriptor query, + ForEachAction action) + where T1 : struct where T2 : struct + { + QueryExecutor.ForEach(_components, query, action); + } + + /// + /// Iterates all entities matching the query, providing ref access to + /// three component types. + /// + public void ForEach( + QueryDescriptor query, + ForEachAction action) + where T1 : struct where T2 : struct where T3 : struct + { + QueryExecutor.ForEach(_components, query, action); + } + + /// + /// Iterates all entities matching the query, providing ref access to + /// four component types. + /// + public void ForEach( + QueryDescriptor query, + ForEachAction action) + where T1 : struct where T2 : struct where T3 : struct where T4 : struct + { + QueryExecutor.ForEach(_components, query, action); + } + + /// + /// Iterates all entities matching the query, providing ref access to + /// five component types. + /// + public void ForEach( + QueryDescriptor query, + ForEachAction action) + where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct + { + QueryExecutor.ForEach(_components, query, action); + } + + /// + /// Iterates all entities matching the query, providing ref access to + /// six component types. + /// + public void ForEach( + QueryDescriptor query, + ForEachAction action) + where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct + { + QueryExecutor.ForEach(_components, query, action); + } + // ── Internal Access ─────────────────────────────────────────────── /// diff --git a/tests/OECS.Tests/QueryAndSystemTests.cs b/tests/OECS.Tests/QueryAndSystemTests.cs new file mode 100644 index 0000000..832273f --- /dev/null +++ b/tests/OECS.Tests/QueryAndSystemTests.cs @@ -0,0 +1,306 @@ +using FluentAssertions; +using Xunit; + +namespace OECS.Tests; + +public class QueryTests +{ + private struct Position { public float X; public float Y; } + private struct Velocity { public float X; public float Y; } + private struct Health { public int Value; } + private struct Frozen { } + + [Fact] + public void Query_WithSingleComponent_ReturnsMatchingEntities() + { + var world = new World(); + var a = world.CreateEntity(); + var b = world.CreateEntity(); + var c = world.CreateEntity(); + + world.AddComponent(a, new Position { X = 1, Y = 2 }); + world.AddComponent(b, new Position { X = 3, Y = 4 }); + // c has no Position + + var query = world.Query().With().Build(); + var results = new List(); + + world.ForEach(query, (Entity entity, ref Position pos) => + { + results.Add(entity); + }); + + results.Should().BeEquivalentTo([a, b]); + } + + [Fact] + public void Query_WithMultipleComponents_ReturnsIntersection() + { + var world = new World(); + var a = world.CreateEntity(); + var b = world.CreateEntity(); + var c = world.CreateEntity(); + + world.AddComponent(a, new Position { X = 1, Y = 2 }); + world.AddComponent(a, new Velocity { X = 1, Y = 0 }); + + world.AddComponent(b, new Position { X = 3, Y = 4 }); + // b has no Velocity + + world.AddComponent(c, new Velocity { X = 0, Y = 1 }); + // c has no Position + + var query = world.Query().With().With().Build(); + var results = new List(); + + world.ForEach(query, (Entity entity, ref Position pos, ref Velocity vel) => + { + results.Add(entity); + }); + + results.Should().BeEquivalentTo([a]); + } + + [Fact] + public void Query_Without_ExcludesEntities() + { + var world = new World(); + var a = world.CreateEntity(); + var b = world.CreateEntity(); + + world.AddComponent(a, new Position { X = 1, Y = 2 }); + world.AddComponent(b, new Position { X = 3, Y = 4 }); + world.AddComponent(b, new Frozen()); + + var query = world.Query().With().Without().Build(); + var results = new List(); + + world.ForEach(query, (Entity entity, ref Position pos) => + { + results.Add(entity); + }); + + results.Should().BeEquivalentTo([a]); + } + + [Fact] + public void Query_EmptyResult_WhenNoEntitiesMatch() + { + var world = new World(); + var query = world.Query().With().Build(); + var count = 0; + + world.ForEach(query, (Entity entity, ref Position pos) => + { + count++; + }); + + count.Should().Be(0); + } + + [Fact] + public void Query_RefMutation_IsVisible() + { + var world = new World(); + var entity = world.CreateEntity(); + world.AddComponent(entity, new Position { X = 0, Y = 0 }); + + var query = world.Query().With().Build(); + + world.ForEach(query, (Entity e, ref Position pos) => + { + pos.X = 42; + }); + + ref var pos = ref world.GetComponent(entity); + pos.X.Should().Be(42); + } + + [Fact] + public void Query_DestroyedEntities_DoNotAppear() + { + var world = new World(); + var a = world.CreateEntity(); + var b = world.CreateEntity(); + + world.AddComponent(a, new Position { X = 1, Y = 2 }); + world.AddComponent(b, new Position { X = 3, Y = 4 }); + + world.DestroyEntity(a); + + var query = world.Query().With().Build(); + var results = new List(); + + world.ForEach(query, (Entity entity, ref Position pos) => + { + results.Add(entity); + }); + + results.Should().BeEquivalentTo([b]); + } + + [Fact] + public void Query_ThreeComponents_Works() + { + 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 }); + + var query = world.Query() + .With() + .With() + .With() + .Build(); + + var count = 0; + world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel, ref Health hp) => + { + count++; + pos.X.Should().Be(1); + vel.Y.Should().Be(4); + hp.Value.Should().Be(100); + }); + + count.Should().Be(1); + } +} + +public class SystemTests +{ + private struct Position { public float X; public float Y; } + private struct Velocity { public float X; public float Y; } + + private class MovementSystem : ITickedSystem + { + public QueryDescriptor Query { get; } + + public MovementSystem(World world) + { + Query = world.Query().With().With().Build(); + } + + public void Run(World world) => Run(world, Tick.Logical()); + + public void Run(World world, Tick tick) + { + float dt = tick.DeltaTime; + world.ForEach(Query, (Entity entity, ref Position pos, ref Velocity vel) => + { + pos.X += vel.X * dt; + pos.Y += vel.Y * dt; + }); + } + } + + [Fact] + public void System_RunTimed_UpdatesComponents() + { + var world = new World(); + var group = new SystemGroup(world); + var system = new MovementSystem(world); + group.Add(system); + + var entity = world.CreateEntity(); + world.AddComponent(entity, new Position { X = 0, Y = 0 }); + world.AddComponent(entity, new Velocity { X = 1, Y = 2 }); + + group.RunTimed(0.5f); + + ref var pos = ref world.GetComponent(entity); + pos.X.Should().Be(0.5f); + pos.Y.Should().Be(1.0f); + } + + [Fact] + public void System_RunLogical_DoesNotUseDeltaTime() + { + var world = new World(); + var group = new SystemGroup(world); + + var system = new TestLogicalSystem(world); + group.Add(system); + + group.RunLogical(); + + system.WasCalled.Should().BeTrue(); + system.ReceivedTick.Type.Should().Be(TickType.Logical); + system.ReceivedTick.DeltaTime.Should().Be(0); + } + + private class TestLogicalSystem : ITickedSystem + { + public QueryDescriptor Query { get; } + public bool WasCalled { get; private set; } + public Tick ReceivedTick { get; private set; } + + public TestLogicalSystem(World world) + { + Query = world.Query().With().Build(); + } + + public void Run(World world) => Run(world, Tick.Logical()); + + public void Run(World world, Tick tick) + { + WasCalled = true; + ReceivedTick = tick; + } + } + + [Fact] + public void Systems_RunInRegistrationOrder() + { + var world = new World(); + var group = new SystemGroup(world); + var order = new List(); + + group.Add(new OrderTrackingSystem(world, 1, order)); + group.Add(new OrderTrackingSystem(world, 2, order)); + group.Add(new OrderTrackingSystem(world, 3, order)); + + group.RunLogical(); + + order.Should().BeInAscendingOrder(); + order.Should().BeEquivalentTo([1, 2, 3]); + } + + private class OrderTrackingSystem : ISystem + { + private readonly int _id; + private readonly List _order; + + public QueryDescriptor Query { get; } + + public OrderTrackingSystem(World world, int id, List order) + { + _id = id; + _order = order; + Query = world.Query().With().Build(); + } + + public void Run(World world) + { + _order.Add(_id); + } + } + + [Fact] + public void System_CanBeRemoved() + { + var world = new World(); + var group = new SystemGroup(world); + var system = new TestLogicalSystem(world); + + group.Add(system); + group.Count.Should().Be(1); + + group.Remove(system); + group.Count.Should().Be(0); + + group.RunLogical(); + system.WasCalled.Should().BeFalse(); + } +}