docs: update documentation and implementation plan

Update the API surface and implementation plan to reflect architectural
changes, including new iterator overloads, system design refinements,
and the introduction of a source generator.
This commit is contained in:
hypercross 2026-07-20 17:44:25 +08:00
parent 10c4caed90
commit b066ac2eba
3 changed files with 269 additions and 126 deletions

View File

@ -139,13 +139,17 @@ public delegate void ForEachAction<T1, T2, T3, T4, T5, T6>(Entity entity, ref T1
Extension methods on `World` for `foreach`-style iteration over queries. Extension methods on `World` for `foreach`-style iteration over queries.
Returns `ref struct` iterators that support zero-allocation iteration with Returns `ref struct` iterators that support zero-allocation iteration with
`ref` access to components. Supports 13 component types. `ref` access to components.
Two sets of overloads: one accepting an explicit `QueryDescriptor` for
filtering with `Without<T>`, and one without for simple "has component" scans.
```csharp ```csharp
namespace OECS; namespace OECS;
public static class EntityIterator public static class EntityIterator
{ {
// With explicit QueryDescriptor (supports Without<T> filters).
public static Select1<T1> Select<T1>( public static Select1<T1> Select<T1>(
this World world, QueryDescriptor query) where T1 : struct; this World world, QueryDescriptor query) where T1 : struct;
@ -156,16 +160,38 @@ public static class EntityIterator
public static Select3<T1, T2, T3> Select<T1, T2, T3>( public static Select3<T1, T2, T3> Select<T1, T2, T3>(
this World world, QueryDescriptor query) this World world, QueryDescriptor query)
where T1 : struct where T2 : struct where T3 : struct; where T1 : struct where T2 : struct where T3 : struct;
// Without QueryDescriptor — iterates all entities with the given component(s).
public static Select1<T1> Select<T1>(
this World world) where T1 : struct;
public static Select2<T1, T2> Select<T1, T2>(
this World world)
where T1 : struct where T2 : struct;
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
this World world)
where T1 : struct where T2 : struct where T3 : struct;
} }
``` ```
Usage: The singletons `CurrentEntity`, `Current1`, `Current2`, `Current3` are
exposed on the ref struct iterators directly. Usage:
```csharp ```csharp
using var iter = world.Select<Position, Velocity>(query); // Simple scan: all entities with PlayerHand.
foreach (ref var item in iter) using var iter = world.Select<PlayerHand>();
while (iter.MoveNext())
{ {
item.Current1.X += item.Current2.X * dt; Console.WriteLine(iter.CurrentEntity);
}
// With query filter:
var query = world.Query().With<Position>().Without<Frozen>().Build();
using var iter2 = world.Select<Position>(query);
while (iter2.MoveNext())
{
iter2.Current1.X += 1;
} }
``` ```
@ -202,20 +228,32 @@ public class QueryDescriptor
## ISystem ## ISystem
A system is a unit of logic that runs during a tick. It receives the `World`
and decides what to do — typically reading singletons, iterating queries,
or enqueuing commands.
```csharp ```csharp
namespace OECS; namespace OECS;
public interface ISystem public interface ISystem
{ {
QueryDescriptor Query { get; }
void Run(World world); void Run(World world);
} }
``` ```
Systems do **not** declare a query on the interface. Instead, they either
read singletons directly (`world.ReadSingleton<T>()`) or use the iterator /
`ForEach` API with a `QueryDescriptor` they build internally. This keeps
the interface minimal and gives systems full flexibility over what they
inspect at runtime.
--- ---
## ITickedSystem ## ITickedSystem
An optional extension for systems that need tick metadata (delta time or
logical tick marker).
```csharp ```csharp
namespace OECS; namespace OECS;
@ -225,6 +263,10 @@ public interface ITickedSystem : ISystem
} }
``` ```
`SystemGroup` checks each system at runtime: if it implements
`ITickedSystem`, the `Run(World, Tick)` overload is called; otherwise
`Run(World)` is called. Both overloads must be implemented.
--- ---
## Tick ## Tick
@ -266,6 +308,9 @@ public class SystemGroup
} }
``` ```
Systems execute in registration order. `SystemGroup` automatically drains
commands and posts changes after each system and after the full tick.
--- ---
## ICommand ## ICommand
@ -279,6 +324,9 @@ public interface ICommand
} }
``` ```
Implementations should be `[MessagePackObject]` structs so they can be
serialized and replayed.
--- ---
## CommandQueue ## CommandQueue
@ -297,6 +345,9 @@ public class CommandQueue
} }
``` ```
`Enqueue<T>` uses a constrained generic to avoid boxing at the call site.
Commands enqueued during `ExecuteAll` are processed in the same drain cycle.
--- ---
## IRelationship ## IRelationship
@ -324,6 +375,7 @@ namespace OECS;
[MessagePackObject] [MessagePackObject]
public struct Relationship<TSelf, TTarget> : IRelationship public struct Relationship<TSelf, TTarget> : IRelationship
where TSelf : struct where TTarget : struct
{ {
[Key(0)] public Entity Source { get; set; } [Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; } [Key(1)] public Entity Target { get; set; }
@ -359,7 +411,7 @@ public enum ChangeKind
## WorldSerializer ## WorldSerializer
Saves and loads a `World` to/from a MessagePack stream. Components are Saves and loads a `World` to/from a MessagePack stream. Components are
serialized by their runtime type. serialized by their runtime type using the source-generated `ComponentRegistry`.
```csharp ```csharp
namespace OECS; namespace OECS;
@ -393,6 +445,7 @@ public class EntitySnapshot
[Key(0)] public uint Id { get; set; } [Key(0)] public uint Id { get; set; }
[Key(1)] public uint Version { get; set; } [Key(1)] public uint Version { get; set; }
[Key(2)] public ComponentEntry[] Components { get; set; } [Key(2)] public ComponentEntry[] Components { get; set; }
[IgnoreMember]
public Entity Entity { get; } public Entity Entity { get; }
} }
@ -410,37 +463,30 @@ public class ComponentEntry
```csharp ```csharp
using OECS; using OECS;
using R3;
// Define components // Define components
[MessagePackObject] [MessagePackObject]
public struct Position : IMessagePackSerializationCallbackReceiver public struct Position
{ {
[Key(0)] public float X; [Key(0)] public float X;
[Key(1)] public float Y; [Key(1)] public float Y;
public void OnBeforeSerialize() { }
public void OnAfterDeserialize() { }
} }
[MessagePackObject] [MessagePackObject]
public struct Velocity : IMessagePackSerializationCallbackReceiver public struct Velocity
{ {
[Key(0)] public float X; [Key(0)] public float X;
[Key(1)] public float Y; [Key(1)] public float Y;
public void OnBeforeSerialize() { }
public void OnAfterDeserialize() { }
} }
// Define a system // Define a system
public class MovementSystem : ITickedSystem public class MovementSystem : ITickedSystem
{ {
public QueryDescriptor Query { get; } private readonly QueryDescriptor _query;
public MovementSystem(World world) public MovementSystem(World world)
{ {
Query = world.Query() _query = world.Query()
.With<Position>() .With<Position>()
.With<Velocity>() .With<Velocity>()
.Build(); .Build();
@ -452,7 +498,7 @@ public class MovementSystem : ITickedSystem
{ {
float dt = tick.DeltaTime; float dt = tick.DeltaTime;
world.ForEach(Query, (Entity entity, ref Position pos, ref Velocity vel) => world.ForEach(_query, (Entity entity, ref Position pos, ref Velocity vel) =>
{ {
pos.X += vel.X * dt; pos.X += vel.X * dt;
pos.Y += vel.Y * dt; pos.Y += vel.Y * dt;
@ -466,16 +512,15 @@ var world = new World();
var group = new SystemGroup(world); var group = new SystemGroup(world);
group.Add(new MovementSystem(world)); group.Add(new MovementSystem(world));
// Observe changes
world.ObserveComponentChanges<Position>()
.Subscribe(change => Console.WriteLine($"{change.Entity} moved"))
.AddTo(disposables);
// Create entities // Create entities
var player = world.CreateEntity(); var player = world.CreateEntity();
world.AddComponent(player, new Position { X = 0, Y = 0 }); world.AddComponent(player, new Position { X = 0, Y = 0 });
world.AddComponent(player, new Velocity { X = 1, Y = 0 }); world.AddComponent(player, new Velocity { X = 1, Y = 0 });
// Observe changes via R3
world.ObserveComponentChanges<Position>()
.Subscribe(change => Console.WriteLine($"{change.Entity} moved"));
// Run a tick // Run a tick
group.RunTimed(0.016f); // ~60 FPS group.RunTimed(0.016f); // ~60 FPS
``` ```
@ -494,4 +539,6 @@ These types are implementation details and may change without notice:
| `ChangeSet` | Deduplicated change accumulator within a single batch. | | `ChangeSet` | Deduplicated change accumulator within a single batch. |
| `RelationshipIndex` | Reverse lookup from target entity to source entities. | | `RelationshipIndex` | Reverse lookup from target entity to source entities. |
| `EntityAllocator` | Free-list + bump allocator for entity IDs. | | `EntityAllocator` | Free-list + bump allocator for entity IDs. |
| `QueryExecutor` | Query iteration logic with smallest-set driver optimization. | | `QueryExecutor` | Query iteration logic with smallest-set driver optimization. |
| `ComponentRegistry` | Source-generated registry of all component types for serialization. |
| `ComponentDescriptor` | Source-generated per-type descriptor with serialize/deserialize callbacks. |

View File

@ -8,9 +8,10 @@ ones. Architecture decisions are captured in `docs/architecture.md`.
## Target ## Target
- .NET 8 (LTS), `net8.0` - .NET 8 (LTS), `net8.0` (C# 12)
- Dependencies: `MessagePack` (serialization), `R3` (reactivity) - Dependencies: `MessagePack` (serialization), `R3` (reactivity)
- Output: `OECS.dll` - Output: `OECS.dll`
- Source generator: `OECS.SourceGen.dll` (component registry for serialization)
--- ---
@ -22,8 +23,12 @@ ones. Architecture decisions are captured in `docs/architecture.md`.
``` ```
OECS.sln OECS.sln
├── src/OECS/OECS.csproj ├── OECS/OECS.csproj
└── tests/OECS.Tests/OECS.Tests.csproj ├── OECS.SourceGen/OECS.SourceGen.csproj
├── OECS.Tests/OECS.Tests.csproj
├── Game.Blackjack/Blackjack.csproj
├── Game.Blackjack.Tests/Game.Blackjack.Tests.csproj
└── Game.TicTacToe/...
``` ```
- `OECS.csproj` targets `net8.0`, references `MessagePack` and `R3`. - `OECS.csproj` targets `net8.0`, references `MessagePack` and `R3`.
@ -76,6 +81,8 @@ Holds a `Dictionary<Type, object>` mapping component types to their
- `void Add<T>(Entity, T)` - `void Add<T>(Entity, T)`
- `void Remove<T>(Entity)` - `void Remove<T>(Entity)`
- `ref T Get<T>(Entity)` - `ref T Get<T>(Entity)`
- `T Read<T>(Entity)` — copy without auto-mark
- `bool TryGet<T>(Entity, out T)`
- `bool Has<T>(Entity)` - `bool Has<T>(Entity)`
- `void RemoveAll(Entity)` — called on entity destruction - `void RemoveAll(Entity)` — called on entity destruction
@ -99,6 +106,8 @@ void DestroyEntity(Entity entity);
void AddComponent<T>(Entity entity, T component) where T : struct; void AddComponent<T>(Entity entity, T component) where T : struct;
void RemoveComponent<T>(Entity entity) where T : struct; void RemoveComponent<T>(Entity entity) where T : struct;
ref T GetComponent<T>(Entity entity) where T : struct; ref T GetComponent<T>(Entity entity) where T : struct;
T ReadComponent<T>(Entity entity) where T : struct;
bool TryGetComponent<T>(Entity entity, out T value) where T : struct;
bool HasComponent<T>(Entity entity) where T : struct; bool HasComponent<T>(Entity entity) where T : struct;
bool IsAlive(Entity entity); bool IsAlive(Entity entity);
``` ```
@ -128,8 +137,8 @@ A query is defined by:
``` ```
class QueryDescriptor class QueryDescriptor
{ {
HashSet<Type> With { get; } IReadOnlySet<Type> With { get; }
HashSet<Type> Without { get; } IReadOnlySet<Type> Without { get; }
} }
``` ```
@ -147,49 +156,74 @@ world.Query()
### 2.3 Query Execution ### 2.3 Query Execution
`World` provides iteration over matching entities. The smallest "with" sparse `World` provides two iteration styles:
set is used as the driver; other sets are probed for membership.
**ForEach callbacks** (16 component types):
```csharp ```csharp
void ForEach<T1, T2>(QueryDescriptor query, Action<Entity, ref T1, ref T2> action); void ForEach<T1, T2>(QueryDescriptor query, ForEachAction<T1, T2> action);
``` ```
Overloads for 16 component types. The `Without` filter is checked by probing **Ref struct iterators** via `EntityIterator.Select<T>()` extensions (13 component types):
the corresponding sparse sets. ```csharp
using var iter = world.Select<Position, Velocity>(query);
while (iter.MoveNext()) { ... }
```
Both accept an optional `QueryDescriptor`. When omitted, all entities with
the given component types are iterated (no `Without` filter).
The smallest "with" sparse set is used as the driver; other sets are probed
for membership. The singleton entity (ID 1) is always skipped.
### 2.4 ISystem ### 2.4 ISystem
``` ```
interface ISystem interface ISystem
{ {
QueryDescriptor Query { get; }
void Run(World world); void Run(World world);
} }
``` ```
Systems declare their query and receive the world in `Run`. They call Systems do **not** declare a query on the interface. Instead, they either
`world.ForEach(query, ...)` to iterate. read singletons directly or build queries internally and iterate with
`ForEach` / `Select`. This keeps the interface minimal and gives systems
full flexibility.
Alternative considered: auto-injection of component refs. Rejected because it **Why no auto-injection?** The explicit API makes the iteration cost
hides the iteration cost and makes the API less explicit. The explicit visible and avoids the need for source generators or reflection for
`ForEach` call keeps the system author aware of what they're iterating. system dispatch.
### 2.5 System Registration & Ordering ### 2.5 ITickedSystem
```
interface ITickedSystem : ISystem
{
void Run(World world, Tick tick);
}
```
Systems that need tick metadata (delta time or logical tick marker)
implement this. Both `Run` overloads must be implemented; `SystemGroup`
dispatches to the appropriate one at runtime.
### 2.6 System Registration & Ordering
``` ```
class SystemGroup class SystemGroup
{ {
void Add(ISystem system); // registration order = execution order void Add(ISystem system); // registration order = execution order
void RunTimed(float deltaTime); // calls each system's Run void Remove(ISystem system);
void RunLogical(); // calls each system's Run void RunTimed(float deltaTime);
void RunLogical();
int Count { get; }
} }
``` ```
Systems run in registration order. For now, no explicit dependency graph — Systems run in registration order. `SystemGroup` automatically:
this is the simplest model that works. If needed later, `Before()`/`After()` - Drains commands before the tick, after each system, and after the full tick.
constraints can be added without breaking the API. - Posts changes after each system and after the full tick.
### 2.6 Tick ### 2.7 Tick
``` ```
readonly struct Tick readonly struct Tick
@ -199,16 +233,7 @@ readonly struct Tick
} }
``` ```
Passed to systems that opt into it via a separate interface: ### 2.8 Tests
```
interface ITickedSystem : ISystem
{
void Run(World world, Tick tick);
}
```
### 2.7 Tests
- Query with single component returns matching entities. - Query with single component returns matching entities.
- Query with multiple components returns intersection. - Query with multiple components returns intersection.
@ -216,6 +241,7 @@ interface ITickedSystem : ISystem
- Adding/removing components updates query results. - Adding/removing components updates query results.
- Systems run in registration order. - Systems run in registration order.
- Destroyed entities don't appear in queries. - Destroyed entities don't appear in queries.
- `ITickedSystem` receives correct tick data.
--- ---
@ -226,31 +252,38 @@ interface ITickedSystem : ISystem
### 3.1 ICommand ### 3.1 ICommand
``` ```
[MessagePackObject]
interface ICommand interface ICommand
{ {
void Execute(World world); void Execute(World world);
} }
``` ```
Commands are `[MessagePackObject]` structs implementing `ICommand`. They are Commands are structs implementing `ICommand`. They are typically
not stored in ECS sparse sets — they live in a queue. `[MessagePackObject]` for serialization. Not stored in ECS sparse
sets — they live in a queue.
### 3.2 CommandQueue ### 3.2 CommandQueue
``` ```
class CommandQueue class CommandQueue
{ {
void Enqueue(ICommand command); void Enqueue<T>(T command) where T : struct, ICommand;
void ExecuteAll(World world); // FIFO, clears queue void ExecuteAll(World world); // FIFO, fully drains queue
void Clear();
void ClearErrors();
int Count { get; } int Count { get; }
IReadOnlyList<Exception> Errors { get; }
} }
``` ```
`Enqueue<T>` uses a constrained generic to avoid boxing at the call site.
Commands enqueued during `ExecuteAll` are processed in the same drain cycle.
### 3.3 Integration with World ### 3.3 Integration with World
`World` owns a `CommandQueue`. After each system runs (or after the full tick), `World` owns a `CommandQueue`. After each system runs (or after the full tick),
the queue is drained. This is configurable: the queue is drained automatically by `SystemGroup`. Manual drain is also
available:
```csharp ```csharp
world.ExecuteCommands(); // manual drain world.ExecuteCommands(); // manual drain
@ -279,36 +312,48 @@ available via `CommandQueue.Errors`.
**Goal:** Relationship components with auto-managed source/target and reverse **Goal:** Relationship components with auto-managed source/target and reverse
lookup. lookup.
### 4.1 Relationship\<TSelf, TTarget\> ### 4.1 IRelationship
```
interface IRelationship
{
Entity Source { get; }
Entity Target { get; }
}
```
### 4.2 Relationship\<TSelf, TTarget\>
``` ```
[MessagePackObject] [MessagePackObject]
struct Relationship<TSelf, TTarget> : IRelationship struct Relationship<TSelf, TTarget> : IRelationship
where TSelf : struct where TTarget : struct
{ {
[Key(0)] Entity Source { get; set; } [Key(0)] Entity Source { get; set; }
[Key(1)] Entity Target { get; set; } [Key(1)] Entity Target { get; set; }
// ... payload fields
} }
``` ```
The `IRelationship` marker interface lets `ComponentStore` detect relationships The `IRelationship` marker interface lets `ComponentStore` detect relationships
and maintain the reverse index. and maintain the reverse index. The `TSelf`/`TTarget` phantom types
differentiate relationship kinds at the type level.
### 4.2 Reverse Index Alternatively, implement `IRelationship` directly on your own struct (see the
Blackjack `Holds` and `InDeck` types).
`World` maintains a `Dictionary<Entity, HashSet<Entity>>` per relationship ### 4.3 Reverse Index
type, mapping target → set of source entities.
When a relationship component is added/removed, the index is updated `World` maintains a reverse index per relationship type, mapping target →
automatically. set of source entities. Updated automatically when relationship components
are added/removed.
### 4.3 Reverse Lookup API ### 4.4 Reverse Lookup API
```csharp ```csharp
IReadOnlyCollection<Entity> GetSources<T>(Entity target) where T : struct, IRelationship; IReadOnlyCollection<Entity> GetSources<T>(Entity target) where T : struct, IRelationship;
``` ```
### 4.4 Cascading Behavior ### 4.5 Cascading Behavior
When an entity is destroyed: When an entity is destroyed:
- All relationships where it is the **source** are removed (components dropped). - All relationships where it is the **source** are removed (components dropped).
@ -316,7 +361,7 @@ When an entity is destroyed:
from source entities). from source entities).
- The reverse index is cleaned up. - The reverse index is cleaned up.
### 4.5 Tests ### 4.6 Tests
- Adding a relationship updates the reverse index. - Adding a relationship updates the reverse index.
- Removing a relationship updates the reverse index. - Removing a relationship updates the reverse index.
@ -346,23 +391,17 @@ struct EntityChange
{ {
Entity Entity { get; } Entity Entity { get; }
ChangeKind Kind { get; } ChangeKind Kind { get; }
Type ComponentType { get; } // null for entity-level changes Type? ComponentType { get; } // null for entity-level changes
} }
``` ```
### 5.2 ChangeSet ### 5.2 ChangeBuffer
``` ```
class ChangeSet class ChangeBuffer
{ {
void MarkEntityAdded(Entity entity); void Mark<...>(...);
void MarkEntityRemoved(Entity entity); void Post(); // pushes to R3 subjects, then clears
void MarkComponentAdded(Entity entity, Type componentType);
void MarkComponentRemoved(Entity entity, Type componentType);
void MarkComponentModified(Entity entity, Type componentType);
IReadOnlyList<EntityChange> Changes { get; }
void Clear();
} }
``` ```
@ -381,25 +420,15 @@ Rationale: structural changes are always detectable. Value mutations inside a
value. Requiring an explicit `MarkModified` call is the simplest correct value. Requiring an explicit `MarkModified` call is the simplest correct
approach. approach.
**Debug aid:** In `DEBUG` builds, `ref T Get<T>(Entity)` returns a wrapper that **`ReadComponent<T>`** (returns a copy) and **`ReadSingleton<T>`** never
tracks whether the value was written. If a system iterates `ref T` and never auto-mark. Use these when you only need to inspect state without signaling
calls `MarkModified`, a warning is logged. This catches the most common changes.
mistake.
### 5.4 Posting Model ### 5.4 Posting Model
``` - During a system's `Run`, changes accumulate in a pending buffer.
class ChangeBuffer - After each system's `Run`, `Post()` is called automatically by `SystemGroup`.
{ - After the full tick, `Post()` is called once more.
ChangeSet Pending { get; } // accumulates during system run
void Post(); // pushes to R3 subjects, then clears
}
```
- During a system's `Run`, changes accumulate in `Pending`.
- After each system's `Run`, `Post()` is called automatically.
- After the full tick, `Post()` is called once more (for any changes made
outside systems, e.g., during command execution).
- When not in a system run, changes are **not** posted automatically — the - When not in a system run, changes are **not** posted automatically — the
caller must call `world.PostChanges()`. caller must call `world.PostChanges()`.
@ -408,9 +437,9 @@ class ChangeBuffer
`World` exposes observables: `World` exposes observables:
```csharp ```csharp
IObservable<EntityChange> ObserveEntityChanges(); Observable<EntityChange> ObserveEntityChanges();
IObservable<EntityChange> ObserveComponentChanges<T>() where T : struct; Observable<EntityChange> ObserveComponentChanges<T>() where T : struct;
IObservable<EntityChange> ObserveQuery(QueryDescriptor query); Observable<EntityChange> ObserveQuery(QueryDescriptor query);
``` ```
These are backed by `Subject<EntityChange>` instances. Subscribers receive These are backed by `Subject<EntityChange>` instances. Subscribers receive
@ -445,19 +474,21 @@ world.ObserveComponentChanges<Health>()
### 6.1 Singleton Entity ### 6.1 Singleton Entity
`World` reserves entity ID `1` as the singleton entity. It is never destroyed `World` reserves entity ID `1` as the singleton entity. It is never destroyed
and is excluded from normal queries by default. and is excluded from normal queries (all iterators skip ID 1).
### 6.2 Singleton Accessors ### 6.2 Singleton Accessors
```csharp ```csharp
void SetSingleton<T>(T component) where T : struct; void SetSingleton<T>(T component) where T : struct;
ref T GetSingleton<T>() where T : struct; ref T GetSingleton<T>() where T : struct;
T ReadSingleton<T>() where T : struct;
bool HasSingleton<T>() where T : struct; bool HasSingleton<T>() where T : struct;
void RemoveSingleton<T>() where T : struct; void RemoveSingleton<T>() where T : struct;
``` ```
These are convenience wrappers around `AddComponent`/`GetComponent` on the These are convenience wrappers around `AddComponent`/`GetComponent` on the
singleton entity. singleton entity. `GetSingleton` returns a `ref` (auto-marks during iteration);
`ReadSingleton` returns a copy (never auto-marks).
### 6.3 Query Exclusion ### 6.3 Query Exclusion
@ -473,28 +504,62 @@ to include it, they can query it by its entity ID directly.
--- ---
## Phase 7 — Polish & Documentation (Week 7) ## Phase 7 — Source Generator & Serialization (Week 7)
### 7.1 XML Docs **Goal:** Compile-time component registry for serialization without reflection.
### 7.1 OECS.SourceGen
A Roslyn incremental source generator that scans for all types used as
generic arguments to `World` methods (`AddComponent<T>`, `GetComponent<T>`,
`SetSingleton<T>`, etc.) and generates a `ComponentRegistry` class with
per-type serialize/deserialize callbacks via MessagePack.
### 7.2 WorldSerializer
```csharp
static class WorldSerializer
{
static void Save(World world, Stream stream);
static void Load(World world, Stream stream);
}
```
Uses `ComponentRegistry` to serialize/deserialize all entities and their
components to/from a MessagePack stream. `IRelationship.Source` is fixed
up to the owning entity on load.
### 7.3 Snapshot Types
`WorldSnapshot`, `EntitySnapshot`, and `ComponentEntry` are the public
serialization DTOs.
---
## Phase 8 — Polish & Documentation (Week 8)
### 8.1 XML Docs
All public API surface gets `<summary>` XML documentation comments. All public API surface gets `<summary>` XML documentation comments.
### 7.2 README ### 8.2 README
Quick-start guide with a minimal example: create world, register system, run Quick-start guide with a minimal example: create world, register system, run
tick, observe changes. tick, observe changes.
### 7.3 NuGet Packaging ### 8.3 NuGet Packaging
`OECS.csproj` includes package metadata: `OECS.csproj` includes package metadata:
- `PackageId`: `OECS` - `PackageId`: `OECS`
- `Description`: "Observable ECS for C# — an entity component system focused on - `Description`: "Observable ECS for C# — an entity component system focused on
a clean reactive API surface." a clean reactive API surface."
- `PackageTags`: `ecs;reactive;observable;gamedev` - `PackageTags`: `ecs;reactive;observable;gamedev`
- Bundles `OECS.SourceGen.dll` as an analyzer for consumers.
### 7.4 CI (optional) ### 8.4 Testing Games
GitHub Actions workflow: build, test, pack. Test games (`Game.Blackjack`, `Game.TicTacToe`) serve as integration tests
and design validation. See `docs/testing-games.md` for the testing strategy.
--- ---
@ -507,7 +572,8 @@ Phase 1 (Core)
└─→ Phase 4 (Relationships) └─→ Phase 4 (Relationships)
└─→ Phase 5 (Reactivity) └─→ Phase 5 (Reactivity)
└─→ Phase 6 (Singletons) └─→ Phase 6 (Singletons)
└─→ Phase 7 (Polish) └─→ Phase 7 (Source Gen & Serialization)
└─→ Phase 8 (Polish)
``` ```
Phases 3 and 4 can be done in parallel; Phase 5 depends on both. Phases 3 and 4 can be done in parallel; Phase 5 depends on both.
@ -520,8 +586,8 @@ Phases 3 and 4 can be done in parallel; Phase 5 depends on both.
default. If needed, systems could declare read/write component access for default. If needed, systems could declare read/write component access for
automatic parallel scheduling — but this adds significant complexity. automatic parallel scheduling — but this adds significant complexity.
2. **World serialization?** Since components are MessagePack-serializable, 2. **Multiple worlds?** The design supports it naturally — `World` is a class,
snapshotting the entire world is feasible. This is a Phase 7+ stretch goal.
3. **Multiple worlds?** The design supports it naturally — `World` is a class,
you can instantiate multiple. No cross-world references are supported. you can instantiate multiple. No cross-world references are supported.
3. **Component import from CSV/MasterMemory?** The `design.md` mentions this as
a potential feature. Not yet implemented.

View File

@ -1,23 +1,53 @@
# Testing games # Testing games
1. Games are dlls. Use a standalone test project to test a game. 1. Games are DLLs. Use a standalone test project to test a game.
2. Create baseline game snapshots in textual format. Save to files. 2. Create baseline game snapshots in textual format. Save to files.
3. Create baseline logs via the r3 observable api. Save to files. 3. Create baseline logs via the R3 observable API. Save to files.
4. Read the snapshot/logs manually to identify issues. 4. Read the snapshot/logs manually to identify issues.
5. Make sure serialization roundtrip works. 5. Make sure serialization roundtrip works.
## Play tests ## Play tests
The goal of playtests is to execute a game with AI players and potentially random seeds. Then the log/snapshots are read by the LLM agent to spot issues. The goal of playtests is to execute a game with AI players and potentially
random seeds. Then the log/snapshots are read by the LLM agent to spot issues.
There is no failure in play tests; all tests pass but the agent should read the log for reference. There is no failure in play tests; all tests pass but the agent should read the
log for reference.
AI players are not LLMs here; they are typically common game AI implementations. AI players are not LLMs here; they are typically common game AI implementations.
Create static functions that analyze the game state and return a command. Each such function is an agent. Each AI player routes its decisions to a random agent in a pool of weighted agents. Create static functions that analyze the game state and return a command. Each
such function is an agent. Each AI player routes its decisions to a random
agent in a pool of weighted agents.
Agents to consider: Agents to consider:
- Random: evenly chooses every move randomly. - **Random:** evenly chooses every move randomly.
- Greedy: uses a specific way to score actions. Always chooses the onewith highest score. There can be multiple Greedy agents wtih different scoring. - **Greedy:** uses a specific way to score actions. Always chooses the one with
highest score. There can be multiple Greedy agents with different scoring.
For each playtest attempt, generate a full game log, analyze and report what you find from the log. For each playtest attempt, generate a full game log, analyze and report what
you find from the log.
### Round Runner
Playtests should run multiple rounds, not just one. The recommended pattern:
```
while chips >= minimum_bet AND chips < 2× starting_chips:
place bet → deal → agent decides hit/stand → resolve → new round
```
This exercises the full game lifecycle including deck reshuffling, round
transitions, and chip management. Safety cap at ~200 rounds to prevent
infinite loops.
### Play Log Format
Each play log should contain:
- **Header:** agent name, seed, result summary, win/loss/push counts.
- **Round Summaries:** per-round result, chip delta, hit count.
- **Decisions:** per-decision hand total and choice (Hit/Stand).
- **Reactivity:** raw R3 observable log of component/entity changes.
- **Final State:** snapshot of the world after all rounds.
Logs are saved as `.playlog` files alongside test output for manual review.