From b066ac2eba5e655df6050cd69b60a589cbc11fb1 Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 20 Jul 2026 17:44:25 +0800 Subject: [PATCH] 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. --- docs/api-surface.md | 97 ++++++++++---- docs/implementation-plan.md | 252 +++++++++++++++++++++++------------- docs/testing-games.md | 46 +++++-- 3 files changed, 269 insertions(+), 126 deletions(-) diff --git a/docs/api-surface.md b/docs/api-surface.md index ea2be7b..e4bc2b6 100644 --- a/docs/api-surface.md +++ b/docs/api-surface.md @@ -139,13 +139,17 @@ public delegate void ForEachAction(Entity entity, ref T1 Extension methods on `World` for `foreach`-style iteration over queries. Returns `ref struct` iterators that support zero-allocation iteration with -`ref` access to components. Supports 1–3 component types. +`ref` access to components. + +Two sets of overloads: one accepting an explicit `QueryDescriptor` for +filtering with `Without`, and one without for simple "has component" scans. ```csharp namespace OECS; public static class EntityIterator { + // With explicit QueryDescriptor (supports Without filters). public static Select1 Select( this World world, QueryDescriptor query) where T1 : struct; @@ -156,16 +160,38 @@ public static class EntityIterator public static Select3 Select( this World world, QueryDescriptor query) where T1 : struct where T2 : struct where T3 : struct; + + // Without QueryDescriptor — iterates all entities with the given component(s). + public static Select1 Select( + this World world) where T1 : struct; + + public static Select2 Select( + this World world) + where T1 : struct where T2 : struct; + + public static Select3 Select( + 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 -using var iter = world.Select(query); -foreach (ref var item in iter) +// Simple scan: all entities with PlayerHand. +using var iter = world.Select(); +while (iter.MoveNext()) { - item.Current1.X += item.Current2.X * dt; + Console.WriteLine(iter.CurrentEntity); +} + +// With query filter: +var query = world.Query().With().Without().Build(); +using var iter2 = world.Select(query); +while (iter2.MoveNext()) +{ + iter2.Current1.X += 1; } ``` @@ -202,20 +228,32 @@ public class QueryDescriptor ## 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 namespace OECS; public interface ISystem { - QueryDescriptor Query { get; } void Run(World world); } ``` +Systems do **not** declare a query on the interface. Instead, they either +read singletons directly (`world.ReadSingleton()`) 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 +An optional extension for systems that need tick metadata (delta time or +logical tick marker). + ```csharp 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 @@ -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 @@ -279,6 +324,9 @@ public interface ICommand } ``` +Implementations should be `[MessagePackObject]` structs so they can be +serialized and replayed. + --- ## CommandQueue @@ -297,6 +345,9 @@ public class CommandQueue } ``` +`Enqueue` uses a constrained generic to avoid boxing at the call site. +Commands enqueued during `ExecuteAll` are processed in the same drain cycle. + --- ## IRelationship @@ -324,6 +375,7 @@ namespace OECS; [MessagePackObject] public struct Relationship : IRelationship + where TSelf : struct where TTarget : struct { [Key(0)] public Entity Source { get; set; } [Key(1)] public Entity Target { get; set; } @@ -359,7 +411,7 @@ public enum ChangeKind ## WorldSerializer 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 namespace OECS; @@ -393,6 +445,7 @@ public class EntitySnapshot [Key(0)] public uint Id { get; set; } [Key(1)] public uint Version { get; set; } [Key(2)] public ComponentEntry[] Components { get; set; } + [IgnoreMember] public Entity Entity { get; } } @@ -410,37 +463,30 @@ public class ComponentEntry ```csharp using OECS; -using R3; // Define components [MessagePackObject] -public struct Position : IMessagePackSerializationCallbackReceiver +public struct Position { [Key(0)] public float X; [Key(1)] public float Y; - - public void OnBeforeSerialize() { } - public void OnAfterDeserialize() { } } [MessagePackObject] -public struct Velocity : IMessagePackSerializationCallbackReceiver +public struct Velocity { [Key(0)] public float X; [Key(1)] public float Y; - - public void OnBeforeSerialize() { } - public void OnAfterDeserialize() { } } // Define a system public class MovementSystem : ITickedSystem { - public QueryDescriptor Query { get; } + private readonly QueryDescriptor _query; public MovementSystem(World world) { - Query = world.Query() + _query = world.Query() .With() .With() .Build(); @@ -452,7 +498,7 @@ public class MovementSystem : ITickedSystem { 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.Y += vel.Y * dt; @@ -466,16 +512,15 @@ var world = new World(); var group = new SystemGroup(world); group.Add(new MovementSystem(world)); -// Observe changes -world.ObserveComponentChanges() - .Subscribe(change => Console.WriteLine($"{change.Entity} moved")) - .AddTo(disposables); - // Create entities var player = world.CreateEntity(); world.AddComponent(player, new Position { X = 0, Y = 0 }); world.AddComponent(player, new Velocity { X = 1, Y = 0 }); +// Observe changes via R3 +world.ObserveComponentChanges() + .Subscribe(change => Console.WriteLine($"{change.Entity} moved")); + // Run a tick 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. | | `RelationshipIndex` | Reverse lookup from target entity to source entities. | | `EntityAllocator` | Free-list + bump allocator for entity IDs. | -| `QueryExecutor` | Query iteration logic with smallest-set driver optimization. | \ No newline at end of file +| `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. | diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 9487ddb..c35b995 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -8,9 +8,10 @@ ones. Architecture decisions are captured in `docs/architecture.md`. ## Target -- .NET 8 (LTS), `net8.0` +- .NET 8 (LTS), `net8.0` (C# 12) - Dependencies: `MessagePack` (serialization), `R3` (reactivity) - 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 -├── src/OECS/OECS.csproj -└── tests/OECS.Tests/OECS.Tests.csproj +├── OECS/OECS.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`. @@ -76,6 +81,8 @@ Holds a `Dictionary` mapping component types to their - `void Add(Entity, T)` - `void Remove(Entity)` - `ref T Get(Entity)` +- `T Read(Entity)` — copy without auto-mark +- `bool TryGet(Entity, out T)` - `bool Has(Entity)` - `void RemoveAll(Entity)` — called on entity destruction @@ -99,6 +106,8 @@ void DestroyEntity(Entity entity); void AddComponent(Entity entity, T component) where T : struct; void RemoveComponent(Entity entity) where T : struct; ref T GetComponent(Entity entity) where T : struct; +T ReadComponent(Entity entity) where T : struct; +bool TryGetComponent(Entity entity, out T value) where T : struct; bool HasComponent(Entity entity) where T : struct; bool IsAlive(Entity entity); ``` @@ -128,8 +137,8 @@ A query is defined by: ``` class QueryDescriptor { - HashSet With { get; } - HashSet Without { get; } + IReadOnlySet With { get; } + IReadOnlySet Without { get; } } ``` @@ -147,49 +156,74 @@ world.Query() ### 2.3 Query Execution -`World` provides iteration over matching entities. The smallest "with" sparse -set is used as the driver; other sets are probed for membership. +`World` provides two iteration styles: +**ForEach callbacks** (1–6 component types): ```csharp -void ForEach(QueryDescriptor query, Action action); +void ForEach(QueryDescriptor query, ForEachAction action); ``` -Overloads for 1–6 component types. The `Without` filter is checked by probing -the corresponding sparse sets. +**Ref struct iterators** via `EntityIterator.Select()` extensions (1–3 component types): +```csharp +using var iter = world.Select(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 ``` interface ISystem { - QueryDescriptor Query { get; } void Run(World world); } ``` -Systems declare their query and receive the world in `Run`. They call -`world.ForEach(query, ...)` to iterate. +Systems do **not** declare a query on the interface. Instead, they either +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 -hides the iteration cost and makes the API less explicit. The explicit -`ForEach` call keeps the system author aware of what they're iterating. +**Why no auto-injection?** The explicit API makes the iteration cost +visible and avoids the need for source generators or reflection for +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 { void Add(ISystem system); // registration order = execution order - void RunTimed(float deltaTime); // calls each system's Run - void RunLogical(); // calls each system's Run + void Remove(ISystem system); + void RunTimed(float deltaTime); + void RunLogical(); + int Count { get; } } ``` -Systems run in registration order. For now, no explicit dependency graph — -this is the simplest model that works. If needed later, `Before()`/`After()` -constraints can be added without breaking the API. +Systems run in registration order. `SystemGroup` automatically: +- Drains commands before the tick, after each system, and after the full tick. +- Posts changes after each system and after the full tick. -### 2.6 Tick +### 2.7 Tick ``` readonly struct Tick @@ -199,16 +233,7 @@ readonly struct Tick } ``` -Passed to systems that opt into it via a separate interface: - -``` -interface ITickedSystem : ISystem -{ - void Run(World world, Tick tick); -} -``` - -### 2.7 Tests +### 2.8 Tests - Query with single component returns matching entities. - Query with multiple components returns intersection. @@ -216,6 +241,7 @@ interface ITickedSystem : ISystem - Adding/removing components updates query results. - Systems run in registration order. - Destroyed entities don't appear in queries. +- `ITickedSystem` receives correct tick data. --- @@ -226,31 +252,38 @@ interface ITickedSystem : ISystem ### 3.1 ICommand ``` -[MessagePackObject] interface ICommand { void Execute(World world); } ``` -Commands are `[MessagePackObject]` structs implementing `ICommand`. They are -not stored in ECS sparse sets — they live in a queue. +Commands are structs implementing `ICommand`. They are typically +`[MessagePackObject]` for serialization. Not stored in ECS sparse +sets — they live in a queue. ### 3.2 CommandQueue ``` class CommandQueue { - void Enqueue(ICommand command); - void ExecuteAll(World world); // FIFO, clears queue + void Enqueue(T command) where T : struct, ICommand; + void ExecuteAll(World world); // FIFO, fully drains queue + void Clear(); + void ClearErrors(); int Count { get; } + IReadOnlyList Errors { get; } } ``` +`Enqueue` 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 `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 world.ExecuteCommands(); // manual drain @@ -279,36 +312,48 @@ available via `CommandQueue.Errors`. **Goal:** Relationship components with auto-managed source/target and reverse lookup. -### 4.1 Relationship\ +### 4.1 IRelationship + +``` +interface IRelationship +{ + Entity Source { get; } + Entity Target { get; } +} +``` + +### 4.2 Relationship\ ``` [MessagePackObject] struct Relationship : IRelationship + where TSelf : struct where TTarget : struct { [Key(0)] Entity Source { get; set; } [Key(1)] Entity Target { get; set; } - // ... payload fields } ``` 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>` per relationship -type, mapping target → set of source entities. +### 4.3 Reverse Index -When a relationship component is added/removed, the index is updated -automatically. +`World` maintains a reverse index per relationship type, mapping target → +set of source entities. Updated automatically when relationship components +are added/removed. -### 4.3 Reverse Lookup API +### 4.4 Reverse Lookup API ```csharp IReadOnlyCollection GetSources(Entity target) where T : struct, IRelationship; ``` -### 4.4 Cascading Behavior +### 4.5 Cascading Behavior When an entity is destroyed: - All relationships where it is the **source** are removed (components dropped). @@ -316,7 +361,7 @@ When an entity is destroyed: from source entities). - The reverse index is cleaned up. -### 4.5 Tests +### 4.6 Tests - Adding a relationship updates the reverse index. - Removing a relationship updates the reverse index. @@ -346,23 +391,17 @@ struct EntityChange { Entity Entity { 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 MarkEntityRemoved(Entity entity); - void MarkComponentAdded(Entity entity, Type componentType); - void MarkComponentRemoved(Entity entity, Type componentType); - void MarkComponentModified(Entity entity, Type componentType); - - IReadOnlyList Changes { get; } - void Clear(); + void Mark<...>(...); + void Post(); // pushes to R3 subjects, then clears } ``` @@ -381,25 +420,15 @@ Rationale: structural changes are always detectable. Value mutations inside a value. Requiring an explicit `MarkModified` call is the simplest correct approach. -**Debug aid:** In `DEBUG` builds, `ref T Get(Entity)` returns a wrapper that -tracks whether the value was written. If a system iterates `ref T` and never -calls `MarkModified`, a warning is logged. This catches the most common -mistake. +**`ReadComponent`** (returns a copy) and **`ReadSingleton`** never +auto-mark. Use these when you only need to inspect state without signaling +changes. ### 5.4 Posting Model -``` -class ChangeBuffer -{ - 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). +- During a system's `Run`, changes accumulate in a pending buffer. +- After each system's `Run`, `Post()` is called automatically by `SystemGroup`. +- After the full tick, `Post()` is called once more. - When not in a system run, changes are **not** posted automatically — the caller must call `world.PostChanges()`. @@ -408,9 +437,9 @@ class ChangeBuffer `World` exposes observables: ```csharp -IObservable ObserveEntityChanges(); -IObservable ObserveComponentChanges() where T : struct; -IObservable ObserveQuery(QueryDescriptor query); +Observable ObserveEntityChanges(); +Observable ObserveComponentChanges() where T : struct; +Observable ObserveQuery(QueryDescriptor query); ``` These are backed by `Subject` instances. Subscribers receive @@ -445,19 +474,21 @@ world.ObserveComponentChanges() ### 6.1 Singleton Entity `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 ```csharp void SetSingleton(T component) where T : struct; ref T GetSingleton() where T : struct; +T ReadSingleton() where T : struct; bool HasSingleton() where T : struct; void RemoveSingleton() where T : struct; ``` 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 @@ -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`, `GetComponent`, +`SetSingleton`, 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 `` XML documentation comments. -### 7.2 README +### 8.2 README Quick-start guide with a minimal example: create world, register system, run tick, observe changes. -### 7.3 NuGet Packaging +### 8.3 NuGet Packaging `OECS.csproj` includes package metadata: - `PackageId`: `OECS` - `Description`: "Observable ECS for C# — an entity component system focused on a clean reactive API surface." - `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 5 (Reactivity) └─→ 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. @@ -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 automatic parallel scheduling — but this adds significant complexity. -2. **World serialization?** Since components are MessagePack-serializable, - 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, +2. **Multiple worlds?** The design supports it naturally — `World` is a class, 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. diff --git a/docs/testing-games.md b/docs/testing-games.md index 86db89c..5f47158 100644 --- a/docs/testing-games.md +++ b/docs/testing-games.md @@ -1,23 +1,53 @@ # 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. -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. 5. Make sure serialization roundtrip works. ## 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. -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: -- 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. +- **Random:** evenly chooses every move randomly. +- **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.