Compare commits
6 Commits
2a3feb556f
...
d73b7ebf37
| Author | SHA1 | Date |
|---|---|---|
|
|
d73b7ebf37 | |
|
|
a6287911aa | |
|
|
53fa3b742d | |
|
|
8a32588c54 | |
|
|
6207872e45 | |
|
|
1bb290e60c |
|
|
@ -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>()`.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# CardWars — Design Document
|
|
||||||
|
|
||||||
<!-- Fill in the rules, mechanics, and design notes here. -->
|
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
# 魔烬纷争
|
||||||
|
|
||||||
|
## 游戏概述
|
||||||
|
|
||||||
|
魔烬纷争是一款快节奏多人桌面卡牌战术游戏。
|
||||||
|
|
||||||
|
玩家扮演一个日式异世界中的领袖,需要带领自己的势力击败其他玩家,建立王国。
|
||||||
|
|
||||||
|
支持人数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倍战力而非增加。
|
||||||
|
- 骷髅与号角相互抵消,同类效果可叠加。战力可能会变为负数。
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
# 魔烬纷争 - 势力扩展
|
||||||
|
|
||||||
|
## 游戏概述
|
||||||
|
|
||||||
|
势力扩展为魔烬纷争增加了不同的专属势力卡牌。
|
||||||
|
|
||||||
|
## 游戏配件
|
||||||
|
|
||||||
|
势力配件:10 组
|
||||||
|
- 领袖牌:每个势力1张
|
||||||
|
- 势力牌:每个势力15张
|
||||||
|
- 金币标记(商人势力):6个
|
||||||
|
|
||||||
|
## 游戏布置
|
||||||
|
|
||||||
|
对玩家布置进行以下调整:
|
||||||
|
- 选择一套势力配件获得。
|
||||||
|
- 将势力牌洗混放在旗帜牌下方。左侧留出空间作为势力牌弃牌堆。
|
||||||
|
- 将领袖牌放在旗帜牌左侧。
|
||||||
|
- 额外抓2张势力牌,然后选择两张牌弃掉。
|
||||||
|
|
||||||
|
领袖牌规则:
|
||||||
|
- 效果总是生效,战力计入总点数。可以获得号角或骷髅。
|
||||||
|
- 不计入旗帜的卡牌数目,战斗结束时只清理号角/骷髅标记不清理牌。
|
||||||
|
|
||||||
|
势力牌规则:
|
||||||
|
- 每轮清理阶段抓牌时,改为抓 2 张公共牌和 1 张势力牌。
|
||||||
|
- 因卡牌效果抓牌时,可以选择抓公共牌或者势力牌。
|
||||||
|
- 势力牌弃掉时,放入独立的势力牌弃牌堆。势力牌堆为空而选择抓势力牌时,将其洗回势力牌堆。
|
||||||
|
|
@ -53,10 +53,7 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
||||||
"RemoveSingleton",
|
"RemoveSingleton",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly HashSet<string> _forEachNames = new()
|
|
||||||
{
|
|
||||||
"ForEach",
|
|
||||||
};
|
|
||||||
|
|
||||||
public void Initialize(IncrementalGeneratorInitializationContext context)
|
public void Initialize(IncrementalGeneratorInitializationContext context)
|
||||||
{
|
{
|
||||||
|
|
@ -68,15 +65,7 @@ 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));
|
||||||
|
|
||||||
// Also collect ForEach type arguments from the World class.
|
var allTypes = invocations.Collect();
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
@ -97,21 +86,7 @@ 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 _)
|
||||||
|
|
@ -154,44 +129,11 @@ 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)> Left,
|
ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship, bool IsSingleton)> invocations)
|
||||||
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)>();
|
||||||
|
|
@ -202,16 +144,6 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -399,6 +399,111 @@ 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>
|
||||||
|
|
@ -413,4 +518,39 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,237 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ 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
|
||||||
|
|
@ -57,6 +58,16 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -190,6 +194,11 @@ 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);
|
||||||
|
|
@ -490,6 +499,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>
|
||||||
|
|
@ -539,20 +594,13 @@ public class World : IDisposable
|
||||||
switch (m.Kind)
|
switch (m.Kind)
|
||||||
{
|
{
|
||||||
case PendingMutationKind.AddComponent:
|
case PendingMutationKind.AddComponent:
|
||||||
// Use reflection to call AddComponentImpl<T>.
|
s_addComponentImpl.MakeGenericMethod(m.ComponentType)
|
||||||
var addMethod = typeof(World).GetMethod(
|
.Invoke(this, [m.Entity, m.Component]);
|
||||||
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:
|
||||||
var removeMethod = typeof(World).GetMethod(
|
s_removeComponentImpl.MakeGenericMethod(m.ComponentType)
|
||||||
nameof(RemoveComponentImpl),
|
.Invoke(this, [m.Entity]);
|
||||||
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:
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,12 @@ 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);
|
||||||
|
|
@ -41,9 +47,20 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -82,6 +86,8 @@ 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();
|
||||||
|
|
@ -111,8 +117,12 @@ 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>(...) where T1 : struct where T2 : struct;
|
public static Entity FindEntity<T1, T2>(
|
||||||
public static Entity FindEntity<T1, T2, T3>(...) where T1 : struct where T2 : struct where T3 : struct;
|
this World world, Query<T1, T2> query = default)
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -322,6 +332,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
|
||||||
|
|
@ -329,7 +385,6 @@ namespace OECS;
|
||||||
|
|
||||||
public interface IRelationship
|
public interface IRelationship
|
||||||
{
|
{
|
||||||
Entity Source { get; }
|
|
||||||
Entity Target { get; }
|
Entity Target { get; }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -349,8 +404,7 @@ 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 Source { get; set; }
|
[Key(0)] public Entity Target { get; set; }
|
||||||
[Key(1)] public Entity Target { get; set; }
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -374,7 +428,8 @@ public enum ChangeKind
|
||||||
EntityRemoved,
|
EntityRemoved,
|
||||||
ComponentAdded,
|
ComponentAdded,
|
||||||
ComponentRemoved,
|
ComponentRemoved,
|
||||||
ComponentModified
|
ComponentModified,
|
||||||
|
RelationshipReordered
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -288,8 +288,9 @@ 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:** Reserve entity ID `1` as the singleton entity. Provide
|
**Decision:** Each singleton component type gets its own dedicated entity
|
||||||
convenience accessors (`SetSingleton<T>`, `GetSingleton<T>`). Exclude from
|
(allocated via `EntityAllocator`). Provide convenience accessors
|
||||||
|
(`SetSingleton<T>`, `GetSingleton<T>`). Exclude all singleton entities from
|
||||||
normal queries.
|
normal queries.
|
||||||
|
|
||||||
**Rationale:**
|
**Rationale:**
|
||||||
|
|
@ -300,11 +301,16 @@ 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:**
|
||||||
|
|
||||||
- Entity ID `1` is permanently reserved.
|
- A `Dictionary<Type, Entity>` maps each singleton component type to its
|
||||||
- `DestroyEntity` on the singleton entity is a no-op or throws.
|
backing entity.
|
||||||
|
- `IsSingletonEntity(Entity)` is checked during query iteration to exclude
|
||||||
|
singleton entities.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -385,3 +391,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.
|
||||||
|
|
|
||||||
|
|
@ -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 4–8.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue