Compare commits

..
28 Commits
Author SHA1 Message Date
hyper d73b7ebf37 feat(serialization): Implement world save/load with MessagePack
Add EntityFormatter to handle readonly fields, fix relationship
target serialization, and add edge case tests for ReorderSources,
RemoveSingleton, and ReadComponent/ReadSingleton.
2026-07-22 18:59:31 +08:00
hyper a6287911aa docs: Update API surface and singleton architecture 2026-07-22 16:37:48 +08:00
hyper 53fa3b742d refactor: Remove ForEach discovery, cache MethodInfo
Remove ForEach type argument collection from source generator.
Cache MethodInfo for RemoveComponentImpl in World.cs.
Update API docs for IRelationship and ChangeKind.
2026-07-22 16:14:58 +08:00
hyper 8a32588c54 feat(docs): Add interrupt system documentation 2026-07-22 16:01:36 +08:00
hyper 6207872e45 feat(oecs): Add interrupt mechanism to block system ticks
Introduce IInterrupt marker interface, IInterruptHandlerCommand for
resolving
interrupts, and InterruptStore. When a pending interrupt exists at the
start
of a tick, system execution is skipped until resolved. Only one
interrupt of
a given type may be pending at a time.
2026-07-22 15:59:13 +08:00
hyper 1bb290e60c docs(Game.CardWars): Add design documents for base game and factions
Replace the empty placeholder design.md with comprehensive
design-basegame.md
and design-factions.md detailing game rules, setup, and mechanics for
the
card game 魔烬纷争.
2026-07-22 11:36:09 +08:00
hypercross 2a3feb556f test: add relationship and reordering tests 2026-07-22 08:07:01 +08:00
hypercross 68eeeeb7a6 feat(oecs): add support for reordering relationship sources
Introduces `RelationshipReordered` change kind and `ReorderSources`
method to allow reordering the source set of a relationship without
removing and re-adding components. This optimizes shuffling operations
like deck setup in Blackjack.
2026-07-21 22:46:32 +08:00
hypercross b0bf10f286 feat: use OrderedEntitySet in RelationshipIndex
Replace HashSet with OrderedEntitySet in RelationshipIndex to
preserve the insertion order of source entities during iteration.
2026-07-21 22:39:35 +08:00
hypercross 535c8d948e test: add TestHelpers for blackjack tests 2026-07-21 22:25:51 +08:00
hypercross 94a9cc9519 refactor(test): migrate blackjack tests to use TestHelpers
Refactor the blackjack test suite to utilize the centralized
`TestHelpers`
from `OECS.PlayTest` instead of local implementations. This includes:

- Replacing local `SetupGame`, `FindEntity`, and `SnapshotWorld` with
  `TestHelpers` equivalents.
- Moving `PlayLog` logic and round running into the `OECS.PlayTest`
  framework.
- Updating project references to include `OECS.PlayTest`.
2026-07-21 18:09:47 +08:00
hypercross 9ef387f010 refactor(tests): extract test helpers and clean up play tests
Refactor the TicTacToe test suite by moving shared logic, such as
game setup, snapshotting, and win checking, into a new
`TestHelpers` class. This simplifies `PlayTests.cs` and
`GameFlowTests.cs`
by removing redundant private methods and local agent implementations,
replacing them with standardized utility calls from `OECS.PlayTest`.
2026-07-21 15:47:33 +08:00
hypercross 4871f68fb8 feat: add OECS.PlayTest project
Introduce a new PlayTest project containing tools for simulating
gameplay, including:

- Agent abstractions (IAgent, GreedyAgent, WeightedAgentPool)
- ObservableCapture for logging entity changes
- PlayLog for generating and saving formatted play logs
2026-07-21 14:31:57 +08:00
hypercross 6e8ac0adc0 feat: make FlushPendingMutations public 2026-07-21 13:57:21 +08:00
hypercross cbb7edd472 refactor: simplify entity lookups and clean up state updates
- Replace manual entity iteration with `world.FindEntity<T>()`
- Remove redundant `MarkModified<GameState>` calls
- Add safety counters to prevent infinite loops in tests and systems
- Call `FlushPendingMutations` after card transfers to ensure
  consistency
- Rename `Run` to `RunImpl` in several systems
2026-07-21 13:57:11 +08:00
hypercross 4cdfe9c957 refactor: update iterator access and simplify loops
Refactor code to use `Val1` and `Val2` instead of `Item1` and `Item2`
for
iterator access. Simplify `PlaceMarkCommand` and `WinCheckSystem` by
replacing `while` loops with `foreach` and removing redundant comments.
2026-07-21 11:09:55 +08:00
hypercross 52e1e8fdc9 docs: update skill guides for auto-tracking and batching changes 2026-07-21 11:03:01 +08:00
hypercross 4b94f411bf refactor: implement auto-tracking for component modifications
Introduce nested batching scopes to automate component dirty-marking
and defer structural mutations.

- Replace manual `MarkModified` with auto-tracking via `RefN` properties
  in `Select` iterators and `GetComponent`/`GetSingleton` calls.
- Implement nested batching scopes: `SystemGroup` (tick level),
  `ISystem.Run`
  (system level), `CommandQueue.ExecuteAll` (command level), and
  `Select`
  (iteration level).
- Update `ISystem` and `ITickedSystem` to use `RunImpl` to separate
  implementation from the batching-aware `Run` extension method.
- Add `ValN` properties to iterators for read-only, non-tracked access.
- Update documentation to reflect the new iteration and batching model.
2026-07-21 11:02:53 +08:00
hypercross 1e8e4e1b38 feat: implement auto-dirty-marking for query iterators
Introduce `TrackAccess` in `World` to record component access
during batching scopes. Update `SelectN` iterators to provide
`RefN` for mutable, tracked access and `ValN` for read-only,
untracked access.
2026-07-21 10:40:18 +08:00
hypercross 5d7eb14911 refactor: simplify iterator usage and improve command execution
- Replace manual `while (iter.MoveNext())` loops with `foreach` loops in
  tests
- Implement batching and mutation flushing in `CommandQueue.ExecuteAll`
  to
  ensure chained commands see each other's changes
2026-07-21 10:09:40 +08:00
hypercross d9cd943c52 test: rename Run to RunImpl in test mocks 2026-07-21 09:50:04 +08:00
hypercross e460c0e70f refactor: rename iteration to batching and expand scope
Rename `BeginIteration`/`EndIteration` to `BeginBatching`/`EndBatching`
to reflect that the mechanism now covers both entity iteration and
system execution.

- Update `ISystem` and `ITickedSystem` to use `RunImpl` for logic.
- Add `SystemExtensions.Run` to wrap system execution in a batching
  scope.
- Update `SystemGroup` to wrap system execution in a batching scope.
- Update `WorldQueryExtensions` to use the new batching terminology.
2026-07-21 09:49:53 +08:00
hypercross 96d732d6ab refactor: simplify component iteration and singleton handling
Update the ECS engine to support automatic mutation tracking for
singletons during iteration, removing the need for manual
`MarkModified` calls.

- Update `ComponentDiscoveryGenerator` to detect singleton components.
- Refactor `PlaceMarkCommand` and `WinCheckSystem` to leverage
  automatic mutation marking.
- Replace `ForEach` usage with `Select` and `ItemN` access in tests
  and systems to align with the updated iterator API.
2026-07-21 09:15:27 +08:00
hypercross b98e8d66af refactor: replace QueryBuilder with generic Query and Select iterators
Replace the old `QueryBuilder` and `ForEach` callback pattern with a
more
performant, zero-allocation `Select` API using `ref struct` iterators.
This change also updates the singleton implementation to use dedicated
entities per component type rather than a single reserved entity.

- Replace `QueryBuilder`/`QueryDescriptor` with generic `Query<T1..T6>`
- Replace `ForEach` callbacks with `world.Select<T1..T6>()` iterators
- Replace `EntityIterator` with `WorldQueryExtensions`
- Update singleton logic to allocate one entity per component type
- Add `FindEntity<T>` and `FindEntities<T>` lookup methods
2026-07-21 00:24:10 +08:00
hypercross 737136e2ef refactor: replace ForEach with Select iterators
Replace the delegate-based ForEach pattern with zero-allocation ref
struct Select iterators. This change moves iteration logic from
QueryExecutor to WorldQueryExtensions and introduces a new fluent
Query<T> API.

Other changes:
- Update singleton handling to use a dictionary instead of a reserved
  ID.
- Add IsSingleton flag to ComponentDescriptor.
- Replace QueryDescriptor with type-safe Query<T> structs.
- Update tests to use the new Select API.
2026-07-21 00:21:01 +08:00
hypercross 91c6f4aa2c refactor: remove Source property from IRelationship
Remove the redundant `Source` property from `IRelationship` and its
implementations. Since the relationship component is inherently
stored on the source entity, the `Source` field was unnecessary and
required manual synchronization during deserialization.

This change simplifies the relationship model, reduces memory usage,
and removes the need for reflection-based fixups in the serializer.
2026-07-20 22:59:27 +08:00
hypercross 2de735498c feat: implement CardWars game engine
Implement the core CardWars game logic using an ECS-based architecture.
This includes:
- Card effect registry and various card effect implementations.
- Game phase management (Setup, Play, Flip, Scoring, Cleanup).
- Command system for player actions (PlayCard, FlipCard, etc.).
- Component-based game state and entity relationships.
- Automated game setup and scoring systems.
- Unit tests for game setup, card effects, and play logs.
2026-07-20 22:52:28 +08:00
hypercross 3b08138580 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.
2026-07-20 18:01:46 +08:00
112 changed files with 6646 additions and 2835 deletions
+204
View File
@@ -0,0 +1,204 @@
---
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 | Auto-tracking in batching scopes | Eliminates manual `MarkModified` in systems |
| 006 | Deferred mutation batching | Prevents mid-system reentrancy, safe iteration |
| 007 | R3 for reactivity | Zero-allocation, UI lifecycle-friendly |
| 008 | Commands as serializable structs in a queue | Not ECS state |
| 009 | Singleton per component type | Each singleton gets its own entity |
| 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.
### `WorldQueryExtensions.Select1..Select6<T...>` — ref struct iterators
Zero-allocation `ref struct` enumerators returned by `world.Select<T1..T6>()`.
Drive from the smallest sparse set to minimize probes. Support `foreach` via
`GetEnumerator()` returning `this`. Expose `Entity`, `Ref1`..`RefN` (mutable,
tracked for auto-dirty-marking), and `Val1`..`ValN` (read-only, untracked).
Constructor/dispose manage `BeginBatching()`/`EndBatching()` for pending
mutation flushing. Singleton entities are excluded via `IsSingletonEntity()`.
Also provides `FindEntity<T>()` (first match).
### `Query<T1..T6>` — generic query descriptors
Structs that encode `With` types as generic parameters. `Without<W>()` fluent
method adds exclusion filters without changing the arity. Replaces the old
`QueryBuilder` / `QueryDescriptor` / `ForEachAction` pattern.
### `SystemGroup` — system orchestration
Manages an ordered list of `ISystem`. `RunAll()`:
1. Drain commands (pre-tick).
2. `BeginBatching()` — outer batching scope for the tick.
3. For each system: run via `Run` extension (nested batch) → drain commands →
flush pending mutations → post changes.
4. `EndBatching()` — auto-marks + flushes remaining.
5. 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
- **Using `ValN` when you meant to mutate.** `ValN` returns a `ref readonly`
writes through it still work but won't be tracked for auto-dirty-marking.
Use `RefN` for any component you intend to mutate.
- **Modifying components during iteration.** Pending mutations are flushed at
the end of the batching scope. 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.
- **Singletons each have their own entity allocated automatically by
`SetSingleton<T>`.** Iterators skip singleton entities automatically.
+195
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 entity with component T (singletons are automatically excluded by query results).
- `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"
```
+302
View File
@@ -0,0 +1,302 @@
---
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).
Implement `RunImpl` with the system logic:
```csharp
public class DealSystem : ISystem
{
public void RunImpl(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.
### Running Systems
Use the `Run` extension method to execute a system with automatic batching:
```csharp
var system = new MySystem();
system.Run(world); // wraps RunImpl in BeginBatching/EndBatching
```
`SystemGroup` handles this automatically — you just register systems and call
`RunLogical()` or `RunTimed()`.
### Iteration
Use `world.Select<T1..T6>()` with `foreach` for zero-allocation iteration with
`ref` access to components. Use `RefN` for components you intend to mutate
(tracked for auto-dirty-marking) and `ValN` for read-only access (untracked):
```csharp
foreach (var it in world.Select<Position, Velocity>())
{
it.Ref1.X += it.Val2.X * dt; // Position tracked, Velocity not
it.Ref1.Y += it.Val2.Y * dt;
}
```
For queries with exclusion filters, create a `Query<T>` with `.Without<W>()`:
```csharp
var query = new Query<Cell>().Without<Mark>();
foreach (var it in world.Select(query))
{
if (it.Val1.Row == row && it.Val1.Col == col) { ... }
}
```
To find a single entity, use `FindEntity<T>()`:
```csharp
var handEntity = world.FindEntity<PlayerHand>();
```
The `Select` ref struct enumerators expose:
- `it.Entity` — the current entity handle.
- `it.Ref1`..`it.RefN``ref` references to components (tracked for auto-dirty).
- `it.Val1`..`it.ValN``ref readonly` references to components (untracked).
Singleton entities are automatically excluded from query results.
### 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, flushes pending mutations, 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;
}
}
```
Enqueue commands via `world.Commands.Enqueue(...)`. They execute deferred
when the queue is drained (automatically by `SystemGroup`). Commands run
inside a batching scope, so `GetSingleton` and `GetComponent` calls are
auto-tracked for modification.
## Singletons
Each singleton component type gets its own dedicated entity, allocated
automatically by `SetSingleton<T>`. Use `SetSingleton<T>`, `GetSingleton<T>`
(ref), and `ReadSingleton<T>` (copy):
```csharp
// Setup:
world.SetSingleton(new GameState { Phase = GamePhase.Betting, Chips = 100 });
// Read-only inspection:
var state = world.ReadSingleton<GameState>();
// Mutation (auto-tracked during batching scopes):
ref var mutable = ref world.GetSingleton<GameState>();
mutable.Phase = GamePhase.RoundOver;
```
`GetSingleton` returns a `ref`. During system execution, command drains, and
`foreach` iterations, mutations via `GetSingleton` are auto-marked for change
tracking. Outside a batching scope, call `MarkModified` after mutating.
`ReadSingleton` returns a copy and never auto-marks.
## Change Tracking
- Structural changes (entity create/destroy, component add/remove) are always
auto-marked.
- Value mutations via `GetComponent<T>`, `GetSingleton<T>`, and `RefN` iterator
properties are auto-tracked during batching scopes (system runs, command
drains, `foreach` iterations).
- Outside batching scopes, call `world.MarkModified<T>(entity)` explicitly.
- 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.
## Interrupts
Interrupts pause system execution until an external response arrives. A system
issues an interrupt, the next tick is skipped, and a handler command resolves it.
### Issuing an Interrupt
```csharp
[MessagePackObject]
public record struct ConfirmInterrupt : IInterrupt
{
[Key(0)] public string Message;
}
// In a system:
world.Interrupt(new ConfirmInterrupt { Message = "Are you sure?" });
```
The current tick completes normally. The **next** tick is blocked — `SystemGroup`
skips all systems but still drains commands and posts changes.
### Handling an Interrupt
Handler commands implement `IInterruptHandlerCommand<T>`. The default `Execute`
calls `TryResolve` with the pending interrupt. Return `true` to resolve it,
`false` to leave it pending for another handler:
```csharp
[MessagePackObject]
public struct ConfirmHandler : IInterruptHandlerCommand<ConfirmInterrupt>
{
[Key(0)] public bool Confirmed;
public bool TryResolve(ConfirmInterrupt interrupt)
{
// Do work here. The interrupt is just a signal.
return true;
}
}
// External code (e.g., UI button) enqueues the handler:
world.Commands.Enqueue(new ConfirmHandler { Confirmed = true });
```
### Key Rules
- Only one interrupt of a given type may be pending at a time. A second call
to `Interrupt<T>` with the same type throws.
- Interrupts are transient — they are not serialized and do not survive save/load.
- If no `SystemGroup` manages the world, `Interrupt<T>()` is a no-op.
- Check pending interrupts with `world.HasInterrupt<T>()`.
@@ -19,6 +19,7 @@
<ItemGroup>
<ProjectReference Include="..\Game.Blackjack\Blackjack.csproj" />
<ProjectReference Include="..\OECS.PlayTest\OECS.PlayTest.csproj" />
</ItemGroup>
</Project>
+28 -99
View File
@@ -10,7 +10,7 @@ public class GameFlowTests
[Fact]
public void NewGame_StartsInBettingPhase()
{
var (world, _) = SetupGame();
var (world, _) = TestHelpers.SetupGame();
var state = world.ReadSingleton<GameState>();
state.Phase.Should().Be(GamePhase.Betting);
@@ -21,13 +21,13 @@ public class GameFlowTests
[Fact]
public void PlaceBet_AdvancesToDealing()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
var state = world.ReadSingleton<GameState>();
state.Phase.Should().Be(GamePhase.PlayerTurn); // Dealing → PlayerTurn happens automatically
state.Phase.Should().Be(GamePhase.PlayerTurn);
state.CurrentBet.Should().Be(10);
state.Chips.Should().Be(90);
}
@@ -35,20 +35,20 @@ public class GameFlowTests
[Fact]
public void PlaceBet_RejectsInsufficientChips()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 200 });
group.RunLogical();
var state = world.ReadSingleton<GameState>();
state.Phase.Should().Be(GamePhase.Betting); // Should not advance
state.Phase.Should().Be(GamePhase.Betting);
state.Chips.Should().Be(100);
}
[Fact]
public void PlaceBet_RejectsZeroOrNegative()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 0 });
group.RunLogical();
@@ -62,60 +62,53 @@ public class GameFlowTests
[Fact]
public void Dealing_CreatesDeckAndHands()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
// Should have a deck entity.
var deckEntity = FindEntity<Deck>(world);
deckEntity.Should().NotBe(Entity.Null);
// Should have player and dealer hand entities.
var playerHand = FindEntity<PlayerHand>(world);
playerHand.Should().NotBe(Entity.Null);
var dealerHand = FindEntity<DealerHand>(world);
dealerHand.Should().NotBe(Entity.Null);
TestHelpers.FindEntity<Deck>(world).Should().NotBe(Entity.Null);
TestHelpers.FindEntity<PlayerHand>(world).Should().NotBe(Entity.Null);
TestHelpers.FindEntity<DealerHand>(world).Should().NotBe(Entity.Null);
}
[Fact]
public void Dealing_DealsTwoCardsToEach()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
var playerHand = FindEntity<PlayerHand>(world);
var dealerHand = FindEntity<DealerHand>(world);
var playerHand = TestHelpers.FindEntity<PlayerHand>(world);
var dealerHand = TestHelpers.FindEntity<DealerHand>(world);
CountCardsInHand(world, playerHand).Should().Be(2);
CountCardsInHand(world, dealerHand).Should().Be(2);
TestHelpers.CountCardsInHand(world, playerHand).Should().Be(2);
TestHelpers.CountCardsInHand(world, dealerHand).Should().Be(2);
}
[Fact]
public void Hit_DrawsOneCard()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
var playerHand = FindEntity<PlayerHand>(world);
var before = CountCardsInHand(world, playerHand);
var playerHand = TestHelpers.FindEntity<PlayerHand>(world);
var before = TestHelpers.CountCardsInHand(world, playerHand);
world.Commands.Enqueue(new HitCommand());
group.RunLogical();
var after = CountCardsInHand(world, playerHand);
var after = TestHelpers.CountCardsInHand(world, playerHand);
after.Should().Be(before + 1);
}
[Fact]
public void Stand_AdvancesToDealerTurn()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
@@ -130,15 +123,13 @@ public class GameFlowTests
[Fact]
public void NewRound_ResetsPhase()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
// Play a round.
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
world.Commands.Enqueue(new StandCommand());
group.RunLogical();
// Start new round.
world.Commands.Enqueue(new NewRoundCommand());
group.RunLogical();
@@ -150,15 +141,15 @@ public class GameFlowTests
[Fact]
public void DeterministicSeed_ProducesSameDeal()
{
var (world1, group1) = SetupGame(seed: 42);
var (world1, group1) = TestHelpers.SetupGame(seed: 42);
world1.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group1.RunLogical();
var cards1 = GetAllCards(world1);
var cards1 = TestHelpers.GetAllCards(world1);
var (world2, group2) = SetupGame(seed: 42);
var (world2, group2) = TestHelpers.SetupGame(seed: 42);
world2.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group2.RunLogical();
var cards2 = GetAllCards(world2);
var cards2 = TestHelpers.GetAllCards(world2);
cards1.Should().Equal(cards2);
}
@@ -166,20 +157,17 @@ public class GameFlowTests
[Fact]
public void PlayerBust_LosesBet()
{
// Use a seed that produces a bust-prone hand, then hit repeatedly.
var (world, group) = SetupGame(seed: 12345);
var (world, group) = TestHelpers.SetupGame(seed: 12345);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
// Hit until bust or stand.
while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn)
{
world.Commands.Enqueue(new HitCommand());
group.RunLogical();
}
// Game should have resolved.
var state = world.ReadSingleton<GameState>();
state.Phase.Should().Be(GamePhase.RoundOver);
}
@@ -187,7 +175,7 @@ public class GameFlowTests
[Fact]
public void Serialization_RoundTrips()
{
var (world, group) = SetupGame(seed: 42);
var (world, group) = TestHelpers.SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
@@ -204,63 +192,4 @@ public class GameFlowTests
state.CurrentBet.Should().Be(10);
state.RoundNumber.Should().Be(1);
}
// ── Helpers ───────────────────────────────────────────────────────
private static (World, SystemGroup) SetupGame(uint seed = 42)
{
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());
world.SetSingleton(new GameState
{
Phase = GamePhase.Betting,
Result = RoundResult.None,
Chips = 100,
CurrentBet = 0,
RoundNumber = 1,
Seed = seed
});
world.PostChanges();
return (world, group);
}
private static Entity FindEntity<T>(World world) where T : struct
{
using var iter = world.Select<T>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
return iter.CurrentEntity;
}
return Entity.Null;
}
private static int CountCardsInHand(World world, Entity handEntity)
{
// Holds relationship: Source = card entity, Target = hand entity.
// GetSources returns all card entities that have a Holds pointing to handEntity.
return world.GetSources<Holds>(handEntity).Count;
}
private static List<(Suit, Rank)> GetAllCards(World world)
{
var cards = new List<(Suit, Rank)>();
var query = world.Query().With<Card>().Build();
world.ForEach(query, (Entity e, ref Card card) =>
{
cards.Add((card.Suit, card.Rank));
});
cards.Sort((a, b) =>
{
int cmp = a.Item1.CompareTo(b.Item1);
return cmp != 0 ? cmp : a.Item2.CompareTo(b.Item2);
});
return cards;
}
}
}
+37 -219
View File
@@ -1,7 +1,7 @@
using FluentAssertions;
using Game.Blackjack;
using OECS;
using R3;
using OECS.PlayTest;
using Xunit;
namespace Game.Blackjack.Tests;
@@ -14,167 +14,73 @@ public class PlayTests
[Fact]
public void Play_BasicStrategy()
{
var log = new PlayLog();
var (world, group) = SetupGame(seed: 42);
world.ObserveComponentChanges<Card>().Subscribe(change =>
{
var card = world.ReadComponent<Card>(change.Entity);
log.Reactivity.Add($"{change.Kind} Card = {card} on {change.Entity}");
});
world.ObserveComponentChanges<GameState>().Subscribe(change =>
{
var state = world.ReadComponent<GameState>(change.Entity);
log.Reactivity.Add($"{change.Kind} GameState = {state}");
});
var (world, group) = TestHelpers.SetupGame(seed: 42);
var agent = new BasicStrategyAgent();
log.Header = $"Blackjack: Basic Strategy (seed=42, agent={agent})";
RunUntilDone(world, group, agent, log);
var log = RunUntilDone(world, group, agent,
$"Blackjack: Basic Strategy (seed=42, agent={agent})");
log.FinalSnapshot = SnapshotWorld(world);
var path = SavePlayLog("blackjack_basic_strategy.playlog", log);
var path = log.SaveTo("blackjack_basic_strategy.playlog");
ReadAndVerify(path);
}
[Fact]
public void Play_RandomAgent()
{
var log = new PlayLog();
var (world, group) = SetupGame(seed: 123);
world.ObserveComponentChanges<Card>().Subscribe(change =>
{
var card = world.ReadComponent<Card>(change.Entity);
log.Reactivity.Add($"{change.Kind} Card = {card} on {change.Entity}");
});
world.ObserveComponentChanges<GameState>().Subscribe(change =>
{
var state = world.ReadComponent<GameState>(change.Entity);
log.Reactivity.Add($"{change.Kind} GameState = {state}");
});
var (world, group) = TestHelpers.SetupGame(seed: 123);
var agent = new RandomBlackjackAgent();
log.Header = $"Blackjack: Random Agent (seed=123, agent={agent})";
RunUntilDone(world, group, agent, log);
var log = RunUntilDone(world, group, agent,
$"Blackjack: Random Agent (seed=123, agent={agent})");
log.FinalSnapshot = SnapshotWorld(world);
var path = SavePlayLog("blackjack_random_agent.playlog", log);
var path = log.SaveTo("blackjack_random_agent.playlog");
ReadAndVerify(path);
}
[Fact]
public void Play_WeightedPool()
{
var log = new PlayLog();
var (world, group) = SetupGame(seed: 77);
var (world, group) = TestHelpers.SetupGame(seed: 77);
world.ObserveComponentChanges<Card>().Subscribe(change =>
{
var card = world.ReadComponent<Card>(change.Entity);
log.Reactivity.Add($"{change.Kind} Card = {card} on {change.Entity}");
});
world.ObserveComponentChanges<GameState>().Subscribe(change =>
{
var state = world.ReadComponent<GameState>(change.Entity);
log.Reactivity.Add($"{change.Kind} GameState = {state}");
});
var pool = new WeightedAgentPool();
var pool = new WeightedAgentPool<BlackjackDecision>();
pool.Add(new BasicStrategyAgent(), 7);
pool.Add(new RandomBlackjackAgent(), 3);
var agent = pool.Pick();
log.Header = $"Blackjack: Weighted Pool (seed=77, agent={agent})";
var log = RunUntilDone(world, group, agent,
$"Blackjack: Weighted Pool (seed=77, agent={agent})");
RunUntilDone(world, group, agent, log);
log.FinalSnapshot = SnapshotWorld(world);
var path = SavePlayLog("blackjack_weighted_pool.playlog", log);
var path = log.SaveTo("blackjack_weighted_pool.playlog");
ReadAndVerify(path);
}
// ── Play Log ──────────────────────────────────────────────────────
private sealed class PlayLog
{
public string Header { get; set; } = "";
public readonly List<string> RoundSummaries = new();
public readonly List<string> Decisions = new();
public readonly List<string> Reactivity = new();
public string FinalSnapshot { get; set; } = "";
public string Build()
{
var sb = new System.Text.StringBuilder();
sb.AppendLine(Header);
sb.AppendLine(new string('=', Header.Length));
sb.AppendLine();
sb.AppendLine("--- Round Summaries ---");
for (int i = 0; i < RoundSummaries.Count; i++)
sb.AppendLine($" Round {i + 1}: {RoundSummaries[i]}");
sb.AppendLine();
sb.AppendLine("--- Decisions ---");
for (int i = 0; i < Decisions.Count; i++)
sb.AppendLine($" {i + 1}. {Decisions[i]}");
sb.AppendLine();
sb.AppendLine("--- Reactivity ---");
foreach (var entry in Reactivity)
sb.AppendLine($" {entry}");
sb.AppendLine();
sb.AppendLine("--- Final State ---");
sb.AppendLine(FinalSnapshot);
return sb.ToString();
}
}
// ── Round Runner ──────────────────────────────────────────────────
private static (World, SystemGroup) SetupGame(uint seed)
{
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());
world.SetSingleton(new GameState
{
Phase = GamePhase.Betting,
Result = RoundResult.None,
Chips = StartingChips,
CurrentBet = 0,
RoundNumber = 1,
Seed = seed
});
world.PostChanges();
return (world, group);
}
/// <summary>
/// Runs rounds until the player runs out of chips (can't afford minimum bet)
/// or doubles their starting chips.
/// </summary>
private static void RunUntilDone(World world, SystemGroup group, IBlackjackAgent agent, PlayLog log)
private static PlayLog RunUntilDone(World world, SystemGroup group,
IAgent<BlackjackDecision> agent, string header)
{
var log = new PlayLog { Header = header };
using var capture = new ObservableCapture(world);
capture.FormatWith<Card>(c => $"{c.Rank} of {c.Suit}");
capture.FormatWith<GameState>(s => $"{s.Phase} Chips={s.Chips} Bet={s.CurrentBet}");
int totalRounds = 0;
int wins = 0;
int losses = 0;
int pushes = 0;
var roundSummaries = new List<string>();
var decisions = new List<string>();
while (true)
{
var state = world.ReadSingleton<GameState>();
int chips = state.Chips;
// Stop conditions.
if (chips < BetAmount)
{
log.Header += $" — BUSTED after {totalRounds} rounds";
@@ -185,25 +91,23 @@ public class PlayTests
log.Header += $" — DOUBLED after {totalRounds} rounds";
break;
}
// Safety: max 200 rounds to prevent infinite loops.
if (totalRounds >= 200)
{
log.Header += $" — MAX ROUNDS ({totalRounds})";
break;
}
// Place bet and run dealing.
world.Commands.Enqueue(new PlaceBetCommand { Amount = BetAmount });
group.RunLogical();
// Player turn: agent decides hit/stand.
int roundHits = 0;
while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn)
int playerTurnSafety = 0;
while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn && playerTurnSafety < 52)
{
playerTurnSafety++;
var total = HandUtil.CalculateHand(world, new PlayerHand());
var decision = agent.Decide(world);
log.Decisions.Add($"R{totalRounds + 1} Hand={total}, Decision={decision}");
decisions.Add($"R{totalRounds + 1} Hand={total}, Decision={decision}");
if (decision == BlackjackDecision.Hit)
{
@@ -218,12 +122,10 @@ public class PlayTests
group.RunLogical();
}
// Record round result.
state = world.ReadSingleton<GameState>();
int newChips = state.Chips;
int delta = newChips - chips;
string summary = $"{state.Result} | Bet={BetAmount} | Chips: {chips}→{newChips} ({delta:+0;-#}) | Hits: {roundHits}";
log.RoundSummaries.Add(summary);
roundSummaries.Add($"Round {totalRounds + 1}: {state.Result} | Bet={BetAmount} | Chips: {chips}→{newChips} ({delta:+0;-#}) | Hits: {roundHits}");
switch (state.Result)
{
@@ -242,75 +144,22 @@ public class PlayTests
totalRounds++;
// Start next round.
world.Commands.Enqueue(new NewRoundCommand());
group.RunLogical();
}
log.Header += $" | W:{wins} L:{losses} P:{pushes}";
}
// ── Snapshot ──────────────────────────────────────────────────────
log.AddSection("Round Summaries", roundSummaries);
log.AddSection("Decisions", decisions);
log.AddSection("Reactivity", capture.GetLogLines());
log.FinalSnapshot = TestHelpers.SnapshotWorld(world);
private static string SnapshotWorld(World world)
{
var sb = new System.Text.StringBuilder();
var state = world.ReadSingleton<GameState>();
sb.AppendLine($"Phase: {state.Phase}");
sb.AppendLine($"Chips: {state.Chips}, Bet: {state.CurrentBet}");
sb.AppendLine($"Round: {state.RoundNumber}");
sb.AppendLine($"Result: {state.Result}");
var playerHand = FindEntity<PlayerHand>(world);
if (playerHand != Entity.Null)
{
sb.AppendLine($"Player ({HandUtil.CalculateHand(world, new PlayerHand())}):");
foreach (var card in GetHandCards(world, playerHand))
sb.AppendLine($" {card}");
}
var dealerHand = FindEntity<DealerHand>(world);
if (dealerHand != Entity.Null)
{
sb.AppendLine($"Dealer ({HandUtil.CalculateHand(world, new DealerHand())}):");
foreach (var card in GetHandCards(world, dealerHand))
sb.AppendLine($" {card}");
}
return sb.ToString().TrimEnd();
}
private static Entity FindEntity<T>(World world) where T : struct
{
using var iter = world.Select<T>();
while (iter.MoveNext())
if (iter.CurrentEntity != World.SingletonEntity)
return iter.CurrentEntity;
return Entity.Null;
}
private static List<string> GetHandCards(World world, Entity hand)
{
var cards = new List<string>();
foreach (var cardEntity in world.GetSources<Holds>(hand))
{
var card = world.ReadComponent<Card>(cardEntity);
cards.Add($"{card.Rank} of {card.Suit}");
}
return cards;
return log;
}
// ── File I/O ──────────────────────────────────────────────────────
private static string SavePlayLog(string filename, PlayLog log)
{
var dir = Path.Combine(AppContext.BaseDirectory, "playlogs");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, filename);
File.WriteAllText(path, log.Build());
return path;
}
private static void ReadAndVerify(string path)
{
var content = File.ReadAllText(path);
@@ -326,12 +175,7 @@ public class PlayTests
private enum BlackjackDecision { Hit, Stand }
private interface IBlackjackAgent
{
BlackjackDecision Decide(World world);
}
private sealed class BasicStrategyAgent : IBlackjackAgent
private sealed class BasicStrategyAgent : IAgent<BlackjackDecision>
{
public BlackjackDecision Decide(World world)
{
@@ -341,7 +185,7 @@ public class PlayTests
public override string ToString() => "BasicStrategy";
}
private sealed class RandomBlackjackAgent : IBlackjackAgent
private sealed class RandomBlackjackAgent : IAgent<BlackjackDecision>
{
private static readonly Random _rng = new();
public BlackjackDecision Decide(World world)
@@ -352,30 +196,4 @@ public class PlayTests
}
public override string ToString() => "Random";
}
private sealed class WeightedAgentPool
{
private readonly List<(IBlackjackAgent Agent, int Weight)> _agents = new();
private int _totalWeight;
private static readonly Random _rng = new();
public void Add(IBlackjackAgent agent, int weight)
{
_agents.Add((agent, weight));
_totalWeight += weight;
}
public IBlackjackAgent Pick()
{
int roll = _rng.Next(_totalWeight);
int cumulative = 0;
foreach (var (agent, weight) in _agents)
{
cumulative += weight;
if (roll < cumulative)
return agent;
}
return _agents[^1].Agent;
}
}
}
}
+21 -134
View File
@@ -1,7 +1,7 @@
using FluentAssertions;
using Game.Blackjack;
using OECS;
using R3;
using OECS.PlayTest;
using Xunit;
using Xunit.Abstractions;
@@ -24,9 +24,9 @@ public class SnapshotTests
[Fact]
public void Snapshot_InitialState()
{
var (world, _) = SetupGame();
var (world, _) = TestHelpers.SetupGame();
var snapshot = SnapshotWorld(world);
var snapshot = TestHelpers.SnapshotWorld(world);
_output.WriteLine(snapshot);
snapshot.Should().Contain("Phase: Betting");
@@ -37,12 +37,12 @@ public class SnapshotTests
[Fact]
public void Snapshot_AfterDeal()
{
var (world, group) = SetupGame(seed: 42);
var (world, group) = TestHelpers.SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
var snapshot = SnapshotWorld(world);
var snapshot = TestHelpers.SnapshotWorld(world);
_output.WriteLine(snapshot);
snapshot.Should().Contain("Phase: PlayerTurn");
@@ -56,7 +56,7 @@ public class SnapshotTests
[Fact]
public void Snapshot_AfterHit()
{
var (world, group) = SetupGame(seed: 42);
var (world, group) = TestHelpers.SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
@@ -64,17 +64,16 @@ public class SnapshotTests
world.Commands.Enqueue(new HitCommand());
group.RunLogical();
var snapshot = SnapshotWorld(world);
var snapshot = TestHelpers.SnapshotWorld(world);
_output.WriteLine(snapshot);
// With seed 42, one hit may bust. Just verify the game resolved.
snapshot.Should().NotContain("Phase: Dealing");
}
[Fact]
public void Snapshot_AfterStand()
{
var (world, group) = SetupGame(seed: 42);
var (world, group) = TestHelpers.SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
@@ -82,7 +81,7 @@ public class SnapshotTests
world.Commands.Enqueue(new StandCommand());
group.RunLogical();
var snapshot = SnapshotWorld(world);
var snapshot = TestHelpers.SnapshotWorld(world);
_output.WriteLine(snapshot);
snapshot.Should().Contain("Phase: RoundOver");
@@ -92,23 +91,11 @@ public class SnapshotTests
[Fact]
public void Log_ReactivityDuringRound()
{
var (world, group) = SetupGame(seed: 42);
var log = new List<string>();
var (world, group) = TestHelpers.SetupGame(seed: 42);
world.ObserveEntityChanges().Subscribe(change =>
{
log.Add($"[entity] {change}");
});
world.ObserveComponentChanges<Card>().Subscribe(change =>
{
log.Add($"[card] {change}");
});
world.ObserveComponentChanges<GameState>().Subscribe(change =>
{
log.Add($"[gamestate] {change}");
});
using var capture = new ObservableCapture(world);
capture.FormatWith<Card>(c => $"{c.Rank} of {c.Suit}");
capture.FormatWith<GameState>(s => $"{s.Phase} Chips={s.Chips} Bet={s.CurrentBet} Round={s.RoundNumber}");
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
@@ -116,24 +103,23 @@ public class SnapshotTests
world.Commands.Enqueue(new StandCommand());
group.RunLogical();
var logText = string.Join("\n", log);
var logText = string.Join("\n", capture.GetLogLines());
_output.WriteLine(logText);
// Should have entity creations (deck, hands, cards) and component changes.
log.Should().Contain(l => l.Contains("EntityAdded"));
log.Should().Contain(l => l.Contains("ComponentAdded") && l.Contains("Card"));
log.Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState"));
capture.GetLogLines().Should().Contain(l => l.Contains("EntityAdded"));
capture.GetLogLines().Should().Contain(l => l.Contains("ComponentAdded") && l.Contains("Card"));
capture.GetLogLines().Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState"));
}
[Fact]
public void Serialization_RoundTrip_PreservesCoreState()
{
var (world, group) = SetupGame(seed: 42);
var (world, group) = TestHelpers.SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
var before = SnapshotWorld(world);
var before = TestHelpers.SnapshotWorld(world);
using var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
@@ -142,118 +128,19 @@ public class SnapshotTests
var world2 = new World();
WorldSerializer.Load(world2, stream);
var after = SnapshotWorld(world2);
var after = TestHelpers.SnapshotWorld(world2);
_output.WriteLine("=== Before ===");
_output.WriteLine(before);
_output.WriteLine("=== After ===");
_output.WriteLine(after);
// Core game state must round-trip.
var state = world2.ReadSingleton<GameState>();
state.Phase.Should().Be(GamePhase.PlayerTurn);
state.Chips.Should().Be(90);
state.CurrentBet.Should().Be(10);
state.RoundNumber.Should().Be(1);
// Card entities must be preserved.
after.Should().Contain("Card entities: 52");
}
// ── Helpers ───────────────────────────────────────────────────────
private static (World, SystemGroup) SetupGame(uint seed = 42)
{
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());
world.SetSingleton(new GameState
{
Phase = GamePhase.Betting,
Result = RoundResult.None,
Chips = 100,
CurrentBet = 0,
RoundNumber = 1,
Seed = seed
});
world.PostChanges();
return (world, group);
}
/// <summary>
/// Produces a human-readable textual snapshot of the world state.
/// </summary>
private static string SnapshotWorld(World world)
{
var sb = new System.Text.StringBuilder();
// GameState singleton.
var state = world.ReadSingleton<GameState>();
sb.AppendLine($"Phase: {state.Phase}");
sb.AppendLine($"Chips: {state.Chips}");
sb.AppendLine($"Bet: {state.CurrentBet}");
sb.AppendLine($"Round: {state.RoundNumber}");
sb.AppendLine($"Result: {state.Result}");
sb.AppendLine($"Seed: {state.Seed}");
// Deck entity.
var deckEntity = FindEntity<Deck>(world);
if (deckEntity != Entity.Null)
{
var cardsInDeck = world.GetSources<InDeck>(deckEntity).ToList();
sb.AppendLine($"Cards in deck: {cardsInDeck.Count}");
}
// Player hand.
var playerHand = FindEntity<PlayerHand>(world);
if (playerHand != Entity.Null)
{
var cards = world.GetSources<Holds>(playerHand).ToList();
sb.AppendLine($"Player hand: {cards.Count} cards");
foreach (var cardEntity in cards)
{
var card = world.ReadComponent<Card>(cardEntity);
sb.AppendLine($" {card.Rank} of {card.Suit}");
}
}
// Dealer hand.
var dealerHand = FindEntity<DealerHand>(world);
if (dealerHand != Entity.Null)
{
var cards = world.GetSources<Holds>(dealerHand).ToList();
sb.AppendLine($"Dealer hand: {cards.Count} cards");
foreach (var cardEntity in cards)
{
var card = world.ReadComponent<Card>(cardEntity);
sb.AppendLine($" {card.Rank} of {card.Suit}");
}
}
// All entities.
int entityCount = 0;
using (var iter = world.Select<Card>())
{
while (iter.MoveNext()) entityCount++;
}
sb.AppendLine($"Card entities: {entityCount}");
return sb.ToString().TrimEnd();
}
private static Entity FindEntity<T>(World world) where T : struct
{
using var iter = world.Select<T>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
return iter.CurrentEntity;
}
return Entity.Null;
}
}
}
+131
View File
@@ -0,0 +1,131 @@
using Game.Blackjack;
using OECS;
using System.Text;
namespace Game.Blackjack.Tests;
internal static class TestHelpers
{
private const int StartingChips = 100;
/// <summary>
/// Creates a fresh Blackjack world with systems registered and GameState singleton initialized.
/// </summary>
public static (World World, SystemGroup Group) SetupGame(uint seed = 42)
{
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());
world.SetSingleton(new GameState
{
Phase = GamePhase.Betting,
Result = RoundResult.None,
Chips = StartingChips,
CurrentBet = 0,
RoundNumber = 1,
Seed = seed
});
world.PostChanges();
return (world, group);
}
/// <summary>
/// Returns a human-readable textual snapshot of the world state.
/// </summary>
public static string SnapshotWorld(World world)
{
var sb = new StringBuilder();
var state = world.ReadSingleton<GameState>();
sb.AppendLine($"Phase: {state.Phase}");
sb.AppendLine($"Chips: {state.Chips}");
sb.AppendLine($"Bet: {state.CurrentBet}");
sb.AppendLine($"Round: {state.RoundNumber}");
sb.AppendLine($"Result: {state.Result}");
sb.AppendLine($"Seed: {state.Seed}");
var deckEntity = FindEntity<Deck>(world);
if (deckEntity != Entity.Null)
{
var cardsInDeck = world.GetSources<InDeck>(deckEntity).ToList();
sb.AppendLine($"Cards in deck: {cardsInDeck.Count}");
}
var playerHand = FindEntity<PlayerHand>(world);
if (playerHand != Entity.Null)
{
var cards = world.GetSources<Holds>(playerHand).ToList();
sb.AppendLine($"Player hand: {cards.Count} cards");
foreach (var cardEntity in cards)
{
var card = world.ReadComponent<Card>(cardEntity);
sb.AppendLine($" {card.Rank} of {card.Suit}");
}
}
var dealerHand = FindEntity<DealerHand>(world);
if (dealerHand != Entity.Null)
{
var cards = world.GetSources<Holds>(dealerHand).ToList();
sb.AppendLine($"Dealer hand: {cards.Count} cards");
foreach (var cardEntity in cards)
{
var card = world.ReadComponent<Card>(cardEntity);
sb.AppendLine($" {card.Rank} of {card.Suit}");
}
}
int entityCount = 0;
using (var iter = world.Select<Card>())
{
while (iter.MoveNext()) entityCount++;
}
sb.AppendLine($"Card entities: {entityCount}");
return sb.ToString().TrimEnd();
}
public static Entity FindEntity<T>(World world) where T : struct
{
return world.FindEntity<T>();
}
public static int CountCardsInHand(World world, Entity handEntity)
{
return world.GetSources<Holds>(handEntity).Count;
}
public static List<string> GetHandCards(World world, Entity hand)
{
var cards = new List<string>();
foreach (var cardEntity in world.GetSources<Holds>(hand))
{
var card = world.ReadComponent<Card>(cardEntity);
cards.Add($"{card.Rank} of {card.Suit}");
}
return cards;
}
public static List<(Suit, Rank)> GetAllCards(World world)
{
var cards = new List<(Suit, Rank)>();
using (var iter = world.Select<Card>())
{
while (iter.MoveNext())
{
var card = iter.Val1;
cards.Add((card.Suit, card.Rank));
}
}
cards.Sort((a, b) =>
{
int cmp = a.Item1.CompareTo(b.Item1);
return cmp != 0 ? cmp : a.Item2.CompareTo(b.Item2);
});
return cards;
}
}
+10 -14
View File
@@ -11,7 +11,7 @@ public struct HitCommand : ICommand
{
public void Execute(World world)
{
ref var state = ref world.GetSingleton<GameState>();
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.PlayerTurn)
return;
@@ -25,9 +25,8 @@ public struct HitCommand : ICommand
internal static void DrawCard<THand>(World world)
where THand : struct
{
var singletonEntity = World.SingletonEntity;
var deckEntity = FindEntity<Deck>(world, singletonEntity);
var handEntity = FindEntity<THand>(world, singletonEntity);
var deckEntity = world.FindEntity<Deck>();
var handEntity = world.FindEntity<THand>();
if (deckEntity == Entity.Null || handEntity == Entity.Null)
return;
@@ -41,18 +40,15 @@ public struct HitCommand : ICommand
// Remove from deck, add to hand.
world.RemoveComponent<InDeck>(cardEntity);
world.AddComponent(cardEntity, new Holds { Source = cardEntity, Target = handEntity });
world.AddComponent(cardEntity, new Holds { Target = handEntity });
// Flush so subsequent DrawCard calls see the updated state.
world.FlushPendingMutations();
}
internal static Entity FindEntity<T>(World world, Entity singletonEntity)
internal static Entity FindEntity<T>(World world)
where T : struct
{
using var iter = world.Select<T>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != singletonEntity)
return iter.CurrentEntity;
}
return Entity.Null;
return world.FindEntity<T>();
}
}
}
+4 -8
View File
@@ -17,38 +17,34 @@ public struct NewRoundCommand : ICommand
return;
// Clear hands from previous round.
var singletonEntity = World.SingletonEntity;
var handEntities = new List<Entity>();
using (var iter = world.Select<PlayerHand>())
{
while (iter.MoveNext())
{
if (iter.CurrentEntity != singletonEntity)
handEntities.Add(iter.CurrentEntity);
handEntities.Add(iter.Entity);
}
}
using (var iter = world.Select<DealerHand>())
{
while (iter.MoveNext())
{
if (iter.CurrentEntity != singletonEntity)
handEntities.Add(iter.CurrentEntity);
handEntities.Add(iter.Entity);
}
}
foreach (var hand in handEntities)
{
var cards = world.GetSources<Holds>(hand);
var deckEntity = HitCommand.FindEntity<Deck>(world, singletonEntity);
var deckEntity = world.FindEntity<Deck>();
foreach (var card in cards)
{
world.RemoveComponent<Holds>(card);
world.AddComponent(card, new InDeck { Source = card, Target = deckEntity });
world.AddComponent(card, new InDeck { Target = deckEntity });
}
}
state.RoundNumber++;
state.Phase = GamePhase.Betting;
state.Result = RoundResult.None;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
@@ -24,6 +24,5 @@ public struct PlaceBetCommand : ICommand
state.CurrentBet = Amount;
state.Chips -= Amount;
state.Phase = GamePhase.Dealing;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
-1
View File
@@ -17,6 +17,5 @@ public struct StandCommand : ICommand
return;
state.Phase = GamePhase.DealerTurn;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
+1 -2
View File
@@ -10,6 +10,5 @@ namespace Game.Blackjack;
[MessagePackObject]
public record struct Holds : IRelationship
{
[Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; }
[Key(0)] public Entity Target { get; set; }
}
+1 -2
View File
@@ -10,6 +10,5 @@ namespace Game.Blackjack;
[MessagePackObject]
public record struct InDeck : IRelationship
{
[Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; }
[Key(0)] public Entity Target { get; set; }
}
+1 -2
View File
@@ -8,7 +8,7 @@ namespace Game.Blackjack;
/// </summary>
public class DealSystem : ISystem
{
public void Run(World world)
public void RunImpl(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Dealing)
@@ -23,7 +23,6 @@ public class DealSystem : ISystem
HitCommand.DrawCard<DealerHand>(world);
mutableState.Phase = GamePhase.PlayerTurn;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
+4 -3
View File
@@ -8,7 +8,7 @@ namespace Game.Blackjack;
/// </summary>
public class DealerSystem : ISystem
{
public void Run(World world)
public void RunImpl(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.DealerTurn)
@@ -18,10 +18,12 @@ public class DealerSystem : ISystem
// Dealer must hit on 16 and below, stand on 17+.
int dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
while (dealerTotal < 17)
int safety = 0;
while (dealerTotal < 17 && safety < 52)
{
HitCommand.DrawCard<DealerHand>(world);
dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
safety++;
}
int playerTotal = HandUtil.CalculateHand(world, PlayerHandTag.Instance);
@@ -49,6 +51,5 @@ public class DealerSystem : ISystem
mutableState.Chips += mutableState.CurrentBet;
}
world.MarkModified<GameState>(World.SingletonEntity);
}
}
+6 -24
View File
@@ -9,18 +9,17 @@ namespace Game.Blackjack;
/// </summary>
public class DeckSetupSystem : ISystem
{
public void Run(World world)
public void RunImpl(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Dealing)
return;
// Only create deck entities if they don't exist yet.
var singletonEntity = World.SingletonEntity;
bool hasDeck = false;
using (var iter = world.Select<Deck>())
{
hasDeck = iter.MoveNext() && iter.CurrentEntity != singletonEntity;
hasDeck = iter.MoveNext();
}
if (!hasDeck)
@@ -36,7 +35,7 @@ public class DeckSetupSystem : ISystem
{
var cardEntity = world.CreateEntity();
world.AddComponent(cardEntity, new Card { Suit = suit, Rank = rank });
world.AddComponent(cardEntity, new InDeck { Source = cardEntity, Target = deckEntity });
world.AddComponent(cardEntity, new InDeck { Target = deckEntity });
}
}
@@ -50,7 +49,7 @@ public class DeckSetupSystem : ISystem
// Shuffle the deck using mulberry32 with the current seed.
ref var mutableState = ref world.GetSingleton<GameState>();
var deckEntity2 = HitCommand.FindEntity<Deck>(world, singletonEntity);
var deckEntity2 = world.FindEntity<Deck>();
var cards = world.GetSources<InDeck>(deckEntity2).ToArray();
Shuffle(world, deckEntity2, cards, ref mutableState.Seed);
}
@@ -61,27 +60,10 @@ public class DeckSetupSystem : ISystem
for (int i = cardEntities.Length - 1; i > 0; i--)
{
int j = Mulberry32.NextInt(ref seed, 0, i);
// Swap the InDeck relationship targets (cards are always in the deck,
// so there's nothing to swap except the cards themselves — but we
// shuffle the card order conceptually by removing and re-adding
// InDeck components in shuffled order). Actually, since InDeck
// is just a tag, we just re-shuffle the order in the source collection.
// The simplest approach: no need to swap component data; we just
// need to ensure the cards are iterated in shuffled order.
// We'll swap the card entities in the array.
(cardEntities[i], cardEntities[j]) = (cardEntities[j], cardEntities[i]);
}
// Now remove all InDeck and re-add in shuffled order so GetSources
// returns them in shuffled order.
for (int i = cardEntities.Length - 1; i >= 0; i--)
{
world.RemoveComponent<InDeck>(cardEntities[i]);
}
for (int i = 0; i < cardEntities.Length; i++)
{
world.AddComponent(cardEntities[i], new InDeck { Source = cardEntities[i], Target = deckEntity });
}
// Reorder the source set in-place — no component add/remove needed.
world.ReorderSources<InDeck>(deckEntity, cardEntities);
}
}
+1 -12
View File
@@ -22,18 +22,7 @@ public static class HandUtil
where T : struct
{
// Find the hand entity.
Entity handEntity = Entity.Null;
using (var iter = world.Select<T>())
{
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
{
handEntity = iter.CurrentEntity;
break;
}
}
}
var handEntity = world.FindEntity<T>();
if (handEntity == Entity.Null)
return 0;
@@ -8,7 +8,7 @@ namespace Game.Blackjack;
/// </summary>
public class PlayerBustCheckSystem : ISystem
{
public void Run(World world)
public void RunImpl(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.PlayerTurn)
@@ -21,6 +21,5 @@ public class PlayerBustCheckSystem : ISystem
ref var mutableState = ref world.GetSingleton<GameState>();
mutableState.Phase = GamePhase.RoundOver;
mutableState.Result = RoundResult.PlayerBust;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
+139
View File
@@ -0,0 +1,139 @@
using FluentAssertions;
using Game.CardWars;
using OECS;
using Xunit;
namespace Game.CardWars.Tests;
public class CardEffectTests
{
private static (World World, SystemGroup Group) Setup()
{
CardEffectRegistry.ClearForTests();
return GameFactory.Create(playerCount: 2, seed: 42);
}
[Fact]
public void WarriorEffect_AddsHornWhenAnotherWarriorExists()
{
var (world, group) = Setup();
var state = world.ReadSingleton<GameState>();
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
// Play two warriors.
var hand = GameUtil.GetHandCards(world, player);
var warrior1 = FindCardWithEffect(world, hand, CardEffect.Warrior);
var warrior2 = FindSecondCardWithEffect(world, hand, CardEffect.Warrior);
if (warrior1 == Entity.Null || warrior2 == Entity.Null) return; // Not enough warriors in deck.
var def = world.ReadComponent<CardDef>(warrior1);
world.Commands.Enqueue(new PlayCardCommand
{
CardEntity = warrior1,
Rank = def.Ranks[0],
FaceDown = false,
TargetPlayer = null
});
group.RunLogical();
// First warrior: no other warrior, no horn.
GameUtil.GetFieldCards(world, player).Should().Contain(warrior1);
world.HasComponent<Horn>(warrior1).Should().BeFalse(because: "no other warrior on field");
// Play second warrior.
var def2 = world.ReadComponent<CardDef>(warrior2);
world.Commands.Enqueue(new PlayCardCommand
{
CardEntity = warrior2,
Rank = def2.Ranks[0],
FaceDown = false,
TargetPlayer = null
});
group.RunLogical();
// Second warrior has another warrior, should get horn.
// But Wait — the effect checks "other" warriors on field. The first warrior
// is on field, so the second warrior should get a horn.
world.HasComponent<Horn>(warrior2).Should().BeTrue(because: "another warrior exists on field");
}
[Fact]
public void MercenaryEffect_SetsPendingChoice()
{
var (world, group) = Setup();
var state = world.ReadSingleton<GameState>();
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
// Play first mercenary (no effect).
var hand = GameUtil.GetHandCards(world, player);
var merc = FindCardWithEffect(world, hand, CardEffect.Mercenary);
if (merc == Entity.Null) return;
var def = world.ReadComponent<CardDef>(merc);
world.Commands.Enqueue(new PlayCardCommand
{
CardEntity = merc,
Rank = def.Ranks[0],
FaceDown = false,
TargetPlayer = null
});
group.RunLogical();
// Play second mercenary. We need another one from a drawn card.
var publicDeck = GameUtil.FindEntity<PublicDeck>(world);
GameUtil.DrawCard(world, publicDeck, player);
var newHand = GameUtil.GetHandCards(world, player);
var merc2 = FindCardWithEffect(world, newHand, CardEffect.Mercenary);
if (merc2 == Entity.Null) return;
var def2 = world.ReadComponent<CardDef>(merc2);
world.Commands.Enqueue(new PlayCardCommand
{
CardEntity = merc2,
Rank = def2.Ranks[0],
FaceDown = false,
TargetPlayer = null
});
group.RunLogical();
// Should have a pending Mercenary choice.
world.HasSingleton<PendingMercenary>().Should().BeTrue();
var pending = world.ReadSingleton<PendingMercenary>();
pending.CardEntity.Should().Be(merc2);
// Resolve the choice (pick a target).
world.Commands.Enqueue(new ResolveChoiceCommand { Target = merc });
group.RunLogical();
// The pending should be cleared.
world.HasSingleton<PendingMercenary>().Should().BeFalse();
}
private static Entity FindCardWithEffect(World world, List<Entity> cards, CardEffect effect)
{
foreach (var c in cards)
{
if (world.HasComponent<CardDef>(c) && world.ReadComponent<CardDef>(c).Effect == effect)
return c;
}
return Entity.Null;
}
private static Entity FindSecondCardWithEffect(World world, List<Entity> cards, CardEffect effect)
{
Entity first = Entity.Null;
foreach (var c in cards)
{
if (world.HasComponent<CardDef>(c) && world.ReadComponent<CardDef>(c).Effect == effect)
{
if (first == Entity.Null) first = c;
else return c;
}
}
return Entity.Null;
}
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Game.CardWars.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.CardWars\CardWars.csproj" />
</ItemGroup>
</Project>
+148
View File
@@ -0,0 +1,148 @@
using FluentAssertions;
using Game.CardWars;
using OECS;
using Xunit;
namespace Game.CardWars.Tests;
public class GameSetupTests
{
private static (World World, SystemGroup Group) Setup()
{
// Reset registry state between tests.
CardEffectRegistry.ClearForTests();
return GameFactory.Create(playerCount: 2, seed: 42);
}
[Fact]
public void CreateGame_CreatesPlayersAndDecks()
{
var (world, _) = Setup();
var state = world.ReadSingleton<GameState>();
state.Phase.Should().Be(GamePhase.PlayPhase);
state.PlayerCount.Should().Be(2);
state.RoundNumber.Should().Be(1);
GameUtil.FindEntity<PublicDeck>(world).Should().NotBe(Entity.Null);
GameUtil.FindAllEntities<Player>(world).Should().HaveCount(2);
GameUtil.FindAllEntities<Leader>(world).Should().HaveCount(2);
GameUtil.FindAllEntities<Banner>(world).Should().HaveCount(2);
GameUtil.FindAllEntities<Castle>(world).Should().NotBeEmpty();
}
[Fact]
public void StartingHand_HasThreeCards()
{
var (world, _) = Setup();
foreach (var player in GameUtil.FindAllEntities<Player>(world))
{
var hand = GameUtil.GetHandCards(world, player);
hand.Should().HaveCount(3);
}
}
[Fact]
public void PlayCard_MovesCardFromHandToField()
{
var (world, group) = Setup();
var state = world.ReadSingleton<GameState>();
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
var hand = GameUtil.GetHandCards(world, player);
var cardInHand = hand[0];
var def = world.ReadComponent<CardDef>(cardInHand);
world.Commands.Enqueue(new PlayCardCommand
{
CardEntity = cardInHand,
Rank = def.Ranks[0],
FaceDown = false,
TargetPlayer = null
});
group.RunLogical();
GameUtil.GetFieldCards(world, player).Should().Contain(cardInHand);
var cardData = world.ReadComponent<Card>(cardInHand);
cardData.Rank.Should().Be(def.Ranks[0]);
cardData.FaceDown.Should().BeFalse();
}
[Fact]
public void PlayCardFaceDown_CardIsHidden()
{
var (world, group) = Setup();
var state = world.ReadSingleton<GameState>();
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
var hand = GameUtil.GetHandCards(world, player);
var cardInHand = hand[0];
var def = world.ReadComponent<CardDef>(cardInHand);
world.Commands.Enqueue(new PlayCardCommand
{
CardEntity = cardInHand,
Rank = def.Ranks[0],
FaceDown = true,
TargetPlayer = null
});
group.RunLogical();
world.ReadComponent<Card>(cardInHand).FaceDown.Should().BeTrue();
}
[Fact]
public void FlipCard_RevealsFaceDownCard()
{
var (world, group) = Setup();
var state = world.ReadSingleton<GameState>();
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
var hand = GameUtil.GetHandCards(world, player);
var cardInHand = hand[0];
var def = world.ReadComponent<CardDef>(cardInHand);
// Play face-down.
world.Commands.Enqueue(new PlayCardCommand
{
CardEntity = cardInHand,
Rank = def.Ranks[0],
FaceDown = true,
TargetPlayer = null
});
group.RunLogical();
// Advance to flip phase: both players skip.
world.Commands.Enqueue(new SkipPlayCommand());
group.RunLogical();
world.Commands.Enqueue(new SkipPlayCommand());
group.RunLogical();
world.ReadSingleton<GameState>().Phase.Should().Be(GamePhase.FlipPhase);
// Flip it.
world.Commands.Enqueue(new FlipCardCommand { CardEntity = cardInHand });
group.RunLogical();
world.ReadComponent<Card>(cardInHand).FaceDown.Should().BeFalse();
}
[Fact]
public void SkipPlay_AdvancesTurn()
{
var (world, group) = Setup();
var state = world.ReadSingleton<GameState>();
int originalPlayer = state.CurrentPlayerIndex;
world.Commands.Enqueue(new SkipPlayCommand());
group.RunLogical();
state = world.ReadSingleton<GameState>();
state.CurrentPlayerIndex.Should().Be((originalPlayer + 1) % 2);
}
}
+288
View File
@@ -0,0 +1,288 @@
using System.Text;
using FluentAssertions;
using Game.CardWars;
using OECS;
using Xunit;
using Xunit.Abstractions;
namespace Game.CardWars.Tests;
public class PlayLogTests
{
private readonly ITestOutputHelper _output;
public PlayLogTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public void OneRound_ThreePlayers_PlayLog()
{
CardEffectRegistry.ClearForTests();
var (world, group) = GameFactory.Create(playerCount: 3, seed: 12345);
var log = new StringBuilder();
var players = GameUtil.FindAllEntities<Player>(world);
var state = world.ReadSingleton<GameState>();
LogSection(log, "=== CardWars Play Log — Round 1 ===");
log.AppendLine($"Seed: {state.Seed} | Players: {state.PlayerCount} | Starting: P{state.StartingPlayerIndex}");
log.AppendLine();
// ── Initial hands ──
LogSection(log, "── Starting Hands ──");
for (int i = 0; i < players.Count; i++)
log.AppendLine($" P{i}: {DescribeHand(world, GameUtil.GetHandCards(world, players[i]))}");
log.AppendLine();
// ── Play Phase ──
LogSection(log, "── Play Phase ──");
RunPlayPhase(world, group, players, log);
// ── Flip Phase ──
LogSection(log, "── Flip Phase ──");
RunFlipPhase(world, group, players, log);
// ── Scoring (capture powers BEFORE cleanup) ──
LogSection(log, "── Scoring ──");
var powers = new int[players.Count];
for (int i = 0; i < players.Count; i++)
powers[i] = GameUtil.CalculatePower(world, players[i]);
// Advance to scoring phase and let systems run.
ref var mutable = ref world.GetSingleton<GameState>();
mutable.Phase = GamePhase.Scoring;
world.MarkModified<GameState>(World.SingletonEntity);
group.RunLogical(); // ScoringSystem → CleanupSystem
for (int i = 0; i < players.Count; i++)
log.AppendLine($" P{i} power: {powers[i]}");
int winnerIdx = Array.IndexOf(powers, powers.Max());
log.AppendLine($" Winner: P{winnerIdx} (power={powers[winnerIdx]})");
// Find the castle that was just awarded (it has HeldBy but no Castle component).
var wonCastleEntity = world.GetSources<HeldBy>(players[winnerIdx])
.FirstOrDefault(c => !world.HasComponent<Castle>(c) && !world.HasComponent<CardDef>(c));
log.AppendLine($" Castle awarded to P{winnerIdx}");
log.AppendLine();
// ── Cleanup already ran ──
LogSection(log, "── After Cleanup ──");
state = world.ReadSingleton<GameState>();
log.AppendLine($" Phase: {state.Phase} | Round: {state.RoundNumber}");
for (int i = 0; i < players.Count; i++)
{
var hand = GameUtil.GetHandCards(world, players[i]);
log.AppendLine($" P{i} hand: {DescribeHand(world, hand)}");
}
log.AppendLine();
// ── Castle counts ──
LogSection(log, "── Castle Tally ──");
for (int i = 0; i < players.Count; i++)
{
// A won castle: has HeldBy to this player, no CardDef, no Card, no Leader, no Banner.
var castleEntities = world.GetSources<HeldBy>(players[i])
.Where(c => !world.HasComponent<CardDef>(c)
&& !world.HasComponent<Card>(c)
&& !world.HasComponent<Leader>(c)
&& !world.HasComponent<Banner>(c))
.ToList();
log.AppendLine($" P{i}: {castleEntities.Count} castle(s)");
}
log.AppendLine();
_output.WriteLine(log.ToString());
state.Phase.Should().BeOneOf(GamePhase.PlayPhase, GamePhase.GameOver);
}
// ── Play phase: greedy agent, plays best card each turn ──
private static void RunPlayPhase(World world, SystemGroup group, List<Entity> players, StringBuilder log)
{
var state = world.ReadSingleton<GameState>();
int startIdx = state.StartingPlayerIndex;
int current = startIdx;
int safety = 0;
while (safety++ < 100)
{
state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.PlayPhase) break;
var player = players[state.CurrentPlayerIndex];
var hand = GameUtil.GetHandCards(world, player);
if (hand.Count > 0)
{
var best = PickBestCard(world, hand);
var def = world.ReadComponent<CardDef>(best);
int rank = def.Ranks.Max();
world.Commands.Enqueue(new PlayCardCommand
{
CardEntity = best,
Rank = rank,
FaceDown = false,
TargetPlayer = null
});
group.RunLogical();
log.AppendLine($" P{state.CurrentPlayerIndex} plays [{def.Name}] rank={rank}");
// Resolve any pending choices automatically.
ResolvePendingChoices(world, group, players, log);
}
else
{
world.Commands.Enqueue(new SkipPlayCommand());
group.RunLogical();
log.AppendLine($" P{state.CurrentPlayerIndex} skips (hand empty)");
}
}
state = world.ReadSingleton<GameState>();
log.AppendLine($" Phase → {state.Phase}");
log.AppendLine();
}
// ── Flip phase ──
private static void RunFlipPhase(World world, SystemGroup group, List<Entity> players, StringBuilder log)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.FlipPhase)
{
ref var m = ref world.GetSingleton<GameState>();
m.Phase = GamePhase.FlipPhase;
m.CurrentPlayerIndex = m.StartingPlayerIndex;
world.MarkModified<GameState>(World.SingletonEntity);
}
for (int i = 0; i < players.Count; i++)
{
state = world.ReadSingleton<GameState>();
var player = players[state.CurrentPlayerIndex];
var field = GameUtil.GetFieldCards(world, player);
bool anyFaceDown = field.Any(c =>
world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).FaceDown);
if (anyFaceDown)
{
var fd = field.First(c =>
world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).FaceDown);
world.Commands.Enqueue(new FlipCardCommand { CardEntity = fd });
group.RunLogical();
var def = world.ReadComponent<CardDef>(fd);
log.AppendLine($" P{state.CurrentPlayerIndex} flips [{def.Name}]");
ResolvePendingChoices(world, group, players, log);
}
else
{
log.AppendLine($" P{state.CurrentPlayerIndex} skips flip (no face-down)");
}
ref var m2 = ref world.GetSingleton<GameState>();
m2.CurrentPlayerIndex = (m2.CurrentPlayerIndex + 1) % m2.PlayerCount;
world.MarkModified<GameState>(World.SingletonEntity);
}
log.AppendLine();
}
// ── Pending choice auto-resolver ──
private static void ResolvePendingChoices(World world, SystemGroup group, List<Entity> players, StringBuilder log)
{
while (PendingChoice.Any(world))
{
Entity target = PickDefaultTarget(world, players);
world.Commands.Enqueue(new ResolveChoiceCommand { Target = target });
group.RunLogical();
log.AppendLine($" ↳ auto-resolve → {DescribeEntity(world, target)}");
}
}
private static Entity PickDefaultTarget(World world, List<Entity> players)
{
if (world.HasSingleton<PendingMercenary>())
{
var p = world.ReadSingleton<PendingMercenary>();
return GameUtil.GetFieldCards(world, p.Player)
.FirstOrDefault(c => world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).Rank > 0);
}
if (world.HasSingleton<PendingDancer>())
{
var p = world.ReadSingleton<PendingDancer>();
return GameUtil.GetFieldCards(world, p.Player)
.FirstOrDefault(c => world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).Rank > 0);
}
if (world.HasSingleton<PendingPaladin>())
{
var p = world.ReadSingleton<PendingPaladin>();
var powered = GameUtil.GetFieldCards(world, p.Player)
.FirstOrDefault(c => world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).Rank > 0);
return powered != Entity.Null ? powered : CardEffectHelpers.GetBanner(world, p.Player);
}
if (world.HasSingleton<PendingNun>())
{
var p = world.ReadSingleton<PendingNun>();
return GameUtil.GetFieldCards(world, p.Player).FirstOrDefault();
}
if (world.HasSingleton<PendingScout>())
{
var p = world.ReadSingleton<PendingScout>();
return players.FirstOrDefault(pl => pl != p.Player);
}
if (world.HasSingleton<PendingPegasus>())
{
var p = world.ReadSingleton<PendingPegasus>();
return GameUtil.GetFieldCards(world, p.Player).FirstOrDefault(c => c != p.CardEntity);
}
if (world.HasSingleton<PendingCurseMaster>())
{
return GameUtil.FindAllEntities<Horn>(world).FirstOrDefault();
}
return Entity.Null;
}
// ── Helpers ──
private static Entity PickBestCard(World world, List<Entity> hand)
{
Entity best = Entity.Null;
int bestRank = -999;
foreach (var c in hand)
{
int maxRank = world.ReadComponent<CardDef>(c).Ranks.Max();
if (maxRank > bestRank) { bestRank = maxRank; best = c; }
}
return best;
}
private static string DescribeHand(World world, List<Entity> hand)
{
var cards = hand.Where(c => world.HasComponent<CardDef>(c)).ToList();
if (cards.Count == 0) return "(empty)";
return string.Join(" ", cards.Select(c =>
{
var def = world.ReadComponent<CardDef>(c);
return $"[{def.Name} {string.Join("/", def.Ranks)}]";
}));
}
private static string DescribeEntity(World world, Entity e)
{
if (e == Entity.Null) return "(none)";
if (world.HasComponent<CardDef>(e)) return $"[{world.ReadComponent<CardDef>(e).Name}]";
if (world.HasComponent<Player>(e)) return $"P{world.ReadComponent<Player>(e).Index}";
if (world.HasComponent<Horn>(e)) return "[Horn token]";
if (world.HasComponent<Skull>(e)) return "[Skull token]";
if (world.HasComponent<Banner>(e)) return "[Banner]";
return e.ToString();
}
private static void LogSection(StringBuilder log, string title) => log.AppendLine(title);
}
+113
View File
@@ -0,0 +1,113 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Loads card definitions from a CSV and creates card entities in the world.
/// </summary>
public static class CardDataLoader
{
/// <summary>
/// Creates all card definition entities in the world from CSV data.
/// Returns a list of (entity, CardDef) pairs.
/// </summary>
public static List<(Entity Entity, CardDef Def)> LoadDefinitions(World world, string csv)
{
var results = new List<(Entity, CardDef)>();
var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (lines.Length < 2)
return results;
// Skip header line.
for (int i = 1; i < lines.Length; i++)
{
var line = lines[i].Trim();
if (string.IsNullOrEmpty(line)) continue;
var def = ParseLine(line);
if (def.Name == null) continue;
var entity = world.CreateEntity();
world.AddComponent(entity, def);
results.Add((entity, def));
}
return results;
}
private static CardDef ParseLine(string line)
{
// Simple CSV parsing: name,ranks,effect
// Ranks are semicolon-separated.
var parts = SplitCsv(line);
if (parts.Length < 3)
return default;
var name = parts[0].Trim();
var rankStrs = parts[1].Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var ranks = new int[rankStrs.Length];
for (int i = 0; i < rankStrs.Length; i++)
ranks[i] = int.Parse(rankStrs[i]);
var effect = ParseEffect(parts[2].Trim());
return new CardDef
{
Name = name,
Ranks = ranks,
Effect = effect,
Kind = CardKind.Public
};
}
private static string[] SplitCsv(string line)
{
var result = new List<string>();
var current = new System.Text.StringBuilder();
bool inQuotes = false;
for (int i = 0; i < line.Length; i++)
{
char c = line[i];
if (c == '"')
{
inQuotes = !inQuotes;
}
else if (c == ',' && !inQuotes)
{
result.Add(current.ToString());
current.Clear();
}
else
{
current.Append(c);
}
}
result.Add(current.ToString());
return result.ToArray();
}
private static CardEffect ParseEffect(string effect)
{
return effect switch
{
string s when s.Contains("战士") => CardEffect.Warrior,
string s when s.Contains("弓手") => CardEffect.Archer,
string s when s.Contains("佣兵") => CardEffect.Mercenary,
string s when s.Contains("商人") => CardEffect.Merchant,
string s when s.Contains("舞姬") => CardEffect.Dancer,
string s when s.Contains("圣骑士") => CardEffect.Paladin,
string s when s.Contains("飞马") => CardEffect.Pegasus,
string s when s.Contains("诅咒师") => CardEffect.CurseMaster,
string s when s.Contains("魔导士") => CardEffect.Mage,
string s when s.Contains("公主") => CardEffect.Princess,
string s when s.Contains("决斗家") => CardEffect.Duelist,
string s when s.Contains("修女") => CardEffect.Nun,
string s when s.Contains("斥候") => CardEffect.Scout,
string s when s.Contains("女巫") => CardEffect.Witch,
string s when s.Contains("盗贼") => CardEffect.Thief,
_ => CardEffect.None
};
}
}
+96
View File
@@ -0,0 +1,96 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// A card effect that can be registered with the effect dispatcher.
/// </summary>
public interface ICardEffect
{
CardEffect Effect { get; }
/// <summary>
/// Called when the card is played face-up or flipped face-up.
/// Return true if fully resolved. Return false if a pending component
/// was set and the effect needs a player command to continue.
/// </summary>
bool Resolve(World world, Entity card, Entity player, Entity owner);
/// <summary>
/// Called when the player provides a choice to continue a pending effect.
/// The effect handler is responsible for reading the correct pending
/// component type from the singleton and removing it when done.
/// </summary>
bool ResolveChoice(World world, Entity target);
}
/// <summary>
/// Registry of card effects, ordered by registration.
/// </summary>
public static class CardEffectRegistry
{
private static readonly List<ICardEffect> _effects = new();
private static bool _frozen;
public static void Add<TEffect>() where TEffect : ICardEffect, new()
{
if (_frozen)
throw new InvalidOperationException("Cannot add effects after the registry is frozen.");
_effects.Add(new TEffect());
}
public static void Freeze() => _frozen = true;
/// <summary>
/// Resolve a card effect. Returns true if fully resolved.
/// </summary>
public static bool Dispatch(World world, CardEffect effect, Entity card, Entity player, Entity owner)
{
foreach (var e in _effects)
{
if (e.Effect == effect)
return e.Resolve(world, card, player, owner);
}
return true;
}
/// <summary>
/// Look up the handler for the currently pending effect and resolve it.
/// </summary>
public static bool DispatchChoice(World world, Entity target)
{
// Find which pending component is active and dispatch to the matching handler.
if (world.HasSingleton<PendingMercenary>())
return DispatchByEffect(world, CardEffect.Mercenary, target);
if (world.HasSingleton<PendingDancer>())
return DispatchByEffect(world, CardEffect.Dancer, target);
if (world.HasSingleton<PendingPaladin>())
return DispatchByEffect(world, CardEffect.Paladin, target);
if (world.HasSingleton<PendingNun>())
return DispatchByEffect(world, CardEffect.Nun, target);
if (world.HasSingleton<PendingScout>())
return DispatchByEffect(world, CardEffect.Scout, target);
if (world.HasSingleton<PendingPegasus>())
return DispatchByEffect(world, CardEffect.Pegasus, target);
if (world.HasSingleton<PendingCurseMaster>())
return DispatchByEffect(world, CardEffect.CurseMaster, target);
return true;
}
private static bool DispatchByEffect(World world, CardEffect effect, Entity target)
{
foreach (var e in _effects)
{
if (e.Effect == effect)
return e.ResolveChoice(world, target);
}
return true;
}
public static void ClearForTests()
{
_effects.Clear();
_frozen = false;
}
}
+402
View File
@@ -0,0 +1,402 @@
using OECS;
namespace Game.CardWars;
// ────────────────────────────────────────────────────────────
// Simple effects that resolve immediately.
// ────────────────────────────────────────────────────────────
public class WarriorEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Warrior;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
if (HasOtherOnField(world, owner, card, CardEffect.Warrior))
CardEffectHelpers.AddHorn(world, card);
return true;
}
public bool ResolveChoice(World world, Entity target) => true;
private static bool HasOtherOnField(World world, Entity player, Entity self, CardEffect effect)
{
return GameUtil.GetFieldCards(world, player)
.Any(c => c != self && world.HasComponent<CardDef>(c)
&& world.ReadComponent<CardDef>(c).Effect == effect);
}
}
public class ArcherEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Archer;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
if (HasOtherOnField(world, owner, card, CardEffect.Archer))
{
var banner = CardEffectHelpers.GetBanner(world, owner);
if (banner != Entity.Null) CardEffectHelpers.AddHorn(world, banner);
}
return true;
}
public bool ResolveChoice(World world, Entity target) => true;
private static bool HasOtherOnField(World world, Entity player, Entity self, CardEffect effect)
{
return GameUtil.GetFieldCards(world, player)
.Any(c => c != self && world.HasComponent<CardDef>(c)
&& world.ReadComponent<CardDef>(c).Effect == effect);
}
}
public class MercenaryEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Mercenary;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
if (!HasOtherOnField(world, owner, card, CardEffect.Mercenary))
return true;
world.SetSingleton(new PendingMercenary { CardEntity = card, Player = player });
return false;
}
public bool ResolveChoice(World world, Entity target)
{
var pending = world.ReadSingleton<PendingMercenary>();
if (target != Entity.Null)
CardEffectHelpers.AddSkull(world, target);
world.RemoveSingleton<PendingMercenary>();
return true;
}
private static bool HasOtherOnField(World world, Entity player, Entity self, CardEffect effect)
{
return GameUtil.GetFieldCards(world, player)
.Any(c => c != self && world.HasComponent<CardDef>(c)
&& world.ReadComponent<CardDef>(c).Effect == effect);
}
}
public class MerchantEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Merchant;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
world.Commands.Enqueue(new DrawCardCommand { Player = player, Kind = null });
world.Commands.Enqueue(new ExtraActionCommand());
return true;
}
public bool ResolveChoice(World world, Entity target) => true;
}
public class DancerEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Dancer;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
var powered = GameUtil.GetFieldCards(world, owner)
.Where(c => world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).Rank > 0)
.ToList();
if (powered.Count == 0) return true;
if (powered.Count <= 2)
{
foreach (var c in powered)
CardEffectHelpers.AddHorn(world, c);
return true;
}
world.SetSingleton(new PendingDancer { CardEntity = card, Player = player, Step = 0 });
return false;
}
public bool ResolveChoice(World world, Entity target)
{
var pending = world.ReadSingleton<PendingDancer>();
if (target != Entity.Null)
CardEffectHelpers.AddHorn(world, target);
if (pending.Step == 0)
{
world.SetSingleton(new PendingDancer { CardEntity = pending.CardEntity, Player = pending.Player, Step = 1 });
return false;
}
world.RemoveSingleton<PendingDancer>();
return true;
}
}
public class PaladinEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Paladin;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
world.SetSingleton(new PendingPaladin { CardEntity = card, Player = player });
return false;
}
public bool ResolveChoice(World world, Entity target)
{
if (target != Entity.Null)
CardEffectHelpers.AddHorn(world, target);
world.RemoveSingleton<PendingPaladin>();
return true;
}
}
public class PrincessEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Princess;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
world.Commands.Enqueue(new ReplaceCastleCommand());
return true;
}
public bool ResolveChoice(World world, Entity target) => true;
}
public class DuelistEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Duelist;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
world.Commands.Enqueue(new EndPlayPhaseCommand());
return true;
}
public bool ResolveChoice(World world, Entity target) => true;
}
public class NunEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Nun;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
var fieldCards = GameUtil.GetFieldCards(world, owner);
if (fieldCards.Count == 0)
{
world.Commands.Enqueue(new DrawCardCommand { Player = player, Kind = null });
return true;
}
world.SetSingleton(new PendingNun { CardEntity = card, Player = player });
return false;
}
public bool ResolveChoice(World world, Entity target)
{
var pending = world.ReadSingleton<PendingNun>();
if (target != Entity.Null)
{
var discardDeck = CardEffectHelpers.GetDiscardDeck(world, pending.Player);
world.RemoveComponent<OnField>(target);
if (world.HasComponent<Card>(target))
world.RemoveComponent<Card>(target);
world.AddComponent(target, new InDeck { Target = discardDeck });
}
world.Commands.Enqueue(new DrawCardCommand { Player = pending.Player, Kind = null });
world.RemoveSingleton<PendingNun>();
return true;
}
}
public class ScoutEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Scout;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
world.SetSingleton(new PendingScout { CardEntity = card, Player = player });
return false;
}
public bool ResolveChoice(World world, Entity target)
{
var pending = world.ReadSingleton<PendingScout>();
if (target != Entity.Null && world.HasComponent<Player>(target))
{
foreach (var c in GameUtil.GetFieldCards(world, target))
{
if (world.TryGetComponent<Card>(c, out var cd) && cd.FaceDown)
{
ref var cardRef = ref world.GetComponent<Card>(c);
cardRef.FaceDown = false;
world.MarkModified<Card>(c);
}
}
}
world.RemoveSingleton<PendingScout>();
return true;
}
}
public class WitchEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Witch;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
var banner = CardEffectHelpers.GetBanner(world, owner);
if (banner != Entity.Null)
CardEffectHelpers.AddSkull(world, banner);
return true;
}
public bool ResolveChoice(World world, Entity target) => true;
}
public class ThiefEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Thief;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
return true;
}
public bool ResolveChoice(World world, Entity target) => true;
}
// ────────────────────────────────────────────────────────────
// Face-down flip effects
// ────────────────────────────────────────────────────────────
public class PegasusEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Pegasus;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
world.SetSingleton(new PendingPegasus { CardEntity = card, Player = player });
return false;
}
public bool ResolveChoice(World world, Entity target)
{
var pending = world.ReadSingleton<PendingPegasus>();
if (target != Entity.Null && target != pending.CardEntity)
{
world.RemoveComponent<OnField>(target);
if (world.HasComponent<Card>(target))
world.RemoveComponent<Card>(target);
world.AddComponent(target, new HeldBy { Target = pending.Player });
}
world.RemoveSingleton<PendingPegasus>();
return true;
}
}
public class CurseMasterEffect : ICardEffect
{
public CardEffect Effect => CardEffect.CurseMaster;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
world.SetSingleton(new PendingCurseMaster { CardEntity = card, Player = player });
return false;
}
public bool ResolveChoice(World world, Entity target)
{
var pending = world.ReadSingleton<PendingCurseMaster>();
if (target != Entity.Null && world.HasComponent<Horn>(target))
{
var placedTargets = world.GetSources<PlacedOn>(target).ToList();
world.DestroyEntity(target);
foreach (var pt in placedTargets)
{
var skull = world.CreateEntity();
world.AddComponent(skull, new Skull());
world.AddComponent(skull, new PlacedOn { Target = pt });
}
}
world.RemoveSingleton<PendingCurseMaster>();
return true;
}
}
public class MageEffect : ICardEffect
{
public CardEffect Effect => CardEffect.Mage;
public bool Resolve(World world, Entity card, Entity player, Entity owner)
{
Entity best = Entity.Null;
int bestRank = int.MinValue;
foreach (var p in GameUtil.FindAllEntities<Player>(world))
{
foreach (var c in GameUtil.GetFieldCards(world, p))
{
if (world.TryGetComponent<Card>(c, out var cd) && !cd.FaceDown && cd.Rank > bestRank)
{
bestRank = cd.Rank;
best = c;
}
}
}
if (best != Entity.Null)
CardEffectHelpers.AddSkull(world, best);
return true;
}
public bool ResolveChoice(World world, Entity target) => true;
}
// ────────────────────────────────────────────────────────────
// Shared helpers
// ────────────────────────────────────────────────────────────
public static class CardEffectHelpers
{
public static void AddHorn(World world, Entity target)
{
var horn = world.CreateEntity();
world.AddComponent(horn, new Horn());
world.AddComponent(horn, new PlacedOn { Target = target });
}
public static void AddSkull(World world, Entity target)
{
var skull = world.CreateEntity();
world.AddComponent(skull, new Skull());
world.AddComponent(skull, new PlacedOn { Target = target });
}
public static Entity GetBanner(World world, Entity player)
{
using var iter = world.Select<Banner>();
while (iter.MoveNext())
{
if (iter.CurrentEntity == World.SingletonEntity) continue;
var onField = world.GetSources<OnField>(player);
if (onField.Contains(iter.CurrentEntity))
return iter.CurrentEntity;
}
return Entity.Null;
}
public static Entity GetDiscardDeck(World world, Entity player)
{
using var iter = world.Select<FactionDeck>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
return iter.CurrentEntity;
}
return Entity.Null;
}
}
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<RootNamespace>Game.CardWars</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OECS\OECS.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OECS.SourceGen\OECS.SourceGen.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>
+173
View File
@@ -0,0 +1,173 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
[MessagePackObject]
public struct SkipPlayCommand : ICommand
{
public void Execute(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.PlayPhase) return;
ref var mutable = ref world.GetSingleton<GameState>();
mutable.PlayPhaseEnded = true;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
[MessagePackObject]
public struct FlipCardCommand : ICommand
{
[Key(0)] public Entity CardEntity;
public void Execute(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.FlipPhase) return;
if (!world.IsAlive(CardEntity)) return;
if (!world.HasComponent<Card>(CardEntity)) return;
ref var card = ref world.GetComponent<Card>(CardEntity);
if (!card.FaceDown) return;
card.FaceDown = false;
world.MarkModified<Card>(CardEntity);
var def = world.ReadComponent<CardDef>(CardEntity);
Entity player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
// Resolve flip effects via registry.
CardEffectRegistry.Dispatch(world, def.Effect, CardEntity, player, player);
}
}
[MessagePackObject]
public struct SkipFlipCommand : ICommand
{
public void Execute(World world)
{
// Skip is only valid if no face-down cards remain.
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.FlipPhase) return;
Entity player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
var fieldCards = GameUtil.GetFieldCards(world, player);
bool hasFaceDown = fieldCards.Any(c =>
world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).FaceDown);
if (hasFaceDown) return;
// Advance is handled by FlipPhaseSystem.
}
}
[MessagePackObject]
public struct DrawCardCommand : ICommand
{
[Key(0)] public Entity Player;
[Key(1)] public CardKind? Kind;
public void Execute(World world)
{
if (!world.IsAlive(Player)) return;
var kind = Kind ?? CardKind.Public;
var deck = GetDeck(world, kind);
if (deck == Entity.Null) return;
var cards = world.GetSources<InDeck>(deck);
if (cards.Count == 0)
{
// Recycle: for now, faction discards recycle to the same deck.
GameUtil.RecycleDiscard(world, deck, deck);
cards = world.GetSources<InDeck>(deck);
if (cards.Count == 0) return;
}
GameUtil.DrawCard(world, deck, Player);
}
private static Entity GetDeck(World world, CardKind kind)
{
if (kind == CardKind.Public)
{
using var iter = world.Select<PublicDeck>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
return iter.CurrentEntity;
}
}
else
{
using var iter = world.Select<FactionDeck>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
return iter.CurrentEntity;
}
}
return Entity.Null;
}
}
[MessagePackObject]
public struct ExtraActionCommand : ICommand
{
public void Execute(World world) { }
}
[MessagePackObject]
public struct EndPlayPhaseCommand : ICommand
{
public void Execute(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Phase != GamePhase.PlayPhase) return;
world.SetSingleton(new PendingDuelist { PlayerIndex = state.CurrentPlayerIndex });
}
}
[MessagePackObject]
public struct ReplaceCastleCommand : ICommand
{
public void Execute(World world)
{
var state = world.ReadSingleton<GameState>();
var castles = GameUtil.FindAllEntities<Castle>(world);
foreach (var c in castles)
world.DestroyEntity(c);
var colors = new[] { CastleColor.Black, CastleColor.White, CastleColor.Blue,
CastleColor.Brown, CastleColor.Yellow, CastleColor.Indigo, CastleColor.Gold };
var seed = state.Seed;
for (int i = 0; i < 3; i++)
{
int idx = Mulberry32.NextInt(ref seed, 0, colors.Length - 1);
var castle = world.CreateEntity();
world.AddComponent(castle, new Castle { Color = colors[idx] });
}
}
}
/// <summary>
/// Player makes a choice to resolve a pending card effect.
/// The target entity depends on the effect:
/// - Mercenary, Dancer, Paladin, Nun: a field card entity
/// - Scout: a player entity
/// - Pegasus: a field card to return (or null)
/// - CurseMaster: a horn token entity
/// </summary>
[MessagePackObject]
public struct ResolveChoiceCommand : ICommand
{
[Key(0)] public Entity Target;
public void Execute(World world)
{
if (!PendingChoice.Any(world)) return;
CardEffectRegistry.DispatchChoice(world, Target);
}
}
+28
View File
@@ -0,0 +1,28 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Initializes a fresh game with the given player count and seed.
/// </summary>
[MessagePackObject]
public struct NewGameCommand : ICommand
{
[Key(0)] public int PlayerCount;
[Key(1)] public uint Seed;
[Key(2)] public string CardDataCsv;
public void Execute(World world)
{
world.SetSingleton(new GameState
{
Phase = GamePhase.Setup,
PlayerCount = PlayerCount,
CurrentPlayerIndex = 0,
StartingPlayerIndex = 0,
RoundNumber = 0,
Seed = Seed
});
}
}
+62
View File
@@ -0,0 +1,62 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Play a card from hand to the field, choosing a rank.
/// Resolves on-play effects via the CardEffectRegistry.
/// </summary>
[MessagePackObject]
public struct PlayCardCommand : ICommand
{
[Key(0)] public Entity CardEntity;
[Key(1)] public int Rank;
[Key(2)] public bool FaceDown;
[Key(3)] public Entity? TargetPlayer;
public void Execute(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase is not GamePhase.PlayPhase) return;
if (!world.IsAlive(CardEntity)) return;
Entity player = FindPlayerByIndex(world, state.CurrentPlayerIndex);
if (player == Entity.Null) return;
// Verify the card is in the player's hand.
if (!world.HasComponent<HeldBy>(CardEntity)) return;
var heldBy = world.ReadComponent<HeldBy>(CardEntity);
if (heldBy.Target != player) return;
var def = world.ReadComponent<CardDef>(CardEntity);
// Determine target: Witch/Thief are played to another player's field.
Entity fieldOwner = def.Effect is CardEffect.Witch or CardEffect.Thief
? (TargetPlayer ?? player)
: player;
// Move from hand to field.
world.RemoveComponent<HeldBy>(CardEntity);
world.AddComponent(CardEntity, new OnField { Target = fieldOwner });
world.AddComponent(CardEntity, new Card { Rank = Rank, FaceDown = FaceDown });
// Resolve on-play effects via registry.
if (!FaceDown)
CardEffectRegistry.Dispatch(world, def.Effect, CardEntity, player, fieldOwner);
world.MarkModified<GameState>(World.SingletonEntity);
}
public static Entity FindPlayerByIndex(World world, int index)
{
using var iter = world.Select<Player>();
while (iter.MoveNext())
{
if (iter.CurrentEntity == World.SingletonEntity) continue;
if (iter.Current1.Index == index)
return iter.CurrentEntity;
}
return Entity.Null;
}
}
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for a player's banner entity.
/// </summary>
[MessagePackObject]
public struct Banner { }
+17
View File
@@ -0,0 +1,17 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Instance data for a card that has been played to the field.
/// Cards in hand/deck only have CardDef; this is added when played.
/// </summary>
[MessagePackObject]
public record struct Card
{
/// <summary>The rank chosen for this play (one of CardDef.Ranks).</summary>
[Key(0)] public int Rank;
/// <summary>Whether the card is face-down on the field.</summary>
[Key(1)] public bool FaceDown;
}
+17
View File
@@ -0,0 +1,17 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Static card definition — name, possible ranks, effect, and kind.
/// Cards in the deck/hand only have CardDef. When played to the field,
/// they also get a Card component with the chosen rank.
/// </summary>
[MessagePackObject]
public record struct CardDef
{
[Key(0)] public string Name;
[Key(1)] public int[] Ranks;
[Key(2)] public CardEffect Effect;
[Key(3)] public CardKind Kind;
}
+12
View File
@@ -0,0 +1,12 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// A castle that can be won each round.
/// </summary>
[MessagePackObject]
public record struct Castle
{
[Key(0)] public CastleColor Color;
}
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for a faction deck entity.
/// </summary>
[MessagePackObject]
public struct FactionDeck { }
+14
View File
@@ -0,0 +1,14 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Relationship from a card entity to the player who holds it in hand.
/// Added to the card: Source=card, Target=player.
/// </summary>
[MessagePackObject]
public record struct HeldBy : IRelationship
{
[Key(0)] public Entity Target { get; set; }
}
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for a horn token. Can be placed on cards, banners, or leaders.
/// </summary>
[MessagePackObject]
public struct Horn { }
+13
View File
@@ -0,0 +1,13 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Relationship from a card entity to a deck entity.
/// </summary>
[MessagePackObject]
public record struct InDeck : IRelationship
{
[Key(0)] public Entity Target { get; set; }
}
+10
View File
@@ -0,0 +1,10 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component marking a card entity as the leader card.
/// The leader is always on the field and contributes its rank to power.
/// </summary>
[MessagePackObject]
public struct Leader { }
+13
View File
@@ -0,0 +1,13 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Relationship from a card entity to the player entity whose field it's on.
/// </summary>
[MessagePackObject]
public record struct OnField : IRelationship
{
[Key(0)] public Entity Target { get; set; }
}
+86
View File
@@ -0,0 +1,86 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
// ────────────────────────────────────────────────────────────
// Per-effect pending choice components.
// Each is placed on the singleton entity when a card effect
// requires player input. Their presence blocks game advancement.
// ────────────────────────────────────────────────────────────
/// <summary>Mercenary: choose a combatant to give a skull.</summary>
[MessagePackObject]
public record struct PendingMercenary
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>Dancer: choose up to 2 combatants for horn. Step tracks progress.</summary>
[MessagePackObject]
public record struct PendingDancer
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
[Key(2)] public int Step; // 0 = first choice, 1 = second choice
}
/// <summary>Paladin: choose a combatant or banner for horn.</summary>
[MessagePackObject]
public record struct PendingPaladin
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>Nun: choose a field card to discard.</summary>
[MessagePackObject]
public record struct PendingNun
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>Scout: choose an opponent player to scout.</summary>
[MessagePackObject]
public record struct PendingScout
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>Pegasus: choose a field card to return to hand (or null to pass).</summary>
[MessagePackObject]
public record struct PendingPegasus
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>CurseMaster: choose a horn token to turn into skull.</summary>
[MessagePackObject]
public record struct PendingCurseMaster
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
// ────────────────────────────────────────────────────────────
// Helper to detect any pending component on the singleton.
// ────────────────────────────────────────────────────────────
public static class PendingChoice
{
/// <summary>Returns true if any pending choice component exists on the singleton.</summary>
public static bool Any(World world)
{
return world.HasSingleton<PendingMercenary>()
|| world.HasSingleton<PendingDancer>()
|| world.HasSingleton<PendingPaladin>()
|| world.HasSingleton<PendingNun>()
|| world.HasSingleton<PendingScout>()
|| world.HasSingleton<PendingPegasus>()
|| world.HasSingleton<PendingCurseMaster>();
}
}
@@ -0,0 +1,14 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Placed on the singleton when a 决斗家 (Duelist) is played.
/// The play phase ends when the turn comes back to this player.
/// Removed automatically when the phase transitions.
/// </summary>
[MessagePackObject]
public struct PendingDuelist
{
[Key(0)] public int PlayerIndex;
}
+13
View File
@@ -0,0 +1,13 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Relationship from a horn/skull token entity to the target entity it's placed on.
/// </summary>
[MessagePackObject]
public record struct PlacedOn : IRelationship
{
[Key(0)] public Entity Target { get; set; }
}
+12
View File
@@ -0,0 +1,12 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag + index for a player entity.
/// </summary>
[MessagePackObject]
public record struct Player
{
[Key(0)] public int Index;
}
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for the public deck entity.
/// </summary>
[MessagePackObject]
public struct PublicDeck { }
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for a skull token. Can be placed on cards, banners, or leaders.
/// </summary>
[MessagePackObject]
public struct Skull { }
+52
View File
@@ -0,0 +1,52 @@
namespace Game.CardWars;
public enum CardKind : byte
{
Public = 0,
Faction = 1
}
public enum CardEffect : byte
{
None = 0,
// On-play effects
Warrior,
Archer,
Mercenary,
Merchant,
Dancer,
Paladin,
Princess,
Duelist,
Nun,
Scout,
Witch,
Thief,
// On-flip effects (played face-down)
Pegasus,
CurseMaster,
Mage
}
public enum CastleColor : byte
{
Black = 0,
White = 1,
Blue = 2,
Brown = 3,
Yellow = 4,
Indigo = 5,
Gold = 6
}
public enum GamePhase : byte
{
Setup = 0,
PlayPhase = 1,
FlipPhase = 2,
Scoring = 3,
Cleanup = 4,
GameOver = 5
}
+51
View File
@@ -0,0 +1,51 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Factory for creating a fully configured game world and system group.
/// </summary>
public static class GameFactory
{
public static string DefaultCardData =>
"name,ranks,effect\n" +
"战士,3;4;4;5,战士\n" +
"弓手,3;4;4;5,弓手\n" +
"佣兵,4;5;5;6,佣兵\n" +
"商人,0;1,商人\n" +
"舞姬,0;1,舞姬\n" +
"圣骑士,2;3,圣骑士\n" +
"飞马,2;3,飞马\n" +
"诅咒师,1;2,诅咒师\n" +
"魔导士,0;1,魔导士\n" +
"公主,4,公主\n" +
"决斗家,4,决斗家\n" +
"修女,3,修女\n" +
"斥候,2,斥候\n" +
"女巫,-1,女巫\n" +
"盗贼,-1,盗贼\n";
public static (World World, SystemGroup Group) Create(int playerCount, uint seed = 42)
{
var world = new World();
var group = new SystemGroup(world);
world.Commands.Enqueue(new NewGameCommand
{
PlayerCount = playerCount,
Seed = seed,
CardDataCsv = DefaultCardData
});
group.Add(new GameSetupSystem(playerCount, DefaultCardData));
group.Add(new PlayPhaseSystem());
group.Add(new FlipPhaseSystem());
group.Add(new ScoringSystem());
group.Add(new CleanupSystem());
// Run setup.
group.RunLogical();
return (world, group);
}
}
+162
View File
@@ -0,0 +1,162 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Shared utility methods for querying and manipulating the game world.
/// </summary>
public static class GameUtil
{
public static Entity FindEntity<T>(World world) where T : struct
{
using var iter = world.Select<T>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
return iter.CurrentEntity;
}
return Entity.Null;
}
public static List<Entity> FindAllEntities<T>(World world) where T : struct
{
var list = new List<Entity>();
using var iter = world.Select<T>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
list.Add(iter.CurrentEntity);
}
return list;
}
/// <summary>Draw the top card from a deck and add it to the player's hand.</summary>
public static bool DrawCard(World world, Entity deck, Entity player)
{
var cards = world.GetSources<InDeck>(deck);
if (cards.Count == 0) return false;
var cardEntity = cards.First();
world.RemoveComponent<InDeck>(cardEntity);
world.AddComponent(cardEntity, new HeldBy { Target = player });
return true;
}
/// <summary>Shuffle cards in a deck using Fisher-Yates with the given seed.</summary>
public static void ShuffleDeck(World world, Entity deck, ref uint seed)
{
var cards = world.GetSources<InDeck>(deck).ToArray();
for (int i = cards.Length - 1; i > 0; i--)
{
int j = Mulberry32.NextInt(ref seed, 0, i);
(cards[i], cards[j]) = (cards[j], cards[i]);
}
for (int i = cards.Length - 1; i >= 0; i--)
world.RemoveComponent<InDeck>(cards[i]);
for (int i = 0; i < cards.Length; i++)
world.AddComponent(cards[i], new InDeck { Target = deck });
}
/// <summary>Get all field cards for a player.</summary>
public static List<Entity> GetFieldCards(World world, Entity player)
{
return world.GetSources<OnField>(player).ToList();
}
/// <summary>Get all cards in a player's hand.</summary>
public static List<Entity> GetHandCards(World world, Entity player)
{
return world.GetSources<HeldBy>(player).ToList();
}
/// <summary>Calculate a player's total battle power.</summary>
public static int CalculatePower(World world, Entity player)
{
int total = 0;
// Leader power.
using var leaderIter = world.Select<Leader>();
while (leaderIter.MoveNext())
{
if (leaderIter.CurrentEntity == World.SingletonEntity) continue;
var onFieldSources = world.GetSources<OnField>(player);
if (onFieldSources.Contains(leaderIter.CurrentEntity))
{
if (world.TryGetComponent<Card>(leaderIter.CurrentEntity, out var leaderCard))
total += leaderCard.Rank;
}
}
// Find the player's banner.
var banner = CardEffectHelpers.GetBanner(world, player);
bool bannerHasHorn = banner != Entity.Null && world.HasComponent<Horn>(banner);
bool bannerHasSkull = banner != Entity.Null && world.HasComponent<Skull>(banner);
// Field cards.
var fieldCards = GetFieldCards(world, player);
int poweredCount = 0;
foreach (var card in fieldCards)
{
if (!world.TryGetComponent<Card>(card, out var cardData)) continue;
if (cardData.FaceDown) continue;
if (cardData.Rank <= 0) continue;
poweredCount++;
int rank = cardData.Rank;
if (world.HasComponent<Horn>(card))
rank *= 2;
if (world.HasComponent<Skull>(card))
rank = 0;
total += rank;
}
if (bannerHasHorn) total += poweredCount;
if (bannerHasSkull) total -= poweredCount;
return total;
}
/// <summary>Move all field cards (except leaders and banners) to the discard pile.</summary>
public static void DiscardField(World world, Entity player, Entity discardDeck)
{
var fieldCards = GetFieldCards(world, player);
var toDiscard = new List<Entity>();
foreach (var card in fieldCards)
{
// Keep leaders and banners on the field.
if (world.HasComponent<Leader>(card)) continue;
if (world.HasComponent<Banner>(card)) continue;
toDiscard.Add(card);
}
foreach (var card in toDiscard)
{
world.RemoveComponent<OnField>(card);
world.RemoveComponent<Card>(card);
world.AddComponent(card, new InDeck { Target = discardDeck });
}
// Destroy horns and skulls placed on discarded cards.
var tokenTargets = new HashSet<Entity>();
foreach (var card in toDiscard)
{
foreach (var token in world.GetSources<PlacedOn>(card))
tokenTargets.Add(token);
}
foreach (var token in tokenTargets)
world.DestroyEntity(token);
}
/// <summary>Recycle discard pile into draw pile.</summary>
public static void RecycleDiscard(World world, Entity drawDeck, Entity discardDeck)
{
var cards = world.GetSources<InDeck>(discardDeck).ToList();
foreach (var card in cards)
{
world.RemoveComponent<InDeck>(card);
world.AddComponent(card, new InDeck { Target = drawDeck });
}
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace Game.CardWars;
/// <summary>
/// Mulberry32 PRNG — a fast, high-quality 32-bit random number generator.
/// </summary>
public static class Mulberry32
{
public static float NextFloat(ref uint state)
{
state += 0x6D2B79F5u;
uint z = state;
z = (z ^ (z >> 15)) * (z | 1u);
z ^= z + (z ^ (z >> 7)) * (z | 61u);
return (z ^ (z >> 14)) / (float)uint.MaxValue;
}
public static int NextInt(ref uint state, int min, int max)
{
return (int)(NextFloat(ref state) * (max - min + 1)) + min;
}
}
+18
View File
@@ -0,0 +1,18 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Global game state on the singleton entity.
/// </summary>
[MessagePackObject]
public record struct GameState
{
[Key(0)] public GamePhase Phase;
[Key(1)] public int PlayerCount;
[Key(2)] public int CurrentPlayerIndex;
[Key(3)] public int StartingPlayerIndex;
[Key(4)] public int RoundNumber;
[Key(5)] public uint Seed;
[Key(6)] public bool PlayPhaseEnded;
}
+14
View File
@@ -0,0 +1,14 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Per-player score tracking.
/// </summary>
[MessagePackObject]
public record struct PlayerScores
{
[Key(0)] public int[] Scores;
[Key(1)] public CastleColor[] WonCastles;
[Key(2)] public int Count;
}
+66
View File
@@ -0,0 +1,66 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Discards field cards, clears tokens, draws new hands, checks game over.
/// </summary>
public class CleanupSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Cleanup) return;
ref var mutable = ref world.GetSingleton<GameState>();
var players = GameUtil.FindAllEntities<Player>(world);
var publicDeck = GameUtil.FindEntity<PublicDeck>(world);
foreach (var player in players)
{
GameUtil.DiscardField(world, player, publicDeck);
// Clear tokens on banners.
var banner = CardEffectHelpers.GetBanner(world, player);
if (banner != Entity.Null)
{
var tokens = world.GetSources<PlacedOn>(banner).ToList();
foreach (var token in tokens)
world.DestroyEntity(token);
}
}
// Draw 2 public cards per player.
foreach (var player in players)
{
for (int i = 0; i < 2; i++)
GameUtil.DrawCard(world, publicDeck, player);
}
// Check game over.
int maxScore = 0;
foreach (var player in players)
{
var castles = world.GetSources<HeldBy>(player)
.Where(c => world.HasComponent<Castle>(c))
.ToList();
maxScore = Math.Max(maxScore, castles.Count);
}
if (maxScore >= 4)
{
mutable.Phase = GamePhase.GameOver;
}
else
{
mutable.Phase = GamePhase.PlayPhase;
mutable.RoundNumber++;
mutable.CurrentPlayerIndex = (mutable.StartingPlayerIndex + 1) % mutable.PlayerCount;
mutable.StartingPlayerIndex = mutable.CurrentPlayerIndex;
mutable.PlayPhaseEnded = false;
}
world.MarkModified<GameState>(World.SingletonEntity);
}
}
+20
View File
@@ -0,0 +1,20 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Manages the flip phase: players take turns flipping cards.
/// Blocks if a PendingChoice exists.
/// </summary>
public class FlipPhaseSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.FlipPhase) return;
// Block if a pending choice exists.
if (PendingChoice.Any(world))
return;
}
}
+104
View File
@@ -0,0 +1,104 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// One-time setup: creates players, leaders, banners, decks, castles, and deals starting hands.
/// </summary>
public class GameSetupSystem : ISystem
{
private readonly int _playerCount;
private readonly string _cardDataCsv;
private bool _hasRun;
public GameSetupSystem(int playerCount, string cardDataCsv)
{
_playerCount = playerCount;
_cardDataCsv = cardDataCsv;
}
public void Run(World world)
{
if (_hasRun) return;
_hasRun = true;
var state = world.ReadSingleton<GameState>();
ref var mutable = ref world.GetSingleton<GameState>();
mutable.PlayerCount = _playerCount;
mutable.Phase = GamePhase.PlayPhase;
mutable.CurrentPlayerIndex = 0;
mutable.StartingPlayerIndex = 0;
mutable.RoundNumber = 1;
world.MarkModified<GameState>(World.SingletonEntity);
// Register card effects.
CardEffectRegistry.Add<WarriorEffect>();
CardEffectRegistry.Add<ArcherEffect>();
CardEffectRegistry.Add<MercenaryEffect>();
CardEffectRegistry.Add<MerchantEffect>();
CardEffectRegistry.Add<DancerEffect>();
CardEffectRegistry.Add<PaladinEffect>();
CardEffectRegistry.Add<PrincessEffect>();
CardEffectRegistry.Add<DuelistEffect>();
CardEffectRegistry.Add<NunEffect>();
CardEffectRegistry.Add<ScoutEffect>();
CardEffectRegistry.Add<WitchEffect>();
CardEffectRegistry.Add<ThiefEffect>();
CardEffectRegistry.Add<PegasusEffect>();
CardEffectRegistry.Add<CurseMasterEffect>();
CardEffectRegistry.Add<MageEffect>();
CardEffectRegistry.Freeze();
// Create public deck.
var publicDeck = world.CreateEntity();
world.AddComponent(publicDeck, new PublicDeck());
// Load card definitions and create card instances for the public deck.
var defs = CardDataLoader.LoadDefinitions(world, _cardDataCsv);
foreach (var (defEntity, _) in defs)
{
world.AddComponent(defEntity, new InDeck { Target = publicDeck });
}
// Shuffle public deck.
var seed = state.Seed;
GameUtil.ShuffleDeck(world, publicDeck, ref seed);
// Create the first castle.
var castle = world.CreateEntity();
world.AddComponent(castle, new Castle { Color = CastleColor.Black });
// Create players.
for (int i = 0; i < _playerCount; i++)
{
var player = world.CreateEntity();
world.AddComponent(player, new Player { Index = i });
// Create leader card (always on field, rank 0 placeholder).
var leader = world.CreateEntity();
world.AddComponent(leader, new Leader());
world.AddComponent(leader, new CardDef { Name = $"领袖{i}", Ranks = new[] { 0 }, Effect = CardEffect.None, Kind = CardKind.Faction });
world.AddComponent(leader, new Card { Rank = 0, FaceDown = false });
world.AddComponent(leader, new OnField { Target = player });
// Create banner.
var banner = world.CreateEntity();
world.AddComponent(banner, new Banner());
world.AddComponent(banner, new OnField { Target = player });
// Create faction deck.
var factionDeck = world.CreateEntity();
world.AddComponent(factionDeck, new FactionDeck());
}
// Deal starting hands: 3 public cards per player.
foreach (var player in GameUtil.FindAllEntities<Player>(world))
{
for (int i = 0; i < 3; i++)
GameUtil.DrawCard(world, publicDeck, player);
}
mutable.Seed = seed;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
+58
View File
@@ -0,0 +1,58 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Manages the play phase: players take turns. If a PendingChoice exists,
/// blocks advancement until the player resolves it.
/// </summary>
public class PlayPhaseSystem : ISystem
{
public void Run(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Phase != GamePhase.PlayPhase) return;
// Block if a pending choice exists.
if (PendingChoice.Any(world))
return;
// If the current player has skipped, move to the next player.
if (state.PlayPhaseEnded)
{
state.PlayPhaseEnded = false;
AdvanceTurn(world, ref state);
return;
}
}
private static void AdvanceTurn(World world, ref GameState state)
{
int next = (state.CurrentPlayerIndex + 1) % state.PlayerCount;
// Duelist: if the next player is the one who played duelist, end the phase.
if (world.HasSingleton<PendingDuelist>())
{
var duelist = world.ReadSingleton<PendingDuelist>();
if (next == duelist.PlayerIndex)
{
world.RemoveSingleton<PendingDuelist>();
state.Phase = GamePhase.FlipPhase;
state.CurrentPlayerIndex = state.StartingPlayerIndex;
world.MarkModified<GameState>(World.SingletonEntity);
return;
}
}
state.CurrentPlayerIndex = next;
// If we've come back to the starting player, everyone has skipped.
if (state.CurrentPlayerIndex == state.StartingPlayerIndex)
{
state.Phase = GamePhase.FlipPhase;
world.RemoveSingleton<PendingDuelist>(); // Clean up in case duelist was never triggered.
}
world.MarkModified<GameState>(World.SingletonEntity);
}
}
+59
View File
@@ -0,0 +1,59 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Calculates each player's battle power, awards the castle to the winner,
/// and checks for game end.
/// </summary>
public class ScoringSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Scoring) return;
ref var mutable = ref world.GetSingleton<GameState>();
var players = GameUtil.FindAllEntities<Player>(world);
if (players.Count == 0) return;
int bestPower = int.MinValue;
Entity winner = Entity.Null;
int winnerIndex = -1;
for (int i = 0; i < players.Count; i++)
{
int idx = (state.StartingPlayerIndex + i) % players.Count;
var player = players[idx];
int power = GameUtil.CalculatePower(world, player);
if (power > bestPower)
{
bestPower = power;
winner = player;
winnerIndex = idx;
}
}
if (winner != Entity.Null)
{
var castles = GameUtil.FindAllEntities<Castle>(world);
if (castles.Count > 0)
{
var castle = castles[0];
world.AddComponent(castle, new HeldBy { Target = winner });
world.RemoveComponent<Castle>(castle);
}
var colors = new[] { CastleColor.Black, CastleColor.White, CastleColor.Blue,
CastleColor.Brown, CastleColor.Yellow, CastleColor.Indigo, CastleColor.Gold };
int colorIdx = Mulberry32.NextInt(ref mutable.Seed, 0, colors.Length - 1);
var newCastle = world.CreateEntity();
world.AddComponent(newCastle, new Castle { Color = colors[colorIdx] });
}
mutable.Phase = GamePhase.Cleanup;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
+107
View File
@@ -0,0 +1,107 @@
# 魔烬纷争
## 游戏概述
魔烬纷争是一款快节奏多人桌面卡牌战术游戏。
玩家扮演一个日式异世界中的领袖,需要带领自己的势力击败其他玩家,建立王国。
支持人数2-5人,每局游戏约10分钟/人。
## 游戏配件
- 公共牌:60张
- 旗帜牌:6张
- 标记若干
- 骷髅标记:15个
- 号角标记:15个
- 城堡标记:33个
- 黑、白、蓝、棕、黄、靛色城堡各5座,正面标记 2 个皇冠。
- 金城堡3座,正面标记 3 个皇冠。
- 背面朝上的城堡当做单个皇冠标记使用。
## 游戏布置
公共布置:
- 将公共牌洗混,放在桌面中央,形成抽牌堆。
- 将城堡标记洗混,随机抽取1张正面朝上与1张背面朝上,放在桌面中央。
-【5+人游戏】:额外抽取1张正面朝上放在桌面中央。
每名玩家布置:
- 选择一张旗帜牌,放在自己面前靠左的位置。
- 抓5张公共牌作为起始手牌。
- 获得1个王冠标记。
以阅读规则的玩家为起始玩家开始游戏。
## 游戏目标
有玩家获得7个王冠时,游戏结束。分数最高的玩家赢得游戏胜利。
## 游戏流程
游戏按轮进行,每轮进行以下流程:
- **出牌阶段**:玩家轮流选择卡牌打出或盖放。
- **翻牌阶段**:玩家轮流选择翻开一张自己的盖放牌。
- **结算阶段**:检查所有玩家的得分情况,决定胜者。
- **清理阶段**:清理战场,准备下一轮游戏。
### 出牌阶段
从起始玩家开始,每名玩家轮流进行出牌。
出牌时,可从手牌打出一张牌,或者选择跳过。
若玩家选择跳过,则会结束该玩家的本次出牌阶段。
出牌时,按照卡牌描述的方式打出牌:
- 将打出的卡牌放到目标玩家面前,放在其他打出卡牌的右侧。
- 卡牌默认打出给自己,除非特殊说明。
- 如果卡牌标记了盖放,则这张牌必须盖放打出,否则必须正面打出。
### 翻牌阶段
从起始玩家开始,每名玩家轮流进行翻牌。
在玩家面前有盖放牌时,玩家必须选择其中一张翻开。
否则,结束该玩家的本次翻牌阶段。
### 结算阶段
将牌面点数和领袖牌点数全部加总,作为玩家本轮的总战力。
按总战力从高到低的顺序,从桌面中央获得正面或背面朝上的城堡标记,直到拿空。
若有玩家打平,则行动顺序更靠前的玩家赢得胜利。
每当有玩家赢得正面朝上的城堡标记,若其他玩家拥有同色城堡,则他们必须为其每个同色城堡选择:
- 支付1个王冠标记给胜者;
- 从胜者处获得1个王冠标记;将城堡交给胜者。
由本轮胜者决定其他玩家做选择的顺序。
最后,按设置的方式补充中央的城堡标记,然后开始下一轮游戏。
### 清理阶段
将本回合打出的所有公共牌放到弃牌堆里。
如果旗帜或其他牌上有骷髅或号角标记,将这些标记放回供应堆。
然后,每名玩家抓3张公共牌开始下一轮。
## 抓牌
当玩家抓牌时,如果抓牌堆为空,则将对应的弃牌堆洗回牌堆再继续。
## 号角与骷髅
- 号角可以放在公共牌上,也可以放在旗帜上。
- 放在旗帜上时,为自己每张公共牌提供+1战力。
- 放在公共牌上时,为这张牌增加1倍战力。
- 骷髅与号角类似,但是扣减1倍战力而非增加。
- 骷髅与号角相互抵消,同类效果可叠加。战力可能会变为负数。
+29
View File
@@ -0,0 +1,29 @@
# 魔烬纷争 - 势力扩展
## 游戏概述
势力扩展为魔烬纷争增加了不同的专属势力卡牌。
## 游戏配件
势力配件:10 组
- 领袖牌:每个势力1张
- 势力牌:每个势力15张
- 金币标记(商人势力):6个
## 游戏布置
对玩家布置进行以下调整:
- 选择一套势力配件获得。
- 将势力牌洗混放在旗帜牌下方。左侧留出空间作为势力牌弃牌堆。
- 将领袖牌放在旗帜牌左侧。
- 额外抓2张势力牌,然后选择两张牌弃掉。
领袖牌规则:
- 效果总是生效,战力计入总点数。可以获得号角或骷髅。
- 不计入旗帜的卡牌数目,战斗结束时只清理号角/骷髅标记不清理牌。
势力牌规则:
- 每轮清理阶段抓牌时,改为抓 2 张公共牌和 1 张势力牌。
- 因卡牌效果抓牌时,可以选择抓公共牌或者势力牌。
- 势力牌弃掉时,放入独立的势力牌弃牌堆。势力牌堆为空而选择抓势力牌时,将其洗回势力牌堆。
@@ -19,6 +19,7 @@
<ItemGroup>
<ProjectReference Include="..\Game.TicTacToe\TicTacToe.csproj" />
<ProjectReference Include="..\OECS.PlayTest\OECS.PlayTest.csproj" />
</ItemGroup>
</Project>
+28 -84
View File
@@ -10,11 +10,11 @@ public class GameFlowTests
[Fact]
public void NewGame_HasEmptyBoard()
{
var (world, _) = SetupGame();
var (world, _) = TestHelpers.SetupGame();
var query = world.Query().With<Cell>().Without<Mark>().Build();
var emptyCount = 0;
world.ForEach(query, (Entity e, ref Cell cell) => emptyCount++);
foreach (var _ in world.Select(new Query<Cell>().Without<Mark>()))
emptyCount++;
emptyCount.Should().Be(9);
}
@@ -22,20 +22,19 @@ public class GameFlowTests
[Fact]
public void PlaceMark_ClaimsCell()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 });
group.RunLogical();
var query = world.Query().With<Cell>().With<Mark>().Build();
var markedCount = 0;
world.ForEach(query, (Entity e, ref Cell cell, ref Mark mark) =>
foreach (var it in world.Select<Cell, Mark>())
{
markedCount++;
cell.Row.Should().Be(0);
cell.Col.Should().Be(0);
mark.Player.Should().Be(Player.X);
});
it.Val1.Row.Should().Be(0);
it.Val1.Col.Should().Be(0);
it.Val2.Player.Should().Be(Player.X);
}
markedCount.Should().Be(1);
}
@@ -43,7 +42,7 @@ public class GameFlowTests
[Fact]
public void PlaceMark_TogglesPlayer()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 }); // X
group.RunLogical();
@@ -57,20 +56,16 @@ public class GameFlowTests
[Fact]
public void PlaceMark_CannotOverwriteClaimedCell()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 }); // X
group.RunLogical();
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 }); // O tries same cell
group.RunLogical();
// Only one mark should exist at (0,0), and it should still be X.
var query = world.Query().With<Cell>().With<Mark>().Build();
var marks = new List<(int Row, int Col, Player Player)>();
world.ForEach(query, (Entity e, ref Cell cell, ref Mark mark) =>
{
marks.Add((cell.Row, cell.Col, mark.Player));
});
foreach (var it in world.Select<Cell, Mark>())
marks.Add((it.Val1.Row, it.Val1.Col, it.Val2.Player));
marks.Should().ContainSingle()
.Which.Should().Be((0, 0, Player.X));
@@ -79,10 +74,9 @@ public class GameFlowTests
[Fact]
public void XWins_Row()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
// X: (0,0), O: (1,0), X: (0,1), O: (1,1), X: (0,2) → X wins row 0
PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.XWon);
}
@@ -90,10 +84,9 @@ public class GameFlowTests
[Fact]
public void OWins_Column()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
// X: (0,0), O: (1,0), X: (0,1), O: (1,1), X: (2,2), O: (1,2) → O wins col 0
PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (2, 2), (1, 2));
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (2, 2), (1, 2));
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.OWon);
}
@@ -101,10 +94,9 @@ public class GameFlowTests
[Fact]
public void XWins_Diagonal()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
// X: (0,0), O: (1,0), X: (1,1), O: (1,2), X: (2,2) → X wins diagonal
PlayMoves(world, group, (0, 0), (1, 0), (1, 1), (1, 2), (2, 2));
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (1, 1), (1, 2), (2, 2));
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.XWon);
}
@@ -112,12 +104,9 @@ public class GameFlowTests
[Fact]
public void Draw_AllCellsFilled()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
// Fill all 9 cells without a winner.
// X: (0,0) (0,2) (1,0) (2,1) (2,2)
// O: (0,1) (1,1) (1,2) (2,0)
PlayMoves(world, group,
TestHelpers.PlayMoves(world, group,
(0, 0), (0, 1), (0, 2),
(1, 1), (1, 0), (1, 2),
(2, 1), (2, 0), (2, 2));
@@ -128,14 +117,12 @@ public class GameFlowTests
[Fact]
public void MovesAfterGameOver_AreIgnored()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
// X wins.
PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.XWon);
// Try to place another mark — should be ignored.
var moveCountBefore = world.ReadSingleton<GameState>().MoveCount;
world.Commands.Enqueue(new PlaceMarkCommand { Row = 2, Col = 2 });
group.RunLogical();
@@ -146,68 +133,25 @@ public class GameFlowTests
[Fact]
public void SaveLoad_RoundTrips()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
// Play a few moves.
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 });
group.RunLogical();
world.Commands.Enqueue(new PlaceMarkCommand { Row = 1, Col = 0 });
group.RunLogical();
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0));
// Save.
using var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
// Load into a new world.
var world2 = new World();
WorldSerializer.Load(world2, stream);
// Verify state.
var state = world2.ReadSingleton<GameState>();
state.CurrentPlayer.Should().Be(Player.X);
state.MoveCount.Should().Be(2);
var query = world2.Query().With<Cell>().With<Mark>().Build();
var marks = new List<(int Row, int Col, Player Player)>();
world2.ForEach(query, (Entity e, ref Cell cell, ref Mark mark) =>
{
marks.Add((cell.Row, cell.Col, mark.Player));
});
foreach (var it in world2.Select<Cell, Mark>())
marks.Add((it.Val1.Row, it.Val1.Col, it.Val2.Player));
marks.Should().BeEquivalentTo([(0, 0, Player.X), (1, 0, Player.O)]);
}
// ── Helpers ───────────────────────────────────────────────────────
private static (World, SystemGroup) SetupGame()
{
var world = new World();
var group = new SystemGroup(world);
group.Add(new WinCheckSystem());
// Create the 9 cells.
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++)
world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c });
world.SetSingleton(new GameState
{
CurrentPlayer = Player.X,
Status = GameStatus.Playing,
MoveCount = 0
});
world.PostChanges();
return (world, group);
}
private static void PlayMoves(World world, SystemGroup group, params (int Row, int Col)[] moves)
{
foreach (var (row, col) in moves)
{
world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col });
group.RunLogical();
}
}
}
}
+55 -197
View File
@@ -1,7 +1,7 @@
using FluentAssertions;
using Game.TicTacToe;
using OECS;
using R3;
using OECS.PlayTest;
using Xunit;
namespace Game.TicTacToe.Tests;
@@ -11,133 +11,53 @@ public class PlayTests
[Fact]
public void Play_GreedyVsRandom()
{
var log = new PlayLog();
var (world, group) = TestHelpers.SetupGame();
var agentX = new GreedyTicTacToeAgent(GreedyTicTacToeAgent.Strategy.WinOrBlock);
var agentO = new RandomTicTacToeAgent();
var (world, group) = SetupGame();
var agentX = new GreedyAgent(GreedyAgent.Strategy.WinOrBlock);
var agentO = new RandomAgent();
var log = RunGame(world, group, agentX, agentO, "TicTacToe: Greedy X vs Random O");
// Subscribe reactivity.
world.ObserveComponentChanges<Mark>().Subscribe(change =>
{
var mark = world.ReadComponent<Mark>(change.Entity);
log.Reactivity.Add($"{change.Kind} Mark = {mark} on {change.Entity}");
});
log.Header = "TicTacToe: Greedy X vs Random O";
RunGame(world, group, agentX, agentO, log);
log.FinalSnapshot = SnapshotWorld(world);
var path = SavePlayLog("tic_tac_toe_greedy_vs_random.playlog", log);
var path = log.SaveTo("tic_tac_toe_greedy_vs_random.playlog");
ReadAndVerify(path);
}
[Fact]
public void Play_RandomVsRandom()
{
var log = new PlayLog();
var (world, group) = TestHelpers.SetupGame();
var agentX = new RandomTicTacToeAgent();
var agentO = new RandomTicTacToeAgent();
var (world, group) = SetupGame();
var agentX = new RandomAgent();
var agentO = new RandomAgent();
var log = RunGame(world, group, agentX, agentO, "TicTacToe: Random X vs Random O");
world.ObserveComponentChanges<Mark>().Subscribe(change =>
{
var mark = world.ReadComponent<Mark>(change.Entity);
log.Reactivity.Add($"{change.Kind} Mark = {mark} on {change.Entity}");
});
log.Header = "TicTacToe: Random X vs Random O";
RunGame(world, group, agentX, agentO, log);
log.FinalSnapshot = SnapshotWorld(world);
var path = SavePlayLog("tic_tac_toe_random_vs_random.playlog", log);
var path = log.SaveTo("tic_tac_toe_random_vs_random.playlog");
ReadAndVerify(path);
}
[Fact]
public void Play_GreedyVsGreedy()
{
var log = new PlayLog();
var (world, group) = TestHelpers.SetupGame();
var agentX = new GreedyTicTacToeAgent(GreedyTicTacToeAgent.Strategy.WinOrBlock);
var agentO = new GreedyTicTacToeAgent(GreedyTicTacToeAgent.Strategy.WinOrBlock);
var (world, group) = SetupGame();
var agentX = new GreedyAgent(GreedyAgent.Strategy.WinOrBlock);
var agentO = new GreedyAgent(GreedyAgent.Strategy.WinOrBlock);
var log = RunGame(world, group, agentX, agentO, "TicTacToe: Greedy X vs Greedy O");
world.ObserveComponentChanges<Mark>().Subscribe(change =>
{
var mark = world.ReadComponent<Mark>(change.Entity);
log.Reactivity.Add($"{change.Kind} Mark = {mark} on {change.Entity}");
});
log.Header = "TicTacToe: Greedy X vs Greedy O";
RunGame(world, group, agentX, agentO, log);
log.FinalSnapshot = SnapshotWorld(world);
var path = SavePlayLog("tic_tac_toe_greedy_vs_greedy.playlog", log);
var path = log.SaveTo("tic_tac_toe_greedy_vs_greedy.playlog");
ReadAndVerify(path);
}
// ── Play Log ──────────────────────────────────────────────────────
private sealed class PlayLog
{
public string Header { get; set; } = "";
public readonly List<string> Moves = new();
public readonly List<string> Reactivity = new();
public string FinalSnapshot { get; set; } = "";
public string Build()
{
var sb = new System.Text.StringBuilder();
sb.AppendLine(Header);
sb.AppendLine(new string('=', Header.Length));
sb.AppendLine();
sb.AppendLine("--- Moves ---");
for (int i = 0; i < Moves.Count; i++)
sb.AppendLine($" {i + 1}. {Moves[i]}");
sb.AppendLine();
sb.AppendLine("--- Reactivity ---");
foreach (var entry in Reactivity)
sb.AppendLine($" {entry}");
sb.AppendLine();
sb.AppendLine("--- Final Board ---");
sb.AppendLine(FinalSnapshot);
return sb.ToString();
}
}
// ── Game Runner ───────────────────────────────────────────────────
private static (World, SystemGroup) SetupGame()
private static PlayLog RunGame(World world, SystemGroup group,
IAgent<PlaceMarkCommand> agentX, IAgent<PlaceMarkCommand> agentO, string header)
{
var world = new World();
var group = new SystemGroup(world);
group.Add(new WinCheckSystem());
var log = new PlayLog { Header = header };
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++)
world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c });
using var capture = new ObservableCapture(world);
capture.FormatWith<Mark>(m => m.Player.ToString());
world.SetSingleton(new GameState
{
CurrentPlayer = Player.X,
Status = GameStatus.Playing,
MoveCount = 0
});
world.PostChanges();
return (world, group);
}
private static void RunGame(World world, SystemGroup group, IAgent agentX, IAgent agentO, PlayLog log)
{
var moves = new List<string>();
while (world.ReadSingleton<GameState>().Status == GameStatus.Playing)
{
var state = world.ReadSingleton<GameState>();
@@ -147,138 +67,76 @@ public class PlayTests
world.Commands.Enqueue(cmd);
group.RunLogical();
log.Moves.Add($"{state.CurrentPlayer} → ({cmd.Row},{cmd.Col})");
moves.Add($"{state.CurrentPlayer} → ({cmd.Row},{cmd.Col})");
}
var final = world.ReadSingleton<GameState>();
log.Header += $" — Result: {final.Status}";
}
// ── Snapshot ──────────────────────────────────────────────────────
log.AddSection("Moves", moves.Select((m, i) => $"{i + 1}. {m}"));
log.AddSection("Reactivity", capture.GetLogLines());
log.FinalSnapshot = TestHelpers.SnapshotWorld(world);
private static string SnapshotWorld(World world)
{
var sb = new System.Text.StringBuilder();
var grid = new char?[3, 3];
using (var iter = world.Select<Cell, Mark>())
{
while (iter.MoveNext())
grid[iter.Current1.Row, iter.Current1.Col] =
iter.Current2.Player == Player.X ? 'X' : 'O';
}
for (int r = 0; r < 3; r++)
{
sb.Append(" ");
for (int c = 0; c < 3; c++)
sb.Append(grid[r, c]?.ToString() ?? ".");
sb.AppendLine();
}
return sb.ToString().TrimEnd();
return log;
}
// ── File I/O ──────────────────────────────────────────────────────
private static string SavePlayLog(string filename, PlayLog log)
{
var dir = Path.Combine(AppContext.BaseDirectory, "playlogs");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, filename);
File.WriteAllText(path, log.Build());
return path;
}
private static void ReadAndVerify(string path)
{
var content = File.ReadAllText(path);
// Verify the play log is well-formed.
content.Should().Contain("--- Moves ---");
content.Should().Contain("--- Reactivity ---");
content.Should().Contain("--- Final Board ---");
// The game must have finished.
content.Should().Contain("--- Final State ---");
content.Should().Contain("Result:");
content.Should().NotContain("Result: Playing");
}
// ── Agents ────────────────────────────────────────────────────────
private interface IAgent
private sealed class RandomTicTacToeAgent : GreedyAgent<PlaceMarkCommand>
{
PlaceMarkCommand Decide(World world);
}
private sealed class RandomAgent : IAgent
{
private static readonly Random _rng = new();
public PlaceMarkCommand Decide(World world)
protected override List<PlaceMarkCommand> GetLegalActions(World world)
{
var empty = GetEmptyCells(world);
var (row, col) = empty[_rng.Next(empty.Count)];
return new PlaceMarkCommand { Row = row, Col = col };
return TestHelpers.GetEmptyCells(world)
.Select(c => new PlaceMarkCommand { Row = c.Row, Col = c.Col })
.ToList();
}
}
private sealed class GreedyAgent : IAgent
private sealed class GreedyTicTacToeAgent : GreedyAgent<PlaceMarkCommand>
{
public enum Strategy { WinOrBlock }
private readonly Strategy _strategy;
private static readonly Random _rng = new();
public GreedyAgent(Strategy strategy) => _strategy = strategy;
public PlaceMarkCommand Decide(World world)
public GreedyTicTacToeAgent(Strategy strategy) => _strategy = strategy;
protected override List<PlaceMarkCommand> GetLegalActions(World world)
{
return TestHelpers.GetEmptyCells(world)
.Select(c => new PlaceMarkCommand { Row = c.Row, Col = c.Col })
.ToList();
}
protected override float ScoreAction(World world, PlaceMarkCommand action)
{
if (_strategy != Strategy.WinOrBlock)
return 0f;
var state = world.ReadSingleton<GameState>();
var me = state.CurrentPlayer;
var empty = GetEmptyCells(world);
foreach (var (r, c) in empty)
if (WouldWin(world, r, c, me))
return new PlaceMarkCommand { Row = r, Col = c };
var opponent = me == Player.X ? Player.O : Player.X;
foreach (var (r, c) in empty)
if (WouldWin(world, r, c, opponent))
return new PlaceMarkCommand { Row = r, Col = c };
if (empty.Any(c => c.Row == 1 && c.Col == 1))
return new PlaceMarkCommand { Row = 1, Col = 1 };
if (TestHelpers.WouldWin(world, action.Row, action.Col, me))
return 100f;
if (TestHelpers.WouldWin(world, action.Row, action.Col, opponent))
return 50f;
if (action.Row == 1 && action.Col == 1)
return 10f;
var (rr, cc) = empty[_rng.Next(empty.Count)];
return new PlaceMarkCommand { Row = rr, Col = cc };
return 0f;
}
}
private static List<(int Row, int Col)> GetEmptyCells(World world)
{
var empty = new List<(int, int)>();
var query = world.Query().With<Cell>().Without<Mark>().Build();
using var iter = world.Select<Cell>(query);
while (iter.MoveNext())
empty.Add((iter.Current1.Row, iter.Current1.Col));
return empty;
}
private static bool WouldWin(World world, int row, int col, Player player)
{
var grid = new Player[3, 3];
using (var iter = world.Select<Cell, Mark>())
while (iter.MoveNext())
grid[iter.Current1.Row, iter.Current1.Col] = iter.Current2.Player;
grid[row, col] = player;
for (int r = 0; r < 3; r++)
if (grid[r, 0] == player && grid[r, 1] == player && grid[r, 2] == player)
return true;
for (int c = 0; c < 3; c++)
if (grid[0, c] == player && grid[1, c] == player && grid[2, c] == player)
return true;
if (grid[0, 0] == player && grid[1, 1] == player && grid[2, 2] == player)
return true;
if (grid[0, 2] == player && grid[1, 1] == player && grid[2, 0] == player)
return true;
return false;
}
}
}
+24 -131
View File
@@ -1,7 +1,7 @@
using FluentAssertions;
using Game.TicTacToe;
using OECS;
using R3;
using OECS.PlayTest;
using Xunit;
using Xunit.Abstractions;
@@ -36,10 +36,9 @@ public class SnapshotTests
MoveCount = 0
});
var snapshot = SnapshotWorld(world);
var snapshot = TestHelpers.SnapshotWorld(world);
_output.WriteLine(snapshot);
// Verify baseline properties.
snapshot.Should().Contain("GameState: X's turn, 0 moves");
snapshot.Should().Contain("Cells: 9 total, 0 marked");
}
@@ -47,12 +46,11 @@ public class SnapshotTests
[Fact]
public void Snapshot_AfterThreeMoves()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
// X: (0,0), O: (1,1), X: (2,2)
PlayMoves(world, group, (0, 0), (1, 1), (2, 2));
TestHelpers.PlayMoves(world, group, (0, 0), (1, 1), (2, 2));
var snapshot = SnapshotWorld(world);
var snapshot = TestHelpers.SnapshotWorld(world);
_output.WriteLine(snapshot);
snapshot.Should().Contain("GameState: O's turn, 3 moves");
@@ -65,12 +63,11 @@ public class SnapshotTests
[Fact]
public void Snapshot_XWins()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
// X: (0,0), O: (1,0), X: (0,1), O: (1,1), X: (0,2) → X wins row 0
PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
var snapshot = SnapshotWorld(world);
var snapshot = TestHelpers.SnapshotWorld(world);
_output.WriteLine(snapshot);
snapshot.Should().Contain("GameState: XWon");
@@ -80,14 +77,14 @@ public class SnapshotTests
[Fact]
public void Snapshot_Draw()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
PlayMoves(world, group,
TestHelpers.PlayMoves(world, group,
(0, 0), (0, 1), (0, 2),
(1, 1), (1, 0), (1, 2),
(2, 1), (2, 0), (2, 2));
var snapshot = SnapshotWorld(world);
var snapshot = TestHelpers.SnapshotWorld(world);
_output.WriteLine(snapshot);
snapshot.Should().Contain("GameState: Draw");
@@ -97,46 +94,29 @@ public class SnapshotTests
[Fact]
public void Log_ReactivityDuringGame()
{
var (world, group) = SetupGame();
var log = new List<string>();
var (world, group) = TestHelpers.SetupGame();
// Subscribe to all entity-level changes.
world.ObserveEntityChanges().Subscribe(change =>
{
log.Add($"[entity] {change}");
});
using var capture = new ObservableCapture(world);
capture.FormatWith<Mark>(m => m.Player.ToString());
capture.FormatWith<GameState>(s => $"{s.Status} ({s.CurrentPlayer}, {s.MoveCount} moves)");
// Subscribe to component-specific changes.
world.ObserveComponentChanges<Mark>().Subscribe(change =>
{
log.Add($"[mark] {change}");
});
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
world.ObserveComponentChanges<GameState>().Subscribe(change =>
{
log.Add($"[gamestate] {change}");
});
// Play a full game: X wins on row 0.
PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
var logText = string.Join("\n", log);
var logText = string.Join("\n", capture.GetLogLines());
_output.WriteLine(logText);
// Verify key events appear in the log.
log.Should().Contain(l => l.Contains("ComponentAdded") && l.Contains("Mark"));
log.Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState"));
capture.GetLogLines().Should().Contain(l => l.Contains("ComponentAdded") && l.Contains("Mark"));
capture.GetLogLines().Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState"));
}
[Fact]
public void Serialization_RoundTrip_PreservesSnapshot()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
// Play a few moves.
PlayMoves(world, group, (0, 0), (1, 1), (2, 2));
TestHelpers.PlayMoves(world, group, (0, 0), (1, 1), (2, 2));
var before = SnapshotWorld(world);
var before = TestHelpers.SnapshotWorld(world);
using var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
@@ -145,7 +125,7 @@ public class SnapshotTests
var world2 = new World();
WorldSerializer.Load(world2, stream);
var after = SnapshotWorld(world2);
var after = TestHelpers.SnapshotWorld(world2);
_output.WriteLine("=== Before ===");
_output.WriteLine(before);
@@ -154,91 +134,4 @@ public class SnapshotTests
after.Should().Be(before);
}
// ── Helpers ───────────────────────────────────────────────────────
private static (World, SystemGroup) SetupGame()
{
var world = new World();
var group = new SystemGroup(world);
group.Add(new WinCheckSystem());
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++)
world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c });
world.SetSingleton(new GameState
{
CurrentPlayer = Player.X,
Status = GameStatus.Playing,
MoveCount = 0
});
world.PostChanges();
return (world, group);
}
private static void PlayMoves(World world, SystemGroup group, params (int Row, int Col)[] moves)
{
foreach (var (row, col) in moves)
{
world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col });
group.RunLogical();
}
}
/// <summary>
/// Produces a human-readable textual snapshot of the world state.
/// </summary>
private static string SnapshotWorld(World world)
{
var sb = new System.Text.StringBuilder();
// Singleton: GameState.
var state = world.ReadSingleton<GameState>();
var statusText = state.Status switch
{
GameStatus.Playing => $"{(state.CurrentPlayer == Player.X ? 'X' : 'O')}'s turn, {state.MoveCount} moves",
GameStatus.XWon => "XWon",
GameStatus.OWon => "OWon",
GameStatus.Draw => "Draw",
_ => "Unknown"
};
sb.AppendLine($"GameState: {statusText}");
// Board: build a 3x3 grid.
var grid = new char?[3, 3];
using (var iter = world.Select<Cell, Mark>())
{
while (iter.MoveNext())
{
grid[iter.Current1.Row, iter.Current1.Col] =
iter.Current2.Player == Player.X ? 'X' : 'O';
}
}
int totalCells = 0;
int markedCells = 0;
for (int r = 0; r < 3; r++)
{
for (int c = 0; c < 3; c++)
{
totalCells++;
var mark = grid[r, c];
if (mark.HasValue)
{
markedCells++;
sb.AppendLine($" ({r},{c}): {mark.Value}");
}
else
{
sb.AppendLine($" ({r},{c}): .");
}
}
}
sb.AppendLine($"Cells: {totalCells} total, {markedCells} marked");
return sb.ToString().TrimEnd();
}
}
}
+126
View File
@@ -0,0 +1,126 @@
using Game.TicTacToe;
using OECS;
using System.Text;
namespace Game.TicTacToe.Tests;
internal static class TestHelpers
{
/// <summary>
/// Creates a fresh TicTacToe world with 9 empty cells, GameState singleton, and WinCheckSystem.
/// </summary>
public static (World World, SystemGroup Group) SetupGame()
{
var world = new World();
var group = new SystemGroup(world);
group.Add(new WinCheckSystem());
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++)
world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c });
world.SetSingleton(new GameState
{
CurrentPlayer = Player.X,
Status = GameStatus.Playing,
MoveCount = 0
});
world.PostChanges();
return (world, group);
}
/// <summary>
/// Enqueues and runs a sequence of moves.
/// </summary>
public static void PlayMoves(World world, SystemGroup group, params (int Row, int Col)[] moves)
{
foreach (var (row, col) in moves)
{
world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col });
group.RunLogical();
}
}
/// <summary>
/// Returns a human-readable textual snapshot of the world state.
/// </summary>
public static string SnapshotWorld(World world)
{
var sb = new StringBuilder();
var state = world.ReadSingleton<GameState>();
var statusText = state.Status switch
{
GameStatus.Playing => $"{(state.CurrentPlayer == Player.X ? 'X' : 'O')}'s turn, {state.MoveCount} moves",
GameStatus.XWon => "XWon",
GameStatus.OWon => "OWon",
GameStatus.Draw => "Draw",
_ => "Unknown"
};
sb.AppendLine($"GameState: {statusText}");
var grid = new char?[3, 3];
foreach (var it in world.Select<Cell, Mark>())
grid[it.Val1.Row, it.Val1.Col] =
it.Val2.Player == Player.X ? 'X' : 'O';
int totalCells = 0;
int markedCells = 0;
for (int r = 0; r < 3; r++)
{
for (int c = 0; c < 3; c++)
{
totalCells++;
var mark = grid[r, c];
if (mark.HasValue)
{
markedCells++;
sb.AppendLine($" ({r},{c}): {mark.Value}");
}
else
{
sb.AppendLine($" ({r},{c}): .");
}
}
}
sb.AppendLine($"Cells: {totalCells} total, {markedCells} marked");
return sb.ToString().TrimEnd();
}
/// <summary>
/// Returns the (Row, Col) of all empty cells.
/// </summary>
public static List<(int Row, int Col)> GetEmptyCells(World world)
{
var empty = new List<(int, int)>();
foreach (var it in world.Select(new Query<Cell>().Without<Mark>()))
empty.Add((it.Val1.Row, it.Val1.Col));
return empty;
}
/// <summary>
/// Checks whether placing a mark at (row, col) for the given player would win the game.
/// </summary>
public static bool WouldWin(World world, int row, int col, Player player)
{
var grid = new Player[3, 3];
foreach (var it in world.Select<Cell, Mark>())
grid[it.Val1.Row, it.Val1.Col] = it.Val2.Player;
grid[row, col] = player;
for (int r = 0; r < 3; r++)
if (grid[r, 0] == player && grid[r, 1] == player && grid[r, 2] == player)
return true;
for (int c = 0; c < 3; c++)
if (grid[0, c] == player && grid[1, c] == player && grid[2, c] == player)
return true;
if (grid[0, 0] == player && grid[1, 1] == player && grid[2, 2] == player)
return true;
if (grid[0, 2] == player && grid[1, 1] == player && grid[2, 0] == player)
return true;
return false;
}
}
+7 -18
View File
@@ -15,36 +15,25 @@ public struct PlaceMarkCommand : ICommand
public void Execute(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Status != GameStatus.Playing)
if (world.ReadSingleton<GameState>().Status != GameStatus.Playing)
return;
// Find the cell entity at (Row, Col) that has no Mark.
// Use the iterator API with early-exit via break.
var query = world.Query().With<Cell>().Without<Mark>().Build();
Entity? target = null;
using var iter = world.Select<Cell>(query);
while (iter.MoveNext())
{
if (iter.Current1.Row == Row && iter.Current1.Col == Col)
{
target = iter.CurrentEntity;
break;
}
ref var state = ref world.GetSingleton<GameState>();
foreach(var iter in world.Select(new Query<Cell>().Without<Mark>())){
if (iter.Val1.Row != Row || iter.Val1.Col != Col) continue;
target = iter.Entity;
break;
}
if (target == null)
return; // Cell already occupied or invalid position.
return;
// Place the mark. ComponentAdded is sufficient — MarkModified
// is only needed when mutating an existing component via GetComponent<T>.
world.AddComponent(target.Value, new Mark { Player = state.CurrentPlayer });
// Advance turn.
state.MoveCount++;
state.CurrentPlayer = state.CurrentPlayer == Player.X ? Player.O : Player.X;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
+10 -18
View File
@@ -8,22 +8,18 @@ namespace Game.TicTacToe;
/// </summary>
public class WinCheckSystem : ISystem
{
public void Run(World world)
public void RunImpl(World world)
{
// Check game status before doing any work. Use ReadSingleton
// to avoid auto-marking GameState as modified when we bail early.
if (world.ReadSingleton<GameState>().Status != GameStatus.Playing)
return;
ref var state = ref world.GetSingleton<GameState>();
// Build a 3×3 grid of marks using the iterator API.
// Build a 3×3 grid of marks.
var grid = new Player[3, 3];
using var iter = world.Select<Cell, Mark>();
while (iter.MoveNext())
foreach (var iter in world.Select<Cell, Mark>())
{
grid[iter.Current1.Row, iter.Current1.Col] = iter.Current2.Player;
grid[iter.Val1.Row, iter.Val1.Col] = iter.Val2.Player;
}
// Check rows.
@@ -31,7 +27,7 @@ public class WinCheckSystem : ISystem
{
if (TryGetWinner(grid[r, 0], grid[r, 1], grid[r, 2], out var winner))
{
SetWinner(world, ref state, winner);
SetWinner(ref state, winner);
return;
}
}
@@ -41,7 +37,7 @@ public class WinCheckSystem : ISystem
{
if (TryGetWinner(grid[0, c], grid[1, c], grid[2, c], out var winner))
{
SetWinner(world, ref state, winner);
SetWinner(ref state, winner);
return;
}
}
@@ -49,21 +45,18 @@ public class WinCheckSystem : ISystem
// Check diagonals.
if (TryGetWinner(grid[0, 0], grid[1, 1], grid[2, 2], out var diag1))
{
SetWinner(world, ref state, diag1);
SetWinner(ref state, diag1);
return;
}
if (TryGetWinner(grid[0, 2], grid[1, 1], grid[2, 0], out var diag2))
{
SetWinner(world, ref state, diag2);
SetWinner(ref state, diag2);
return;
}
// Check draw.
if (state.MoveCount >= 9)
{
state.Status = GameStatus.Draw;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
private static bool TryGetWinner(Player a, Player b, Player c, out Player winner)
@@ -77,9 +70,8 @@ public class WinCheckSystem : ISystem
return false;
}
private static void SetWinner(World world, ref GameState state, Player winner)
private static void SetWinner(ref GameState state, Player winner)
{
state.Status = winner == Player.X ? GameStatus.XWon : GameStatus.OWon;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
}
+54
View File
@@ -0,0 +1,54 @@
using OECS;
namespace OECS.PlayTest;
/// <summary>
/// Abstract agent that picks the action with the highest score.
/// When <see cref="ScoreAction"/> returns 0 for all actions (the default),
/// the agent behaves as a uniform random agent over legal actions.
/// Ties are broken randomly.
/// </summary>
public abstract class GreedyAgent<TDecision> : IAgent<TDecision>
{
private static readonly Random _rng = new();
/// <summary>
/// Returns the list of currently legal actions.
/// </summary>
protected abstract List<TDecision> GetLegalActions(World world);
/// <summary>
/// Scores an action. Defaults to 0 (uniform random).
/// </summary>
protected virtual float ScoreAction(World world, TDecision action) => 0f;
public TDecision Decide(World world)
{
var actions = GetLegalActions(world);
if (actions.Count == 0)
throw new InvalidOperationException(
$"{GetType().Name}: no legal actions available");
if (actions.Count == 1)
return actions[0];
float bestScore = float.MinValue;
var bestActions = new List<TDecision>();
foreach (var action in actions)
{
float score = ScoreAction(world, action);
if (score > bestScore)
{
bestScore = score;
bestActions.Clear();
bestActions.Add(action);
}
else if (score == bestScore)
{
bestActions.Add(action);
}
}
return bestActions[_rng.Next(bestActions.Count)];
}
}
+11
View File
@@ -0,0 +1,11 @@
using OECS;
namespace OECS.PlayTest;
/// <summary>
/// An AI player that inspects the world and returns a decision.
/// </summary>
public interface IAgent<TDecision>
{
TDecision Decide(World world);
}
+33
View File
@@ -0,0 +1,33 @@
namespace OECS.PlayTest;
/// <summary>
/// A pool of agents selected by weight. Higher weight = more likely to be picked.
/// </summary>
public class WeightedAgentPool<TDecision>
{
private readonly List<(IAgent<TDecision> Agent, int Weight)> _agents = new();
private int _totalWeight;
private static readonly Random _rng = new();
public void Add(IAgent<TDecision> agent, int weight)
{
_agents.Add((agent, weight));
_totalWeight += weight;
}
public IAgent<TDecision> Pick()
{
if (_agents.Count == 0)
throw new InvalidOperationException("WeightedAgentPool is empty");
int roll = _rng.Next(_totalWeight);
int cumulative = 0;
foreach (var (agent, weight) in _agents)
{
cumulative += weight;
if (roll < cumulative)
return agent;
}
return _agents[^1].Agent;
}
}
+12
View File
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>OECS.PlayTest</RootNamespace>
<AssemblyName>OECS.PlayTest</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OECS\OECS.csproj" />
</ItemGroup>
</Project>
+95
View File
@@ -0,0 +1,95 @@
using OECS;
using R3;
namespace OECS.PlayTest;
/// <summary>
/// Captures all entity changes from the World's observable API and produces
/// a compact text log on demand. Call <see cref="FormatWith{T}"/> to enrich
/// component entries with human-readable values.
/// </summary>
public sealed class ObservableCapture : IDisposable
{
private readonly World _world;
private readonly IDisposable _subscription;
private readonly List<CapturedEvent> _events = new();
private readonly Dictionary<Type, Func<World, Entity, string>> _formatters = new();
public ObservableCapture(World world)
{
_world = world;
_subscription = world.ObserveEntityChanges().Subscribe(OnChange);
}
/// <summary>
/// Registers a formatter for component type <typeparamref name="T"/>.
/// When a change for this type is captured, the formatted value is
/// included in the log output.
/// </summary>
public void FormatWith<T>(Func<T, string> formatter) where T : struct
{
_formatters[typeof(T)] = (w, e) =>
{
var value = w.ReadComponent<T>(e);
return formatter(value);
};
}
/// <summary>
/// Returns the captured log as a single string with one line per change.
/// </summary>
public string GetLog() => string.Join(Environment.NewLine, GetLogLines());
/// <summary>
/// Returns the captured log as a list of lines, one per change.
/// </summary>
public List<string> GetLogLines()
{
var lines = new List<string>(_events.Count);
foreach (var evt in _events)
{
var change = evt.Change;
var kind = change.Kind.ToString();
if (change.ComponentType != null)
{
var typeName = change.ComponentType.Name;
if (evt.FormattedValue != null)
lines.Add($"{kind} {typeName} = {evt.FormattedValue} on {change.Entity}");
else
lines.Add($"{kind} {typeName} on {change.Entity}");
}
else
{
lines.Add($"{kind} on {change.Entity}");
}
}
return lines;
}
public void Dispose() => _subscription.Dispose();
private void OnChange(EntityChange change)
{
string? formattedValue = null;
if (change.ComponentType != null
&& change.Kind != ChangeKind.ComponentRemoved
&& change.Kind != ChangeKind.EntityRemoved
&& change.Kind != ChangeKind.RelationshipReordered
&& _formatters.TryGetValue(change.ComponentType, out var formatter))
{
formattedValue = formatter(_world, change.Entity);
}
_events.Add(new CapturedEvent(change, formattedValue));
}
private readonly struct CapturedEvent
{
public readonly EntityChange Change;
public readonly string? FormattedValue;
public CapturedEvent(EntityChange change, string? formattedValue)
{
Change = change;
FormattedValue = formattedValue;
}
}
}
+57
View File
@@ -0,0 +1,57 @@
using System.Text;
namespace OECS.PlayTest;
/// <summary>
/// Builds a sectioned play log with header, sections, and final snapshot,
/// then saves it to a .playlog file.
/// </summary>
public class PlayLog
{
public string Header { get; set; } = "";
public string FinalSnapshot { get; set; } = "";
private readonly List<(string Title, List<string> Lines)> _sections = new();
/// <summary>
/// Adds a section with the given title and lines.
/// </summary>
public void AddSection(string title, IEnumerable<string> lines)
{
_sections.Add((title, lines.ToList()));
}
/// <summary>
/// Builds the complete play log text.
/// </summary>
public string Build()
{
var sb = new StringBuilder();
sb.AppendLine(Header);
sb.AppendLine(new string('=', Header.Length));
sb.AppendLine();
foreach (var (title, lines) in _sections)
{
sb.AppendLine($"--- {title} ---");
foreach (var line in lines)
sb.AppendLine($" {line}");
sb.AppendLine();
}
sb.AppendLine("--- Final State ---");
sb.AppendLine(FinalSnapshot);
return sb.ToString();
}
/// <summary>
/// Saves the play log to AppContext.BaseDirectory/playlogs/<paramref name="filename"/>
/// and returns the full path.
/// </summary>
public string SaveTo(string filename)
{
var dir = Path.Combine(AppContext.BaseDirectory, "playlogs");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, filename);
File.WriteAllText(path, Build());
return path;
}
}
+23 -71
View File
@@ -44,11 +44,17 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
"GetSources",
};
private static readonly HashSet<string> _forEachNames = new()
private static readonly HashSet<string> _singletonMethods = new()
{
"ForEach",
"SetSingleton",
"GetSingleton",
"ReadSingleton",
"HasSingleton",
"RemoveSingleton",
};
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Collect all generic method invocations that reference component types.
@@ -57,17 +63,9 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
predicate: IsCandidateInvocation,
transform: ExtractComponentType)
.Where(t => t.FullyQualifiedName != null)
.Select((t, _) => (t.FullyQualifiedName!, t.AssemblyQualifiedName!, t.IsRelationship));
.Select((t, _) => (t.FullyQualifiedName!, t.AssemblyQualifiedName!, t.IsRelationship, t.IsSingleton));
// Also collect ForEach type arguments from the World class.
var forEachCalls = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: IsForEachCandidate,
transform: ExtractForEachTypes)
.Where(list => list.Length > 0)
.SelectMany((list, _) => list);
var allTypes = invocations.Collect().Combine(forEachCalls.Collect());
var allTypes = invocations.Collect();
context.RegisterSourceOutput(allTypes, GenerateRegistry);
}
@@ -88,23 +86,9 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
return false;
}
private static bool IsForEachCandidate(SyntaxNode node, CancellationToken _)
{
if (node is not InvocationExpressionSyntax invocation)
return false;
if (invocation.Expression is MemberAccessExpressionSyntax memberAccess)
{
var name = memberAccess.Name is GenericNameSyntax gn
? gn.Identifier.Text
: memberAccess.Name.Identifier.Text;
return _forEachNames.Contains(name);
}
return false;
}
private static (string? FullyQualifiedName, string? AssemblyQualifiedName, bool IsRelationship) ExtractComponentType(
private static (string? FullyQualifiedName, string? AssemblyQualifiedName, bool IsRelationship, bool IsSingleton) ExtractComponentType(
GeneratorSyntaxContext ctx, CancellationToken _)
{
if (ctx.Node is not InvocationExpressionSyntax invocation)
@@ -140,58 +124,25 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
bool isRelationship = typeSymbol.AllInterfaces.Any(i =>
i.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::OECS.IRelationship");
return (fqn, aqn, isRelationship);
bool isSingleton = _singletonMethods.Contains(genericName.Identifier.Text);
return (fqn, aqn, isRelationship, isSingleton);
}
private static ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> ExtractForEachTypes(
GeneratorSyntaxContext ctx, CancellationToken _)
{
if (ctx.Node is not InvocationExpressionSyntax invocation)
return ImmutableArray<(string, string, bool)>.Empty;
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
return ImmutableArray<(string, string, bool)>.Empty;
if (memberAccess.Name is not GenericNameSyntax genericName)
return ImmutableArray<(string, string, bool)>.Empty;
var types = ImmutableArray.CreateBuilder<(string, string, bool)>();
foreach (var typeArg in genericName.TypeArgumentList.Arguments)
{
var symbolInfo = ctx.SemanticModel.GetSymbolInfo(typeArg);
if (symbolInfo.Symbol is INamedTypeSymbol typeSymbol &&
typeSymbol.IsValueType && !typeSymbol.IsAbstract &&
typeSymbol.DeclaredAccessibility == Accessibility.Public)
{
var fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var aqn = typeSymbol.ContainingAssembly != null
? $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted))}, {typeSymbol.ContainingAssembly.Name}"
: fqn;
bool isRelationship = typeSymbol.AllInterfaces.Any(i =>
i.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::OECS.IRelationship");
types.Add((fqn, aqn, isRelationship));
}
}
return types.ToImmutable();
}
private static void GenerateRegistry(SourceProductionContext context,
(ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> Left,
ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> Right) data)
ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship, bool IsSingleton)> invocations)
{
var (invocations, forEachTypes) = data;
// Collect unique types by fully-qualified name, deduplicating.
var typeMap = new Dictionary<string, (string Fqn, string Aqn, bool IsRel)>();
var typeMap = new Dictionary<string, (string Fqn, string Aqn, bool IsRel, bool IsSingleton)>();
foreach (var t in invocations)
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship);
foreach (var t in forEachTypes)
{
if (!typeMap.ContainsKey(t.FullyQualifiedName))
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship);
else if (t.IsRelationship)
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, true);
if (typeMap.TryGetValue(t.FullyQualifiedName, out var existing))
typeMap[t.FullyQualifiedName] = (existing.Fqn, existing.Aqn, existing.IsRel || t.IsRelationship, existing.IsSingleton || t.IsSingleton);
else
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship, t.IsSingleton);
}
if (typeMap.Count == 0)
@@ -213,7 +164,7 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
sb.AppendLine(" {");
bool first = true;
foreach (var (fqn, aqn, isRel) in typeMap.Values.OrderBy(t => t.Fqn))
foreach (var (fqn, aqn, isRel, isSingleton) in typeMap.Values.OrderBy(t => t.Fqn))
{
if (!first)
sb.AppendLine(",");
@@ -225,7 +176,8 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
sb.AppendLine($" serialize: obj => MessagePackSerializer.Serialize(({fqn})obj),");
sb.AppendLine($" deserializeAndAdd: (world, entity, data) =>");
sb.AppendLine($" world.AddComponent(entity, MessagePackSerializer.Deserialize<{fqn}>(data)),");
sb.AppendLine($" deserialize: data => MessagePackSerializer.Deserialize<{fqn}>(data)");
sb.AppendLine($" deserialize: data => MessagePackSerializer.Deserialize<{fqn}>(data),");
sb.AppendLine($" isSingleton: {(isSingleton ? "true" : "false")}");
sb.Append(" )");
}
+15 -27
View File
@@ -114,13 +114,11 @@ public class CommandTests
world.ExecuteCommands();
var query = world.Query().With<TestPosition>().With<TestHealth>().Build();
var positions = new List<(float X, float Y, int Health)>();
world.ForEach(query, (Entity e, ref TestPosition pos, ref TestHealth hp) =>
foreach (var it in world.Select<TestPosition, TestHealth>())
{
positions.Add((pos.X, pos.Y, hp.Value));
});
positions.Add((it.Val1.X, it.Val1.Y, it.Val2.Value));
}
positions.Should().BeEquivalentTo([
(1, 2, 100),
@@ -151,13 +149,11 @@ public class CommandTests
world.Commands.Enqueue(new ChainedCommand { X = 0, Y = 0 });
world.ExecuteCommands();
var query = world.Query().With<TestPosition>().Build();
var positions = new List<(float X, float Y)>();
world.ForEach(query, (Entity e, ref TestPosition pos) =>
foreach (var it in world.Select<TestPosition>())
{
positions.Add((pos.X, pos.Y));
});
positions.Add((it.Val1.X, it.Val1.Y));
}
positions.Should().BeEquivalentTo([(10, 20)]);
}
@@ -173,9 +169,9 @@ public class CommandTests
world.ExecuteCommands();
var query = world.Query().With<TestPosition>().Build();
var count = 0;
world.ForEach(query, (Entity e, ref TestPosition pos) => count++);
foreach (var it in world.Select<TestPosition>())
count++;
count.Should().Be(2);
world.Commands.Errors.Should().HaveCount(1);
@@ -222,9 +218,9 @@ public class CommandTests
world.ExecuteCommands();
var query = world.Query().With<TestPosition>().Build();
var count = 0;
world.ForEach(query, (Entity e, ref TestPosition pos) => count++);
foreach (var it in world.Select<TestPosition>())
count++;
count.Should().Be(0);
}
@@ -236,7 +232,7 @@ public class CommandTests
group.Add(new CommandEnqueueSystem(world, new SpawnCommand { X = 1, Y = 2, Health = 100 }));
var observer = new EntityCountObserver(world);
var observer = new EntityCountObserver();
group.Add(observer);
group.RunLogical();
@@ -255,7 +251,7 @@ public class CommandTests
_command = command;
}
public void Run(World world)
public void RunImpl(World world)
{
_world.Commands.Enqueue(_command);
}
@@ -263,20 +259,12 @@ public class CommandTests
private class EntityCountObserver : ISystem
{
private readonly QueryDescriptor _query;
public int SeenCount { get; private set; }
public EntityCountObserver(World world)
public void RunImpl(World world)
{
_query = world.Query().With<TestPosition>().Build();
}
public void Run(World world)
{
world.ForEach(_query, (Entity e, ref TestPosition pos) =>
{
foreach (var it in world.Select<TestPosition>())
SeenCount++;
});
}
}
}
}
+135 -128
View File
@@ -111,7 +111,6 @@ public class EdgeCaseTests
sources.Add(source);
world.AddComponent(source, new Relationship<ChildOf, ParentOf>
{
Source = source,
Target = target
});
}
@@ -154,45 +153,6 @@ public class EdgeCaseTests
c.Kind == ChangeKind.EntityRemoved && c.Entity == entity);
}
// ── QueryDescriptor equality ─────────────────────────────────────
[Fact]
public void QueryDescriptor_EqualQueries_AreEqual()
{
var q1 = new QueryBuilder().With<Position>().With<Velocity>().Without<Frozen>().Build();
var q2 = new QueryBuilder().With<Position>().With<Velocity>().Without<Frozen>().Build();
q1.Should().Be(q2);
q1.GetHashCode().Should().Be(q2.GetHashCode());
}
[Fact]
public void QueryDescriptor_DifferentWith_AreNotEqual()
{
var q1 = new QueryBuilder().With<Position>().Build();
var q2 = new QueryBuilder().With<Velocity>().Build();
q1.Should().NotBe(q2);
}
[Fact]
public void QueryDescriptor_DifferentWithout_AreNotEqual()
{
var q1 = new QueryBuilder().With<Position>().Without<Frozen>().Build();
var q2 = new QueryBuilder().With<Position>().Without<Burning>().Build();
q1.Should().NotBe(q2);
}
[Fact]
public void QueryDescriptor_DifferentCount_AreNotEqual()
{
var q1 = new QueryBuilder().With<Position>().Build();
var q2 = new QueryBuilder().With<Position>().With<Velocity>().Build();
q1.Should().NotBe(q2);
}
// ── Query with 4, 5, 6 components ────────────────────────────────
[Fact]
@@ -205,18 +165,14 @@ public class EdgeCaseTests
world.AddComponent(entity, new Health { Value = 100 });
world.AddComponent(entity, new Frozen());
var query = world.Query()
.With<Position>().With<Velocity>().With<Health>().With<Frozen>()
.Build();
var count = 0;
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel, ref Health hp, ref Frozen f) =>
foreach (var it in world.Select<Position, Velocity, Health, Frozen>())
{
count++;
pos.X.Should().Be(1);
vel.Y.Should().Be(4);
hp.Value.Should().Be(100);
});
it.Val1.X.Should().Be(1);
it.Val2.Y.Should().Be(4);
it.Val3.Value.Should().Be(100);
}
count.Should().Be(1);
}
@@ -231,15 +187,11 @@ public class EdgeCaseTests
world.AddComponent(entity, new Frozen());
world.AddComponent(entity, new Burning());
var query = world.Query()
.With<Position>().With<Velocity>().With<Health>().With<Frozen>().With<Burning>()
.Build();
var count = 0;
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel, ref Health hp, ref Frozen f, ref Burning b) =>
foreach (var it in world.Select<Position, Velocity, Health, Frozen, Burning>())
{
count++;
});
}
count.Should().Be(1);
}
@@ -255,17 +207,11 @@ public class EdgeCaseTests
world.AddComponent(entity, new Burning());
world.AddComponent(entity, new Poisoned());
var query = world.Query()
.With<Position>().With<Velocity>().With<Health>()
.With<Frozen>().With<Burning>().With<Poisoned>()
.Build();
var count = 0;
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel, ref Health hp,
ref Frozen f, ref Burning b, ref Poisoned p) =>
foreach (var it in world.Select<Position, Velocity, Health, Frozen, Burning, Poisoned>())
{
count++;
});
}
count.Should().Be(1);
}
@@ -283,13 +229,13 @@ public class EdgeCaseTests
world.AddComponent(b, new Frozen());
// Without<Frozen> filters out entity b which has Frozen.
var query = world.Query().Without<Frozen>().Build();
var query = new Query<Position>().Without<Frozen>();
var results = new List<Entity>();
world.ForEach(query, (Entity e, ref Position pos) =>
foreach (var it in world.Select(query))
{
results.Add(e);
});
results.Add(it.Entity);
}
results.Should().BeEquivalentTo([a]);
}
@@ -417,16 +363,15 @@ public class EdgeCaseTests
world.AddComponent(a, new Position { X = 1, Y = 2 });
world.AddComponent(b, new Position { X = 3, Y = 4 });
var query = world.Query().With<Position>().Build();
var seen = new List<Entity>();
world.ForEach(query, (Entity e, ref Position pos) =>
foreach (var it in world.Select<Position>())
{
seen.Add(e);
seen.Add(it.Entity);
// Add a component to the other entity during iteration.
// This should not affect the current iteration.
world.AddComponent(e, new Velocity { X = 1, Y = 1 });
});
world.AddComponent(it.Entity, new Velocity { X = 1, Y = 1 });
}
seen.Should().BeEquivalentTo([a, b]);
}
@@ -440,96 +385,123 @@ public class EdgeCaseTests
world.AddComponent(a, new Position { X = 1, Y = 2 });
world.AddComponent(b, new Position { X = 3, Y = 4 });
var query = world.Query().With<Position>().Build();
// Destroying an entity during iteration: the sparse set uses
// swap-remove, so the destroyed entity's slot is replaced by
// the last element. This is safe as long as we don't re-iterate
// the destroyed entity (which we won't since it's swap-removed).
var act = () =>
{
world.ForEach(query, (Entity e, ref Position pos) =>
foreach (var it in world.Select<Position>())
{
world.DestroyEntity(e);
});
world.DestroyEntity(it.Entity);
}
};
act.Should().NotThrow();
}
// ── QueryDescriptor.With enforced during ForEach ─────────────────
// ── ReorderSources edge cases ────────────────────────────────────
[Fact]
public void ForEach_Throws_WhenTypeParamsDivergeFromQueryWith()
public void ReorderSources_WithDuplicateEntities_ReordersToMatch()
{
var world = new World();
var entity = world.CreateEntity();
world.AddComponent(entity, new Position { X = 1, Y = 2 });
world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
var target = world.CreateEntity();
// Build a query requiring Position + Velocity, but iterate only Position.
var query = world.Query().With<Position>().With<Velocity>().Build();
var a = world.CreateEntity();
var b = world.CreateEntity();
var act = () =>
{
world.ForEach(query, (Entity e, ref Position pos) => { });
};
act.Should().Throw<InvalidOperationException>()
.WithMessage("*ForEach*type*Position*Velocity*");
world.AddComponent(a, new Relationship<ChildOf, ParentOf> { Target = target });
world.AddComponent(b, new Relationship<ChildOf, ParentOf> { Target = target });
// Reorder with duplicate entries — should still work (last occurrence wins).
world.ReorderSources<Relationship<ChildOf, ParentOf>>(target, [b, a, b]);
var result = world.GetSources<Relationship<ChildOf, ParentOf>>(target);
result.Should().Equal([b, a, b]);
}
[Fact]
public void ForEach_Throws_WhenTypeParamsAreSubsetOfQueryWith()
public void ReorderSources_WithMissingEntity_DoesNotThrow()
{
var world = new World();
var target = world.CreateEntity();
var a = world.CreateEntity();
world.AddComponent(a, new Relationship<ChildOf, ParentOf> { Target = target });
// Reorder with an entity not in the set — the missing entity is simply
// absent from the reordered result.
var extra = world.CreateEntity();
world.ReorderSources<Relationship<ChildOf, ParentOf>>(target, [extra, a]);
var result = world.GetSources<Relationship<ChildOf, ParentOf>>(target);
result.Should().Equal([extra, a]);
}
// ── RemoveSingleton during batching ───────────────────────────────
[Fact]
public void RemoveSingleton_DuringForEach_IsDeferred()
{
var world = new World();
world.SetSingleton(new Position { X = 1, Y = 2 });
var entity = world.CreateEntity();
world.AddComponent(entity, new Position { X = 3, Y = 4 });
var seen = new List<Entity>();
foreach (var it in world.Select<Position>())
{
seen.Add(it.Entity);
// Remove the singleton during iteration — should be deferred.
world.RemoveSingleton<Position>();
}
// The singleton entity should still have been iterated (deferred removal).
seen.Should().HaveCount(1);
seen[0].Should().Be(entity);
// After the foreach scope ends, the singleton removal is applied.
world.HasSingleton<Position>().Should().BeFalse();
}
// ── ReadComponent / ReadSingleton do not auto-track ───────────────
[Fact]
public void ReadComponent_DoesNotAutoMarkModified()
{
var world = new World();
var entity = world.CreateEntity();
world.AddComponent(entity, new Position { X = 1, Y = 2 });
world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
world.AddComponent(entity, new Health { Value = 100 });
world.PostChanges(); // Clear pending
// Build a query requiring 3 components, but iterate only 2.
var query = world.Query().With<Position>().With<Velocity>().With<Health>().Build();
var collector = new ChangeCollector();
using var sub = world.ObserveComponentChanges<Position>().Subscribe(collector.ToObserver());
var act = () =>
{
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => { });
};
act.Should().Throw<InvalidOperationException>()
.WithMessage("*ForEach*type*Health*");
// Run a system that only reads via ReadComponent — no auto-marking.
var group = new SystemGroup(world);
group.Add(new ReadOnlySystem(world, entity));
group.RunLogical();
collector.Changes.Should().BeEmpty();
}
[Fact]
public void ForEach_Throws_WhenTypeParamsAreSupersetOfQueryWith()
public void ReadSingleton_DoesNotAutoMarkModified()
{
var world = new World();
var entity = world.CreateEntity();
world.AddComponent(entity, new Position { X = 1, Y = 2 });
world.SetSingleton(new Position { X = 1, Y = 2 });
world.PostChanges(); // Clear pending
// Build a query requiring only Position, but iterate Position + Velocity.
var query = world.Query().With<Position>().Build();
var collector = new ChangeCollector();
using var sub = world.ObserveComponentChanges<Position>().Subscribe(collector.ToObserver());
var act = () =>
{
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => { });
};
act.Should().Throw<InvalidOperationException>()
.WithMessage("*ForEach*type*Velocity*");
}
// Run a system that only reads the singleton via ReadSingleton.
var group = new SystemGroup(world);
group.Add(new ReadSingletonSystem(world));
group.RunLogical();
[Fact]
public void ForEach_DoesNotThrow_WhenTypeParamsMatchQueryWith()
{
var world = new World();
var entity = world.CreateEntity();
world.AddComponent(entity, new Position { X = 1, Y = 2 });
world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
var query = world.Query().With<Position>().With<Velocity>().Build();
var act = () =>
{
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => { });
};
act.Should().NotThrow();
collector.Changes.Should().BeEmpty();
}
// ── Helpers ──────────────────────────────────────────────────────
@@ -546,4 +518,39 @@ public class EdgeCaseTests
// Phantom types for relationship tests.
public struct ChildOf { }
public struct ParentOf { }
// Helper systems for ReadComponent/ReadSingleton tests.
private class ReadOnlySystem : ISystem
{
private readonly World _world;
private readonly Entity _entity;
public ReadOnlySystem(World world, Entity entity)
{
_world = world;
_entity = entity;
}
public void RunImpl(World world)
{
var val = _world.ReadComponent<Position>(_entity);
_ = val.X;
}
}
private class ReadSingletonSystem : ISystem
{
private readonly World _world;
public ReadSingletonSystem(World world)
{
_world = world;
}
public void RunImpl(World world)
{
var val = _world.ReadSingleton<Position>();
_ = val.X;
}
}
}
+235
View File
@@ -0,0 +1,235 @@
using FluentAssertions;
using Xunit;
namespace OECS.Tests;
public class InterruptTests
{
// ── Test types ──
private record struct TestInterrupt : IInterrupt
{
public string Message;
}
[MessagePack.MessagePackObject]
private struct TestHandler : IInterruptHandlerCommand<TestInterrupt>
{
[MessagePack.Key(0)]
public bool ShouldResolve;
public bool TryResolve(TestInterrupt interrupt)
{
return ShouldResolve;
}
}
private record struct AnotherInterrupt : IInterrupt
{
public int Value;
}
[MessagePack.MessagePackObject]
private struct AnotherHandler : IInterruptHandlerCommand<AnotherInterrupt>
{
[MessagePack.Key(0)]
public bool ShouldResolve;
public bool TryResolve(AnotherInterrupt interrupt)
{
return ShouldResolve;
}
}
// ── Issue and resolve ──
[Fact]
public void Interrupt_blocks_next_tick()
{
var world = new World();
var group = new SystemGroup(world);
var systemRan = false;
group.Add(new TestSystem(() => systemRan = true));
// Issue an interrupt.
world.Interrupt(new TestInterrupt { Message = "hello" });
// Run a tick — systems should be skipped.
group.RunLogical();
systemRan.Should().BeFalse();
}
[Fact]
public void Interrupt_resolved_by_handler_unblocks_world()
{
var world = new World();
var group = new SystemGroup(world);
var systemRan = false;
group.Add(new TestSystem(() => systemRan = true));
// Issue an interrupt.
world.Interrupt(new TestInterrupt { Message = "hello" });
group.RunLogical();
systemRan.Should().BeFalse();
// Enqueue a handler that resolves it.
world.Commands.Enqueue(new TestHandler { ShouldResolve = true });
group.RunLogical();
systemRan.Should().BeTrue();
}
[Fact]
public void Interrupt_handler_rejects_leaves_interrupt_pending()
{
var world = new World();
var group = new SystemGroup(world);
var systemRan = false;
group.Add(new TestSystem(() => systemRan = true));
world.Interrupt(new TestInterrupt { Message = "hello" });
group.RunLogical();
systemRan.Should().BeFalse();
// Handler rejects — interrupt stays pending.
world.Commands.Enqueue(new TestHandler { ShouldResolve = false });
group.RunLogical();
systemRan.Should().BeFalse();
// Second handler accepts.
world.Commands.Enqueue(new TestHandler { ShouldResolve = true });
group.RunLogical();
systemRan.Should().BeTrue();
}
[Fact]
public void HasInterrupt_returns_true_for_pending_interrupt()
{
var world = new World();
var group = new SystemGroup(world);
world.HasInterrupt<TestInterrupt>().Should().BeFalse();
world.Interrupt(new TestInterrupt { Message = "hello" });
world.HasInterrupt<TestInterrupt>().Should().BeTrue();
world.Commands.Enqueue(new TestHandler { ShouldResolve = true });
group.RunLogical();
world.HasInterrupt<TestInterrupt>().Should().BeFalse();
}
[Fact]
public void Duplicate_interrupt_type_throws()
{
var world = new World();
var group = new SystemGroup(world);
world.Interrupt(new TestInterrupt { Message = "first" });
var act = () => world.Interrupt(new TestInterrupt { Message = "second" });
act.Should().Throw<InvalidOperationException>()
.WithMessage("*TestInterrupt*");
}
[Fact]
public void Multiple_interrupt_types_independent()
{
var world = new World();
var group = new SystemGroup(world);
world.Interrupt(new TestInterrupt { Message = "a" });
world.Interrupt(new AnotherInterrupt { Value = 42 });
// Resolve only one.
world.Commands.Enqueue(new TestHandler { ShouldResolve = true });
group.RunLogical();
// TestInterrupt resolved, AnotherInterrupt still pending.
world.HasInterrupt<TestInterrupt>().Should().BeFalse();
world.HasInterrupt<AnotherInterrupt>().Should().BeTrue();
// Resolve the other.
world.Commands.Enqueue(new AnotherHandler { ShouldResolve = true });
group.RunLogical();
world.HasInterrupt<AnotherInterrupt>().Should().BeFalse();
}
[Fact]
public void Handler_receives_correct_interrupt_data()
{
var world = new World();
var group = new SystemGroup(world);
world.Interrupt(new TestInterrupt { Message = "hello" });
// Enqueue handler and run — the default Execute calls TryResolve
// with the interrupt data.
world.Commands.Enqueue(new TestHandler { ShouldResolve = true });
group.RunLogical();
// Interrupt was resolved — the TryResolve received the correct data.
world.HasInterrupt<TestInterrupt>().Should().BeFalse();
}
[Fact]
public void Interrupt_without_system_group_is_noop()
{
var world = new World();
// No SystemGroup — interrupt is silently ignored.
world.Interrupt(new TestInterrupt { Message = "hello" });
world.HasInterrupt<TestInterrupt>().Should().BeFalse();
}
[Fact]
public void Commands_still_execute_when_blocked()
{
var world = new World();
var group = new SystemGroup(world);
var commandExecuted = false;
var handler = new TestHandler { ShouldResolve = true };
world.Interrupt(new TestInterrupt { Message = "hello" });
// Enqueue both a handler and a regular command.
world.Commands.Enqueue(new TestHandler { ShouldResolve = true });
world.Commands.Enqueue(new SetFlagCommand(() => commandExecuted = true));
group.RunLogical();
// Both should have executed.
world.HasInterrupt<TestInterrupt>().Should().BeFalse();
commandExecuted.Should().BeTrue();
}
// ── Helpers ──
private class TestSystem : ISystem
{
private readonly Action _action;
public TestSystem(Action action) => _action = action;
public void RunImpl(World world) => _action();
}
[MessagePack.MessagePackObject]
private struct SetFlagCommand : ICommand
{
private Action? _action;
[MessagePack.IgnoreMember]
public Action Action
{
get => _action!;
set => _action = value;
}
public SetFlagCommand(Action action) => _action = action;
public void Execute(World world) => _action?.Invoke();
}
}
+42 -121
View File
@@ -22,13 +22,9 @@ public class QueryTests
world.AddComponent(b, new Position { X = 3, Y = 4 });
// c has no Position
var query = world.Query().With<Position>().Build();
var results = new List<Entity>();
world.ForEach(query, (Entity entity, ref Position pos) =>
{
results.Add(entity);
});
foreach (var it in world.Select<Position>())
results.Add(it.Entity);
results.Should().BeEquivalentTo([a, b]);
}
@@ -50,13 +46,9 @@ public class QueryTests
world.AddComponent(c, new Velocity { X = 0, Y = 1 });
// c has no Position
var query = world.Query().With<Position>().With<Velocity>().Build();
var results = new List<Entity>();
world.ForEach(query, (Entity entity, ref Position pos, ref Velocity vel) =>
{
results.Add(entity);
});
foreach (var it in world.Select<Position, Velocity>())
results.Add(it.Entity);
results.Should().BeEquivalentTo([a]);
}
@@ -72,13 +64,10 @@ public class QueryTests
world.AddComponent(b, new Position { X = 3, Y = 4 });
world.AddComponent(b, new Frozen());
var query = world.Query().With<Position>().Without<Frozen>().Build();
var query = new Query<Position>().Without<Frozen>();
var results = new List<Entity>();
world.ForEach(query, (Entity entity, ref Position pos) =>
{
results.Add(entity);
});
foreach (var it in world.Select(query))
results.Add(it.Entity);
results.Should().BeEquivalentTo([a]);
}
@@ -87,14 +76,9 @@ public class QueryTests
public void Query_EmptyResult_WhenNoEntitiesMatch()
{
var world = new World();
var query = world.Query().With<Position>().Build();
var count = 0;
world.ForEach(query, (Entity entity, ref Position pos) =>
{
foreach (var it in world.Select<Position>())
count++;
});
count.Should().Be(0);
}
@@ -105,12 +89,8 @@ public class QueryTests
var entity = world.CreateEntity();
world.AddComponent(entity, new Position { X = 0, Y = 0 });
var query = world.Query().With<Position>().Build();
world.ForEach(query, (Entity e, ref Position pos) =>
{
pos.X = 42;
});
foreach (var it in world.Select<Position>())
it.Ref1.X = 42;
ref var pos = ref world.GetComponent<Position>(entity);
pos.X.Should().Be(42);
@@ -128,13 +108,9 @@ public class QueryTests
world.DestroyEntity(a);
var query = world.Query().With<Position>().Build();
var results = new List<Entity>();
world.ForEach(query, (Entity entity, ref Position pos) =>
{
results.Add(entity);
});
foreach (var it in world.Select<Position>())
results.Add(it.Entity);
results.Should().BeEquivalentTo([b]);
}
@@ -149,23 +125,31 @@ public class QueryTests
world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
world.AddComponent(entity, new Health { Value = 100 });
var query = world.Query()
.With<Position>()
.With<Velocity>()
.With<Health>()
.Build();
var count = 0;
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel, ref Health hp) =>
foreach (var it in world.Select<Position, Velocity, Health>())
{
count++;
pos.X.Should().Be(1);
vel.Y.Should().Be(4);
hp.Value.Should().Be(100);
});
it.Val1.X.Should().Be(1);
it.Val2.Y.Should().Be(4);
it.Val3.Value.Should().Be(100);
}
count.Should().Be(1);
}
[Fact]
public void FindEntity_ReturnsFirstMatch()
{
var world = new World();
var a = world.CreateEntity();
var b = world.CreateEntity();
world.AddComponent(a, new Position { X = 1, Y = 2 });
world.AddComponent(b, new Position { X = 3, Y = 4 });
var found = world.FindEntity<Position>();
found.Should().NotBe(Entity.Null);
}
}
public class SystemTests
@@ -175,23 +159,16 @@ public class SystemTests
private class MovementSystem : ITickedSystem
{
private readonly QueryDescriptor _query;
public void RunImpl(World world) => RunImpl(world, Tick.Logical());
public MovementSystem(World world)
{
_query = world.Query().With<Position>().With<Velocity>().Build();
}
public void Run(World world) => Run(world, Tick.Logical());
public void Run(World world, Tick tick)
public void RunImpl(World world, Tick tick)
{
float dt = tick.DeltaTime;
world.ForEach(_query, (Entity entity, ref Position pos, ref Velocity vel) =>
foreach (var it in world.Select<Position, Velocity>())
{
pos.X += vel.X * dt;
pos.Y += vel.Y * dt;
});
it.Ref1.X += it.Val2.X * dt;
it.Ref1.Y += it.Val2.Y * dt;
}
}
}
@@ -200,8 +177,7 @@ public class SystemTests
{
var world = new World();
var group = new SystemGroup(world);
var system = new MovementSystem(world);
group.Add(system);
group.Add(new MovementSystem());
var entity = world.CreateEntity();
world.AddComponent(entity, new Position { X = 0, Y = 0 });
@@ -220,7 +196,7 @@ public class SystemTests
var world = new World();
var group = new SystemGroup(world);
var system = new TestLogicalSystem(world);
var system = new TestLogicalSystem();
group.Add(system);
group.RunLogical();
@@ -235,67 +211,12 @@ public class SystemTests
public bool WasCalled { get; private set; }
public Tick ReceivedTick { get; private set; }
public TestLogicalSystem(World world)
{
}
public void RunImpl(World world) { }
public void Run(World world) => Run(world, Tick.Logical());
public void Run(World world, Tick tick)
public void RunImpl(World world, Tick tick)
{
WasCalled = true;
ReceivedTick = tick;
}
}
[Fact]
public void Systems_RunInRegistrationOrder()
{
var world = new World();
var group = new SystemGroup(world);
var order = new List<int>();
group.Add(new OrderTrackingSystem(world, 1, order));
group.Add(new OrderTrackingSystem(world, 2, order));
group.Add(new OrderTrackingSystem(world, 3, order));
group.RunLogical();
order.Should().BeInAscendingOrder();
order.Should().BeEquivalentTo([1, 2, 3]);
}
private class OrderTrackingSystem : ISystem
{
private readonly int _id;
private readonly List<int> _order;
public OrderTrackingSystem(World world, int id, List<int> order)
{
_id = id;
_order = order;
}
public void Run(World world)
{
_order.Add(_id);
}
}
[Fact]
public void System_CanBeRemoved()
{
var world = new World();
var group = new SystemGroup(world);
var system = new TestLogicalSystem(world);
group.Add(system);
group.Count.Should().Be(1);
group.Remove(system);
group.Count.Should().Be(0);
group.RunLogical();
system.WasCalled.Should().BeFalse();
}
}
}
+29 -2
View File
@@ -9,6 +9,8 @@ public class ReactivityTests
private struct Position { public float X; public float Y; }
private struct Health { public int Value; }
private struct Frozen { }
private struct ChildOf { }
private struct ParentOf { }
/// <summary>
/// Helper that collects changes into a list and exposes an R3 Observer.
@@ -208,7 +210,7 @@ public class ReactivityTests
public void ObserveQuery_OnlyReceivesMatchingChanges()
{
var world = new World();
var query = world.Query().With<Position>().Without<Frozen>().Build();
var query = new Query<Position>().Without<Frozen>();
var collector = new ChangeCollector();
using var sub = world.ObserveQuery(query).Subscribe(collector.ToObserver());
@@ -264,6 +266,31 @@ public class ReactivityTests
collector.Changes.Should().Contain(c => c.Kind == ChangeKind.EntityAdded);
}
[Fact]
public void ReorderSources_PostsRelationshipReordered()
{
var world = new World();
var parent = world.CreateEntity();
var a = world.CreateEntity();
var b = world.CreateEntity();
world.AddComponent(a, new Relationship<ChildOf, ParentOf> { Target = parent });
world.AddComponent(b, new Relationship<ChildOf, ParentOf> { Target = parent });
world.PostChanges(); // Clear pending
var collector = new ChangeCollector();
using var sub = world.ObserveEntityChanges().Subscribe(collector.ToObserver());
world.ReorderSources<Relationship<ChildOf, ParentOf>>(parent, [b, a]);
world.PostChanges();
collector.Changes.Should().ContainSingle()
.Which.Should().Match<EntityChange>(c =>
c.Entity == parent &&
c.Kind == ChangeKind.RelationshipReordered &&
c.ComponentType == typeof(Relationship<ChildOf, ParentOf>));
}
private class EntityCreatorSystem : ISystem
{
private readonly World _world;
@@ -273,7 +300,7 @@ public class ReactivityTests
_world = world;
}
public void Run(World world)
public void RunImpl(World world)
{
_world.CreateEntity();
}
+87 -20
View File
@@ -24,7 +24,6 @@ public class RelationshipTests
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
{
Source = child,
Target = parent
});
@@ -41,7 +40,6 @@ public class RelationshipTests
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
{
Source = child,
Target = parent
});
@@ -61,14 +59,12 @@ public class RelationshipTests
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
{
Source = child,
Target = parent1
});
// Replace with a new target.
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
{
Source = child,
Target = parent2
});
@@ -85,7 +81,6 @@ public class RelationshipTests
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
{
Source = child,
Target = parent
});
@@ -107,7 +102,6 @@ public class RelationshipTests
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
{
Source = child,
Target = parent
});
@@ -131,15 +125,15 @@ public class RelationshipTests
world.AddComponent(child1, new Relationship<ChildOf, ParentOf>
{
Source = child1, Target = parent
Target = parent
});
world.AddComponent(child2, new Relationship<ChildOf, ParentOf>
{
Source = child2, Target = parent
Target = parent
});
world.AddComponent(child3, new Relationship<ChildOf, ParentOf>
{
Source = child3, Target = parent
Target = parent
});
var sources = world.GetSources<Relationship<ChildOf, ParentOf>>(parent);
@@ -156,11 +150,11 @@ public class RelationshipTests
world.AddComponent(entity, new Relationship<OwnedBy, Thing>
{
Source = entity, Target = owner
Target = owner
});
world.AddComponent(entity, new Relationship<MemberOf, Thing>
{
Source = entity, Target = group
Target = group
});
world.GetSources<Relationship<OwnedBy, Thing>>(owner).Should().BeEquivalentTo([entity]);
@@ -189,21 +183,94 @@ public class RelationshipTests
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
{
Source = child, Target = parent
Target = parent
});
var query = world.Query().With<Relationship<ChildOf, ParentOf>>().Build();
var results = new List<Entity>();
world.ForEach(query, (Entity e, ref Relationship<ChildOf, ParentOf> rel) =>
foreach (var it in world.Select<Relationship<ChildOf, ParentOf>>())
{
results.Add(e);
rel.Target.Should().Be(parent);
});
results.Add(it.Entity);
it.Val1.Target.Should().Be(parent);
}
results.Should().BeEquivalentTo([child]);
}
[Fact]
public void GetSources_PreservesInsertionOrder()
{
var world = new World();
var parent = world.CreateEntity();
// Add sources in a specific order.
var sources = new Entity[5];
for (int i = 0; i < 5; i++)
{
sources[i] = world.CreateEntity();
world.AddComponent(sources[i], new Relationship<ChildOf, ParentOf>
{
Target = parent
});
}
var result = world.GetSources<Relationship<ChildOf, ParentOf>>(parent);
result.Should().Equal(sources);
}
[Fact]
public void GetSources_OrderIsStableAfterMiddleRemove()
{
var world = new World();
var parent = world.CreateEntity();
var a = world.CreateEntity();
var b = world.CreateEntity();
var c = world.CreateEntity();
world.AddComponent(a, new Relationship<ChildOf, ParentOf> { Target = parent });
world.AddComponent(b, new Relationship<ChildOf, ParentOf> { Target = parent });
world.AddComponent(c, new Relationship<ChildOf, ParentOf> { Target = parent });
// Remove the middle source.
world.RemoveComponent<Relationship<ChildOf, ParentOf>>(b);
// Remaining sources should preserve insertion order.
var result = world.GetSources<Relationship<ChildOf, ParentOf>>(parent);
result.Should().Equal([a, c]);
}
[Fact]
public void ReorderSources_ChangesIterationOrder()
{
var world = new World();
var parent = world.CreateEntity();
var a = world.CreateEntity();
var b = world.CreateEntity();
var c = world.CreateEntity();
world.AddComponent(a, new Relationship<ChildOf, ParentOf> { Target = parent });
world.AddComponent(b, new Relationship<ChildOf, ParentOf> { Target = parent });
world.AddComponent(c, new Relationship<ChildOf, ParentOf> { Target = parent });
// Reverse the order.
world.ReorderSources<Relationship<ChildOf, ParentOf>>(parent, [c, b, a]);
var result = world.GetSources<Relationship<ChildOf, ParentOf>>(parent);
result.Should().Equal([c, b, a]);
}
[Fact]
public void ReorderSources_NoopsWhenTargetHasNoRelationships()
{
var world = new World();
var entity = world.CreateEntity();
// Should not throw.
world.ReorderSources<Relationship<ChildOf, ParentOf>>(entity, []);
}
[Fact]
public void DestroyEntity_WithBothIncomingAndOutgoingRelationships()
{
@@ -215,13 +282,13 @@ public class RelationshipTests
// a → b (a is child of b)
world.AddComponent(a, new Relationship<ChildOf, ParentOf>
{
Source = a, Target = b
Target = b
});
// c → a (c is child of a)
world.AddComponent(c, new Relationship<ChildOf, ParentOf>
{
Source = c, Target = a
Target = a
});
// Destroy a. This should:
@@ -233,4 +300,4 @@ public class RelationshipTests
world.HasComponent<Relationship<ChildOf, ParentOf>>(c).Should().BeFalse();
world.GetSources<Relationship<ChildOf, ParentOf>>(b).Should().BeEmpty();
}
}
}
+237
View File
@@ -0,0 +1,237 @@
using FluentAssertions;
using MessagePack;
using Xunit;
namespace OECS.Tests;
// ── Test components for serialization ─────────────────────────────────
[MessagePackObject]
public struct SerialPos : IMessagePackSerializationCallbackReceiver
{
[Key(0)] public float X { get; set; }
[Key(1)] public float Y { get; set; }
public void OnBeforeSerialize() { }
public void OnAfterDeserialize() { }
}
[MessagePackObject]
public struct SerialHealth : IMessagePackSerializationCallbackReceiver
{
[Key(0)] public int Value { get; set; }
public void OnBeforeSerialize() { }
public void OnAfterDeserialize() { }
}
[MessagePackObject]
public struct SerialConfig : IMessagePackSerializationCallbackReceiver
{
[Key(0)] public float Gravity { get; set; }
[Key(1)] public int MaxPlayers { get; set; }
public void OnBeforeSerialize() { }
public void OnAfterDeserialize() { }
}
// ── Phantom types for relationship serialization ──────────────────────
public struct SerialChildOf { }
public struct SerialParentOf { }
// ── Tests ────────────────────────────────────────────────────────────
public class SerializationTests
{
[Fact]
public void SaveLoad_RoundTrip_PreservesComponents()
{
var world = new World();
var entity = world.CreateEntity();
world.AddComponent(entity, new SerialPos { X = 1.5f, Y = 2.5f });
world.AddComponent(entity, new SerialHealth { Value = 100 });
var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
// Load into a fresh world.
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
// Find the entity in the loaded world.
var found = world2.FindEntity<SerialPos, SerialHealth>();
found.Should().NotBe(Entity.Null);
ref var pos = ref world2.GetComponent<SerialPos>(found);
pos.X.Should().Be(1.5f);
pos.Y.Should().Be(2.5f);
ref var hp = ref world2.GetComponent<SerialHealth>(found);
hp.Value.Should().Be(100);
}
[Fact]
public void SaveLoad_RoundTrip_PreservesMultipleEntities()
{
var world = new World();
var a = world.CreateEntity();
var b = world.CreateEntity();
world.AddComponent(a, new SerialPos { X = 1, Y = 2 });
world.AddComponent(a, new SerialHealth { Value = 10 });
world.AddComponent(b, new SerialPos { X = 3, Y = 4 });
world.AddComponent(b, new SerialHealth { Value = 20 });
var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
var positions = new List<(float X, float Y, int Health)>();
foreach (var it in world2.Select<SerialPos, SerialHealth>())
{
positions.Add((it.Val1.X, it.Val1.Y, it.Val2.Value));
}
positions.Should().BeEquivalentTo([(1, 2, 10), (3, 4, 20)]);
}
[Fact]
public void SaveLoad_RoundTrip_PreservesEntityIds()
{
var world = new World();
var entity = world.CreateEntity();
world.AddComponent(entity, new SerialPos { X = 1, Y = 2 });
var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
var found = world2.FindEntity<SerialPos>();
found.Id.Should().Be(entity.Id);
found.Version.Should().Be(entity.Version);
}
[Fact]
public void SaveLoad_RoundTrip_PreservesSingletons()
{
var world = new World();
world.SetSingleton(new SerialConfig { Gravity = 9.8f, MaxPlayers = 4 });
var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
world2.HasSingleton<SerialConfig>().Should().BeTrue();
ref var config = ref world2.GetSingleton<SerialConfig>();
config.Gravity.Should().Be(9.8f);
config.MaxPlayers.Should().Be(4);
}
[Fact]
public void SaveLoad_RoundTrip_PreservesRelationships()
{
var world = new World();
var child = world.CreateEntity();
var parent = world.CreateEntity();
world.AddComponent(child, new Relationship<SerialChildOf, SerialParentOf>
{
Target = parent
});
var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
// Find the relationship.
var found = world2.FindEntity<Relationship<SerialChildOf, SerialParentOf>>();
found.Should().NotBe(Entity.Null);
ref var rel = ref world2.GetComponent<Relationship<SerialChildOf, SerialParentOf>>(found);
rel.Target.Should().NotBe(Entity.Null);
// The target should be alive and have the same ID as the original parent.
world2.IsAlive(rel.Target).Should().BeTrue();
rel.Target.Id.Should().Be(parent.Id);
}
[Fact]
public void SaveLoad_RoundTrip_PreservesRelationshipTargetIntegrity()
{
var world = new World();
var child = world.CreateEntity();
var parent = world.CreateEntity();
world.AddComponent(parent, new SerialPos { X = 10, Y = 20 });
world.AddComponent(child, new Relationship<SerialChildOf, SerialParentOf>
{
Target = parent
});
var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
var foundChild = world2.FindEntity<Relationship<SerialChildOf, SerialParentOf>>();
ref var rel = ref world2.GetComponent<Relationship<SerialChildOf, SerialParentOf>>(foundChild);
// The target entity should still have its component.
world2.HasComponent<SerialPos>(rel.Target).Should().BeTrue();
ref var pos = ref world2.GetComponent<SerialPos>(rel.Target);
pos.X.Should().Be(10);
pos.Y.Should().Be(20);
}
[Fact]
public void SaveLoad_EmptyWorld_Works()
{
var world = new World();
var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
// Loading an empty world should not throw.
var count = 0;
foreach (var it in world2.Select<SerialPos>())
count++;
count.Should().Be(0);
}
[Fact]
public void SaveLoad_EntityWithNoComponents_IsNotSerialized()
{
var world = new World();
world.CreateEntity(); // Entity with no components.
var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
stream.Position = 0;
var world2 = new World();
WorldSerializer.Load(world2, stream);
// No entities should be loaded since the empty entity wasn't serialized.
var count = 0;
foreach (var it in world2.Select<SerialPos>())
count++;
count.Should().Be(0);
}
}
+8 -7
View File
@@ -75,13 +75,12 @@ public class SingletonTests
var regular = world.CreateEntity();
world.AddComponent(regular, new GameConfig { Gravity = 1.6f, MaxPlayers = 2 });
var query = world.Query().With<GameConfig>().Build();
var results = new List<Entity>();
world.ForEach(query, (Entity e, ref GameConfig cfg) =>
foreach (var it in world.Select<GameConfig>())
{
results.Add(e);
});
results.Add(it.Entity);
}
// Only the regular entity should appear, not the singleton.
results.Should().BeEquivalentTo([regular]);
@@ -94,7 +93,9 @@ public class SingletonTests
world.SetSingleton(new GameConfig());
world.IsAlive(World.SingletonEntity).Should().BeTrue();
// Singletons are now on their own dedicated entities.
// Verify the singleton exists (its entity is alive).
world.HasSingleton<GameConfig>().Should().BeTrue();
}
[Fact]
@@ -153,9 +154,9 @@ public class SingletonTests
_world = world;
}
public void Run(World world) => Run(world, Tick.Logical());
public void RunImpl(World world) => RunImpl(world, Tick.Logical());
public void Run(World world, Tick tick)
public void RunImpl(World world, Tick tick)
{
ref var time = ref _world.GetSingleton<TimeState>();
time.Elapsed += tick.DeltaTime;
+71 -29
View File
@@ -1,4 +1,5 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
@@ -8,14 +9,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.SourceGen", "OECS.Sour
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.Tests", "OECS.Tests\OECS.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.Blackjack", "Game.Blackjack\Blackjack.csproj", "{85631EDE-F984-4DA6-8EE9-0715AF1204E6}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Blackjack", "Game.Blackjack\Blackjack.csproj", "{85631EDE-F984-4DA6-8EE9-0715AF1204E6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.TicTacToe", "Game.TicTacToe\TicTacToe.csproj", "{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TicTacToe", "Game.TicTacToe\TicTacToe.csproj", "{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.Blackjack.Tests", "Game.Blackjack.Tests\Game.Blackjack.Tests.csproj", "{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.TicTacToe.Tests", "Game.TicTacToe.Tests\Game.TicTacToe.Tests.csproj", "{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CardWars", "Game.CardWars\CardWars.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.CardWars.Tests", "Game.CardWars.Tests\Game.CardWars.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678902}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.PlayTest", "OECS.PlayTest\OECS.PlayTest.csproj", "{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -86,33 +93,68 @@ Global
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x64.Build.0 = Release|Any CPU
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x86.ActiveCfg = Release|Any CPU
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x86.Build.0 = Release|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x64.ActiveCfg = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x64.Build.0 = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x86.ActiveCfg = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x86.Build.0 = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|Any CPU.Build.0 = Release|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x64.ActiveCfg = Release|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x64.Build.0 = Release|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x86.ActiveCfg = Release|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x86.Build.0 = Release|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x64.ActiveCfg = Debug|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x64.Build.0 = Debug|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x86.ActiveCfg = Debug|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x86.Build.0 = Debug|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|Any CPU.Build.0 = Release|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x64.ActiveCfg = Release|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x64.Build.0 = Release|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x86.ActiveCfg = Release|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x86.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|x64.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|x64.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|x86.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|Any CPU.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|x64.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|x64.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|x86.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|x86.Build.0 = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Debug|x64.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Debug|x64.Build.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Debug|x86.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Debug|x86.Build.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|Any CPU.Build.0 = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|x64.ActiveCfg = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|x64.Build.0 = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|x86.ActiveCfg = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|x86.Build.0 = Release|Any CPU
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Debug|x64.ActiveCfg = Debug|Any CPU
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Debug|x64.Build.0 = Debug|Any CPU
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Debug|x86.ActiveCfg = Debug|Any CPU
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Debug|x86.Build.0 = Debug|Any CPU
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Release|Any CPU.Build.0 = Release|Any CPU
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Release|x64.ActiveCfg = Release|Any CPU
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Release|x64.Build.0 = Release|Any CPU
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Release|x86.ActiveCfg = Release|Any CPU
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x64.ActiveCfg = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x64.Build.0 = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x86.ActiveCfg = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x86.Build.0 = Debug|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|Any CPU.Build.0 = Release|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x64.ActiveCfg = Release|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x64.Build.0 = Release|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x86.ActiveCfg = Release|Any CPU
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x86.Build.0 = Release|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x64.ActiveCfg = Debug|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x64.Build.0 = Debug|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x86.ActiveCfg = Debug|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x86.Build.0 = Debug|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|Any CPU.Build.0 = Release|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x64.ActiveCfg = Release|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x64.Build.0 = Release|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x86.ActiveCfg = Release|Any CPU
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
+95 -18
View File
@@ -15,7 +15,7 @@ internal class ChangeBuffer
private readonly Subject<EntityChange> _entitySubject = new();
private readonly Dictionary<Type, TrackedSubject> _componentSubjects = new();
private readonly Dictionary<QueryDescriptor, TrackedSubject> _querySubjects = new();
private readonly Dictionary<QueryKey, TrackedSubject> _querySubjects = new();
/// <summary>
/// Wraps a <see cref="Subject{T}"/> with a subscriber count so that
@@ -27,6 +27,42 @@ internal class ChangeBuffer
public int SubscriberCount;
}
/// <summary>
/// Identifies a query subscription for cleanup tracking.
/// </summary>
private readonly struct QueryKey : IEquatable<QueryKey>
{
public readonly Type[] WithTypes;
public readonly Type[] WithoutTypes;
public QueryKey(Type[] withTypes, Type[] withoutTypes)
{
WithTypes = withTypes;
WithoutTypes = withoutTypes;
}
public bool Equals(QueryKey other)
{
if (WithTypes.Length != other.WithTypes.Length) return false;
if (WithoutTypes.Length != other.WithoutTypes.Length) return false;
for (int i = 0; i < WithTypes.Length; i++)
if (WithTypes[i] != other.WithTypes[i]) return false;
for (int i = 0; i < WithoutTypes.Length; i++)
if (WithoutTypes[i] != other.WithoutTypes[i]) return false;
return true;
}
public override bool Equals(object? obj) => obj is QueryKey other && Equals(other);
public override int GetHashCode()
{
var hash = new HashCode();
foreach (var t in WithTypes) hash.Add(t);
foreach (var t in WithoutTypes) hash.Add(t);
return hash.ToHashCode();
}
}
/// <summary>
/// The change set currently accumulating. Cleared after each <see cref="Post"/>.
/// </summary>
@@ -101,14 +137,33 @@ internal class ChangeBuffer
}
/// <summary>
/// Returns an observable that emits changes matching the given query.
/// Returns an observable that emits component-level changes matching
/// the given query's With types and excluding its Without types.
/// </summary>
public Observable<EntityChange> ObserveQuery(QueryDescriptor query)
public Observable<EntityChange> ObserveQuery<T1>(Query<T1> query)
where T1 : struct
{
if (!_querySubjects.TryGetValue(query, out var tracked))
var key = MakeKey(query);
return SubscribeQuery(key);
}
/// <summary>
/// Returns an observable that emits component-level changes matching
/// the given query.
/// </summary>
public Observable<EntityChange> ObserveQuery<T1, T2>(Query<T1, T2> query)
where T1 : struct where T2 : struct
{
var key = MakeKey(query);
return SubscribeQuery(key);
}
private Observable<EntityChange> SubscribeQuery(QueryKey key)
{
if (!_querySubjects.TryGetValue(key, out var tracked))
{
tracked = new TrackedSubject();
_querySubjects[query] = tracked;
_querySubjects[key] = tracked;
}
tracked.SubscriberCount++;
@@ -118,11 +173,28 @@ internal class ChangeBuffer
if (tracked.SubscriberCount <= 0)
{
tracked.Subject.Dispose();
_querySubjects.Remove(query);
_querySubjects.Remove(key);
}
});
}
private static QueryKey MakeKey<T1>(Query<T1> query) where T1 : struct
{
var without = query.WithoutTypes;
return new QueryKey(
[typeof(T1)],
without != null ? [.. without] : []);
}
private static QueryKey MakeKey<T1, T2>(Query<T1, T2> query)
where T1 : struct where T2 : struct
{
var without = query.WithoutTypes;
return new QueryKey(
[typeof(T1), typeof(T2)],
without != null ? [.. without] : []);
}
/// <summary>
/// Wraps an observable so that <paramref name="onLastDispose"/> is called
/// when the last subscriber disposes.
@@ -159,21 +231,26 @@ internal class ChangeBuffer
_querySubjects.Clear();
}
private static bool ChangeMatchesQuery(EntityChange change, QueryDescriptor query)
private static bool ChangeMatchesQuery(EntityChange change, QueryKey query)
{
// Entity-level changes: match if the entity was added/removed and
// the query has any "with" types (we can't know component state for
// removed entities, so we only match added entities that would satisfy
// the query — but we don't have component data here).
// For simplicity, we only match component-level changes against queries.
// Entity-level changes don't have a component type.
if (change.ComponentType == null)
return false;
// A component change matches a query if the component type is in the
// query's With set and not in the Without set.
if (query.Without.Contains(change.ComponentType))
return false;
// Must be in With types.
bool inWith = false;
foreach (var t in query.WithTypes)
{
if (t == change.ComponentType) { inWith = true; break; }
}
if (!inWith) return false;
return query.With.Contains(change.ComponentType);
// Must not be in Without types.
foreach (var t in query.WithoutTypes)
{
if (t == change.ComponentType) return false;
}
return true;
}
}
}
+8 -1
View File
@@ -29,5 +29,12 @@ public enum ChangeKind
/// A component's value was modified. Must be explicitly marked
/// via <see cref="World.MarkModified{T}"/>.
/// </summary>
ComponentModified
ComponentModified,
/// <summary>
/// The source set for a relationship type on a target entity was
/// reordered. The <see cref="EntityChange.Entity"/> is the target
/// and <see cref="EntityChange.ComponentType"/> is the relationship type.
/// </summary>
RelationshipReordered
}
+9
View File
@@ -64,6 +64,15 @@ internal class ChangeSet
TryAdd(new EntityChange(entity, ChangeKind.ComponentModified, componentType));
}
/// <summary>
/// Records that the source set for a relationship type on a target entity
/// was reordered.
/// </summary>
public void MarkRelationshipReordered(Entity target, Type relationshipType)
{
TryAdd(new EntityChange(target, ChangeKind.RelationshipReordered, relationshipType));
}
/// <summary>
/// Clears all accumulated changes.
/// </summary>
+11
View File
@@ -41,11 +41,18 @@ public class CommandQueue
/// <summary>
/// Executes all queued commands in FIFO order against the given world.
///
/// Commands run inside a batching scope — structural mutations are
/// deferred and <see cref="World.GetComponent{T}"/> calls are
/// auto-tracked for modification until the drain completes.
/// Pending mutations are flushed after each command, so chained
/// commands see each other's changes within the same drain cycle.
///
/// The queue is fully drained — commands enqueued by other commands during
/// this call are also executed before the method returns.
/// </summary>
public void ExecuteAll(World world)
{
world.BeginBatching();
int index = 0;
while (index < _commands.Count)
{
@@ -60,9 +67,13 @@ public class CommandQueue
{
_errors.Add(ex);
}
// Flush after each command so chained commands see mutations.
world.FlushPendingMutations();
}
_commands.Clear();
world.EndBatching();
}
/// <summary>
+9 -1
View File
@@ -34,17 +34,25 @@ public sealed class ComponentDescriptor
/// </summary>
public Func<byte[], object> Deserialize { get; }
/// <summary>
/// True if this component type is used as a singleton (via <c>SetSingleton</c>).
/// Set by the source generator at compile time.
/// </summary>
public bool IsSingleton { get; }
public ComponentDescriptor(
string typeName,
Type type,
Func<object, byte[]> serialize,
Action<World, Entity, byte[]> deserializeAndAdd,
Func<byte[], object> deserialize)
Func<byte[], object> deserialize,
bool isSingleton = false)
{
TypeName = typeName;
Type = type;
Serialize = serialize;
DeserializeAndAdd = deserializeAndAdd;
Deserialize = deserialize;
IsSingleton = isSingleton;
}
}
+11
View File
@@ -8,6 +8,7 @@ namespace OECS;
/// use-after-free bugs when entity IDs are recycled.
/// </summary>
[MessagePackObject(AllowPrivate = true)]
[MessagePackFormatter(typeof(EntityFormatter))]
public readonly partial struct Entity : IEquatable<Entity>
{
private const uint IdMask = 0x00FF_FFFF; // lower 24 bits
@@ -57,6 +58,16 @@ public readonly partial struct Entity : IEquatable<Entity>
/// </summary>
internal Entity WithVersion(uint version) => new(Id, version);
/// <summary>
/// Converts the entity to its raw 32-bit packed representation.
/// </summary>
public static explicit operator uint(Entity entity) => entity._value;
/// <summary>
/// Creates an entity from its raw 32-bit packed representation.
/// </summary>
public static explicit operator Entity(uint value) => new(value);
public bool Equals(Entity other) => _value == other._value;
public override bool Equals(object? obj) => obj is Entity other && Equals(other);
+1 -12
View File
@@ -10,7 +10,7 @@ namespace OECS;
internal class EntityAllocator
{
private const int DefaultCapacity = 1024;
private const uint FirstValidId = 2; // 0 = Null, 1 = Singleton
private const uint FirstValidId = 1; // 0 = Null
private byte[] _versions;
private readonly Stack<uint> _freeIds;
@@ -63,16 +63,6 @@ internal class EntityAllocator
_freeIds.Push(id);
}
/// <summary>
/// Registers the singleton entity so that <see cref="IsAlive"/> returns true for it.
/// Called once by <see cref="World"/> when the first singleton accessor is used.
/// </summary>
public void RegisterSingleton(Entity singleton)
{
EnsureCapacity(singleton.Id);
_versions[singleton.Id] = (byte)singleton.Version;
}
/// <summary>
/// Reserves a specific entity ID and version for deserialization.
/// Advances <see cref="_nextId"/> past the reserved ID so future
@@ -94,7 +84,6 @@ internal class EntityAllocator
{
uint id = entity.Id;
if (entity.IsNull) return false;
if (id == 1) return true; // Singleton is always alive.
if (id >= _versions.Length) return false;
return _versions[id] == entity.Version && _versions[id] != 0;
}
+25
View File
@@ -0,0 +1,25 @@
using MessagePack;
using MessagePack.Formatters;
namespace OECS;
/// <summary>
/// Custom MessagePack formatter for <see cref="Entity"/>.
///
/// Reads and writes the raw 32-bit packed value as a single uint.
/// This avoids the readonly-field issue where MessagePack's built-in
/// deserialization path (reflection-based field assignment) cannot
/// write to <c>readonly</c> fields on value types.
/// </summary>
public class EntityFormatter : IMessagePackFormatter<Entity>
{
public void Serialize(ref MessagePackWriter writer, Entity value, MessagePackSerializerOptions options)
{
writer.Write((uint)value);
}
public Entity Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
return (Entity)reader.ReadUInt32();
}
}
-327
View File
@@ -1,327 +0,0 @@
using System.Runtime.CompilerServices;
namespace OECS;
/// <summary>
/// Provides zero-allocation ref struct iterators for querying entities.
/// Use with <c>foreach</c> or manual <c>while (iter.MoveNext())</c>.
/// Supports <c>break</c>, <c>continue</c>, and early returns.
/// </summary>
public static class EntityIterator
{
/// <summary>
/// Returns an iterator over entities matching the query, with ref access
/// to one component type.
/// </summary>
public static Select1<T1> Select<T1>(
this World world, QueryDescriptor query)
where T1 : struct
{
return new Select1<T1>(world, query);
}
/// <summary>
/// Returns an iterator over entities that have a component of type
/// <typeparamref name="T1"/>. No <c>Without</c> filter is applied.
/// </summary>
public static Select1<T1> Select<T1>(
this World world)
where T1 : struct
{
return new Select1<T1>(world, new QueryDescriptor(new HashSet<Type>(), new HashSet<Type>()));
}
/// <summary>
/// Returns an iterator over entities matching the query, with ref access
/// to two component types.
/// </summary>
public static Select2<T1, T2> Select<T1, T2>(
this World world, QueryDescriptor query)
where T1 : struct where T2 : struct
{
return new Select2<T1, T2>(world, query);
}
/// <summary>
/// Returns an iterator over entities that have both components of type
/// <typeparamref name="T1"/> and <typeparamref name="T2"/>.
/// No <c>Without</c> filter is applied.
/// </summary>
public static Select2<T1, T2> Select<T1, T2>(
this World world)
where T1 : struct where T2 : struct
{
return new Select2<T1, T2>(world, new QueryDescriptor(new HashSet<Type>(), new HashSet<Type>()));
}
/// <summary>
/// Returns an iterator over entities matching the query, with ref access
/// to three component types.
/// </summary>
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
this World world, QueryDescriptor query)
where T1 : struct where T2 : struct where T3 : struct
{
return new Select3<T1, T2, T3>(world, query);
}
/// <summary>
/// Returns an iterator over entities that have all three components of type
/// <typeparamref name="T1"/>, <typeparamref name="T2"/>, and
/// <typeparamref name="T3"/>. No <c>Without</c> filter is applied.
/// </summary>
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
this World world)
where T1 : struct where T2 : struct where T3 : struct
{
return new Select3<T1, T2, T3>(world, new QueryDescriptor(new HashSet<Type>(), new HashSet<Type>()));
}
}
/// <summary>
/// Ref struct iterator for queries with one component type.
/// </summary>
public ref struct Select1<T1> where T1 : struct
{
private readonly World _world;
private readonly SparseSet<T1>? _set;
private readonly ComponentStore _store;
private readonly IReadOnlySet<Type> _without;
private int _index;
private readonly int _count;
internal Select1(World world, QueryDescriptor query)
{
_world = world;
_store = world.Components;
_without = query.Without;
_set = _store.GetSet(typeof(T1)) as SparseSet<T1>;
_count = _set?.Count ?? 0;
_index = -1;
_world.BeginIteration();
}
public Entity CurrentEntity { get; private set; }
public ref T1 Current1 => ref _set!.Get(CurrentEntity);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool MoveNext()
{
if (_set == null) return false;
while (++_index < _count)
{
CurrentEntity = _set.DenseEntities[_index];
if (CurrentEntity.Id == 1) continue; // Skip singleton.
if (!PassesWithout()) continue;
return true;
}
return false;
}
public Select1<T1> GetEnumerator() => this;
public void Dispose()
{
_world.EndIteration();
}
private bool PassesWithout()
{
foreach (var type in _without)
{
var set = _store.GetSet(type);
if (set != null && set.Contains(CurrentEntity))
return false;
}
return true;
}
}
/// <summary>
/// Ref struct iterator for queries with two component types.
/// Drives iteration from the smaller sparse set to minimize probes.
/// </summary>
public ref struct Select2<T1, T2>
where T1 : struct where T2 : struct
{
private readonly World _world;
private readonly SparseSet<T1>? _set1;
private readonly SparseSet<T2>? _set2;
private readonly ComponentStore _store;
private readonly IReadOnlySet<Type> _without;
private int _index;
private readonly int _count;
private readonly bool _swapped; // true when driving from _set2
internal Select2(World world, QueryDescriptor query)
{
_world = world;
_store = world.Components;
_without = query.Without;
_set1 = _store.GetSet(typeof(T1)) as SparseSet<T1>;
_set2 = _store.GetSet(typeof(T2)) as SparseSet<T2>;
int count1 = _set1?.Count ?? 0;
int count2 = _set2?.Count ?? 0;
_swapped = count2 < count1;
_count = _swapped ? count2 : count1;
_index = -1;
_world.BeginIteration();
}
public Entity CurrentEntity { get; private set; }
public ref T1 Current1 => ref _set1!.Get(CurrentEntity);
public ref T2 Current2 => ref _set2!.Get(CurrentEntity);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool MoveNext()
{
if (_set1 == null || _set2 == null) return false;
while (++_index < _count)
{
if (_swapped)
{
CurrentEntity = _set2!.DenseEntities[_index];
if (CurrentEntity.Id == 1) continue;
if (!_set1.Contains(CurrentEntity)) continue;
}
else
{
CurrentEntity = _set1.DenseEntities[_index];
if (CurrentEntity.Id == 1) continue;
if (!_set2.Contains(CurrentEntity)) continue;
}
if (!PassesWithout()) continue;
return true;
}
return false;
}
public Select2<T1, T2> GetEnumerator() => this;
public void Dispose()
{
_world.EndIteration();
}
private bool PassesWithout()
{
foreach (var type in _without)
{
var set = _store.GetSet(type);
if (set != null && set.Contains(CurrentEntity))
return false;
}
return true;
}
}
/// <summary>
/// Ref struct iterator for queries with three component types.
/// Drives iteration from the smallest sparse set to minimize probes.
/// </summary>
public ref struct Select3<T1, T2, T3>
where T1 : struct where T2 : struct where T3 : struct
{
private readonly World _world;
private readonly SparseSet<T1>? _set1;
private readonly SparseSet<T2>? _set2;
private readonly SparseSet<T3>? _set3;
private readonly ComponentStore _store;
private readonly IReadOnlySet<Type> _without;
private int _index;
private readonly int _count;
private readonly int _driver; // 0 = _set1, 1 = _set2, 2 = _set3
internal Select3(World world, QueryDescriptor query)
{
_world = world;
_store = world.Components;
_without = query.Without;
_set1 = _store.GetSet(typeof(T1)) as SparseSet<T1>;
_set2 = _store.GetSet(typeof(T2)) as SparseSet<T2>;
_set3 = _store.GetSet(typeof(T3)) as SparseSet<T3>;
int count1 = _set1?.Count ?? 0;
int count2 = _set2?.Count ?? 0;
int count3 = _set3?.Count ?? 0;
if (count2 <= count1 && count2 <= count3)
{
_driver = 1;
_count = count2;
}
else if (count3 <= count1 && count3 <= count2)
{
_driver = 2;
_count = count3;
}
else
{
_driver = 0;
_count = count1;
}
_index = -1;
_world.BeginIteration();
}
public Entity CurrentEntity { get; private set; }
public ref T1 Current1 => ref _set1!.Get(CurrentEntity);
public ref T2 Current2 => ref _set2!.Get(CurrentEntity);
public ref T3 Current3 => ref _set3!.Get(CurrentEntity);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool MoveNext()
{
if (_set1 == null || _set2 == null || _set3 == null) return false;
while (++_index < _count)
{
switch (_driver)
{
case 0:
CurrentEntity = _set1.DenseEntities[_index];
if (CurrentEntity.Id == 1) continue;
if (!_set2.Contains(CurrentEntity)) continue;
if (!_set3.Contains(CurrentEntity)) continue;
break;
case 1:
CurrentEntity = _set2!.DenseEntities[_index];
if (CurrentEntity.Id == 1) continue;
if (!_set1.Contains(CurrentEntity)) continue;
if (!_set3.Contains(CurrentEntity)) continue;
break;
case 2:
CurrentEntity = _set3!.DenseEntities[_index];
if (CurrentEntity.Id == 1) continue;
if (!_set1.Contains(CurrentEntity)) continue;
if (!_set2.Contains(CurrentEntity)) continue;
break;
}
if (!PassesWithout()) continue;
return true;
}
return false;
}
public Select3<T1, T2, T3> GetEnumerator() => this;
public void Dispose()
{
_world.EndIteration();
}
private bool PassesWithout()
{
foreach (var type in _without)
{
var set = _store.GetSet(type);
if (set != null && set.Contains(CurrentEntity))
return false;
}
return true;
}
}
-39
View File
@@ -1,39 +0,0 @@
namespace OECS;
// Custom delegate types for query iteration with ref parameters.
// The built-in Action<...> delegates do not support ref parameters.
/// <summary>
/// Callback for iterating entities with one component type.
/// </summary>
public delegate void ForEachAction<T1>(Entity entity, ref T1 c1) where T1 : struct;
/// <summary>
/// Callback for iterating entities with two component types.
/// </summary>
public delegate void ForEachAction<T1, T2>(Entity entity, ref T1 c1, ref T2 c2)
where T1 : struct where T2 : struct;
/// <summary>
/// Callback for iterating entities with three component types.
/// </summary>
public delegate void ForEachAction<T1, T2, T3>(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3)
where T1 : struct where T2 : struct where T3 : struct;
/// <summary>
/// Callback for iterating entities with four component types.
/// </summary>
public delegate void ForEachAction<T1, T2, T3, T4>(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3, ref T4 c4)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct;
/// <summary>
/// Callback for iterating entities with five component types.
/// </summary>
public delegate void ForEachAction<T1, T2, T3, T4, T5>(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3, ref T4 c4, ref T5 c5)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct;
/// <summary>
/// Callback for iterating entities with six component types.
/// </summary>
public delegate void ForEachAction<T1, T2, T3, T4, T5, T6>(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3, ref T4 c4, ref T5 c5, ref T6 c6)
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct;
+8
View File
@@ -0,0 +1,8 @@
namespace OECS;
/// <summary>
/// Marker interface for interrupt types. An interrupt is a signal issued by a
/// system that blocks the next tick until a matching <see cref="IInterruptHandlerCommand{TInterrupt}"/>
/// resolves it.
/// </summary>
public interface IInterrupt { }
+31
View File
@@ -0,0 +1,31 @@
namespace OECS;
/// <summary>
/// A command that resolves a pending interrupt of type <typeparamref name="TInterrupt"/>.
///
/// The default <see cref="ICommand.Execute"/> implementation looks up the pending
/// interrupt, calls <see cref="TryResolve"/> to let the handler inspect it, and
/// resolves the interrupt if the handler returns <c>true</c>.
/// </summary>
/// <typeparam name="TInterrupt">The interrupt type this handler resolves.</typeparam>
public interface IInterruptHandlerCommand<TInterrupt> : ICommand
where TInterrupt : struct, IInterrupt
{
void ICommand.Execute(World world)
{
if (world.TryGetPendingInterrupt<TInterrupt>(out var interrupt))
{
if (TryResolve(interrupt))
{
world.ResolveInterrupt<TInterrupt>();
}
}
}
/// <summary>
/// Called by the default <see cref="ICommand.Execute"/> with the pending interrupt.
/// Return <c>true</c> to resolve and remove the interrupt; <c>false</c> to leave it
/// pending for another handler.
/// </summary>
bool TryResolve(TInterrupt interrupt);
}
+2 -7
View File
@@ -2,19 +2,14 @@ namespace OECS;
/// <summary>
/// Marker interface for components that represent a directed relationship
/// between two entities. The component is stored on the <see cref="Source"/>
/// entity and points to the <see cref="Target"/> entity.
/// between two entities. The component is stored on the source entity
/// (the entity it's added to) and points to the <see cref="Target"/> entity.
///
/// The <see cref="World"/> automatically maintains a reverse index so that
/// all sources pointing to a given target can be looked up efficiently.
/// </summary>
public interface IRelationship
{
/// <summary>
/// The entity that owns this relationship component.
/// </summary>
Entity Source { get; }
/// <summary>
/// The entity that this relationship points to.
/// </summary>
+35 -3
View File
@@ -2,12 +2,44 @@ namespace OECS;
/// <summary>
/// A system that runs on every tick.
/// Systems receive the world in their <see cref="Run"/> method.
/// Implement <see cref="RunImpl"/> with the system logic.
/// Call <see cref="ISystem.Run"/> to execute with batching.
/// </summary>
public interface ISystem
{
/// <summary>
/// Executes the system logic for this tick.
/// Implement this with the system's logic for this tick.
/// The caller (e.g., <see cref="ISystem.Run"/>) wraps this
/// in a batching scope so mutations and component accesses
/// are automatically tracked.
/// </summary>
void Run(World world);
void RunImpl(World world);
}
/// <summary>
/// Extension methods for running systems with proper batching.
/// </summary>
public static class SystemExtensions
{
/// <summary>
/// Runs a system with a batching scope. Structural mutations are
/// buffered, and <see cref="World.GetComponent{T}"/> calls are
/// auto-tracked for modification until the system returns.
/// </summary>
public static void Run(this ISystem system, World world)
{
world.BeginBatching();
system.RunImpl(world);
world.EndBatching();
}
/// <summary>
/// Runs a ticked system with a batching scope.
/// </summary>
public static void Run(this ITickedSystem system, World world, Tick tick)
{
world.BeginBatching();
system.RunImpl(world, tick);
world.EndBatching();
}
}
+2 -2
View File
@@ -7,7 +7,7 @@ namespace OECS;
public interface ITickedSystem : ISystem
{
/// <summary>
/// Executes the system logic with tick information.
/// Implement this with the system's ticked logic.
/// </summary>
void Run(World world, Tick tick);
void RunImpl(World world, Tick tick);
}
+69
View File
@@ -0,0 +1,69 @@
namespace OECS;
/// <summary>
/// Internal store for pending interrupts. Owned by <see cref="SystemGroup"/>
/// and injected into <see cref="World"/> so the handler's default Execute
/// can access it.
/// </summary>
internal class InterruptStore
{
private readonly Dictionary<Type, object> _interrupts = new();
/// <summary>
/// Stores an interrupt. Only one interrupt of a given type may be pending.
/// </summary>
public void Add<T>(T interrupt) where T : struct, IInterrupt
{
var type = typeof(T);
if (_interrupts.ContainsKey(type))
throw new InvalidOperationException(
$"An interrupt of type {type.Name} is already pending.");
_interrupts[type] = interrupt;
}
/// <summary>
/// Tries to retrieve a pending interrupt of type <typeparamref name="T"/>.
/// </summary>
public bool TryGet<T>(out T interrupt) where T : struct, IInterrupt
{
if (_interrupts.TryGetValue(typeof(T), out var boxed))
{
interrupt = (T)boxed;
return true;
}
interrupt = default;
return false;
}
/// <summary>
/// Removes the pending interrupt of type <typeparamref name="T"/>.
/// No-op if none is pending.
/// </summary>
public bool Remove<T>() where T : struct, IInterrupt
{
return _interrupts.Remove(typeof(T));
}
/// <summary>
/// True if any interrupt is currently pending.
/// </summary>
public bool HasAny => _interrupts.Count > 0;
/// <summary>
/// True if an interrupt of type <typeparamref name="T"/> is pending.
/// </summary>
public bool Has<T>() where T : struct, IInterrupt
{
return _interrupts.ContainsKey(typeof(T));
}
/// <summary>
/// Clears all pending interrupts. Used during world reset or serialization load.
/// </summary>
internal void Clear()
{
_interrupts.Clear();
}
}
+64
View File
@@ -0,0 +1,64 @@
using System.Collections;
namespace OECS;
/// <summary>
/// An insertion-ordered set of entities that supports O(1) Add, Remove, and Contains
/// while iterating in insertion order. Drop-in for cases where both
/// stable ordering and fast membership checks are needed.
///
/// Not thread-safe.
/// </summary>
internal sealed class OrderedEntitySet : IReadOnlyCollection<Entity>
{
// Entities are stored in a packed list; a secondary dictionary maps
// each entity to its index for O(1) removal via swap-with-last.
private readonly List<Entity> _order = new();
private readonly Dictionary<Entity, int> _index = new();
public int Count => _order.Count;
public bool Add(Entity entity)
{
if (_index.ContainsKey(entity))
return false;
_index[entity] = _order.Count;
_order.Add(entity);
return true;
}
public bool Remove(Entity entity)
{
if (!_index.Remove(entity, out var idx))
return false;
int lastIdx = _order.Count - 1;
if (idx < lastIdx)
{
var last = _order[lastIdx];
_order[idx] = last;
_index[last] = idx;
}
_order.RemoveAt(lastIdx);
return true;
}
/// <summary>
/// Reorders the set to match the given entity order.
/// The provided list must contain exactly the same entities as the set.
/// </summary>
public void Reorder(IReadOnlyList<Entity> ordered)
{
_order.Clear();
_index.Clear();
for (int i = 0; i < ordered.Count; i++)
{
_order.Add(ordered[i]);
_index[ordered[i]] = i;
}
}
public IEnumerator<Entity> GetEnumerator() => _order.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
+100
View File
@@ -0,0 +1,100 @@
namespace OECS;
/// <summary>
/// Describes a query over the ECS world with one required component type.
/// Add optional <c>Without</c> filters via the fluent API.
/// </summary>
public struct Query<T1> where T1 : struct
{
internal HashSet<Type>? WithoutTypes;
/// <summary>
/// Excludes entities that have a component of type <typeparamref name="W"/>.
/// </summary>
public Query<T1> Without<W>() where W : struct
{
WithoutTypes ??= new HashSet<Type>();
WithoutTypes.Add(typeof(W));
return this;
}
}
/// <summary>
/// Describes a query over the ECS world with two required component types.
/// </summary>
public struct Query<T1, T2> where T1 : struct where T2 : struct
{
internal HashSet<Type>? WithoutTypes;
public Query<T1, T2> Without<W>() where W : struct
{
WithoutTypes ??= new HashSet<Type>();
WithoutTypes.Add(typeof(W));
return this;
}
}
/// <summary>
/// Describes a query over the ECS world with three required component types.
/// </summary>
public struct Query<T1, T2, T3>
where T1 : struct where T2 : struct where T3 : struct
{
internal HashSet<Type>? WithoutTypes;
public Query<T1, T2, T3> Without<W>() where W : struct
{
WithoutTypes ??= new HashSet<Type>();
WithoutTypes.Add(typeof(W));
return this;
}
}
/// <summary>
/// Describes a query over the ECS world with four required component types.
/// </summary>
public struct Query<T1, T2, T3, T4>
where T1 : struct where T2 : struct where T3 : struct where T4 : struct
{
internal HashSet<Type>? WithoutTypes;
public Query<T1, T2, T3, T4> Without<W>() where W : struct
{
WithoutTypes ??= new HashSet<Type>();
WithoutTypes.Add(typeof(W));
return this;
}
}
/// <summary>
/// Describes a query over the ECS world with five required component types.
/// </summary>
public struct Query<T1, T2, T3, T4, T5>
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct
{
internal HashSet<Type>? WithoutTypes;
public Query<T1, T2, T3, T4, T5> Without<W>() where W : struct
{
WithoutTypes ??= new HashSet<Type>();
WithoutTypes.Add(typeof(W));
return this;
}
}
/// <summary>
/// Describes a query over the ECS world with six required component types.
/// </summary>
public struct Query<T1, T2, T3, T4, T5, T6>
where T1 : struct where T2 : struct where T3 : struct
where T4 : struct where T5 : struct where T6 : struct
{
internal HashSet<Type>? WithoutTypes;
public Query<T1, T2, T3, T4, T5, T6> Without<W>() where W : struct
{
WithoutTypes ??= new HashSet<Type>();
WithoutTypes.Add(typeof(W));
return this;
}
}

Some files were not shown because too many files have changed in this diff Show More