feat(docs): Add interrupt system documentation

This commit is contained in:
hyper 2026-07-22 16:01:36 +08:00
parent 6207872e45
commit 8a32588c54
4 changed files with 212 additions and 1 deletions

View File

@ -248,3 +248,55 @@ tracking. Outside a batching scope, call `MarkModified` after mutating.
`WorldSerializer.Save/Load` uses the source-generated `ComponentRegistry`. `WorldSerializer.Save/Load` uses the source-generated `ComponentRegistry`.
All component types used with `World` generic methods are automatically All component types used with `World` generic methods are automatically
discovered. Serialization round-trips must be tested — see `testing-games` skill. discovered. Serialization round-trips must be tested — see `testing-games` skill.
## Interrupts
Interrupts pause system execution until an external response arrives. A system
issues an interrupt, the next tick is skipped, and a handler command resolves it.
### Issuing an Interrupt
```csharp
[MessagePackObject]
public record struct ConfirmInterrupt : IInterrupt
{
[Key(0)] public string Message;
}
// In a system:
world.Interrupt(new ConfirmInterrupt { Message = "Are you sure?" });
```
The current tick completes normally. The **next** tick is blocked — `SystemGroup`
skips all systems but still drains commands and posts changes.
### Handling an Interrupt
Handler commands implement `IInterruptHandlerCommand<T>`. The default `Execute`
calls `TryResolve` with the pending interrupt. Return `true` to resolve it,
`false` to leave it pending for another handler:
```csharp
[MessagePackObject]
public struct ConfirmHandler : IInterruptHandlerCommand<ConfirmInterrupt>
{
[Key(0)] public bool Confirmed;
public bool TryResolve(ConfirmInterrupt interrupt)
{
// Do work here. The interrupt is just a signal.
return true;
}
}
// External code (e.g., UI button) enqueues the handler:
world.Commands.Enqueue(new ConfirmHandler { Confirmed = true });
```
### Key Rules
- Only one interrupt of a given type may be pending at a time. A second call
to `Interrupt<T>` with the same type throws.
- Interrupts are transient — they are not serialized and do not survive save/load.
- If no `SystemGroup` manages the world, `Interrupt<T>()` is a no-op.
- Check pending interrupts with `world.HasInterrupt<T>()`.

View File

@ -67,6 +67,10 @@ public class World : IDisposable
public CommandQueue Commands { get; } public CommandQueue Commands { get; }
public void ExecuteCommands(); public void ExecuteCommands();
// --- Interrupts ---
public void Interrupt<T>(T interrupt) where T : struct, IInterrupt;
public bool HasInterrupt<T>() where T : struct, IInterrupt;
// --- Reactivity --- // --- Reactivity ---
public void MarkModified<T>(Entity entity) where T : struct; public void MarkModified<T>(Entity entity) where T : struct;
public void PostChanges(); public void PostChanges();
@ -322,6 +326,52 @@ command so chained commands see each other's changes within the same drain cycle
--- ---
## IInterrupt
```csharp
namespace OECS;
public interface IInterrupt { }
```
Marker interface for interrupt types. Interrupts are issued by systems to
block the next tick until a matching handler command resolves them.
---
## IInterruptHandlerCommand\<TInterrupt\>
```csharp
namespace OECS;
public interface IInterruptHandlerCommand<TInterrupt> : ICommand
where TInterrupt : struct, IInterrupt
{
bool TryResolve(TInterrupt interrupt);
}
```
A command that resolves a pending interrupt. The default `ICommand.Execute`
looks up the pending interrupt, calls `TryResolve`, and resolves the
interrupt if `TryResolve` returns `true`.
Usage:
```csharp
[MessagePackObject]
public struct ConfirmHandler : IInterruptHandlerCommand<ConfirmInterrupt>
{
[Key(0)] public bool Confirmed;
public bool TryResolve(ConfirmInterrupt interrupt)
{
// Do work here. Return false to reject.
return true;
}
}
```
---
## IRelationship ## IRelationship
```csharp ```csharp

View File

@ -385,3 +385,53 @@ package is included for compile-time validation.
- The `MessagePackAnalyzer` package is a compile-time dependency. - The `MessagePackAnalyzer` package is a compile-time dependency.
- `[Key]` indices should be sequential starting from 0 to avoid null - `[Key]` indices should be sequential starting from 0 to avoid null
placeholders in the binary output. placeholders in the binary output.
---
## ADR-013: Interrupts for System-Blocking Prompts
**Status:** Accepted
**Context:** Systems sometimes need to pause execution until an external
response arrives (e.g., a UI confirmation dialog). The pause must be
serialization-safe (the world is fully consistent when blocked) and
must not interrupt mid-tick (which would leave partial state).
**Decision:** Add an interrupt system. Interrupts are issued via
`world.Interrupt<T>(interrupt)` and block the **next** tick, not the
current one. The interrupt is stored in an `InterruptStore` owned by
`SystemGroup`. A matching `IInterruptHandlerCommand<T>` (a regular
`ICommand`) resolves the interrupt when executed.
**Rationale:**
- Deferring the block to the next tick avoids mid-tick state
inconsistency. The current tick completes normally, all mutations
are flushed, and the world is serializable before the block takes
effect.
- The interrupt store lives in `SystemGroup`, not `World`. This keeps
`World` lean and means interrupts are a no-op when no `SystemGroup`
is managing the world.
- Resolution is done via a regular `ICommand` with a default
`Execute` implementation. The handler's `TryResolve` method receives
the interrupt data and can inspect it before accepting or rejecting
the resolution.
- One interrupt per type ensures no ambiguity about which handler
resolves which interrupt.
**Alternatives considered:**
- **Mid-tick blocking:** Would leave partial system state, making
serialization and recovery unsafe.
- **Storing interrupts in World:** Adds complexity to `World` without
benefit — interrupts are a tick-level concern, not a component-level
one.
**Consequences:**
- Systems that issue interrupts must be prepared for the next tick to
be skipped entirely.
- Interrupts are transient — they are not serialized and do not survive
save/load.
- `IInterruptHandlerCommand<T>` types must be `[MessagePackObject]`
structs (like all commands) to enable serialization for replay.

View File

@ -563,12 +563,70 @@ and design validation. See `docs/testing-games.md` for the testing strategy.
--- ---
## Phase 9 — Interrupts (Week 9)
**Goal:** System-issued blocking signals resolved by handler commands.
### 9.1 IInterrupt
Marker interface for interrupt struct types. Interrupts are not components —
they're transient signals stored in the `InterruptStore`.
### 9.2 IInterruptHandlerCommand\<T\>
```csharp
interface IInterruptHandlerCommand<TInterrupt> : ICommand
where TInterrupt : struct, IInterrupt
{
bool TryResolve(TInterrupt interrupt);
}
```
Extends `ICommand` with a default `Execute` that looks up the pending
interrupt of type `TInterrupt`, calls `TryResolve`, and resolves the
interrupt if the handler returns `true`.
### 9.3 InterruptStore
Internal class owned by `SystemGroup`. Maps `Type` → boxed `IInterrupt`.
Supports `Add`, `TryGet`, `Remove`, `HasAny`, `Has<T>`, `Clear`.
### 9.4 World API
```csharp
void Interrupt<T>(T interrupt) where T : struct, IInterrupt;
bool HasInterrupt<T>() where T : struct, IInterrupt;
```
Lightweight pass-through to the `InterruptStore` injected by `SystemGroup`.
When no `SystemGroup` manages the world, interrupts are no-ops.
### 9.5 SystemGroup Integration
At the start of each tick, `SystemGroup.RunAll` drains commands and checks
`_interrupts.HasAny`. If pending, the tick skips all systems but still posts
changes. Interrupts block the **next** tick, not the current one.
### 9.6 Tests
- Interrupt blocks the next tick.
- Handler resolves interrupt and unblocks.
- Handler rejection leaves interrupt pending.
- `HasInterrupt` query works.
- Duplicate interrupt type throws.
- Multiple interrupt types are independent.
- Interrupt is a no-op without SystemGroup.
- Commands still execute when blocked.
---
## Dependency Graph ## Dependency Graph
``` ```
Phase 1 (Core) Phase 1 (Core)
└─→ Phase 2 (Queries & Systems) └─→ Phase 2 (Queries & Systems)
└─→ Phase 3 (Commands) └─→ Phase 3 (Commands)
└─→ Phase 9 (Interrupts)
└─→ Phase 4 (Relationships) └─→ Phase 4 (Relationships)
└─→ Phase 5 (Reactivity) └─→ Phase 5 (Reactivity)
└─→ Phase 6 (Singletons) └─→ Phase 6 (Singletons)
@ -576,7 +634,8 @@ Phase 1 (Core)
└─→ Phase 8 (Polish) └─→ Phase 8 (Polish)
``` ```
Phases 3 and 4 can be done in parallel; Phase 5 depends on both. Interrupts depend on Phase 3 (Commands) because they use `ICommand` for
handler commands. They're independent of Phases 48.
--- ---