feat: add deferred command queue system

This commit is contained in:
hypercross 2026-07-18 19:38:10 +08:00
parent e4afaafd02
commit c5578d4412
5 changed files with 401 additions and 0 deletions

74
src/OECS/CommandQueue.cs Normal file
View File

@ -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();
}
}

14
src/OECS/ICommand.cs Normal file
View File

@ -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);
}

View File

@ -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();
} }
} }

View File

@ -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>

View File

@ -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++;
});
}
}
}