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:
hyper 2026-07-22 15:59:13 +08:00
parent 1bb290e60c
commit 6207872e45
6 changed files with 413 additions and 3 deletions

View File

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

8
OECS/IInterrupt.cs Normal file
View File

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

View File

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

69
OECS/InterruptStore.cs Normal file
View File

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

View File

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

View File

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