feat: add deferred command queue system
This commit is contained in:
parent
e4afaafd02
commit
c5578d4412
|
|
@ -0,0 +1,74 @@
|
||||||
|
namespace OECS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A FIFO queue of <see cref="ICommand"/> instances with deferred execution.
|
||||||
|
///
|
||||||
|
/// Commands are executed in the order they were enqueued. If a command's
|
||||||
|
/// <see cref="ICommand.Execute"/> throws, the exception is caught and stored;
|
||||||
|
/// remaining commands continue to execute.
|
||||||
|
///
|
||||||
|
/// Commands enqueued during <see cref="ExecuteAll"/> are processed in the
|
||||||
|
/// same drain cycle — the queue keeps draining until empty.
|
||||||
|
/// </summary>
|
||||||
|
public class CommandQueue
|
||||||
|
{
|
||||||
|
private readonly List<ICommand> _commands = new();
|
||||||
|
private readonly List<Exception> _errors = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of commands currently waiting in the queue.
|
||||||
|
/// </summary>
|
||||||
|
public int Count => _commands.Count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exceptions thrown by commands during the most recent <see cref="ExecuteAll"/> call.
|
||||||
|
/// Cleared at the start of each drain cycle.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<Exception> Errors => _errors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a command to the end of the queue.
|
||||||
|
/// </summary>
|
||||||
|
public void Enqueue(ICommand command)
|
||||||
|
{
|
||||||
|
_commands.Add(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears all queued commands without executing them.
|
||||||
|
/// </summary>
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
_commands.Clear();
|
||||||
|
_errors.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
namespace OECS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A serializable command that can be enqueued and executed against a <see cref="World"/>.
|
||||||
|
/// Implementations should be <c>[MessagePackObject]</c> structs so they can be
|
||||||
|
/// serialized and replayed.
|
||||||
|
/// </summary>
|
||||||
|
public interface ICommand
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Executes the command against the given world.
|
||||||
|
/// </summary>
|
||||||
|
void Execute(World world);
|
||||||
|
}
|
||||||
|
|
@ -68,6 +68,13 @@ public class SystemGroup
|
||||||
{
|
{
|
||||||
system.Run(_world);
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -10,11 +10,13 @@ public class World : IDisposable
|
||||||
{
|
{
|
||||||
private readonly EntityAllocator _allocator;
|
private readonly EntityAllocator _allocator;
|
||||||
private readonly ComponentStore _components;
|
private readonly ComponentStore _components;
|
||||||
|
private readonly CommandQueue _commands;
|
||||||
|
|
||||||
public World()
|
public World()
|
||||||
{
|
{
|
||||||
_allocator = new EntityAllocator();
|
_allocator = new EntityAllocator();
|
||||||
_components = new ComponentStore();
|
_components = new ComponentStore();
|
||||||
|
_commands = new CommandQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Entity Management ────────────────────────────────────────────
|
// ── Entity Management ────────────────────────────────────────────
|
||||||
|
|
@ -89,6 +91,24 @@ public class World : IDisposable
|
||||||
return _components.Has<T>(entity);
|
return _components.Has<T>(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Commands ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The command queue for deferred, serializable commands.
|
||||||
|
/// Systems enqueue commands via <c>world.Commands.Enqueue(...)</c>.
|
||||||
|
/// </summary>
|
||||||
|
public CommandQueue Commands => _commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manually drains the command queue, executing all pending commands.
|
||||||
|
/// Called automatically by <see cref="SystemGroup"/> after each system
|
||||||
|
/// and after the full tick.
|
||||||
|
/// </summary>
|
||||||
|
public void ExecuteCommands()
|
||||||
|
{
|
||||||
|
_commands.ExecuteAll(this);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Queries ──────────────────────────────────────────────────────
|
// ── Queries ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -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<TestPosition>(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<TestPosition>().With<TestHealth>().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<TestPosition>(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<TestPosition>().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<TestPosition>().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<InvalidOperationException>()
|
||||||
|
.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<SpawnCommand>(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<TestPosition>().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<TestPosition>().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<TestPosition>().Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Run(World world)
|
||||||
|
{
|
||||||
|
world.ForEach(Query, (Entity e, ref TestPosition pos) =>
|
||||||
|
{
|
||||||
|
SeenCount++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue