diff --git a/.agents/skills/developing-oecs/SKILL.md b/.agents/skills/developing-oecs/SKILL.md new file mode 100644 index 0000000..4f5b662 --- /dev/null +++ b/.agents/skills/developing-oecs/SKILL.md @@ -0,0 +1,190 @@ +--- +name: developing-oecs +description: Develop and modify the OECS entity component system library itself. Use this when changing the core ECS types (World, Entity, ComponentStore, SparseSet, query execution, commands, relationships, reactivity, singletons, serialization) or the OECS.SourceGen incremental generator. +--- + +# Developing OECS + +OECS is a single-threaded, observable-first ECS for C#. It targets `net8.0` +(C# 12). The source lives under `OECS/` and the incremental source generator is +under `OECS.SourceGen/`. + +## Project Layout + +``` +OECS.sln +├── Directory.Build.props # Shared: net8.0, ImplicitUsings, Nullable, C# 12 +├── OECS/ +│ └── OECS.csproj # Core library + NuGet metadata +├── OECS.SourceGen/ +│ └── OECS.SourceGen.csproj # Roslyn incremental source generator +├── OECS.Tests/ # Unit tests for OECS itself +├── Game.Blackjack/ # Integration test: blackjack game +├── Game.Blackjack.Tests/ # Blackjack tests (unit + snapshot + play) +├── Game.TicTacToe/ # Integration test: tic-tac-toe game +├── Game.TicTacToe.Tests/ +├── docs/ # Architecture, API surface, implementation plan +└── nupkgs/ # Built NuGet packages +``` + +## Core Architecture + +Read `docs/architecture.md` for the full rationale behind each design decision. +Key ADRs to keep in mind when changing the library: + +| ADR | Decision | Why | +|---|---|---| +| 001 | Sparse sets over archetypes | Cheap add/remove for reactivity | +| 002 | 32-bit Entity (24 ID + 8 version) | Fits in register, enough headroom | +| 003 | Explicit query iteration | Visible cost model, no code-gen needed | +| 004 | Registration order for systems | Simplest model that works at this scale | +| 005 | Manual `MarkModified` for value changes | No per-component overhead | +| 006 | Deferred change posting | Prevents mid-system reentrancy | +| 007 | R3 for reactivity | Zero-allocation, UI lifecycle-friendly | +| 008 | Commands as serializable structs in a queue | Not ECS state | +| 009 | Singleton as reserved entity (ID 1) | Reuses component storage | +| 010 | Single-threaded by default | Simpler, no locks needed | +| 011 | .NET 8 target | LTS through Nov 2026 | +| 012 | Public types required for MessagePack | Build-time validation via analyzer | + +## Key Types + +### `World` — the public entry point + +Everything routes through `World`. It owns: +- `EntityAllocator _allocator` — entity lifecycle (create, destroy, recycle). +- `ComponentStore _components` — generic sparse set registry. +- `RelationshipIndex _relationships` — reverse lookup for relationships. +- `ChangeBuffer _changes` — accumulates and posts changes to R3 subjects. +- `CommandQueue _commands` — deferred command execution. + +### `ComponentStore` — sparse set registry + +Maps `Type` → `SparseSet`. Exposes `GetSet(Type)` for internal use. +Public API on `World` delegates here with generic type resolution. + +### `SparseSet` — per-type component storage + +Dense/sparse array pair. `dense[]` is packed values, `denseEntities[]` is +parallel entity IDs, `sparse[]` maps entity ID → dense index (-1 = absent). +Uses swap-remove for O(1) deletion. + +### `EntityIterator.Select1/2/3` — ref struct iterators + +Zero-allocation iterators. Drive from the smallest sparse set to minimize +probes. Support `foreach` via `GetEnumerator()` returning `this`. Must call +`world.BeginIteration()` / `EndIteration()` for pending mutation flushing. +Always skip singleton entity (ID 1). + +### `SystemGroup` — system orchestration + +Manages an ordered list of `ISystem`. `RunAll()`: +1. Drain commands (pre-tick). +2. For each system: run it → drain commands → post changes. +3. Drain commands + post changes (post-tick). + +### `WorldSerializer` — save/load + +Uses the source-generated `ComponentRegistry` to discover component types +without reflection. Serializes each entity's components to MessagePack blobs. +On load, fixates `IRelationship.Source` to match the owning entity. + +## Source Generator (`OECS.SourceGen`) + +A Roslyn incremental source generator that: +1. Scans for all invocations of World methods with generic type arguments + (e.g., `AddComponent`, `SetSingleton`). +2. Generates a `ComponentRegistry` class with `ComponentDescriptor[]` listing + every discovered type. +3. Each descriptor includes the type name, `Type` reference, and static lambdas + for serialization/deserialization via MessagePack. + +The generator must be rebuilt before `OECS.csproj` packs into a NuGet package +(see the `BuildSourceGenerator` target in `OECS.csproj`). + +## API Surface Contract + +The public API surface documented in `docs/api-surface.md` is the contract. +Any change to a public type signature must update the doc. Check for drift +regularly — the doc should match the code exactly, not the other way around. + +## Dependencies + +| Package | Version | Purpose | +|---|---|---| +| `MessagePack` | 3.1.7 | Binary serialization for components, snapshots | +| `MessagePackAnalyzer` | 3.1.7 | Compile-time validation of `[MessagePackObject]` types | +| `R3` | 1.2.9 | Reactive observables for change subscriptions | +| `Microsoft.CodeAnalysis.CSharp` | via SourceGen | Roslyn incremental generator API | + +## Building & Testing + +```bash +# Build solution +dotnet build OECS.sln + +# Run OECS core tests +dotnet test OECS.Tests/OECS.Tests.csproj + +# Run blackjack integration tests +dotnet test Game.Blackjack.Tests/Game.Blackjack.Tests.csproj + +# Pack NuGet +dotnet pack OECS/OECS.csproj -c Release -o nupkgs +``` + +## Adding a New Feature + +1. Check ADRs in `docs/architecture.md` — does the change fit the existing + decisions? If it contradicts one, write a new ADR or revise the existing one. +2. Implement the feature in `OECS/`. +3. If it touches public API, update `docs/api-surface.md`. +4. Add tests in `OECS.Tests/`. +5. Update `docs/implementation-plan.md` if the phase descriptions need adjusting. +6. Verify the blackjack game still works (`dotnet test Game.Blackjack.Tests`). + +## Changing the Source Generator + +1. Modify `OECS.SourceGen/`. +2. Build it explicitly: `dotnet build OECS.SourceGen/OECS.SourceGen.csproj`. +3. The DLL at `OECS.SourceGen/bin/Debug/netstandard2.0/OECS.SourceGen.dll` is + referenced as an analyzer by downstream projects. +4. Test with a game project to verify component registry is generated correctly. + +## Conventions + +### Component and command types + +Game-level components and commands should be `public record struct` types with +explicit fields/properties — **not** positional syntax. Positional record structs +produce `init`-only properties that can't be mutated through `ref T`, which +breaks the core OECS pattern of in-place component mutation. + +```csharp +// ✅ Correct: explicit fields, ref-mutable +[MessagePackObject] +public record struct Card +{ + [Key(0)] public Suit Suit; + [Key(1)] public Rank Rank; +} + +// ❌ Wrong: positional syntax produces init-only properties +[MessagePackObject] +public record struct Card([Key(0)] Suit Suit, [Key(1)] Rank Rank); +``` + +Tag components (no data) can be plain `struct` — `record struct` adds no value +when there are no fields to compare. + +## Common Pitfalls + +- **Forgetting `MarkModified` after mutating a `ref` component.** Changes won't + propagate to R3 subscribers. Debug builds have a warning for this. +- **Modifying components during iteration.** Pending mutations are flushed at + the end of iteration (via `BeginIteration`/`EndIteration`). Adding/removing + components mid-iteration is safe — they're deferred. +- **Type not public or missing `[MessagePackObject]`.** Serialization will fail. + The `MessagePackAnalyzer` catches most issues at compile time. +- **Entity ID 1 is the singleton.** Don't destroy it. Iterators skip it + automatically. diff --git a/.agents/skills/testing-games/SKILL.md b/.agents/skills/testing-games/SKILL.md new file mode 100644 index 0000000..8be2096 --- /dev/null +++ b/.agents/skills/testing-games/SKILL.md @@ -0,0 +1,195 @@ +--- +name: testing-games +description: Test OECS-based games via unit tests, snapshots, playtests with AI agents, reactivity logs, and serialization round-trips. Use this when writing or running tests for a game built on OECS. +--- + +# Testing Games with OECS + +Read `docs/testing-games.md` for the testing strategy overview. This skill +provides the detailed patterns and conventions. + +## Test Project Setup + +A test project references the game DLL plus xUnit and FluentAssertions: + +```xml + + + Game.YourGame.Tests + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + +``` + +## Three Test Categories + +### 1. Unit / Game Flow Tests + +Test individual game rules in isolation. Each test sets up a fresh `World`, +enqueues commands, runs a tick, and asserts state: + +```csharp +[Fact] +public void PlaceBet_AdvancesToDealing() +{ + var (world, group) = SetupGame(); + + world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); + group.RunLogical(); + + var state = world.ReadSingleton(); + state.Phase.Should().Be(GamePhase.PlayerTurn); + state.Chips.Should().Be(90); +} +``` + +Common test helpers: +- `SetupGame(seed?)` — create world, register systems, set initial singletons. +- `FindEntity(world)` — find first non-singleton entity with component T. +- `CountCardsInHand(world, handEntity)` or `GetHandCards(world, handEntity)`. + +Always keep helpers in the test class (or a shared base) rather than in the +game DLL — they are test infrastructure. + +### 2. Snapshot & Log Tests + +Capture world state as text and R3 change logs for manual review: + +```csharp +[Fact] +public void Snapshot_AfterDeal() +{ + var (world, group) = SetupGame(seed: 42); + world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); + group.RunLogical(); + + var snapshot = SnapshotWorld(world); + _output.WriteLine(snapshot); // ITestOutputHelper + + snapshot.Should().Contain("Phase: PlayerTurn"); + snapshot.Should().Contain("Player hand:"); + snapshot.Should().Contain("Dealer hand:"); +} +``` + +`SnapshotWorld(world)` should produce a human-readable text block containing +all singletons, entity counts, hand contents, and any other debug-relevant state. + +Reactivity log test pattern: + +```csharp +var log = new List(); +world.ObserveComponentChanges().Subscribe(change => + log.Add($"[card] {change}")); +world.ObserveComponentChanges().Subscribe(change => + log.Add($"[gamestate] {change}")); + +// ... run game ... + +log.Should().Contain(l => l.Contains("EntityAdded")); +log.Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState")); +``` + +### 3. Play Tests + +AI-driven multi-round playtests. See `docs/testing-games.md` for the full +strategy. Key patterns: + +**Agents** implement a simple interface: +```csharp +private interface IYourGameAgent +{ + Decision Decide(World world); +} +``` + +Built-in agent types: +- **Greedy/Basic Strategy:** score actions, pick the highest. For blackjack: + hit if total < 17, stand otherwise. +- **Random:** evenly choose moves randomly (but never do illegal moves). +- **Weighted Pool:** pick from a pool of agents by weight each round. + +**Round runner** loops until a terminal condition: +```csharp +while (true) +{ + int chips = world.ReadSingleton().Chips; + if (chips < BetAmount) { /* busted */ break; } + if (chips >= StartingChips * 2) { /* doubled */ break; } + if (totalRounds >= 200) { /* safety cap */ break; } + + // Place bet, deal, agent decides hit/stand, resolve, new round. +} +``` + +**Play logs** are saved as `.playlog` files to `AppContext.BaseDirectory/playlogs/`: + +``` +Blackjack: Basic Strategy (seed=42, agent=BasicStrategy) — BUSTED after 38 rounds | W:12 L:22 P:4 +================================================================================================= + +--- Round Summaries --- + Round 1: PlayerBust | Bet=10 | Chips: 100→90 (-10) | Hits: 1 + ... + +--- Decisions --- + 1. R1 Hand=12, Decision=Hit + ... + +--- Reactivity --- + ComponentModified GameState = ... + ... + +--- Final State --- +Phase: Betting +Chips: 0, Bet: 10 +... +``` + +## Serialization Round-Trip + +Every game must have a serialization test: + +```csharp +[Fact] +public void Serialization_RoundTrips() +{ + var (world, group) = SetupGame(seed: 42); + // ... run some game state ... + + using var stream = new MemoryStream(); + WorldSerializer.Save(world, stream); + stream.Position = 0; + + var world2 = new World(); + WorldSerializer.Load(world2, stream); + + // Assert key state survived: + var state = world2.ReadSingleton(); + state.Chips.Should().Be(expected); +} +``` + +## Running Tests + +```bash +dotnet test "E:/projects/oecs-sharp/Game.YourGame.Tests/Game.YourGame.Tests.csproj" +``` + +To run only playtests: +```bash +dotnet test ... --filter "FullyQualifiedName~PlayTests" +``` diff --git a/.agents/skills/writing-games/SKILL.md b/.agents/skills/writing-games/SKILL.md new file mode 100644 index 0000000..eeee58f --- /dev/null +++ b/.agents/skills/writing-games/SKILL.md @@ -0,0 +1,219 @@ +--- +name: writing-games +description: Create game logic using the OECS entity component system (C#). Use this when building a new game or game feature with OECS — defining components, systems, commands, relationships, and singletons. +--- + +# Writing Games with OECS + +OECS is a single-threaded, observable-first ECS for C#. It targets `net8.0` (C# 12) +and depends on `MessagePack` (serialization) and `R3` (reactivity). + +Before writing any code, read `docs/api-surface.md` for the full type reference +and `docs/architecture.md` for the design rationale behind the key decisions. + +## Project Setup + +A game is a class library referencing `OECS`: + +```xml + + + Library + Game.YourGameName + + + + + +``` + +The game DLL must also reference `OECS.SourceGen` as an analyzer so the +component registry is generated for serialization. See `Blackjack.csproj` for +the exact MSBuild incantation. + +## Defining Components + +Components are `public record struct` types annotated with `[MessagePackObject]` +and `[Key]` attributes. Prefer `record struct` by default — it gives you value +equality and a generated `ToString()` for free. + +Use **explicit properties or fields** — not positional syntax. OECS mutates +components in-place via `ref T`, which requires settable fields/properties. +Positional record structs produce `init`-only properties that can't be mutated +through a `ref`. + +```csharp +[MessagePackObject] +public record struct Card +{ + [Key(0)] public Suit Suit; + [Key(1)] public Rank Rank; +} +``` + +- Types must be `public` — MessagePack requires public accessibility. +- Use sequential integer keys starting from 0. +- Tag components (no data) are just empty structs: + +```csharp +[MessagePackObject] +public struct PlayerHand { } +``` + +## Relationships + +Relationships are components that implement `IRelationship`. They model a +directed edge between a source entity and a target entity. + +You can either use the generic `Relationship` base struct or +implement `IRelationship` directly: + +```csharp +// Using the base struct: +world.AddComponent(child, new Relationship +{ + Source = child, + Target = parent +}); + +// Direct implementation (preferred for domain-specific names): +[MessagePackObject] +public record struct Holds : IRelationship +{ + [Key(0)] public Entity Source { get; set; } + [Key(1)] public Entity Target { get; set; } +} +``` + +Reverse lookup is automatic. The `World` maintains a reverse index so you can +query all sources pointing to a target: + +```csharp +var cards = world.GetSources(handEntity); +``` + +When an entity is destroyed, all relationships it participates in (as source or +target) are cleaned up automatically. + +## Defining Systems + +Systems implement `ISystem` (or `ITickedSystem` if they need delta time): + +```csharp +public class DealSystem : ISystem +{ + public void Run(World world) + { + var state = world.ReadSingleton(); + if (state.Phase != GamePhase.Dealing) + return; + + // Do work... + } +} +``` + +The `ISystem` interface has no `Query` property. Systems read singletons, +build queries, and iterate on their own — this keeps the interface minimal +and gives systems full flexibility. + +### Iteration Styles + +Two options: + +**ForEach callbacks** (1–6 components): +```csharp +var query = world.Query().With().With().Build(); +world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => +{ + pos.X += vel.X * dt; + world.MarkModified(e); +}); +``` + +**Ref struct iterators** via `EntityIterator.Select()` (1–3 components): +```csharp +using var iter = world.Select(); +while (iter.MoveNext()) +{ + // iter.CurrentEntity, iter.Current1 (ref) +} +``` + +The singleton entity (ID 1) is automatically skipped by all iterators. + +### System Registration + +Systems run in registration order via `SystemGroup`: + +```csharp +var world = new World(); +var group = new SystemGroup(world); +group.Add(new DeckSetupSystem()); +group.Add(new DealSystem()); +group.Add(new PlayerBustCheckSystem()); +group.Add(new DealerSystem()); +``` + +`SystemGroup` automatically drains commands and posts changes after each system +and after the full tick. + +## Defining Commands + +Commands are `public record struct` types annotated with `[MessagePackObject]` +and implementing `ICommand`: + +```csharp +[MessagePackObject] +public record struct PlaceBetCommand : ICommand +{ + [Key(0)] public int Amount; + + public void Execute(World world) + { + ref var state = ref world.GetSingleton(); + if (state.Phase != GamePhase.Betting) return; + state.CurrentBet = Amount; + state.Chips -= Amount; + state.Phase = GamePhase.Dealing; + world.MarkModified(World.SingletonEntity); + } +} +``` + +Enqueue commands via `world.Commands.Enqueue(...)`. They execute deferred +when the queue is drained (automatically by `SystemGroup`). + +## Singletons + +Global state lives on the singleton entity (ID 1). Use `SetSingleton`, +`GetSingleton` (ref), and `ReadSingleton` (copy): + +```csharp +world.SetSingleton(new GameState { Phase = GamePhase.Betting, Chips = 100 }); + +// Read-only inspection: +var state = world.ReadSingleton(); + +// Mutation: +ref var mutable = ref world.GetSingleton(); +mutable.Phase = GamePhase.RoundOver; +world.MarkModified(World.SingletonEntity); +``` + +`GetSingleton` returns a `ref` — always call `MarkModified` after mutating +so reactivity subscribers see the change. `ReadSingleton` returns a copy and +never auto-marks. + +## Change Tracking + +- Structural changes (entity create/destroy, component add/remove) are auto-marked. +- Value mutations (modifying a `ref T` component) must be manually marked via + `world.MarkModified(entity)`. +- Changes are posted after each system runs (automatic via `SystemGroup`). + +## Serialization + +`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. diff --git a/docs/api-surface.md b/docs/api-surface.md index e4bc2b6..b97b936 100644 --- a/docs/api-surface.md +++ b/docs/api-surface.md @@ -461,19 +461,23 @@ public class ComponentEntry ## Usage Example +Components and commands should be `public record struct` types with explicit +fields/properties (not positional syntax). See the `writing-games` skill for +the rationale. + ```csharp using OECS; // Define components [MessagePackObject] -public struct Position +public record struct Position { [Key(0)] public float X; [Key(1)] public float Y; } [MessagePackObject] -public struct Velocity +public record struct Velocity { [Key(0)] public float X; [Key(1)] public float Y;