Compare commits

..

No commits in common. "d73b7ebf378b9b7795e201f31375ec244faa4f78" and "2a3feb556fa4387d793ecf161ce93a66c5df809d" have entirely different histories.

19 changed files with 100 additions and 1225 deletions

View File

@ -248,55 +248,3 @@ 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>()`.

3
Game.CardWars/design.md Normal file
View File

@ -0,0 +1,3 @@
# CardWars — Design Document
<!-- Fill in the rules, mechanics, and design notes here. -->

View File

@ -1,107 +0,0 @@
# 魔烬纷争
## 游戏概述
魔烬纷争是一款快节奏多人桌面卡牌战术游戏。
玩家扮演一个日式异世界中的领袖,需要带领自己的势力击败其他玩家,建立王国。
支持人数2-5人每局游戏约10分钟/人。
## 游戏配件
- 公共牌60张
- 旗帜牌6张
- 标记若干
- 骷髅标记15个
- 号角标记15个
- 城堡标记33个
- 黑、白、蓝、棕、黄、靛色城堡各5座正面标记 2 个皇冠。
- 金城堡3座正面标记 3 个皇冠。
- 背面朝上的城堡当做单个皇冠标记使用。
## 游戏布置
公共布置:
- 将公共牌洗混,放在桌面中央,形成抽牌堆。
- 将城堡标记洗混随机抽取1张正面朝上与1张背面朝上放在桌面中央。
-【5+人游戏】额外抽取1张正面朝上放在桌面中央。
每名玩家布置:
- 选择一张旗帜牌,放在自己面前靠左的位置。
- 抓5张公共牌作为起始手牌。
- 获得1个王冠标记。
以阅读规则的玩家为起始玩家开始游戏。
## 游戏目标
有玩家获得7个王冠时游戏结束。分数最高的玩家赢得游戏胜利。
## 游戏流程
游戏按轮进行,每轮进行以下流程:
- **出牌阶段**:玩家轮流选择卡牌打出或盖放。
- **翻牌阶段**:玩家轮流选择翻开一张自己的盖放牌。
- **结算阶段**:检查所有玩家的得分情况,决定胜者。
- **清理阶段**:清理战场,准备下一轮游戏。
### 出牌阶段
从起始玩家开始,每名玩家轮流进行出牌。
出牌时,可从手牌打出一张牌,或者选择跳过。
若玩家选择跳过,则会结束该玩家的本次出牌阶段。
出牌时,按照卡牌描述的方式打出牌:
- 将打出的卡牌放到目标玩家面前,放在其他打出卡牌的右侧。
- 卡牌默认打出给自己,除非特殊说明。
- 如果卡牌标记了盖放,则这张牌必须盖放打出,否则必须正面打出。
### 翻牌阶段
从起始玩家开始,每名玩家轮流进行翻牌。
在玩家面前有盖放牌时,玩家必须选择其中一张翻开。
否则,结束该玩家的本次翻牌阶段。
### 结算阶段
将牌面点数和领袖牌点数全部加总,作为玩家本轮的总战力。
按总战力从高到低的顺序,从桌面中央获得正面或背面朝上的城堡标记,直到拿空。
若有玩家打平,则行动顺序更靠前的玩家赢得胜利。
每当有玩家赢得正面朝上的城堡标记,若其他玩家拥有同色城堡,则他们必须为其每个同色城堡选择:
- 支付1个王冠标记给胜者
- 从胜者处获得1个王冠标记将城堡交给胜者。
由本轮胜者决定其他玩家做选择的顺序。
最后,按设置的方式补充中央的城堡标记,然后开始下一轮游戏。
### 清理阶段
将本回合打出的所有公共牌放到弃牌堆里。
如果旗帜或其他牌上有骷髅或号角标记,将这些标记放回供应堆。
然后每名玩家抓3张公共牌开始下一轮。
## 抓牌
当玩家抓牌时,如果抓牌堆为空,则将对应的弃牌堆洗回牌堆再继续。
## 号角与骷髅
- 号角可以放在公共牌上,也可以放在旗帜上。
- 放在旗帜上时,为自己每张公共牌提供+1战力。
- 放在公共牌上时为这张牌增加1倍战力。
- 骷髅与号角类似但是扣减1倍战力而非增加。
- 骷髅与号角相互抵消,同类效果可叠加。战力可能会变为负数。

View File

@ -1,29 +0,0 @@
# 魔烬纷争 - 势力扩展
## 游戏概述
势力扩展为魔烬纷争增加了不同的专属势力卡牌。
## 游戏配件
势力配件10 组
- 领袖牌每个势力1张
- 势力牌每个势力15张
- 金币标记商人势力6个
## 游戏布置
对玩家布置进行以下调整:
- 选择一套势力配件获得。
- 将势力牌洗混放在旗帜牌下方。左侧留出空间作为势力牌弃牌堆。
- 将领袖牌放在旗帜牌左侧。
- 额外抓2张势力牌然后选择两张牌弃掉。
领袖牌规则:
- 效果总是生效,战力计入总点数。可以获得号角或骷髅。
- 不计入旗帜的卡牌数目,战斗结束时只清理号角/骷髅标记不清理牌。
势力牌规则:
- 每轮清理阶段抓牌时,改为抓 2 张公共牌和 1 张势力牌。
- 因卡牌效果抓牌时,可以选择抓公共牌或者势力牌。
- 势力牌弃掉时,放入独立的势力牌弃牌堆。势力牌堆为空而选择抓势力牌时,将其洗回势力牌堆。

View File

@ -53,7 +53,10 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
"RemoveSingleton", "RemoveSingleton",
}; };
private static readonly HashSet<string> _forEachNames = new()
{
"ForEach",
};
public void Initialize(IncrementalGeneratorInitializationContext context) public void Initialize(IncrementalGeneratorInitializationContext context)
{ {
@ -65,7 +68,15 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
.Where(t => t.FullyQualifiedName != null) .Where(t => t.FullyQualifiedName != null)
.Select((t, _) => (t.FullyQualifiedName!, t.AssemblyQualifiedName!, t.IsRelationship, t.IsSingleton)); .Select((t, _) => (t.FullyQualifiedName!, t.AssemblyQualifiedName!, t.IsRelationship, t.IsSingleton));
var allTypes = invocations.Collect(); // Also collect ForEach type arguments from the World class.
var forEachCalls = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: IsForEachCandidate,
transform: ExtractForEachTypes)
.Where(list => list.Length > 0)
.SelectMany((list, _) => list);
var allTypes = invocations.Collect().Combine(forEachCalls.Collect());
context.RegisterSourceOutput(allTypes, GenerateRegistry); context.RegisterSourceOutput(allTypes, GenerateRegistry);
} }
@ -86,7 +97,21 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
return false; return false;
} }
private static bool IsForEachCandidate(SyntaxNode node, CancellationToken _)
{
if (node is not InvocationExpressionSyntax invocation)
return false;
if (invocation.Expression is MemberAccessExpressionSyntax memberAccess)
{
var name = memberAccess.Name is GenericNameSyntax gn
? gn.Identifier.Text
: memberAccess.Name.Identifier.Text;
return _forEachNames.Contains(name);
}
return false;
}
private static (string? FullyQualifiedName, string? AssemblyQualifiedName, bool IsRelationship, bool IsSingleton) ExtractComponentType( private static (string? FullyQualifiedName, string? AssemblyQualifiedName, bool IsRelationship, bool IsSingleton) ExtractComponentType(
GeneratorSyntaxContext ctx, CancellationToken _) GeneratorSyntaxContext ctx, CancellationToken _)
@ -129,11 +154,44 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
return (fqn, aqn, isRelationship, isSingleton); return (fqn, aqn, isRelationship, isSingleton);
} }
private static ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> ExtractForEachTypes(
GeneratorSyntaxContext ctx, CancellationToken _)
{
if (ctx.Node is not InvocationExpressionSyntax invocation)
return ImmutableArray<(string, string, bool)>.Empty;
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
return ImmutableArray<(string, string, bool)>.Empty;
if (memberAccess.Name is not GenericNameSyntax genericName)
return ImmutableArray<(string, string, bool)>.Empty;
var types = ImmutableArray.CreateBuilder<(string, string, bool)>();
foreach (var typeArg in genericName.TypeArgumentList.Arguments)
{
var symbolInfo = ctx.SemanticModel.GetSymbolInfo(typeArg);
if (symbolInfo.Symbol is INamedTypeSymbol typeSymbol &&
typeSymbol.IsValueType && !typeSymbol.IsAbstract &&
typeSymbol.DeclaredAccessibility == Accessibility.Public)
{
var fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var aqn = typeSymbol.ContainingAssembly != null
? $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted))}, {typeSymbol.ContainingAssembly.Name}"
: fqn;
bool isRelationship = typeSymbol.AllInterfaces.Any(i =>
i.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::OECS.IRelationship");
types.Add((fqn, aqn, isRelationship));
}
}
return types.ToImmutable();
}
private static void GenerateRegistry(SourceProductionContext context, private static void GenerateRegistry(SourceProductionContext context,
ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship, bool IsSingleton)> invocations) (ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship, bool IsSingleton)> Left,
ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> Right) data)
{ {
var (invocations, forEachTypes) = data;
// Collect unique types by fully-qualified name, deduplicating. // Collect unique types by fully-qualified name, deduplicating.
var typeMap = new Dictionary<string, (string Fqn, string Aqn, bool IsRel, bool IsSingleton)>(); var typeMap = new Dictionary<string, (string Fqn, string Aqn, bool IsRel, bool IsSingleton)>();
@ -144,6 +202,16 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
else else
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship, t.IsSingleton); typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship, t.IsSingleton);
} }
foreach (var t in forEachTypes)
{
if (!typeMap.ContainsKey(t.FullyQualifiedName))
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship, false);
else if (t.IsRelationship)
{
var existing = typeMap[t.FullyQualifiedName];
typeMap[t.FullyQualifiedName] = (existing.Fqn, existing.Aqn, true, existing.IsSingleton);
}
}
if (typeMap.Count == 0) if (typeMap.Count == 0)
return; return;

View File

@ -399,111 +399,6 @@ public class EdgeCaseTests
act.Should().NotThrow(); act.Should().NotThrow();
} }
// ── ReorderSources edge cases ────────────────────────────────────
[Fact]
public void ReorderSources_WithDuplicateEntities_ReordersToMatch()
{
var world = new World();
var target = world.CreateEntity();
var a = world.CreateEntity();
var b = world.CreateEntity();
world.AddComponent(a, new Relationship<ChildOf, ParentOf> { Target = target });
world.AddComponent(b, new Relationship<ChildOf, ParentOf> { Target = target });
// Reorder with duplicate entries — should still work (last occurrence wins).
world.ReorderSources<Relationship<ChildOf, ParentOf>>(target, [b, a, b]);
var result = world.GetSources<Relationship<ChildOf, ParentOf>>(target);
result.Should().Equal([b, a, b]);
}
[Fact]
public void ReorderSources_WithMissingEntity_DoesNotThrow()
{
var world = new World();
var target = world.CreateEntity();
var a = world.CreateEntity();
world.AddComponent(a, new Relationship<ChildOf, ParentOf> { Target = target });
// Reorder with an entity not in the set — the missing entity is simply
// absent from the reordered result.
var extra = world.CreateEntity();
world.ReorderSources<Relationship<ChildOf, ParentOf>>(target, [extra, a]);
var result = world.GetSources<Relationship<ChildOf, ParentOf>>(target);
result.Should().Equal([extra, a]);
}
// ── RemoveSingleton during batching ───────────────────────────────
[Fact]
public void RemoveSingleton_DuringForEach_IsDeferred()
{
var world = new World();
world.SetSingleton(new Position { X = 1, Y = 2 });
var entity = world.CreateEntity();
world.AddComponent(entity, new Position { X = 3, Y = 4 });
var seen = new List<Entity>();
foreach (var it in world.Select<Position>())
{
seen.Add(it.Entity);
// Remove the singleton during iteration — should be deferred.
world.RemoveSingleton<Position>();
}
// The singleton entity should still have been iterated (deferred removal).
seen.Should().HaveCount(1);
seen[0].Should().Be(entity);
// After the foreach scope ends, the singleton removal is applied.
world.HasSingleton<Position>().Should().BeFalse();
}
// ── ReadComponent / ReadSingleton do not auto-track ───────────────
[Fact]
public void ReadComponent_DoesNotAutoMarkModified()
{
var world = new World();
var entity = world.CreateEntity();
world.AddComponent(entity, new Position { X = 1, Y = 2 });
world.PostChanges(); // Clear pending
var collector = new ChangeCollector();
using var sub = world.ObserveComponentChanges<Position>().Subscribe(collector.ToObserver());
// Run a system that only reads via ReadComponent — no auto-marking.
var group = new SystemGroup(world);
group.Add(new ReadOnlySystem(world, entity));
group.RunLogical();
collector.Changes.Should().BeEmpty();
}
[Fact]
public void ReadSingleton_DoesNotAutoMarkModified()
{
var world = new World();
world.SetSingleton(new Position { X = 1, Y = 2 });
world.PostChanges(); // Clear pending
var collector = new ChangeCollector();
using var sub = world.ObserveComponentChanges<Position>().Subscribe(collector.ToObserver());
// Run a system that only reads the singleton via ReadSingleton.
var group = new SystemGroup(world);
group.Add(new ReadSingletonSystem(world));
group.RunLogical();
collector.Changes.Should().BeEmpty();
}
// ── Helpers ────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────
private sealed class ChangeCollector : IObserver<EntityChange> private sealed class ChangeCollector : IObserver<EntityChange>
@ -518,39 +413,4 @@ public class EdgeCaseTests
// Phantom types for relationship tests. // Phantom types for relationship tests.
public struct ChildOf { } public struct ChildOf { }
public struct ParentOf { } public struct ParentOf { }
// Helper systems for ReadComponent/ReadSingleton tests.
private class ReadOnlySystem : ISystem
{
private readonly World _world;
private readonly Entity _entity;
public ReadOnlySystem(World world, Entity entity)
{
_world = world;
_entity = entity;
}
public void RunImpl(World world)
{
var val = _world.ReadComponent<Position>(_entity);
_ = val.X;
}
}
private class ReadSingletonSystem : ISystem
{
private readonly World _world;
public ReadSingletonSystem(World world)
{
_world = world;
}
public void RunImpl(World world)
{
var val = _world.ReadSingleton<Position>();
_ = val.X;
}
}
} }

View File

@ -1,235 +0,0 @@
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();
}
}

View File

@ -1,237 +0,0 @@
using FluentAssertions;
using MessagePack;
using Xunit;
namespace OECS.Tests;
// ── Test components for serialization ─────────────────────────────────
[MessagePackObject]
public struct SerialPos : IMessagePackSerializationCallbackReceiver
{
[Key(0)] public float X { get; set; }
[Key(1)] public float Y { get; set; }
public void OnBeforeSerialize() { }
public void OnAfterDeserialize() { }
}
[MessagePackObject]
public struct SerialHealth : IMessagePackSerializationCallbackReceiver
{
[Key(0)] public int Value { get; set; }
public void OnBeforeSerialize() { }
public void OnAfterDeserialize() { }
}
[MessagePackObject]
public struct SerialConfig : IMessagePackSerializationCallbackReceiver
{
[Key(0)] public float Gravity { get; set; }
[Key(1)] public int MaxPlayers { get; set; }
public void OnBeforeSerialize() { }
public void OnAfterDeserialize() { }
}
// ── Phantom types for relationship serialization ──────────────────────
public struct SerialChildOf { }
public struct SerialParentOf { }
// ── Tests ────────────────────────────────────────────────────────────
public class SerializationTests
{
[Fact]
public void SaveLoad_RoundTrip_PreservesComponents()
{
var world = new World();
var entity = world.CreateEntity();
world.AddComponent(entity, new SerialPos { X = 1.5f, Y = 2.5f });
world.AddComponent(entity, new SerialHealth { Value = 100 });
var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
// Load into a fresh world.
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
// Find the entity in the loaded world.
var found = world2.FindEntity<SerialPos, SerialHealth>();
found.Should().NotBe(Entity.Null);
ref var pos = ref world2.GetComponent<SerialPos>(found);
pos.X.Should().Be(1.5f);
pos.Y.Should().Be(2.5f);
ref var hp = ref world2.GetComponent<SerialHealth>(found);
hp.Value.Should().Be(100);
}
[Fact]
public void SaveLoad_RoundTrip_PreservesMultipleEntities()
{
var world = new World();
var a = world.CreateEntity();
var b = world.CreateEntity();
world.AddComponent(a, new SerialPos { X = 1, Y = 2 });
world.AddComponent(a, new SerialHealth { Value = 10 });
world.AddComponent(b, new SerialPos { X = 3, Y = 4 });
world.AddComponent(b, new SerialHealth { Value = 20 });
var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
var positions = new List<(float X, float Y, int Health)>();
foreach (var it in world2.Select<SerialPos, SerialHealth>())
{
positions.Add((it.Val1.X, it.Val1.Y, it.Val2.Value));
}
positions.Should().BeEquivalentTo([(1, 2, 10), (3, 4, 20)]);
}
[Fact]
public void SaveLoad_RoundTrip_PreservesEntityIds()
{
var world = new World();
var entity = world.CreateEntity();
world.AddComponent(entity, new SerialPos { X = 1, Y = 2 });
var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
var found = world2.FindEntity<SerialPos>();
found.Id.Should().Be(entity.Id);
found.Version.Should().Be(entity.Version);
}
[Fact]
public void SaveLoad_RoundTrip_PreservesSingletons()
{
var world = new World();
world.SetSingleton(new SerialConfig { Gravity = 9.8f, MaxPlayers = 4 });
var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
world2.HasSingleton<SerialConfig>().Should().BeTrue();
ref var config = ref world2.GetSingleton<SerialConfig>();
config.Gravity.Should().Be(9.8f);
config.MaxPlayers.Should().Be(4);
}
[Fact]
public void SaveLoad_RoundTrip_PreservesRelationships()
{
var world = new World();
var child = world.CreateEntity();
var parent = world.CreateEntity();
world.AddComponent(child, new Relationship<SerialChildOf, SerialParentOf>
{
Target = parent
});
var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
// Find the relationship.
var found = world2.FindEntity<Relationship<SerialChildOf, SerialParentOf>>();
found.Should().NotBe(Entity.Null);
ref var rel = ref world2.GetComponent<Relationship<SerialChildOf, SerialParentOf>>(found);
rel.Target.Should().NotBe(Entity.Null);
// The target should be alive and have the same ID as the original parent.
world2.IsAlive(rel.Target).Should().BeTrue();
rel.Target.Id.Should().Be(parent.Id);
}
[Fact]
public void SaveLoad_RoundTrip_PreservesRelationshipTargetIntegrity()
{
var world = new World();
var child = world.CreateEntity();
var parent = world.CreateEntity();
world.AddComponent(parent, new SerialPos { X = 10, Y = 20 });
world.AddComponent(child, new Relationship<SerialChildOf, SerialParentOf>
{
Target = parent
});
var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
var foundChild = world2.FindEntity<Relationship<SerialChildOf, SerialParentOf>>();
ref var rel = ref world2.GetComponent<Relationship<SerialChildOf, SerialParentOf>>(foundChild);
// The target entity should still have its component.
world2.HasComponent<SerialPos>(rel.Target).Should().BeTrue();
ref var pos = ref world2.GetComponent<SerialPos>(rel.Target);
pos.X.Should().Be(10);
pos.Y.Should().Be(20);
}
[Fact]
public void SaveLoad_EmptyWorld_Works()
{
var world = new World();
var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
// Loading an empty world should not throw.
var count = 0;
foreach (var it in world2.Select<SerialPos>())
count++;
count.Should().Be(0);
}
[Fact]
public void SaveLoad_EntityWithNoComponents_IsNotSerialized()
{
var world = new World();
world.CreateEntity(); // Entity with no components.
var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
// No entities should be loaded since the empty entity wasn't serialized.
var count = 0;
foreach (var it in world2.Select<SerialPos>())
count++;
count.Should().Be(0);
}
}

View File

@ -8,7 +8,6 @@ namespace OECS;
/// use-after-free bugs when entity IDs are recycled. /// use-after-free bugs when entity IDs are recycled.
/// </summary> /// </summary>
[MessagePackObject(AllowPrivate = true)] [MessagePackObject(AllowPrivate = true)]
[MessagePackFormatter(typeof(EntityFormatter))]
public readonly partial struct Entity : IEquatable<Entity> public readonly partial struct Entity : IEquatable<Entity>
{ {
private const uint IdMask = 0x00FF_FFFF; // lower 24 bits private const uint IdMask = 0x00FF_FFFF; // lower 24 bits
@ -58,16 +57,6 @@ public readonly partial struct Entity : IEquatable<Entity>
/// </summary> /// </summary>
internal Entity WithVersion(uint version) => new(Id, version); internal Entity WithVersion(uint version) => new(Id, version);
/// <summary>
/// Converts the entity to its raw 32-bit packed representation.
/// </summary>
public static explicit operator uint(Entity entity) => entity._value;
/// <summary>
/// Creates an entity from its raw 32-bit packed representation.
/// </summary>
public static explicit operator Entity(uint value) => new(value);
public bool Equals(Entity other) => _value == other._value; public bool Equals(Entity other) => _value == other._value;
public override bool Equals(object? obj) => obj is Entity other && Equals(other); public override bool Equals(object? obj) => obj is Entity other && Equals(other);

View File

@ -1,25 +0,0 @@
using MessagePack;
using MessagePack.Formatters;
namespace OECS;
/// <summary>
/// Custom MessagePack formatter for <see cref="Entity"/>.
///
/// Reads and writes the raw 32-bit packed value as a single uint.
/// This avoids the readonly-field issue where MessagePack's built-in
/// deserialization path (reflection-based field assignment) cannot
/// write to <c>readonly</c> fields on value types.
/// </summary>
public class EntityFormatter : IMessagePackFormatter<Entity>
{
public void Serialize(ref MessagePackWriter writer, Entity value, MessagePackSerializerOptions options)
{
writer.Write((uint)value);
}
public Entity Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
return (Entity)reader.ReadUInt32();
}
}

View File

@ -1,8 +0,0 @@
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

@ -1,31 +0,0 @@
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);
}

View File

@ -1,69 +0,0 @@
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,25 +3,18 @@ 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>
@ -66,19 +59,9 @@ 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. This also processes interrupt handler // sees their effects.
// 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)
{ {
@ -111,4 +94,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,10 +43,6 @@ 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();
@ -194,11 +190,6 @@ public class World : IDisposable
nameof(AddComponentImpl), nameof(AddComponentImpl),
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
private static readonly System.Reflection.MethodInfo s_removeComponentImpl =
typeof(World).GetMethod(
nameof(RemoveComponentImpl),
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
private void AddComponentImpl<T>(Entity entity, T component) where T : struct private void AddComponentImpl<T>(Entity entity, T component) where T : struct
{ {
ThrowIfNotAlive(entity); ThrowIfNotAlive(entity);
@ -499,52 +490,6 @@ 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>
@ -594,13 +539,20 @@ public class World : IDisposable
switch (m.Kind) switch (m.Kind)
{ {
case PendingMutationKind.AddComponent: case PendingMutationKind.AddComponent:
s_addComponentImpl.MakeGenericMethod(m.ComponentType) // Use reflection to call AddComponentImpl<T>.
.Invoke(this, [m.Entity, m.Component]); var addMethod = typeof(World).GetMethod(
nameof(AddComponentImpl),
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
var genericAdd = addMethod.MakeGenericMethod(m.ComponentType);
genericAdd.Invoke(this, [m.Entity, m.Component]);
break; break;
case PendingMutationKind.RemoveComponent: case PendingMutationKind.RemoveComponent:
s_removeComponentImpl.MakeGenericMethod(m.ComponentType) var removeMethod = typeof(World).GetMethod(
.Invoke(this, [m.Entity]); nameof(RemoveComponentImpl),
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
var genericRemove = removeMethod.MakeGenericMethod(m.ComponentType);
genericRemove.Invoke(this, [m.Entity]);
break; break;
case PendingMutationKind.DestroyEntity: case PendingMutationKind.DestroyEntity:
@ -664,4 +616,4 @@ public class World : IDisposable
_disposed = true; _disposed = true;
_changes.Dispose(); _changes.Dispose();
} }
} }

View File

@ -19,12 +19,6 @@ public static class WorldSerializer
{ {
var entityComponents = new Dictionary<Entity, List<ComponentEntry>>(); var entityComponents = new Dictionary<Entity, List<ComponentEntry>>();
// Collect relationship target entities that may have no components.
// They must be serialized so that Relationship.Target references remain
// valid after deserialization (bare entity handles round-trip correctly
// thanks to the EntityFormatter which reads/writes the raw uint).
var relationshipTargets = new HashSet<Entity>();
foreach (var desc in ComponentRegistry.Descriptors) foreach (var desc in ComponentRegistry.Descriptors)
{ {
var set = world.Components.GetSet(desc.Type); var set = world.Components.GetSet(desc.Type);
@ -47,20 +41,9 @@ public static class WorldSerializer
TypeName = desc.TypeName, TypeName = desc.TypeName,
Data = desc.Serialize(component) Data = desc.Serialize(component)
}); });
// Track relationship targets.
if (component is IRelationship rel && rel.Target != Entity.Null)
relationshipTargets.Add(rel.Target);
} }
} }
// Ensure bare relationship target entities appear in the snapshot.
foreach (var target in relationshipTargets)
{
if (!entityComponents.ContainsKey(target))
entityComponents[target] = new List<ComponentEntry>();
}
var snapshot = new WorldSnapshot var snapshot = new WorldSnapshot
{ {
Entities = entityComponents Entities = entityComponents
@ -133,4 +116,4 @@ public static class WorldSerializer
} }
} }
} }
} }

View File

@ -67,10 +67,6 @@ 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();
@ -86,8 +82,6 @@ public class World : IDisposable
// --- Relationships --- // --- Relationships ---
public IReadOnlyCollection<Entity> GetSources<T>(Entity target) public IReadOnlyCollection<Entity> GetSources<T>(Entity target)
where T : struct, IRelationship; where T : struct, IRelationship;
public void ReorderSources<T>(Entity target, IReadOnlyList<Entity> ordered)
where T : struct, IRelationship;
// --- Cleanup --- // --- Cleanup ---
public void Dispose(); public void Dispose();
@ -117,12 +111,8 @@ public static class WorldQueryExtensions
// FindEntity — returns the first matching entity or Entity.Null. // FindEntity — returns the first matching entity or Entity.Null.
public static Entity FindEntity<T1>( public static Entity FindEntity<T1>(
this World world, Query<T1> query = default) where T1 : struct; this World world, Query<T1> query = default) where T1 : struct;
public static Entity FindEntity<T1, T2>( public static Entity FindEntity<T1, T2>(...) where T1 : struct where T2 : struct;
this World world, Query<T1, T2> query = default) public static Entity FindEntity<T1, T2, T3>(...) where T1 : struct where T2 : struct where T3 : struct;
where T1 : struct where T2 : struct;
public static Entity FindEntity<T1, T2, T3>(
this World world, Query<T1, T2, T3> query = default)
where T1 : struct where T2 : struct where T3 : struct;
} }
``` ```
@ -332,52 +322,6 @@ 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
@ -385,6 +329,7 @@ namespace OECS;
public interface IRelationship public interface IRelationship
{ {
Entity Source { get; }
Entity Target { get; } Entity Target { get; }
} }
``` ```
@ -404,7 +349,8 @@ namespace OECS;
public struct Relationship<TSelf, TTarget> : IRelationship public struct Relationship<TSelf, TTarget> : IRelationship
where TSelf : struct where TTarget : struct where TSelf : struct where TTarget : struct
{ {
[Key(0)] public Entity Target { get; set; } [Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; }
} }
``` ```
@ -428,8 +374,7 @@ public enum ChangeKind
EntityRemoved, EntityRemoved,
ComponentAdded, ComponentAdded,
ComponentRemoved, ComponentRemoved,
ComponentModified, ComponentModified
RelationshipReordered
} }
``` ```

View File

@ -288,9 +288,8 @@ serializable and executed deferred.
**Context:** Singletons (global state like `Time`, `Config`, `InputState`) need **Context:** Singletons (global state like `Time`, `Config`, `InputState`) need
a home in the ECS. a home in the ECS.
**Decision:** Each singleton component type gets its own dedicated entity **Decision:** Reserve entity ID `1` as the singleton entity. Provide
(allocated via `EntityAllocator`). Provide convenience accessors convenience accessors (`SetSingleton<T>`, `GetSingleton<T>`). Exclude from
(`SetSingleton<T>`, `GetSingleton<T>`). Exclude all singleton entities from
normal queries. normal queries.
**Rationale:** **Rationale:**
@ -301,16 +300,11 @@ normal queries.
queries. queries.
- Simpler than a separate "resource" system (as in Bevy). One concept (entity + - Simpler than a separate "resource" system (as in Bevy). One concept (entity +
components) covers both entities and singletons. components) covers both entities and singletons.
- Each singleton type having its own entity means `DestroyEntity` on a
singleton entity is a normal operation, and singletons can have multiple
component types attached without conflict.
**Consequences:** **Consequences:**
- A `Dictionary<Type, Entity>` maps each singleton component type to its - Entity ID `1` is permanently reserved.
backing entity. - `DestroyEntity` on the singleton entity is a no-op or throws.
- `IsSingletonEntity(Entity)` is checked during query iteration to exclude
singleton entities.
--- ---
@ -391,53 +385,3 @@ 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,70 +563,12 @@ 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)
@ -634,8 +576,7 @@ Phase 1 (Core)
└─→ Phase 8 (Polish) └─→ Phase 8 (Polish)
``` ```
Interrupts depend on Phase 3 (Commands) because they use `ICommand` for Phases 3 and 4 can be done in parallel; Phase 5 depends on both.
handler commands. They're independent of Phases 48.
--- ---