diff --git a/src/OECS/CommandQueue.cs b/src/OECS/CommandQueue.cs new file mode 100644 index 0000000..09d2a8f --- /dev/null +++ b/src/OECS/CommandQueue.cs @@ -0,0 +1,74 @@ +namespace OECS; + +/// +/// A FIFO queue of instances with deferred execution. +/// +/// Commands are executed in the order they were enqueued. If a command's +/// throws, the exception is caught and stored; +/// remaining commands continue to execute. +/// +/// Commands enqueued during are processed in the +/// same drain cycle — the queue keeps draining until empty. +/// +public class CommandQueue +{ + private readonly List _commands = new(); + private readonly List _errors = new(); + + /// + /// Number of commands currently waiting in the queue. + /// + public int Count => _commands.Count; + + /// + /// Exceptions thrown by commands during the most recent call. + /// Cleared at the start of each drain cycle. + /// + public IReadOnlyList Errors => _errors; + + /// + /// Adds a command to the end of the queue. + /// + public void Enqueue(ICommand command) + { + _commands.Add(command); + } + + /// + /// Executes all queued commands in FIFO order against the given world. + /// + /// The queue is fully drained — commands enqueued by other commands during + /// this call are also executed before the method returns. + /// + public void ExecuteAll(World world) + { + _errors.Clear(); + + int index = 0; + while (index < _commands.Count) + { + var command = _commands[index]; + index++; + + try + { + command.Execute(world); + } + catch (Exception ex) + { + _errors.Add(ex); + } + } + + _commands.Clear(); + } + + /// + /// Clears all queued commands without executing them. + /// + public void Clear() + { + _commands.Clear(); + _errors.Clear(); + } +} diff --git a/src/OECS/ICommand.cs b/src/OECS/ICommand.cs new file mode 100644 index 0000000..571029f --- /dev/null +++ b/src/OECS/ICommand.cs @@ -0,0 +1,14 @@ +namespace OECS; + +/// +/// A serializable command that can be enqueued and executed against a . +/// Implementations should be [MessagePackObject] structs so they can be +/// serialized and replayed. +/// +public interface ICommand +{ + /// + /// Executes the command against the given world. + /// + void Execute(World world); +} diff --git a/src/OECS/SystemGroup.cs b/src/OECS/SystemGroup.cs index 0e4043b..bf424ef 100644 --- a/src/OECS/SystemGroup.cs +++ b/src/OECS/SystemGroup.cs @@ -68,6 +68,13 @@ public class SystemGroup { system.Run(_world); } + + // Drain commands after each system so subsequent systems + // see the effects of commands enqueued by prior systems. + _world.ExecuteCommands(); } + + // Drain any remaining commands (e.g., those enqueued outside systems). + _world.ExecuteCommands(); } } \ No newline at end of file diff --git a/src/OECS/World.cs b/src/OECS/World.cs index 7e62c55..dd62cdb 100644 --- a/src/OECS/World.cs +++ b/src/OECS/World.cs @@ -10,11 +10,13 @@ public class World : IDisposable { private readonly EntityAllocator _allocator; private readonly ComponentStore _components; + private readonly CommandQueue _commands; public World() { _allocator = new EntityAllocator(); _components = new ComponentStore(); + _commands = new CommandQueue(); } // ── Entity Management ──────────────────────────────────────────── @@ -89,6 +91,24 @@ public class World : IDisposable return _components.Has(entity); } + // ── Commands ────────────────────────────────────────────────────── + + /// + /// The command queue for deferred, serializable commands. + /// Systems enqueue commands via world.Commands.Enqueue(...). + /// + public CommandQueue Commands => _commands; + + /// + /// Manually drains the command queue, executing all pending commands. + /// Called automatically by after each system + /// and after the full tick. + /// + public void ExecuteCommands() + { + _commands.ExecuteAll(this); + } + // ── Queries ────────────────────────────────────────────────────── /// diff --git a/tests/OECS.Tests/CommandTests.cs b/tests/OECS.Tests/CommandTests.cs new file mode 100644 index 0000000..a3626a2 --- /dev/null +++ b/tests/OECS.Tests/CommandTests.cs @@ -0,0 +1,286 @@ +using FluentAssertions; +using MessagePack; +using Xunit; + +namespace OECS.Tests; + +// ── Test components ────────────────────────────────────────────────── + +[MessagePackObject] +public struct TestPosition : IMessagePackSerializationCallbackReceiver +{ + [Key(0)] public float X { get; set; } + [Key(1)] public float Y { get; set; } + + public void OnBeforeSerialize() { } + public void OnAfterDeserialize() { } +} + +[MessagePackObject] +public struct TestHealth : IMessagePackSerializationCallbackReceiver +{ + [Key(0)] public int Value { get; set; } + + public void OnBeforeSerialize() { } + public void OnAfterDeserialize() { } +} + +// ── Test commands ──────────────────────────────────────────────────── + +[MessagePackObject] +public struct SpawnCommand : ICommand +{ + [Key(0)] public float X; + [Key(1)] public float Y; + [Key(2)] public int Health; + + public void Execute(World world) + { + var entity = world.CreateEntity(); + world.AddComponent(entity, new TestPosition { X = X, Y = Y }); + world.AddComponent(entity, new TestHealth { Value = Health }); + } +} + +[MessagePackObject] +public struct MoveCommand : ICommand +{ + [Key(0)] public Entity Entity; + [Key(1)] public float Dx; + [Key(2)] public float Dy; + + public void Execute(World world) + { + ref var pos = ref world.GetComponent(Entity); + pos.X += Dx; + pos.Y += Dy; + } +} + +[MessagePackObject] +public struct DestroyCommand : ICommand +{ + [Key(0)] public Entity Entity; + + public void Execute(World world) + { + world.DestroyEntity(Entity); + } +} + +[MessagePackObject] +public struct FailingCommand : ICommand +{ + [Key(0)] public string Message; + + public void Execute(World world) + { + throw new InvalidOperationException(Message); + } +} + +[MessagePackObject] +public struct ChainedCommand : ICommand +{ + [Key(0)] public float X; + [Key(1)] public float Y; + + public void Execute(World world) + { + var entity = world.CreateEntity(); + world.AddComponent(entity, new TestPosition { X = X, Y = Y }); + + // Enqueue a follow-up command during execution. + world.Commands.Enqueue(new MoveCommand + { + Entity = entity, + Dx = 10, + Dy = 20 + }); + } +} + +// ── Test class ─────────────────────────────────────────────────────── + +public class CommandTests +{ + [Fact] + public void Commands_ExecuteInFifoOrder() + { + var world = new World(); + + world.Commands.Enqueue(new SpawnCommand { X = 1, Y = 2, Health = 100 }); + world.Commands.Enqueue(new SpawnCommand { X = 3, Y = 4, Health = 200 }); + + world.ExecuteCommands(); + + var query = world.Query().With().With().Build(); + var positions = new List<(float X, float Y, int Health)>(); + + world.ForEach(query, (Entity e, ref TestPosition pos, ref TestHealth hp) => + { + positions.Add((pos.X, pos.Y, hp.Value)); + }); + + positions.Should().BeEquivalentTo([ + (1, 2, 100), + (3, 4, 200) + ]); + } + + [Fact] + public void Command_CanReadWriteEcsState() + { + var world = new World(); + var entity = world.CreateEntity(); + world.AddComponent(entity, new TestPosition { X = 0, Y = 0 }); + + world.Commands.Enqueue(new MoveCommand { Entity = entity, Dx = 5, Dy = 10 }); + world.ExecuteCommands(); + + ref var pos = ref world.GetComponent(entity); + pos.X.Should().Be(5); + pos.Y.Should().Be(10); + } + + [Fact] + public void Command_EnqueuedDuringExecuteAll_RunsInSameDrainCycle() + { + var world = new World(); + + world.Commands.Enqueue(new ChainedCommand { X = 0, Y = 0 }); + world.ExecuteCommands(); + + var query = world.Query().With().Build(); + var positions = new List<(float X, float Y)>(); + + world.ForEach(query, (Entity e, ref TestPosition pos) => + { + positions.Add((pos.X, pos.Y)); + }); + + positions.Should().BeEquivalentTo([(10, 20)]); + } + + [Fact] + public void Exceptions_AreCollected_NotLost() + { + var world = new World(); + + world.Commands.Enqueue(new SpawnCommand { X = 1, Y = 2, Health = 100 }); + world.Commands.Enqueue(new FailingCommand { Message = "boom" }); + world.Commands.Enqueue(new SpawnCommand { X = 3, Y = 4, Health = 200 }); + + world.ExecuteCommands(); + + var query = world.Query().With().Build(); + var count = 0; + world.ForEach(query, (Entity e, ref TestPosition pos) => count++); + count.Should().Be(2); + + world.Commands.Errors.Should().HaveCount(1); + world.Commands.Errors[0].Should().BeOfType() + .Which.Message.Should().Be("boom"); + } + + [Fact] + public void Serialization_RoundTrip_PreservesCommandData() + { + var original = new SpawnCommand { X = 42, Y = 99, Health = 500 }; + + var bytes = MessagePackSerializer.Serialize(original); + var deserialized = MessagePackSerializer.Deserialize(bytes); + + deserialized.X.Should().Be(42); + deserialized.Y.Should().Be(99); + deserialized.Health.Should().Be(500); + } + + [Fact] + public void Command_CanDestroyEntity() + { + var world = new World(); + var entity = world.CreateEntity(); + world.AddComponent(entity, new TestPosition { X = 1, Y = 2 }); + + world.Commands.Enqueue(new DestroyCommand { Entity = entity }); + world.ExecuteCommands(); + + world.IsAlive(entity).Should().BeFalse(); + } + + [Fact] + public void Clear_DiscardsPendingCommands() + { + var world = new World(); + + world.Commands.Enqueue(new SpawnCommand { X = 1, Y = 2, Health = 100 }); + world.Commands.Count.Should().Be(1); + + world.Commands.Clear(); + world.Commands.Count.Should().Be(0); + + world.ExecuteCommands(); + + var query = world.Query().With().Build(); + var count = 0; + world.ForEach(query, (Entity e, ref TestPosition pos) => count++); + count.Should().Be(0); + } + + [Fact] + public void Commands_AreDrainedAfterEachSystem() + { + var world = new World(); + var group = new SystemGroup(world); + + group.Add(new CommandEnqueueSystem(world, new SpawnCommand { X = 1, Y = 2, Health = 100 })); + + var observer = new EntityCountObserver(world); + group.Add(observer); + + group.RunLogical(); + + observer.SeenCount.Should().Be(1); + } + + private class CommandEnqueueSystem : ISystem + { + private readonly World _world; + private readonly ICommand _command; + + public QueryDescriptor Query { get; } + + public CommandEnqueueSystem(World world, ICommand command) + { + _world = world; + _command = command; + Query = world.Query().With().Build(); + } + + public void Run(World world) + { + _world.Commands.Enqueue(_command); + } + } + + private class EntityCountObserver : ISystem + { + public int SeenCount { get; private set; } + + public QueryDescriptor Query { get; } + + public EntityCountObserver(World world) + { + Query = world.Query().With().Build(); + } + + public void Run(World world) + { + world.ForEach(Query, (Entity e, ref TestPosition pos) => + { + SeenCount++; + }); + } + } +}