From 8a32588c542381b7ecc105d4fb1aa1f24a958a1f Mon Sep 17 00:00:00 2001 From: hyper Date: Wed, 22 Jul 2026 16:01:36 +0800 Subject: [PATCH] feat(docs): Add interrupt system documentation --- .agents/skills/writing-games/SKILL.md | 52 +++++++++++++++++++++++ docs/api-surface.md | 50 ++++++++++++++++++++++ docs/architecture.md | 50 ++++++++++++++++++++++ docs/implementation-plan.md | 61 ++++++++++++++++++++++++++- 4 files changed, 212 insertions(+), 1 deletion(-) diff --git a/.agents/skills/writing-games/SKILL.md b/.agents/skills/writing-games/SKILL.md index e9c17de..a91dd79 100644 --- a/.agents/skills/writing-games/SKILL.md +++ b/.agents/skills/writing-games/SKILL.md @@ -248,3 +248,55 @@ tracking. Outside a batching scope, call `MarkModified` after mutating. `WorldSerializer.Save/Load` uses the source-generated `ComponentRegistry`. All component types used with `World` generic methods are automatically 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`. 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 +{ + [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` 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()` is a no-op. +- Check pending interrupts with `world.HasInterrupt()`. diff --git a/docs/api-surface.md b/docs/api-surface.md index 76efe35..242598a 100644 --- a/docs/api-surface.md +++ b/docs/api-surface.md @@ -67,6 +67,10 @@ public class World : IDisposable public CommandQueue Commands { get; } public void ExecuteCommands(); + // --- Interrupts --- + public void Interrupt(T interrupt) where T : struct, IInterrupt; + public bool HasInterrupt() where T : struct, IInterrupt; + // --- Reactivity --- public void MarkModified(Entity entity) where T : struct; 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\ + +```csharp +namespace OECS; + +public interface IInterruptHandlerCommand : 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 +{ + [Key(0)] public bool Confirmed; + + public bool TryResolve(ConfirmInterrupt interrupt) + { + // Do work here. Return false to reject. + return true; + } +} +``` + +--- + ## IRelationship ```csharp diff --git a/docs/architecture.md b/docs/architecture.md index 16bc4f2..563fe52 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -385,3 +385,53 @@ package is included for compile-time validation. - The `MessagePackAnalyzer` package is a compile-time dependency. - `[Key]` indices should be sequential starting from 0 to avoid null 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(interrupt)` and block the **next** tick, not the +current one. The interrupt is stored in an `InterruptStore` owned by +`SystemGroup`. A matching `IInterruptHandlerCommand` (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` types must be `[MessagePackObject]` + structs (like all commands) to enable serialization for replay. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index c35b995..9ab834b 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -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\ + +```csharp +interface IInterruptHandlerCommand : 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`, `Clear`. + +### 9.4 World API + +```csharp +void Interrupt(T interrupt) where T : struct, IInterrupt; +bool HasInterrupt() 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 ``` Phase 1 (Core) └─→ Phase 2 (Queries & Systems) └─→ Phase 3 (Commands) + └─→ Phase 9 (Interrupts) └─→ Phase 4 (Relationships) └─→ Phase 5 (Reactivity) └─→ Phase 6 (Singletons) @@ -576,7 +634,8 @@ Phase 1 (Core) └─→ 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 4–8. ---