diff --git a/OECS.Tests/InterruptTests.cs b/OECS.Tests/InterruptTests.cs new file mode 100644 index 0000000..d383dc2 --- /dev/null +++ b/OECS.Tests/InterruptTests.cs @@ -0,0 +1,235 @@ +using FluentAssertions; +using Xunit; + +namespace OECS.Tests; + +public class InterruptTests +{ + // ── Test types ── + + private record struct TestInterrupt : IInterrupt + { + public string Message; + } + + [MessagePack.MessagePackObject] + private struct TestHandler : IInterruptHandlerCommand + { + [MessagePack.Key(0)] + public bool ShouldResolve; + + public bool TryResolve(TestInterrupt interrupt) + { + return ShouldResolve; + } + } + + private record struct AnotherInterrupt : IInterrupt + { + public int Value; + } + + [MessagePack.MessagePackObject] + private struct AnotherHandler : IInterruptHandlerCommand + { + [MessagePack.Key(0)] + public bool ShouldResolve; + + public bool TryResolve(AnotherInterrupt interrupt) + { + return ShouldResolve; + } + } + + // ── Issue and resolve ── + + [Fact] + public void Interrupt_blocks_next_tick() + { + var world = new World(); + var group = new SystemGroup(world); + + var systemRan = false; + group.Add(new TestSystem(() => systemRan = true)); + + // Issue an interrupt. + world.Interrupt(new TestInterrupt { Message = "hello" }); + + // Run a tick — systems should be skipped. + group.RunLogical(); + + systemRan.Should().BeFalse(); + } + + [Fact] + public void Interrupt_resolved_by_handler_unblocks_world() + { + var world = new World(); + var group = new SystemGroup(world); + + var systemRan = false; + group.Add(new TestSystem(() => systemRan = true)); + + // Issue an interrupt. + world.Interrupt(new TestInterrupt { Message = "hello" }); + group.RunLogical(); + systemRan.Should().BeFalse(); + + // Enqueue a handler that resolves it. + world.Commands.Enqueue(new TestHandler { ShouldResolve = true }); + group.RunLogical(); + systemRan.Should().BeTrue(); + } + + [Fact] + public void Interrupt_handler_rejects_leaves_interrupt_pending() + { + var world = new World(); + var group = new SystemGroup(world); + + var systemRan = false; + group.Add(new TestSystem(() => systemRan = true)); + + world.Interrupt(new TestInterrupt { Message = "hello" }); + group.RunLogical(); + systemRan.Should().BeFalse(); + + // Handler rejects — interrupt stays pending. + world.Commands.Enqueue(new TestHandler { ShouldResolve = false }); + group.RunLogical(); + systemRan.Should().BeFalse(); + + // Second handler accepts. + world.Commands.Enqueue(new TestHandler { ShouldResolve = true }); + group.RunLogical(); + systemRan.Should().BeTrue(); + } + + [Fact] + public void HasInterrupt_returns_true_for_pending_interrupt() + { + var world = new World(); + var group = new SystemGroup(world); + + world.HasInterrupt().Should().BeFalse(); + + world.Interrupt(new TestInterrupt { Message = "hello" }); + world.HasInterrupt().Should().BeTrue(); + + world.Commands.Enqueue(new TestHandler { ShouldResolve = true }); + group.RunLogical(); + + world.HasInterrupt().Should().BeFalse(); + } + + [Fact] + public void Duplicate_interrupt_type_throws() + { + var world = new World(); + var group = new SystemGroup(world); + + world.Interrupt(new TestInterrupt { Message = "first" }); + + var act = () => world.Interrupt(new TestInterrupt { Message = "second" }); + act.Should().Throw() + .WithMessage("*TestInterrupt*"); + } + + [Fact] + public void Multiple_interrupt_types_independent() + { + var world = new World(); + var group = new SystemGroup(world); + + world.Interrupt(new TestInterrupt { Message = "a" }); + world.Interrupt(new AnotherInterrupt { Value = 42 }); + + // Resolve only one. + world.Commands.Enqueue(new TestHandler { ShouldResolve = true }); + group.RunLogical(); + + // TestInterrupt resolved, AnotherInterrupt still pending. + world.HasInterrupt().Should().BeFalse(); + world.HasInterrupt().Should().BeTrue(); + + // Resolve the other. + world.Commands.Enqueue(new AnotherHandler { ShouldResolve = true }); + group.RunLogical(); + + world.HasInterrupt().Should().BeFalse(); + } + + [Fact] + public void Handler_receives_correct_interrupt_data() + { + var world = new World(); + var group = new SystemGroup(world); + + world.Interrupt(new TestInterrupt { Message = "hello" }); + + // Enqueue handler and run — the default Execute calls TryResolve + // with the interrupt data. + world.Commands.Enqueue(new TestHandler { ShouldResolve = true }); + group.RunLogical(); + + // Interrupt was resolved — the TryResolve received the correct data. + world.HasInterrupt().Should().BeFalse(); + } + + [Fact] + public void Interrupt_without_system_group_is_noop() + { + var world = new World(); + + // No SystemGroup — interrupt is silently ignored. + world.Interrupt(new TestInterrupt { Message = "hello" }); + world.HasInterrupt().Should().BeFalse(); + } + + [Fact] + public void Commands_still_execute_when_blocked() + { + var world = new World(); + var group = new SystemGroup(world); + + var commandExecuted = false; + var handler = new TestHandler { ShouldResolve = true }; + world.Interrupt(new TestInterrupt { Message = "hello" }); + + // Enqueue both a handler and a regular command. + world.Commands.Enqueue(new TestHandler { ShouldResolve = true }); + world.Commands.Enqueue(new SetFlagCommand(() => commandExecuted = true)); + + group.RunLogical(); + + // Both should have executed. + world.HasInterrupt().Should().BeFalse(); + commandExecuted.Should().BeTrue(); + } + + // ── Helpers ── + + private class TestSystem : ISystem + { + private readonly Action _action; + public TestSystem(Action action) => _action = action; + public void RunImpl(World world) => _action(); + } + + [MessagePack.MessagePackObject] + private struct SetFlagCommand : ICommand + { + private Action? _action; + + [MessagePack.IgnoreMember] + public Action Action + { + get => _action!; + set => _action = value; + } + + public SetFlagCommand(Action action) => _action = action; + + public void Execute(World world) => _action?.Invoke(); + } +} diff --git a/OECS/IInterrupt.cs b/OECS/IInterrupt.cs new file mode 100644 index 0000000..b7c7205 --- /dev/null +++ b/OECS/IInterrupt.cs @@ -0,0 +1,8 @@ +namespace OECS; + +/// +/// Marker interface for interrupt types. An interrupt is a signal issued by a +/// system that blocks the next tick until a matching +/// resolves it. +/// +public interface IInterrupt { } diff --git a/OECS/IInterruptHandlerCommand.cs b/OECS/IInterruptHandlerCommand.cs new file mode 100644 index 0000000..dbfd5c5 --- /dev/null +++ b/OECS/IInterruptHandlerCommand.cs @@ -0,0 +1,31 @@ +namespace OECS; + +/// +/// A command that resolves a pending interrupt of type . +/// +/// The default implementation looks up the pending +/// interrupt, calls to let the handler inspect it, and +/// resolves the interrupt if the handler returns true. +/// +/// The interrupt type this handler resolves. +public interface IInterruptHandlerCommand : ICommand + where TInterrupt : struct, IInterrupt +{ + void ICommand.Execute(World world) + { + if (world.TryGetPendingInterrupt(out var interrupt)) + { + if (TryResolve(interrupt)) + { + world.ResolveInterrupt(); + } + } + } + + /// + /// Called by the default with the pending interrupt. + /// Return true to resolve and remove the interrupt; false to leave it + /// pending for another handler. + /// + bool TryResolve(TInterrupt interrupt); +} diff --git a/OECS/InterruptStore.cs b/OECS/InterruptStore.cs new file mode 100644 index 0000000..55604c2 --- /dev/null +++ b/OECS/InterruptStore.cs @@ -0,0 +1,69 @@ +namespace OECS; + +/// +/// Internal store for pending interrupts. Owned by +/// and injected into so the handler's default Execute +/// can access it. +/// +internal class InterruptStore +{ + private readonly Dictionary _interrupts = new(); + + /// + /// Stores an interrupt. Only one interrupt of a given type may be pending. + /// + public void Add(T interrupt) where T : struct, IInterrupt + { + var type = typeof(T); + if (_interrupts.ContainsKey(type)) + throw new InvalidOperationException( + $"An interrupt of type {type.Name} is already pending."); + + _interrupts[type] = interrupt; + } + + /// + /// Tries to retrieve a pending interrupt of type . + /// + public bool TryGet(out T interrupt) where T : struct, IInterrupt + { + if (_interrupts.TryGetValue(typeof(T), out var boxed)) + { + interrupt = (T)boxed; + return true; + } + + interrupt = default; + return false; + } + + /// + /// Removes the pending interrupt of type . + /// No-op if none is pending. + /// + public bool Remove() where T : struct, IInterrupt + { + return _interrupts.Remove(typeof(T)); + } + + /// + /// True if any interrupt is currently pending. + /// + public bool HasAny => _interrupts.Count > 0; + + /// + /// True if an interrupt of type is pending. + /// + public bool Has() where T : struct, IInterrupt + { + return _interrupts.ContainsKey(typeof(T)); + } + + /// + /// Clears all pending interrupts. Used during world reset or serialization load. + /// + internal void Clear() + { + _interrupts.Clear(); + } +} diff --git a/OECS/SystemGroup.cs b/OECS/SystemGroup.cs index d110d67..a4bdebf 100644 --- a/OECS/SystemGroup.cs +++ b/OECS/SystemGroup.cs @@ -3,18 +3,25 @@ namespace OECS; /// /// Manages a group of systems, running them in registration order /// against a specific . +/// +/// Owns the interrupt store — when a pending interrupt exists at the start +/// of a tick, system execution is skipped until the interrupt is resolved. /// public class SystemGroup { private readonly World _world; private readonly List _systems = new(); + private readonly InterruptStore _interrupts = new(); /// /// Creates a system group bound to the given world. + /// Injects the interrupt store into the world so + /// and can route through it. /// public SystemGroup(World world) { _world = world ?? throw new ArgumentNullException(nameof(world)); + _world._interruptStore = _interrupts; } /// @@ -59,9 +66,19 @@ public class SystemGroup private void RunAll(Tick tick) { // Drain commands enqueued before the tick so the first system - // sees their effects. + // sees their effects. This also processes interrupt handler + // commands that may resolve pending interrupts. _world.ExecuteCommands(); + // If an interrupt is still pending after draining commands, + // skip all systems for this tick. The world is fully consistent + // (the previous tick completed) and is safe to serialize. + if (_interrupts.HasAny) + { + _world.PostChanges(); + return; + } + _world.BeginBatching(); foreach (var system in _systems) { @@ -94,4 +111,4 @@ public class SystemGroup // Post any remaining changes (e.g., from command execution). _world.PostChanges(); } -} \ No newline at end of file +} diff --git a/OECS/World.cs b/OECS/World.cs index 5f5e661..8c0c7fd 100644 --- a/OECS/World.cs +++ b/OECS/World.cs @@ -43,6 +43,10 @@ public class World : IDisposable private readonly HashSet<(Entity Entity, Type ComponentType)> _accessedComponents = new(); private readonly HashSet<(Entity Entity, Type ComponentType)> _markedModified = new(); + // Interrupt store: injected by SystemGroup. Null when no SystemGroup is managing + // this world, in which case Interrupt() is a no-op. + internal InterruptStore? _interruptStore; + public World() { _allocator = new EntityAllocator(); @@ -490,6 +494,52 @@ public class World : IDisposable _commands.ExecuteAll(this); } + // ── Interrupts ──────────────────────────────────────────────────── + + /// + /// Issues an interrupt of type , blocking the + /// next tick from running systems until a matching + /// resolves it. + /// + /// Only one interrupt of a given type may be pending at a time. + /// If no is managing this world, interrupts + /// are silently ignored (calls are no-ops). + /// + public void Interrupt(T interrupt) where T : struct, IInterrupt + { + _interruptStore?.Add(interrupt); + } + + /// + /// Returns true if an interrupt of type is + /// currently pending. + /// + public bool HasInterrupt() where T : struct, IInterrupt + { + return _interruptStore != null && _interruptStore.Has(); + } + + /// + /// Retrieves a pending interrupt for the handler's default Execute. + /// + internal bool TryGetPendingInterrupt(out T interrupt) where T : struct, IInterrupt + { + if (_interruptStore != null) + return _interruptStore.TryGet(out interrupt); + + interrupt = default; + return false; + } + + /// + /// Resolves and removes a pending interrupt. Called by the default + /// implementation. + /// + internal void ResolveInterrupt() where T : struct, IInterrupt + { + _interruptStore?.Remove(); + } + // ── Iteration Lifecycle ────────────────────────────────────────── /// @@ -616,4 +666,4 @@ public class World : IDisposable _disposed = true; _changes.Dispose(); } -} \ No newline at end of file +}