feat(oecs): Add interrupt mechanism to block system ticks
Introduce IInterrupt marker interface, IInterruptHandlerCommand for resolving interrupts, and InterruptStore. When a pending interrupt exists at the start of a tick, system execution is skipped until resolved. Only one interrupt of a given type may be pending at a time.
This commit is contained in:
parent
1bb290e60c
commit
6207872e45
|
|
@ -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<TestInterrupt>
|
||||||
|
{
|
||||||
|
[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<AnotherInterrupt>
|
||||||
|
{
|
||||||
|
[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<TestInterrupt>().Should().BeFalse();
|
||||||
|
|
||||||
|
world.Interrupt(new TestInterrupt { Message = "hello" });
|
||||||
|
world.HasInterrupt<TestInterrupt>().Should().BeTrue();
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new TestHandler { ShouldResolve = true });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
world.HasInterrupt<TestInterrupt>().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<InvalidOperationException>()
|
||||||
|
.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<TestInterrupt>().Should().BeFalse();
|
||||||
|
world.HasInterrupt<AnotherInterrupt>().Should().BeTrue();
|
||||||
|
|
||||||
|
// Resolve the other.
|
||||||
|
world.Commands.Enqueue(new AnotherHandler { ShouldResolve = true });
|
||||||
|
group.RunLogical();
|
||||||
|
|
||||||
|
world.HasInterrupt<AnotherInterrupt>().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<TestInterrupt>().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<TestInterrupt>().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<TestInterrupt>().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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace OECS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marker interface for interrupt types. An interrupt is a signal issued by a
|
||||||
|
/// system that blocks the next tick until a matching <see cref="IInterruptHandlerCommand{TInterrupt}"/>
|
||||||
|
/// resolves it.
|
||||||
|
/// </summary>
|
||||||
|
public interface IInterrupt { }
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
namespace OECS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A command that resolves a pending interrupt of type <typeparamref name="TInterrupt"/>.
|
||||||
|
///
|
||||||
|
/// The default <see cref="ICommand.Execute"/> implementation looks up the pending
|
||||||
|
/// interrupt, calls <see cref="TryResolve"/> to let the handler inspect it, and
|
||||||
|
/// resolves the interrupt if the handler returns <c>true</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TInterrupt">The interrupt type this handler resolves.</typeparam>
|
||||||
|
public interface IInterruptHandlerCommand<TInterrupt> : ICommand
|
||||||
|
where TInterrupt : struct, IInterrupt
|
||||||
|
{
|
||||||
|
void ICommand.Execute(World world)
|
||||||
|
{
|
||||||
|
if (world.TryGetPendingInterrupt<TInterrupt>(out var interrupt))
|
||||||
|
{
|
||||||
|
if (TryResolve(interrupt))
|
||||||
|
{
|
||||||
|
world.ResolveInterrupt<TInterrupt>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called by the default <see cref="ICommand.Execute"/> with the pending interrupt.
|
||||||
|
/// Return <c>true</c> to resolve and remove the interrupt; <c>false</c> to leave it
|
||||||
|
/// pending for another handler.
|
||||||
|
/// </summary>
|
||||||
|
bool TryResolve(TInterrupt interrupt);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
namespace OECS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Internal store for pending interrupts. Owned by <see cref="SystemGroup"/>
|
||||||
|
/// and injected into <see cref="World"/> so the handler's default Execute
|
||||||
|
/// can access it.
|
||||||
|
/// </summary>
|
||||||
|
internal class InterruptStore
|
||||||
|
{
|
||||||
|
private readonly Dictionary<Type, object> _interrupts = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores an interrupt. Only one interrupt of a given type may be pending.
|
||||||
|
/// </summary>
|
||||||
|
public void Add<T>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to retrieve a pending interrupt of type <typeparamref name="T"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryGet<T>(out T interrupt) where T : struct, IInterrupt
|
||||||
|
{
|
||||||
|
if (_interrupts.TryGetValue(typeof(T), out var boxed))
|
||||||
|
{
|
||||||
|
interrupt = (T)boxed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
interrupt = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the pending interrupt of type <typeparamref name="T"/>.
|
||||||
|
/// No-op if none is pending.
|
||||||
|
/// </summary>
|
||||||
|
public bool Remove<T>() where T : struct, IInterrupt
|
||||||
|
{
|
||||||
|
return _interrupts.Remove(typeof(T));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if any interrupt is currently pending.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasAny => _interrupts.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if an interrupt of type <typeparamref name="T"/> is pending.
|
||||||
|
/// </summary>
|
||||||
|
public bool Has<T>() where T : struct, IInterrupt
|
||||||
|
{
|
||||||
|
return _interrupts.ContainsKey(typeof(T));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears all pending interrupts. Used during world reset or serialization load.
|
||||||
|
/// </summary>
|
||||||
|
internal void Clear()
|
||||||
|
{
|
||||||
|
_interrupts.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,18 +3,25 @@ namespace OECS;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Manages a group of systems, running them in registration order
|
/// Manages a group of systems, running them in registration order
|
||||||
/// against a specific <see cref="World"/>.
|
/// against a specific <see cref="World"/>.
|
||||||
|
///
|
||||||
|
/// Owns the interrupt store — when a pending interrupt exists at the start
|
||||||
|
/// of a tick, system execution is skipped until the interrupt is resolved.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SystemGroup
|
public class SystemGroup
|
||||||
{
|
{
|
||||||
private readonly World _world;
|
private readonly World _world;
|
||||||
private readonly List<ISystem> _systems = new();
|
private readonly List<ISystem> _systems = new();
|
||||||
|
private readonly InterruptStore _interrupts = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a system group bound to the given world.
|
/// Creates a system group bound to the given world.
|
||||||
|
/// Injects the interrupt store into the world so <see cref="World.Interrupt{T}"/>
|
||||||
|
/// and <see cref="IInterruptHandlerCommand{TInterrupt}"/> can route through it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public SystemGroup(World world)
|
public SystemGroup(World world)
|
||||||
{
|
{
|
||||||
_world = world ?? throw new ArgumentNullException(nameof(world));
|
_world = world ?? throw new ArgumentNullException(nameof(world));
|
||||||
|
_world._interruptStore = _interrupts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -59,9 +66,19 @@ public class SystemGroup
|
||||||
private void RunAll(Tick tick)
|
private void RunAll(Tick tick)
|
||||||
{
|
{
|
||||||
// Drain commands enqueued before the tick so the first system
|
// 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();
|
_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();
|
_world.BeginBatching();
|
||||||
foreach (var system in _systems)
|
foreach (var system in _systems)
|
||||||
{
|
{
|
||||||
|
|
@ -94,4 +111,4 @@ public class SystemGroup
|
||||||
// Post any remaining changes (e.g., from command execution).
|
// Post any remaining changes (e.g., from command execution).
|
||||||
_world.PostChanges();
|
_world.PostChanges();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,10 @@ public class World : IDisposable
|
||||||
private readonly HashSet<(Entity Entity, Type ComponentType)> _accessedComponents = new();
|
private readonly HashSet<(Entity Entity, Type ComponentType)> _accessedComponents = new();
|
||||||
private readonly HashSet<(Entity Entity, Type ComponentType)> _markedModified = 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()
|
public World()
|
||||||
{
|
{
|
||||||
_allocator = new EntityAllocator();
|
_allocator = new EntityAllocator();
|
||||||
|
|
@ -490,6 +494,52 @@ public class World : IDisposable
|
||||||
_commands.ExecuteAll(this);
|
_commands.ExecuteAll(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Interrupts ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issues an interrupt of type <typeparamref name="T"/>, blocking the
|
||||||
|
/// next tick from running systems until a matching
|
||||||
|
/// <see cref="IInterruptHandlerCommand{TInterrupt}"/> resolves it.
|
||||||
|
///
|
||||||
|
/// Only one interrupt of a given type may be pending at a time.
|
||||||
|
/// If no <see cref="SystemGroup"/> is managing this world, interrupts
|
||||||
|
/// are silently ignored (calls are no-ops).
|
||||||
|
/// </summary>
|
||||||
|
public void Interrupt<T>(T interrupt) where T : struct, IInterrupt
|
||||||
|
{
|
||||||
|
_interruptStore?.Add(interrupt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if an interrupt of type <typeparamref name="T"/> is
|
||||||
|
/// currently pending.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasInterrupt<T>() where T : struct, IInterrupt
|
||||||
|
{
|
||||||
|
return _interruptStore != null && _interruptStore.Has<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a pending interrupt for the handler's default Execute.
|
||||||
|
/// </summary>
|
||||||
|
internal bool TryGetPendingInterrupt<T>(out T interrupt) where T : struct, IInterrupt
|
||||||
|
{
|
||||||
|
if (_interruptStore != null)
|
||||||
|
return _interruptStore.TryGet(out interrupt);
|
||||||
|
|
||||||
|
interrupt = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves and removes a pending interrupt. Called by the default
|
||||||
|
/// <see cref="IInterruptHandlerCommand{TInterrupt}.TryResolve"/> implementation.
|
||||||
|
/// </summary>
|
||||||
|
internal void ResolveInterrupt<T>() where T : struct, IInterrupt
|
||||||
|
{
|
||||||
|
_interruptStore?.Remove<T>();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Iteration Lifecycle ──────────────────────────────────────────
|
// ── Iteration Lifecycle ──────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -616,4 +666,4 @@ public class World : IDisposable
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
_changes.Dispose();
|
_changes.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue