feat: add agent skill documentation

Add new skill documentation files for developing OECS, testing games,
and writing games. Also update the API surface documentation to include
guidance on component and command definitions.
This commit is contained in:
hypercross 2026-07-20 18:01:46 +08:00
parent b066ac2eba
commit 3b08138580
4 changed files with 610 additions and 2 deletions

View File

@ -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<T>`. Exposes `GetSet(Type)` for internal use.
Public API on `World` delegates here with generic type resolution.
### `SparseSet<T>` — 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<T...>` — 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<Card>`, `SetSingleton<GameState>`).
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.

View File

@ -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
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Game.YourGame.Tests</RootNamespace>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Game.YourGame\YourGame.csproj" />
</ItemGroup>
</Project>
```
## 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<GameState>();
state.Phase.Should().Be(GamePhase.PlayerTurn);
state.Chips.Should().Be(90);
}
```
Common test helpers:
- `SetupGame(seed?)` — create world, register systems, set initial singletons.
- `FindEntity<T>(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<string>();
world.ObserveComponentChanges<Card>().Subscribe(change =>
log.Add($"[card] {change}"));
world.ObserveComponentChanges<GameState>().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<GameState>().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<GameState>();
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"
```

View File

@ -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
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<RootNamespace>Game.YourGameName</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OECS\OECS.csproj" />
</ItemGroup>
</Project>
```
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<TSelf, TTarget>` base struct or
implement `IRelationship` directly:
```csharp
// Using the base struct:
world.AddComponent(child, new Relationship<ChildOf, Parent>
{
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<Holds>(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<GameState>();
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** (16 components):
```csharp
var query = world.Query().With<Position>().With<Velocity>().Build();
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) =>
{
pos.X += vel.X * dt;
world.MarkModified<Position>(e);
});
```
**Ref struct iterators** via `EntityIterator.Select<T>()` (13 components):
```csharp
using var iter = world.Select<PlayerHand>();
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<GameState>();
if (state.Phase != GamePhase.Betting) return;
state.CurrentBet = Amount;
state.Chips -= Amount;
state.Phase = GamePhase.Dealing;
world.MarkModified<GameState>(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<T>`,
`GetSingleton<T>` (ref), and `ReadSingleton<T>` (copy):
```csharp
world.SetSingleton(new GameState { Phase = GamePhase.Betting, Chips = 100 });
// Read-only inspection:
var state = world.ReadSingleton<GameState>();
// Mutation:
ref var mutable = ref world.GetSingleton<GameState>();
mutable.Phase = GamePhase.RoundOver;
world.MarkModified<GameState>(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<T>(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.

View File

@ -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;