Compare commits
No commits in common. "d73b7ebf378b9b7795e201f31375ec244faa4f78" and "2a3feb556fa4387d793ecf161ce93a66c5df809d" have entirely different histories.
d73b7ebf37
...
2a3feb556f
|
|
@ -248,55 +248,3 @@ tracking. Outside a batching scope, call `MarkModified` after mutating.
|
|||
`WorldSerializer.Save/Load` uses the source-generated `ComponentRegistry`.
|
||||
All component types used with `World` generic methods are automatically
|
||||
discovered. Serialization round-trips must be tested — see `testing-games` skill.
|
||||
|
||||
## Interrupts
|
||||
|
||||
Interrupts pause system execution until an external response arrives. A system
|
||||
issues an interrupt, the next tick is skipped, and a handler command resolves it.
|
||||
|
||||
### Issuing an Interrupt
|
||||
|
||||
```csharp
|
||||
[MessagePackObject]
|
||||
public record struct ConfirmInterrupt : IInterrupt
|
||||
{
|
||||
[Key(0)] public string Message;
|
||||
}
|
||||
|
||||
// In a system:
|
||||
world.Interrupt(new ConfirmInterrupt { Message = "Are you sure?" });
|
||||
```
|
||||
|
||||
The current tick completes normally. The **next** tick is blocked — `SystemGroup`
|
||||
skips all systems but still drains commands and posts changes.
|
||||
|
||||
### Handling an Interrupt
|
||||
|
||||
Handler commands implement `IInterruptHandlerCommand<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>()`.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
# CardWars — Design Document
|
||||
|
||||
<!-- Fill in the rules, mechanics, and design notes here. -->
|
||||
|
|
@ -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倍战力而非增加。
|
||||
- 骷髅与号角相互抵消,同类效果可叠加。战力可能会变为负数。
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
# 魔烬纷争 - 势力扩展
|
||||
|
||||
## 游戏概述
|
||||
|
||||
势力扩展为魔烬纷争增加了不同的专属势力卡牌。
|
||||
|
||||
## 游戏配件
|
||||
|
||||
势力配件:10 组
|
||||
- 领袖牌:每个势力1张
|
||||
- 势力牌:每个势力15张
|
||||
- 金币标记(商人势力):6个
|
||||
|
||||
## 游戏布置
|
||||
|
||||
对玩家布置进行以下调整:
|
||||
- 选择一套势力配件获得。
|
||||
- 将势力牌洗混放在旗帜牌下方。左侧留出空间作为势力牌弃牌堆。
|
||||
- 将领袖牌放在旗帜牌左侧。
|
||||
- 额外抓2张势力牌,然后选择两张牌弃掉。
|
||||
|
||||
领袖牌规则:
|
||||
- 效果总是生效,战力计入总点数。可以获得号角或骷髅。
|
||||
- 不计入旗帜的卡牌数目,战斗结束时只清理号角/骷髅标记不清理牌。
|
||||
|
||||
势力牌规则:
|
||||
- 每轮清理阶段抓牌时,改为抓 2 张公共牌和 1 张势力牌。
|
||||
- 因卡牌效果抓牌时,可以选择抓公共牌或者势力牌。
|
||||
- 势力牌弃掉时,放入独立的势力牌弃牌堆。势力牌堆为空而选择抓势力牌时,将其洗回势力牌堆。
|
||||
|
|
@ -53,7 +53,10 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
|||
"RemoveSingleton",
|
||||
};
|
||||
|
||||
|
||||
private static readonly HashSet<string> _forEachNames = new()
|
||||
{
|
||||
"ForEach",
|
||||
};
|
||||
|
||||
public void Initialize(IncrementalGeneratorInitializationContext context)
|
||||
{
|
||||
|
|
@ -65,7 +68,15 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
|||
.Where(t => t.FullyQualifiedName != null)
|
||||
.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);
|
||||
}
|
||||
|
|
@ -86,7 +97,21 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
|||
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(
|
||||
GeneratorSyntaxContext ctx, CancellationToken _)
|
||||
|
|
@ -129,11 +154,44 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
|||
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,
|
||||
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.
|
||||
var typeMap = new Dictionary<string, (string Fqn, string Aqn, bool IsRel, bool IsSingleton)>();
|
||||
|
|
@ -144,6 +202,16 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
|||
else
|
||||
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)
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -399,111 +399,6 @@ public class EdgeCaseTests
|
|||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
private sealed class ChangeCollector : IObserver<EntityChange>
|
||||
|
|
@ -518,39 +413,4 @@ public class EdgeCaseTests
|
|||
// Phantom types for relationship tests.
|
||||
public struct ChildOf { }
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@ namespace OECS;
|
|||
/// use-after-free bugs when entity IDs are recycled.
|
||||
/// </summary>
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
[MessagePackFormatter(typeof(EntityFormatter))]
|
||||
public readonly partial struct Entity : IEquatable<Entity>
|
||||
{
|
||||
private const uint IdMask = 0x00FF_FFFF; // lower 24 bits
|
||||
|
|
@ -58,16 +57,6 @@ public readonly partial struct Entity : IEquatable<Entity>
|
|||
/// </summary>
|
||||
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 override bool Equals(object? obj) => obj is Entity other && Equals(other);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 { }
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -3,25 +3,18 @@ namespace OECS;
|
|||
/// <summary>
|
||||
/// Manages a group of systems, running them in registration order
|
||||
/// 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>
|
||||
public class SystemGroup
|
||||
{
|
||||
private readonly World _world;
|
||||
private readonly List<ISystem> _systems = new();
|
||||
private readonly InterruptStore _interrupts = new();
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public SystemGroup(World world)
|
||||
{
|
||||
_world = world ?? throw new ArgumentNullException(nameof(world));
|
||||
_world._interruptStore = _interrupts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -66,19 +59,9 @@ public class SystemGroup
|
|||
private void RunAll(Tick tick)
|
||||
{
|
||||
// Drain commands enqueued before the tick so the first system
|
||||
// sees their effects. This also processes interrupt handler
|
||||
// commands that may resolve pending interrupts.
|
||||
// sees their effects.
|
||||
_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();
|
||||
foreach (var system in _systems)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -43,10 +43,6 @@ public class World : IDisposable
|
|||
private readonly HashSet<(Entity Entity, Type ComponentType)> _accessedComponents = 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()
|
||||
{
|
||||
_allocator = new EntityAllocator();
|
||||
|
|
@ -194,11 +190,6 @@ public class World : IDisposable
|
|||
nameof(AddComponentImpl),
|
||||
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
|
||||
{
|
||||
ThrowIfNotAlive(entity);
|
||||
|
|
@ -499,52 +490,6 @@ public class World : IDisposable
|
|||
_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 ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -594,13 +539,20 @@ public class World : IDisposable
|
|||
switch (m.Kind)
|
||||
{
|
||||
case PendingMutationKind.AddComponent:
|
||||
s_addComponentImpl.MakeGenericMethod(m.ComponentType)
|
||||
.Invoke(this, [m.Entity, m.Component]);
|
||||
// Use reflection to call AddComponentImpl<T>.
|
||||
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;
|
||||
|
||||
case PendingMutationKind.RemoveComponent:
|
||||
s_removeComponentImpl.MakeGenericMethod(m.ComponentType)
|
||||
.Invoke(this, [m.Entity]);
|
||||
var removeMethod = typeof(World).GetMethod(
|
||||
nameof(RemoveComponentImpl),
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
||||
var genericRemove = removeMethod.MakeGenericMethod(m.ComponentType);
|
||||
genericRemove.Invoke(this, [m.Entity]);
|
||||
break;
|
||||
|
||||
case PendingMutationKind.DestroyEntity:
|
||||
|
|
|
|||
|
|
@ -19,12 +19,6 @@ public static class WorldSerializer
|
|||
{
|
||||
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)
|
||||
{
|
||||
var set = world.Components.GetSet(desc.Type);
|
||||
|
|
@ -47,20 +41,9 @@ public static class WorldSerializer
|
|||
TypeName = desc.TypeName,
|
||||
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
|
||||
{
|
||||
Entities = entityComponents
|
||||
|
|
|
|||
|
|
@ -67,10 +67,6 @@ public class World : IDisposable
|
|||
public CommandQueue Commands { get; }
|
||||
public void ExecuteCommands();
|
||||
|
||||
// --- Interrupts ---
|
||||
public void Interrupt<T>(T interrupt) where T : struct, IInterrupt;
|
||||
public bool HasInterrupt<T>() where T : struct, IInterrupt;
|
||||
|
||||
// --- Reactivity ---
|
||||
public void MarkModified<T>(Entity entity) where T : struct;
|
||||
public void PostChanges();
|
||||
|
|
@ -86,8 +82,6 @@ public class World : IDisposable
|
|||
// --- Relationships ---
|
||||
public IReadOnlyCollection<Entity> GetSources<T>(Entity target)
|
||||
where T : struct, IRelationship;
|
||||
public void ReorderSources<T>(Entity target, IReadOnlyList<Entity> ordered)
|
||||
where T : struct, IRelationship;
|
||||
|
||||
// --- Cleanup ---
|
||||
public void Dispose();
|
||||
|
|
@ -117,12 +111,8 @@ public static class WorldQueryExtensions
|
|||
// FindEntity — returns the first matching entity or Entity.Null.
|
||||
public static Entity FindEntity<T1>(
|
||||
this World world, Query<T1> query = default) where T1 : struct;
|
||||
public static Entity FindEntity<T1, T2>(
|
||||
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;
|
||||
public static Entity FindEntity<T1, T2>(...) where T1 : struct where T2 : struct;
|
||||
public static Entity FindEntity<T1, T2, T3>(...) 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
|
||||
|
||||
```csharp
|
||||
|
|
@ -385,6 +329,7 @@ namespace OECS;
|
|||
|
||||
public interface IRelationship
|
||||
{
|
||||
Entity Source { get; }
|
||||
Entity Target { get; }
|
||||
}
|
||||
```
|
||||
|
|
@ -404,7 +349,8 @@ namespace OECS;
|
|||
public struct Relationship<TSelf, TTarget> : IRelationship
|
||||
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,
|
||||
ComponentAdded,
|
||||
ComponentRemoved,
|
||||
ComponentModified,
|
||||
RelationshipReordered
|
||||
ComponentModified
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -288,9 +288,8 @@ serializable and executed deferred.
|
|||
**Context:** Singletons (global state like `Time`, `Config`, `InputState`) need
|
||||
a home in the ECS.
|
||||
|
||||
**Decision:** Each singleton component type gets its own dedicated entity
|
||||
(allocated via `EntityAllocator`). Provide convenience accessors
|
||||
(`SetSingleton<T>`, `GetSingleton<T>`). Exclude all singleton entities from
|
||||
**Decision:** Reserve entity ID `1` as the singleton entity. Provide
|
||||
convenience accessors (`SetSingleton<T>`, `GetSingleton<T>`). Exclude from
|
||||
normal queries.
|
||||
|
||||
**Rationale:**
|
||||
|
|
@ -301,16 +300,11 @@ normal queries.
|
|||
queries.
|
||||
- Simpler than a separate "resource" system (as in Bevy). One concept (entity +
|
||||
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:**
|
||||
|
||||
- A `Dictionary<Type, Entity>` maps each singleton component type to its
|
||||
backing entity.
|
||||
- `IsSingletonEntity(Entity)` is checked during query iteration to exclude
|
||||
singleton entities.
|
||||
- Entity ID `1` is permanently reserved.
|
||||
- `DestroyEntity` on the singleton entity is a no-op or throws.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -391,53 +385,3 @@ package is included for compile-time validation.
|
|||
- The `MessagePackAnalyzer` package is a compile-time dependency.
|
||||
- `[Key]` indices should be sequential starting from 0 to avoid null
|
||||
placeholders in the binary output.
|
||||
|
||||
---
|
||||
|
||||
## ADR-013: Interrupts for System-Blocking Prompts
|
||||
|
||||
**Status:** Accepted
|
||||
|
||||
**Context:** Systems sometimes need to pause execution until an external
|
||||
response arrives (e.g., a UI confirmation dialog). The pause must be
|
||||
serialization-safe (the world is fully consistent when blocked) and
|
||||
must not interrupt mid-tick (which would leave partial state).
|
||||
|
||||
**Decision:** Add an interrupt system. Interrupts are issued via
|
||||
`world.Interrupt<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,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
|
||||
|
||||
```
|
||||
Phase 1 (Core)
|
||||
└─→ Phase 2 (Queries & Systems)
|
||||
└─→ Phase 3 (Commands)
|
||||
└─→ Phase 9 (Interrupts)
|
||||
└─→ Phase 4 (Relationships)
|
||||
└─→ Phase 5 (Reactivity)
|
||||
└─→ Phase 6 (Singletons)
|
||||
|
|
@ -634,8 +576,7 @@ Phase 1 (Core)
|
|||
└─→ Phase 8 (Polish)
|
||||
```
|
||||
|
||||
Interrupts depend on Phase 3 (Commands) because they use `ICommand` for
|
||||
handler commands. They're independent of Phases 4–8.
|
||||
Phases 3 and 4 can be done in parallel; Phase 5 depends on both.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue